Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ The macOS app target lives under `OpenMessage/`.
| `OPENMESSAGES_HOST` | `127.0.0.1` | Host/interface to bind the local web server to |
| `OPENMESSAGES_MY_NAME` | system user name | Display name for outgoing imported iMessage/WhatsApp messages |
| `OPENMESSAGES_STARTUP_BACKFILL` | `auto` | Startup history sync mode: `auto`, `shallow`, `deep`, or `off` |
| `OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS` | `0` | Opt in to deep backfill's Phase C (contact-based orphan discovery). **Off by default** because it creates an empty SMS thread on your phone for each contact without prior message history. Enable with `1`/`true`/`yes`/`on` only if you understand the side effect. |
| `OPENMESSAGES_MACOS_NOTIFICATIONS` | interactive macOS `serve` sessions only | Enable/disable native macOS notifications for fresh inbound live messages (`1`/`0`). Click-through opens the matching thread when `terminal-notifier` is available. |
| `OPENMESSAGE_TELEMETRY` | unset (off) | Set to `1` to send one anonymous heartbeat per launch (max one per 24h). Reports only: random install ID, version, OS/arch, and which platforms are paired (Google Messages / WhatsApp / Signal). No message content, no contact info, no IP-based identity. See `internal/telemetry/`. |

Expand Down
41 changes: 35 additions & 6 deletions internal/app/backfill.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"os"
"strings"
"time"

Expand All @@ -19,6 +20,26 @@ const (
recentReconcileMaxPages = 4
)

// orphanContactDiscoveryEnabled reports whether deep backfill should run
// Phase C (contact-based orphan discovery).
//
// Phase C calls GetOrCreateConversation for every contact without prior
// message history. Google Messages treats GetOrCreateConversation as a
// thread-creation call: for each contact that has no existing thread, an
// empty SMS thread is created on the user's phone. For users who only want
// a deep history sync, that is an unwanted side effect.
//
// Phase C is therefore opt-in. Set OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS=1
// (or "true"/"yes"/"on") to enable. Default is disabled.
func orphanContactDiscoveryEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS"))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}

// Backfill fetches existing conversations and recent messages from
// Google Messages and stores them in the local database.
func (a *App) Backfill() error {
Expand Down Expand Up @@ -125,12 +146,20 @@ func (a *App) deepBackfill() {
}
}

// Phase C: Contact-based discovery for orphan phone numbers
a.BackfillProgress.setPhase(BackfillPhaseContacts)
if a.discoverFromContacts(gm, seen, clientToken) {
a.emitConversationsChange()
a.emitMessagesChange("")
return
// Phase C: Contact-based discovery for orphan phone numbers.
// Off by default because GetOrCreateConversation creates an empty SMS
// thread on the user's phone for each contact lacking one. Opt in via
// OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS=1.
if orphanContactDiscoveryEnabled() {
a.BackfillProgress.setPhase(BackfillPhaseContacts)
if a.discoverFromContacts(gm, seen, clientToken) {
a.emitConversationsChange()
a.emitMessagesChange("")
return
}
} else {
a.Logger.Info().
Msg("Skipping Phase C (orphan-contact discovery); set OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS=1 to enable. Note: enabling creates empty SMS threads for contacts without prior message history.")
}

progress := a.BackfillProgress.snapshot()
Expand Down
76 changes: 76 additions & 0 deletions internal/app/backfill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ func TestDeepBackfillMessagePagination(t *testing.T) {
}

func TestDeepBackfillContactDiscovery(t *testing.T) {
t.Setenv("OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS", "1")
mock := &mockGMClient{
conversations: map[gmproto.ListConversationsRequest_Folder][][]*gmproto.Conversation{
gmproto.ListConversationsRequest_INBOX: {
Expand Down Expand Up @@ -371,6 +372,7 @@ func TestDeepBackfillContactDiscovery(t *testing.T) {
}

func TestDeepBackfillContactDiscoverySkipsAlreadySeen(t *testing.T) {
t.Setenv("OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS", "1")
// Contact's phone maps to a conversation already found in INBOX
mock := &mockGMClient{
conversations: map[gmproto.ListConversationsRequest_Folder][][]*gmproto.Conversation{
Expand Down Expand Up @@ -401,6 +403,48 @@ func TestDeepBackfillContactDiscoverySkipsAlreadySeen(t *testing.T) {
}
}

// TestDeepBackfillSkipsPhaseCWhenOptOut confirms Phase C does NOT run when
// the env var is unset. The mock has a contact whose phone would map to a
// brand-new conversation; without orphan discovery enabled, that contact
// must not be queried via GetOrCreateConversation, so the conversation
// must not appear in the store.
func TestDeepBackfillSkipsPhaseCWhenOptOut(t *testing.T) {
// Explicitly empty (default behavior).
t.Setenv("OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS", "")
mock := &mockGMClient{
conversations: map[gmproto.ListConversationsRequest_Folder][][]*gmproto.Conversation{
gmproto.ListConversationsRequest_INBOX: {
{makeConv("c1", "Alice")},
},
},
messages: map[string][][]*gmproto.Message{
"c1": {{makeMsg("m1", "c1", "hi", 100)}},
"c-mary": {{makeMsg("m2", "c-mary", "old msg", 50)}},
},
contacts: []*gmproto.Contact{
{
Name: "Mary",
Number: &gmproto.ContactNumber{Number: "+15555555555"},
},
},
getOrCreateResults: map[string]*gmproto.Conversation{
"+15555555555": makeConv("c-mary", "Mary"),
},
}

a := newTestApp(t, mock)
a.DeepBackfill()

convos, _ := a.Store.ListConversations(50)
if len(convos) != 1 {
t.Fatalf("got %d conversations, want 1 (Phase C should be skipped)", len(convos))
}
progress := a.BackfillProgress.snapshot()
if progress.ContactsChecked != 0 {
t.Fatalf("got %d contacts checked, want 0 (Phase C should be skipped)", progress.ContactsChecked)
}
}

func TestDeepBackfillFolderListError(t *testing.T) {
mock := &mockGMClient{
conversations: map[gmproto.ListConversationsRequest_Folder][][]*gmproto.Conversation{
Expand Down Expand Up @@ -463,6 +507,7 @@ func TestDeepBackfillMessageFetchError(t *testing.T) {
}

func TestDeepBackfillGetOrCreateError(t *testing.T) {
t.Setenv("OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS", "1")
mock := &mockGMClient{
contacts: []*gmproto.Contact{
{
Expand Down Expand Up @@ -1008,3 +1053,34 @@ func TestBackfillPopulatesDB(t *testing.T) {
t.Fatalf("got body %q", msgs[0].Body)
}
}


func TestOrphanContactDiscoveryEnabled(t *testing.T) {
cases := []struct {
name string
env string
want bool
}{
{"unset", "", false},
{"empty", "", false},
{"explicit zero", "0", false},
{"explicit false", "false", false},
{"no", "no", false},
{"off", "off", false},
{"one", "1", true},
{"true", "true", true},
{"True mixed case", "True", true},
{"YES upper", "YES", true},
{"on padded", " on ", true},
{"unrecognized", "maybe", false},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS", tc.env)
if got := orphanContactDiscoveryEnabled(); got != tc.want {
t.Fatalf("orphanContactDiscoveryEnabled() = %v with env=%q, want %v", got, tc.env, tc.want)
}
})
}
}