Skip to content

Commit 9a9924c

Browse files
etbyrdwesmclaude
authored
Fix RecipientName filters to include BCC recipients (#6)
RecipientName filters only searched `to` and `cc` recipients, while Recipient filters correctly included `bcc`. This meant you could find emails by BCC recipient email but not by their display name. **Before:** - `Recipient: "secret@example.com"` finds message (good) - `RecipientName: "Secret Bob"` finds nothing (unintended/unexpected?) **After:** Both work as expected. Fixed in both SQLiteEngine and DuckDBEngine across all affected queries (ListMessages, aggregates, SubAggregate, GetGmailIDsByFilter). Test included that fails before fix, passes after. --------- Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1d6ba2f commit 9a9924c

File tree

5 files changed

+196
-22
lines changed

5 files changed

+196
-22
lines changed

internal/query/duckdb.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ func (e *DuckDBEngine) buildAggregateSearchConditions(searchQuery string, keyCol
232232
SELECT 1 FROM mr mr_to
233233
JOIN p p_to ON p_to.id = mr_to.participant_id
234234
WHERE mr_to.message_id = msg.id
235-
AND mr_to.recipient_type IN ('to', 'cc')
235+
AND mr_to.recipient_type IN ('to', 'cc', 'bcc')
236236
AND p_to.email_address ILIKE ? ESCAPE '\'
237237
)`)
238238
args = append(args, toPattern)
@@ -309,7 +309,7 @@ func (e *DuckDBEngine) buildStatsSearchConditions(searchQuery string, groupBy Vi
309309
SELECT 1 FROM mr mr_rs
310310
JOIN p p_rs ON p_rs.id = mr_rs.participant_id
311311
WHERE mr_rs.message_id = msg.id
312-
AND mr_rs.recipient_type IN ('to', 'cc')
312+
AND mr_rs.recipient_type IN ('to', 'cc', 'bcc')
313313
AND (p_rs.email_address ILIKE ? ESCAPE '\' OR p_rs.display_name ILIKE ? ESCAPE '\')
314314
)`)
315315
args = append(args, termPattern, termPattern)
@@ -489,7 +489,7 @@ func (e *DuckDBEngine) AggregateBySenderName(ctx context.Context, opts Aggregate
489489
}
490490

491491
// AggregateByRecipient groups messages by recipient email.
492-
// Includes to, cc recipients (bcc not exported to Parquet for privacy).
492+
// Includes to, cc, and bcc recipients.
493493
func (e *DuckDBEngine) AggregateByRecipient(ctx context.Context, opts AggregateOptions) ([]AggregateRow, error) {
494494
where, args := e.buildWhereClause(opts, "p.email_address", "p.display_name")
495495

@@ -498,7 +498,7 @@ func (e *DuckDBEngine) AggregateByRecipient(ctx context.Context, opts AggregateO
498498
limit = 100
499499
}
500500

501-
// Join messages -> message_recipients (to/cc) -> participants for recipient email
501+
// Join messages -> message_recipients (to/cc/bcc) -> participants for recipient email
502502
query := fmt.Sprintf(`
503503
WITH %s
504504
SELECT key, count, total_size, attachment_size, attachment_count, total_unique
@@ -511,7 +511,7 @@ func (e *DuckDBEngine) AggregateByRecipient(ctx context.Context, opts AggregateO
511511
CAST(COALESCE(SUM(att.attachment_count), 0) AS BIGINT) as attachment_count,
512512
COUNT(*) OVER() as total_unique
513513
FROM msg
514-
JOIN mr ON mr.message_id = msg.id AND mr.recipient_type IN ('to', 'cc')
514+
JOIN mr ON mr.message_id = msg.id AND mr.recipient_type IN ('to', 'cc', 'bcc')
515515
JOIN p ON p.id = mr.participant_id
516516
LEFT JOIN att ON att.message_id = msg.id
517517
WHERE %s AND p.email_address IS NOT NULL
@@ -548,7 +548,7 @@ func (e *DuckDBEngine) AggregateByRecipientName(ctx context.Context, opts Aggreg
548548
CAST(COALESCE(SUM(att.attachment_count), 0) AS BIGINT) as attachment_count,
549549
COUNT(*) OVER() as total_unique
550550
FROM msg
551-
JOIN mr ON mr.message_id = msg.id AND mr.recipient_type IN ('to', 'cc')
551+
JOIN mr ON mr.message_id = msg.id AND mr.recipient_type IN ('to', 'cc', 'bcc')
552552
JOIN p ON p.id = mr.participant_id
553553
LEFT JOIN att ON att.message_id = msg.id
554554
WHERE %s AND COALESCE(NULLIF(TRIM(p.display_name), ''), p.email_address) IS NOT NULL
@@ -759,12 +759,12 @@ func (e *DuckDBEngine) buildFilterConditions(filter MessageFilter) (string, []in
759759
SELECT 1 FROM mr
760760
JOIN p ON p.id = mr.participant_id
761761
WHERE mr.message_id = msg.id
762-
AND mr.recipient_type IN ('to', 'cc')
762+
AND mr.recipient_type IN ('to', 'cc', 'bcc')
763763
AND p.email_address = ?
764764
)`)
765765
args = append(args, filter.Recipient)
766766
} else if filter.MatchEmptyRecipient {
767-
conditions = append(conditions, "NOT EXISTS (SELECT 1 FROM mr WHERE mr.message_id = msg.id AND mr.recipient_type IN ('to', 'cc'))")
767+
conditions = append(conditions, "NOT EXISTS (SELECT 1 FROM mr WHERE mr.message_id = msg.id AND mr.recipient_type IN ('to', 'cc', 'bcc'))")
768768
}
769769

770770
// Recipient name filter - use EXISTS subquery (becomes semi-join)
@@ -773,7 +773,7 @@ func (e *DuckDBEngine) buildFilterConditions(filter MessageFilter) (string, []in
773773
SELECT 1 FROM mr
774774
JOIN p ON p.id = mr.participant_id
775775
WHERE mr.message_id = msg.id
776-
AND mr.recipient_type IN ('to', 'cc')
776+
AND mr.recipient_type IN ('to', 'cc', 'bcc')
777777
AND COALESCE(NULLIF(TRIM(p.display_name), ''), p.email_address) = ?
778778
)`)
779779
args = append(args, filter.RecipientName)
@@ -782,7 +782,7 @@ func (e *DuckDBEngine) buildFilterConditions(filter MessageFilter) (string, []in
782782
SELECT 1 FROM mr
783783
JOIN p ON p.id = mr.participant_id
784784
WHERE mr.message_id = msg.id
785-
AND mr.recipient_type IN ('to', 'cc')
785+
AND mr.recipient_type IN ('to', 'cc', 'bcc')
786786
AND COALESCE(NULLIF(TRIM(p.display_name), ''), p.email_address) IS NOT NULL
787787
)`)
788788
}
@@ -961,7 +961,7 @@ func (e *DuckDBEngine) SubAggregate(ctx context.Context, filter MessageFilter, g
961961
CAST(COALESCE(SUM(att.attachment_count), 0) AS BIGINT) as attachment_count,
962962
COUNT(*) OVER() as total_unique
963963
FROM msg
964-
JOIN mr mr_agg ON mr_agg.message_id = msg.id AND mr_agg.recipient_type IN ('to', 'cc')
964+
JOIN mr mr_agg ON mr_agg.message_id = msg.id AND mr_agg.recipient_type IN ('to', 'cc', 'bcc')
965965
JOIN p p_agg ON p_agg.id = mr_agg.participant_id
966966
LEFT JOIN att ON att.message_id = msg.id
967967
WHERE %s AND p_agg.email_address IS NOT NULL
@@ -984,7 +984,7 @@ func (e *DuckDBEngine) SubAggregate(ctx context.Context, filter MessageFilter, g
984984
CAST(COALESCE(SUM(att.attachment_count), 0) AS BIGINT) as attachment_count,
985985
COUNT(*) OVER() as total_unique
986986
FROM msg
987-
JOIN mr mr_agg ON mr_agg.message_id = msg.id AND mr_agg.recipient_type IN ('to', 'cc')
987+
JOIN mr mr_agg ON mr_agg.message_id = msg.id AND mr_agg.recipient_type IN ('to', 'cc', 'bcc')
988988
JOIN p p_agg ON p_agg.id = mr_agg.participant_id
989989
LEFT JOIN att ON att.message_id = msg.id
990990
WHERE %s AND COALESCE(NULLIF(TRIM(p_agg.display_name), ''), p_agg.email_address) IS NOT NULL
@@ -1879,7 +1879,7 @@ func (e *DuckDBEngine) GetGmailIDsByFilter(ctx context.Context, filter MessageFi
18791879
SELECT 1 FROM mr
18801880
JOIN p ON p.id = mr.participant_id
18811881
WHERE mr.message_id = msg.id
1882-
AND mr.recipient_type IN ('to', 'cc')
1882+
AND mr.recipient_type IN ('to', 'cc', 'bcc')
18831883
AND COALESCE(NULLIF(TRIM(p.display_name), ''), p.email_address) = ?
18841884
)`)
18851885
args = append(args, filter.RecipientName)
@@ -2217,7 +2217,7 @@ func (e *DuckDBEngine) buildSearchConditions(q *search.Query, filter MessageFilt
22172217
conditions = append(conditions, `EXISTS (
22182218
SELECT 1 FROM mr
22192219
JOIN p ON p.id = mr.participant_id
2220-
WHERE mr.message_id = msg.id AND mr.recipient_type IN ('to', 'cc')
2220+
WHERE mr.message_id = msg.id AND mr.recipient_type IN ('to', 'cc', 'bcc')
22212221
AND p.email_address ILIKE ? ESCAPE '\'
22222222
)`)
22232223
args = append(args, "%"+escapeILIKE(addr)+"%")

internal/query/duckdb_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1915,7 +1915,7 @@ func TestBuildWhereClause_SearchOperators(t *testing.T) {
19151915
{
19161916
name: "to operator",
19171917
searchQuery: "to:bob",
1918-
wantClauses: []string{"recipient_type IN ('to', 'cc')", "email_address ILIKE"},
1918+
wantClauses: []string{"recipient_type IN ('to', 'cc', 'bcc')", "email_address ILIKE"},
19191919
},
19201920
{
19211921
name: "subject operator",

internal/query/sqlite.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ func buildFilterJoinsAndConditions(filter MessageFilter, tableAlias string) (str
225225
`)
226226
} else if filter.Recipient == "" && !filter.MatchEmptyRecipient {
227227
joins = append(joins, `
228-
JOIN message_recipients mr_filter_to ON mr_filter_to.message_id = m.id AND mr_filter_to.recipient_type IN ('to', 'cc')
228+
JOIN message_recipients mr_filter_to ON mr_filter_to.message_id = m.id AND mr_filter_to.recipient_type IN ('to', 'cc', 'bcc')
229229
JOIN participants p_filter_to ON p_filter_to.id = mr_filter_to.participant_id
230230
`)
231231
}
@@ -236,7 +236,7 @@ func buildFilterJoinsAndConditions(filter MessageFilter, tableAlias string) (str
236236
SELECT 1 FROM message_recipients mr_rn
237237
JOIN participants p_rn ON p_rn.id = mr_rn.participant_id
238238
WHERE mr_rn.message_id = m.id
239-
AND mr_rn.recipient_type IN ('to', 'cc')
239+
AND mr_rn.recipient_type IN ('to', 'cc', 'bcc')
240240
AND COALESCE(NULLIF(TRIM(p_rn.display_name), ''), p_rn.email_address) IS NOT NULL
241241
)`)
242242
}
@@ -430,7 +430,7 @@ func (e *SQLiteEngine) SubAggregate(ctx context.Context, filter MessageFilter, g
430430
COALESCE(SUM(att.att_count), 0) as attachment_count,
431431
COUNT(*) OVER() as total_unique
432432
FROM messages m
433-
JOIN message_recipients mr ON mr.message_id = m.id AND mr.recipient_type IN ('to', 'cc')
433+
JOIN message_recipients mr ON mr.message_id = m.id AND mr.recipient_type IN ('to', 'cc', 'bcc')
434434
JOIN participants p ON p.id = mr.participant_id
435435
LEFT JOIN (
436436
SELECT message_id, SUM(size) as att_size, COUNT(*) as att_count
@@ -686,7 +686,7 @@ func (e *SQLiteEngine) AggregateByRecipientName(ctx context.Context, opts Aggreg
686686
COALESCE(SUM(att.att_count), 0) as attachment_count,
687687
COUNT(*) OVER() as total_unique
688688
FROM messages m
689-
JOIN message_recipients mr ON mr.message_id = m.id AND mr.recipient_type IN ('to', 'cc')
689+
JOIN message_recipients mr ON mr.message_id = m.id AND mr.recipient_type IN ('to', 'cc', 'bcc')
690690
JOIN participants p ON p.id = mr.participant_id
691691
LEFT JOIN (
692692
SELECT message_id, SUM(size) as att_size, COUNT(*) as att_count
@@ -949,7 +949,7 @@ func (e *SQLiteEngine) ListMessages(ctx context.Context, filter MessageFilter) (
949949
`)
950950
} else if filter.Recipient == "" && !filter.MatchEmptyRecipient {
951951
joins = append(joins, `
952-
JOIN message_recipients mr_to ON mr_to.message_id = m.id AND mr_to.recipient_type IN ('to', 'cc')
952+
JOIN message_recipients mr_to ON mr_to.message_id = m.id AND mr_to.recipient_type IN ('to', 'cc', 'bcc')
953953
JOIN participants p_to ON p_to.id = mr_to.participant_id
954954
`)
955955
}
@@ -960,7 +960,7 @@ func (e *SQLiteEngine) ListMessages(ctx context.Context, filter MessageFilter) (
960960
SELECT 1 FROM message_recipients mr_rn
961961
JOIN participants p_rn ON p_rn.id = mr_rn.participant_id
962962
WHERE mr_rn.message_id = m.id
963-
AND mr_rn.recipient_type IN ('to', 'cc')
963+
AND mr_rn.recipient_type IN ('to', 'cc', 'bcc')
964964
AND COALESCE(NULLIF(TRIM(p_rn.display_name), ''), p_rn.email_address) IS NOT NULL
965965
)`)
966966
}
@@ -1575,7 +1575,7 @@ func (e *SQLiteEngine) GetGmailIDsByFilter(ctx context.Context, filter MessageFi
15751575
// have a standalone MatchEmptyRecipient handler, so mr_to may
15761576
// not exist yet.
15771577
joins = append(joins, `
1578-
JOIN message_recipients mr_to ON mr_to.message_id = m.id AND mr_to.recipient_type IN ('to', 'cc')
1578+
JOIN message_recipients mr_to ON mr_to.message_id = m.id AND mr_to.recipient_type IN ('to', 'cc', 'bcc')
15791579
JOIN participants p_to ON p_to.id = mr_to.participant_id
15801580
`)
15811581
}

internal/query/sqlite_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2522,3 +2522,171 @@ func TestRecipientAndRecipientNameAndMatchEmptyRecipient(t *testing.T) {
25222522
t.Errorf("SubAggregate: unexpected rows: %v", rows)
25232523
}
25242524
}
2525+
2526+
// TestRecipientNameFilter_IncludesBCC verifies that RecipientName filter includes BCC recipients.
2527+
// This is a regression test for a bug where RecipientName only searched 'to' and 'cc' but not 'bcc'.
2528+
func TestRecipientNameFilter_IncludesBCC(t *testing.T) {
2529+
// Setup: Create a fresh database with a BCC recipient
2530+
db, err := sql.Open("sqlite3", ":memory:")
2531+
if err != nil {
2532+
t.Fatalf("open db: %v", err)
2533+
}
2534+
defer db.Close()
2535+
2536+
schema := `
2537+
CREATE TABLE sources (
2538+
id INTEGER PRIMARY KEY,
2539+
source_type TEXT NOT NULL,
2540+
identifier TEXT NOT NULL
2541+
);
2542+
CREATE TABLE participants (
2543+
id INTEGER PRIMARY KEY,
2544+
email_address TEXT,
2545+
display_name TEXT,
2546+
domain TEXT
2547+
);
2548+
CREATE TABLE conversations (
2549+
id INTEGER PRIMARY KEY,
2550+
source_id INTEGER NOT NULL,
2551+
source_conversation_id TEXT,
2552+
conversation_type TEXT NOT NULL,
2553+
title TEXT
2554+
);
2555+
CREATE TABLE messages (
2556+
id INTEGER PRIMARY KEY,
2557+
conversation_id INTEGER NOT NULL,
2558+
source_id INTEGER NOT NULL,
2559+
source_message_id TEXT,
2560+
message_type TEXT NOT NULL,
2561+
sent_at DATETIME,
2562+
subject TEXT,
2563+
snippet TEXT,
2564+
size_estimate INTEGER,
2565+
has_attachments BOOLEAN DEFAULT FALSE,
2566+
attachment_count INTEGER DEFAULT 0,
2567+
deleted_from_source_at DATETIME
2568+
);
2569+
CREATE TABLE message_bodies (
2570+
message_id INTEGER PRIMARY KEY,
2571+
body_text TEXT,
2572+
body_html TEXT
2573+
);
2574+
CREATE TABLE message_recipients (
2575+
id INTEGER PRIMARY KEY,
2576+
message_id INTEGER NOT NULL,
2577+
participant_id INTEGER NOT NULL,
2578+
recipient_type TEXT NOT NULL,
2579+
display_name TEXT
2580+
);
2581+
CREATE TABLE message_labels (
2582+
message_id INTEGER NOT NULL,
2583+
label_id INTEGER NOT NULL,
2584+
PRIMARY KEY (message_id, label_id)
2585+
);
2586+
CREATE TABLE labels (
2587+
id INTEGER PRIMARY KEY,
2588+
source_id INTEGER,
2589+
name TEXT NOT NULL,
2590+
label_type TEXT
2591+
);
2592+
CREATE TABLE attachments (
2593+
id INTEGER PRIMARY KEY,
2594+
message_id INTEGER NOT NULL,
2595+
filename TEXT,
2596+
size INTEGER,
2597+
storage_path TEXT
2598+
);
2599+
`
2600+
if _, err := db.Exec(schema); err != nil {
2601+
t.Fatalf("create schema: %v", err)
2602+
}
2603+
2604+
// Insert test data: one message with a BCC recipient "Secret Bob"
2605+
testData := `
2606+
INSERT INTO sources (id, source_type, identifier) VALUES (1, 'gmail', 'test@gmail.com');
2607+
INSERT INTO participants (id, email_address, display_name, domain) VALUES
2608+
(1, 'alice@example.com', 'Alice Sender', 'example.com'),
2609+
(2, 'bob@example.com', 'Bob ToRecipient', 'example.com'),
2610+
(3, 'secret@example.com', 'Secret Bob', 'example.com');
2611+
INSERT INTO conversations (id, source_id, source_conversation_id, conversation_type, title)
2612+
VALUES (1, 1, 'thread1', 'email_thread', 'Test Thread');
2613+
INSERT INTO messages (id, conversation_id, source_id, source_message_id, message_type, sent_at, subject, snippet, size_estimate)
2614+
VALUES (1, 1, 1, 'msg1', 'email', '2024-01-15 10:00:00', 'Test Subject', 'Preview', 1000);
2615+
INSERT INTO message_bodies (message_id, body_text) VALUES (1, 'Test body');
2616+
-- From Alice
2617+
INSERT INTO message_recipients (message_id, participant_id, recipient_type, display_name) VALUES (1, 1, 'from', 'Alice Sender');
2618+
-- To Bob
2619+
INSERT INTO message_recipients (message_id, participant_id, recipient_type, display_name) VALUES (1, 2, 'to', 'Bob ToRecipient');
2620+
-- BCC Secret Bob - this should be findable by RecipientName filter
2621+
INSERT INTO message_recipients (message_id, participant_id, recipient_type, display_name) VALUES (1, 3, 'bcc', 'Secret Bob');
2622+
`
2623+
if _, err := db.Exec(testData); err != nil {
2624+
t.Fatalf("insert test data: %v", err)
2625+
}
2626+
2627+
engine := NewSQLiteEngine(db)
2628+
ctx := context.Background()
2629+
2630+
// Test 1: ListMessages with RecipientName filter should find the BCC recipient
2631+
t.Run("ListMessages_RecipientName_BCC", func(t *testing.T) {
2632+
messages, err := engine.ListMessages(ctx, MessageFilter{RecipientName: "Secret Bob"})
2633+
if err != nil {
2634+
t.Fatalf("ListMessages: %v", err)
2635+
}
2636+
if len(messages) != 1 {
2637+
t.Errorf("expected 1 message with BCC recipient 'Secret Bob', got %d", len(messages))
2638+
}
2639+
})
2640+
2641+
// Test 2: AggregateByRecipientName should include BCC recipients
2642+
t.Run("AggregateByRecipientName_BCC", func(t *testing.T) {
2643+
rows, err := engine.AggregateByRecipientName(ctx, AggregateOptions{Limit: 100})
2644+
if err != nil {
2645+
t.Fatalf("AggregateByRecipientName: %v", err)
2646+
}
2647+
// Should have both "Bob ToRecipient" and "Secret Bob"
2648+
found := false
2649+
for _, row := range rows {
2650+
if row.Key == "Secret Bob" {
2651+
found = true
2652+
break
2653+
}
2654+
}
2655+
if !found {
2656+
t.Errorf("expected BCC recipient 'Secret Bob' in aggregate, got: %v", rows)
2657+
}
2658+
})
2659+
2660+
// Test 3: SubAggregate with RecipientName filter should find BCC
2661+
t.Run("SubAggregate_RecipientName_BCC", func(t *testing.T) {
2662+
rows, err := engine.SubAggregate(ctx, MessageFilter{RecipientName: "Secret Bob"}, ViewSenders, AggregateOptions{Limit: 100})
2663+
if err != nil {
2664+
t.Fatalf("SubAggregate: %v", err)
2665+
}
2666+
if len(rows) != 1 || rows[0].Key != "alice@example.com" {
2667+
t.Errorf("expected sender Alice for message with BCC 'Secret Bob', got: %v", rows)
2668+
}
2669+
})
2670+
2671+
// Test 4: GetGmailIDsByFilter with RecipientName should find BCC
2672+
t.Run("GetGmailIDsByFilter_RecipientName_BCC", func(t *testing.T) {
2673+
ids, err := engine.GetGmailIDsByFilter(ctx, MessageFilter{RecipientName: "Secret Bob"})
2674+
if err != nil {
2675+
t.Fatalf("GetGmailIDsByFilter: %v", err)
2676+
}
2677+
if len(ids) != 1 || ids[0] != "msg1" {
2678+
t.Errorf("expected gmail ID 'msg1' for BCC recipient, got: %v", ids)
2679+
}
2680+
})
2681+
2682+
// Test 5: For comparison, Recipient filter (by email) should also find BCC (this worked before)
2683+
t.Run("ListMessages_Recipient_BCC", func(t *testing.T) {
2684+
messages, err := engine.ListMessages(ctx, MessageFilter{Recipient: "secret@example.com"})
2685+
if err != nil {
2686+
t.Fatalf("ListMessages: %v", err)
2687+
}
2688+
if len(messages) != 1 {
2689+
t.Errorf("expected 1 message with BCC recipient email, got %d", len(messages))
2690+
}
2691+
})
2692+
}

internal/tui/view.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,12 @@ func (m Model) buildDetailLines() []string {
719719
lines = append(lines, fmt.Sprintf("Cc: %s", cc))
720720
}
721721

722+
// Bcc
723+
if len(msg.Bcc) > 0 {
724+
bcc := formatAddresses(msg.Bcc)
725+
lines = append(lines, fmt.Sprintf("Bcc: %s", bcc))
726+
}
727+
722728
// Labels
723729
if len(msg.Labels) > 0 {
724730
lines = append(lines, fmt.Sprintf("Labels: %s", strings.Join(msg.Labels, ", ")))

0 commit comments

Comments
 (0)