From dfd171a1715223ca103bc38f1956872e1ea9b376 Mon Sep 17 00:00:00 2001 From: DiscoBard <223926914+DiscoBard@users.noreply.github.com> Date: Tue, 5 May 2026 10:44:49 -0400 Subject: [PATCH] feat(backfill): make Phase C orphan-contact discovery opt-in Deep backfill's Phase C iterates the user's contacts and calls GetOrCreateConversation for every phone number. Google Messages treats that call as a thread-creation: for each contact lacking a prior thread, an empty SMS thread is created on the user's phone. For users running deep backfill primarily to sync existing history, that side effect is unwanted -- it produces dozens of new "blank" threads on the device with no way to undo individually. Make Phase C opt-in via a new env var: OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS=1 When unset (default), deepBackfill runs Phases A (folder pagination) and B (per-conversation messages) only and logs an informational line explaining the gate. When set to a truthy value, behavior is unchanged from the current default. - internal/app/backfill.go: add orphanContactDiscoveryEnabled() helper; gate Phase C call with informational log line on the off path - internal/app/backfill_test.go: add TestOrphanContactDiscoveryEnabled with truthy/falsy parsing cases; add TestDeepBackfillSkipsPhaseCWhen OptOut to assert the default-off path produces no contact-discovery side effects; opt the existing Phase C tests in via t.Setenv - README.md: document the new env var with the side-effect warning Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + internal/app/backfill.go | 41 ++++++++++++++++--- internal/app/backfill_test.go | 76 +++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d3d339b..28dae5e 100644 --- a/README.md +++ b/README.md @@ -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/`. | diff --git a/internal/app/backfill.go b/internal/app/backfill.go index a58bb47..b4c6d4e 100644 --- a/internal/app/backfill.go +++ b/internal/app/backfill.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "os" "strings" "time" @@ -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 { @@ -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() diff --git a/internal/app/backfill_test.go b/internal/app/backfill_test.go index 922af3d..916bc23 100644 --- a/internal/app/backfill_test.go +++ b/internal/app/backfill_test.go @@ -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: { @@ -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{ @@ -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{ @@ -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{ { @@ -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) + } + }) + } +}