Skip to content

Commit 7964aa7

Browse files
DiscoBardclaude
andauthored
feat(backfill): make Phase C orphan-contact discovery opt-in (#21)
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: DiscoBard <223926914+DiscoBard@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent acc4692 commit 7964aa7

3 files changed

Lines changed: 112 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ The macOS app target lives under `OpenMessage/`.
168168
| `OPENMESSAGES_HOST` | `127.0.0.1` | Host/interface to bind the local web server to |
169169
| `OPENMESSAGES_MY_NAME` | system user name | Display name for outgoing imported iMessage/WhatsApp messages |
170170
| `OPENMESSAGES_STARTUP_BACKFILL` | `auto` | Startup history sync mode: `auto`, `shallow`, `deep`, or `off` |
171+
| `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. |
171172
| `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. |
172173
| `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/`. |
173174

internal/app/backfill.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/hex"
55
"encoding/json"
66
"fmt"
7+
"os"
78
"strings"
89
"time"
910

@@ -19,6 +20,26 @@ const (
1920
recentReconcileMaxPages = 4
2021
)
2122

23+
// orphanContactDiscoveryEnabled reports whether deep backfill should run
24+
// Phase C (contact-based orphan discovery).
25+
//
26+
// Phase C calls GetOrCreateConversation for every contact without prior
27+
// message history. Google Messages treats GetOrCreateConversation as a
28+
// thread-creation call: for each contact that has no existing thread, an
29+
// empty SMS thread is created on the user's phone. For users who only want
30+
// a deep history sync, that is an unwanted side effect.
31+
//
32+
// Phase C is therefore opt-in. Set OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS=1
33+
// (or "true"/"yes"/"on") to enable. Default is disabled.
34+
func orphanContactDiscoveryEnabled() bool {
35+
switch strings.ToLower(strings.TrimSpace(os.Getenv("OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS"))) {
36+
case "1", "true", "yes", "on":
37+
return true
38+
default:
39+
return false
40+
}
41+
}
42+
2243
// Backfill fetches existing conversations and recent messages from
2344
// Google Messages and stores them in the local database.
2445
func (a *App) Backfill() error {
@@ -125,12 +146,20 @@ func (a *App) deepBackfill() {
125146
}
126147
}
127148

128-
// Phase C: Contact-based discovery for orphan phone numbers
129-
a.BackfillProgress.setPhase(BackfillPhaseContacts)
130-
if a.discoverFromContacts(gm, seen, clientToken) {
131-
a.emitConversationsChange()
132-
a.emitMessagesChange("")
133-
return
149+
// Phase C: Contact-based discovery for orphan phone numbers.
150+
// Off by default because GetOrCreateConversation creates an empty SMS
151+
// thread on the user's phone for each contact lacking one. Opt in via
152+
// OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS=1.
153+
if orphanContactDiscoveryEnabled() {
154+
a.BackfillProgress.setPhase(BackfillPhaseContacts)
155+
if a.discoverFromContacts(gm, seen, clientToken) {
156+
a.emitConversationsChange()
157+
a.emitMessagesChange("")
158+
return
159+
}
160+
} else {
161+
a.Logger.Info().
162+
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.")
134163
}
135164

136165
progress := a.BackfillProgress.snapshot()

internal/app/backfill_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ func TestDeepBackfillMessagePagination(t *testing.T) {
335335
}
336336

337337
func TestDeepBackfillContactDiscovery(t *testing.T) {
338+
t.Setenv("OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS", "1")
338339
mock := &mockGMClient{
339340
conversations: map[gmproto.ListConversationsRequest_Folder][][]*gmproto.Conversation{
340341
gmproto.ListConversationsRequest_INBOX: {
@@ -371,6 +372,7 @@ func TestDeepBackfillContactDiscovery(t *testing.T) {
371372
}
372373

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

406+
// TestDeepBackfillSkipsPhaseCWhenOptOut confirms Phase C does NOT run when
407+
// the env var is unset. The mock has a contact whose phone would map to a
408+
// brand-new conversation; without orphan discovery enabled, that contact
409+
// must not be queried via GetOrCreateConversation, so the conversation
410+
// must not appear in the store.
411+
func TestDeepBackfillSkipsPhaseCWhenOptOut(t *testing.T) {
412+
// Explicitly empty (default behavior).
413+
t.Setenv("OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS", "")
414+
mock := &mockGMClient{
415+
conversations: map[gmproto.ListConversationsRequest_Folder][][]*gmproto.Conversation{
416+
gmproto.ListConversationsRequest_INBOX: {
417+
{makeConv("c1", "Alice")},
418+
},
419+
},
420+
messages: map[string][][]*gmproto.Message{
421+
"c1": {{makeMsg("m1", "c1", "hi", 100)}},
422+
"c-mary": {{makeMsg("m2", "c-mary", "old msg", 50)}},
423+
},
424+
contacts: []*gmproto.Contact{
425+
{
426+
Name: "Mary",
427+
Number: &gmproto.ContactNumber{Number: "+15555555555"},
428+
},
429+
},
430+
getOrCreateResults: map[string]*gmproto.Conversation{
431+
"+15555555555": makeConv("c-mary", "Mary"),
432+
},
433+
}
434+
435+
a := newTestApp(t, mock)
436+
a.DeepBackfill()
437+
438+
convos, _ := a.Store.ListConversations(50)
439+
if len(convos) != 1 {
440+
t.Fatalf("got %d conversations, want 1 (Phase C should be skipped)", len(convos))
441+
}
442+
progress := a.BackfillProgress.snapshot()
443+
if progress.ContactsChecked != 0 {
444+
t.Fatalf("got %d contacts checked, want 0 (Phase C should be skipped)", progress.ContactsChecked)
445+
}
446+
}
447+
404448
func TestDeepBackfillFolderListError(t *testing.T) {
405449
mock := &mockGMClient{
406450
conversations: map[gmproto.ListConversationsRequest_Folder][][]*gmproto.Conversation{
@@ -463,6 +507,7 @@ func TestDeepBackfillMessageFetchError(t *testing.T) {
463507
}
464508

465509
func TestDeepBackfillGetOrCreateError(t *testing.T) {
510+
t.Setenv("OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS", "1")
466511
mock := &mockGMClient{
467512
contacts: []*gmproto.Contact{
468513
{
@@ -1008,3 +1053,34 @@ func TestBackfillPopulatesDB(t *testing.T) {
10081053
t.Fatalf("got body %q", msgs[0].Body)
10091054
}
10101055
}
1056+
1057+
1058+
func TestOrphanContactDiscoveryEnabled(t *testing.T) {
1059+
cases := []struct {
1060+
name string
1061+
env string
1062+
want bool
1063+
}{
1064+
{"unset", "", false},
1065+
{"empty", "", false},
1066+
{"explicit zero", "0", false},
1067+
{"explicit false", "false", false},
1068+
{"no", "no", false},
1069+
{"off", "off", false},
1070+
{"one", "1", true},
1071+
{"true", "true", true},
1072+
{"True mixed case", "True", true},
1073+
{"YES upper", "YES", true},
1074+
{"on padded", " on ", true},
1075+
{"unrecognized", "maybe", false},
1076+
}
1077+
1078+
for _, tc := range cases {
1079+
t.Run(tc.name, func(t *testing.T) {
1080+
t.Setenv("OPENMESSAGES_BACKFILL_DISCOVER_ORPHANS", tc.env)
1081+
if got := orphanContactDiscoveryEnabled(); got != tc.want {
1082+
t.Fatalf("orphanContactDiscoveryEnabled() = %v with env=%q, want %v", got, tc.env, tc.want)
1083+
}
1084+
})
1085+
}
1086+
}

0 commit comments

Comments
 (0)