Skip to content

Commit b2e89c6

Browse files
danshapiroDan Shapiroclaudewesm
authored
feat: expose CC and BCC recipients in message API (#242)
## Summary - The `message_recipients` table already stores `cc` and `bcc` recipient types from Gmail sync (215K CC rows, 4.5K BCC rows in production), but the API only returned `to` recipients - Adds `cc` and `bcc` fields to `MessageSummary` (omitted from JSON when empty) - Populates them via the existing `batchGetRecipients` / `getRecipients` helpers for both list and detail endpoints - Refactors `ListMessages` to use `batchPopulate` (like `SearchMessages` already does) to avoid duplicating batch-loading logic ## Test plan - [x] All existing tests pass (`go test ./internal/store/... ./internal/api/...`) - [ ] Verify CC/BCC appears in `/api/v1/messages` list response for messages with CC recipients - [ ] Verify CC/BCC appears in `/api/v1/messages/{id}` detail response - [ ] Verify messages without CC/BCC omit the fields (omitempty) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Dan Shapiro <dan@example.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com>
1 parent ab87f11 commit b2e89c6

File tree

4 files changed

+261
-14
lines changed

4 files changed

+261
-14
lines changed

internal/api/handlers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ type MessageSummary struct {
7272
Subject string `json:"subject"`
7373
From string `json:"from"`
7474
To []string `json:"to"`
75+
Cc []string `json:"cc,omitempty"`
76+
Bcc []string `json:"bcc,omitempty"`
7577
SentAt string `json:"sent_at"`
7678
DeletedAt string `json:"deleted_at,omitempty"`
7779
Snippet string `json:"snippet"`
@@ -131,6 +133,8 @@ func toMessageSummary(m APIMessage) MessageSummary {
131133
Subject: m.Subject,
132134
From: m.From,
133135
To: to,
136+
Cc: m.Cc,
137+
Bcc: m.Bcc,
134138
SentAt: m.SentAt.UTC().Format(time.RFC3339),
135139
DeletedAt: formatDeletedAt(m.DeletedAt),
136140
Snippet: m.Snippet,

internal/api/handlers_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http/httptest"
88
"os"
99
"path/filepath"
10+
"slices"
1011
"strings"
1112
"testing"
1213
"time"
@@ -375,6 +376,106 @@ func TestMessageSummaryNilSlices(t *testing.T) {
375376
}
376377
}
377378

379+
func TestMessageSummaryCcBccInResponse(t *testing.T) {
380+
srv, ms := newTestServerWithMockStore(t)
381+
ms.messages[0].Cc = []string{"cc1@example.com", "cc2@example.com"}
382+
ms.messages[0].Bcc = []string{"bcc@example.com"}
383+
384+
req := httptest.NewRequest("GET", "/api/v1/messages", nil)
385+
w := httptest.NewRecorder()
386+
srv.Router().ServeHTTP(w, req)
387+
388+
if w.Code != http.StatusOK {
389+
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
390+
}
391+
392+
var resp map[string]interface{}
393+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
394+
t.Fatalf("decode: %v", err)
395+
}
396+
397+
msg := resp["messages"].([]interface{})[0].(map[string]interface{})
398+
399+
ccRaw, ok := msg["cc"].([]interface{})
400+
if !ok {
401+
t.Fatalf("expected 'cc' array, got %T", msg["cc"])
402+
}
403+
var gotCc []string
404+
for _, v := range ccRaw {
405+
gotCc = append(gotCc, v.(string))
406+
}
407+
slices.Sort(gotCc)
408+
wantCc := []string{"cc1@example.com", "cc2@example.com"}
409+
if !slices.Equal(gotCc, wantCc) {
410+
t.Errorf("cc = %v, want %v", gotCc, wantCc)
411+
}
412+
413+
bcc, ok := msg["bcc"].([]interface{})
414+
if !ok {
415+
t.Fatalf("expected 'bcc' array, got %T", msg["bcc"])
416+
}
417+
if len(bcc) != 1 || bcc[0] != "bcc@example.com" {
418+
t.Errorf("bcc = %v, want [bcc@example.com]", bcc)
419+
}
420+
}
421+
422+
func TestMessageSummaryCcBccOmittedWhenEmpty(t *testing.T) {
423+
srv, _ := newTestServerWithMockStore(t)
424+
425+
req := httptest.NewRequest("GET", "/api/v1/messages", nil)
426+
w := httptest.NewRecorder()
427+
srv.Router().ServeHTTP(w, req)
428+
429+
// Parse raw JSON to check field presence
430+
var raw map[string]json.RawMessage
431+
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
432+
t.Fatalf("decode: %v", err)
433+
}
434+
435+
var messages []json.RawMessage
436+
if err := json.Unmarshal(raw["messages"], &messages); err != nil {
437+
t.Fatalf("decode messages: %v", err)
438+
}
439+
440+
var msg map[string]json.RawMessage
441+
if err := json.Unmarshal(messages[0], &msg); err != nil {
442+
t.Fatalf("decode message: %v", err)
443+
}
444+
445+
if _, exists := msg["cc"]; exists {
446+
t.Error("expected 'cc' to be omitted from JSON when empty")
447+
}
448+
if _, exists := msg["bcc"]; exists {
449+
t.Error("expected 'bcc' to be omitted from JSON when empty")
450+
}
451+
}
452+
453+
func TestGetMessageCcBccInResponse(t *testing.T) {
454+
srv, ms := newTestServerWithMockStore(t)
455+
ms.messages[0].Cc = []string{"cc@example.com"}
456+
ms.messages[0].Bcc = []string{"bcc@example.com"}
457+
458+
req := httptest.NewRequest("GET", "/api/v1/messages/1", nil)
459+
w := httptest.NewRecorder()
460+
srv.Router().ServeHTTP(w, req)
461+
462+
if w.Code != http.StatusOK {
463+
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
464+
}
465+
466+
var resp MessageDetail
467+
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
468+
t.Fatalf("decode: %v", err)
469+
}
470+
471+
if len(resp.Cc) != 1 || resp.Cc[0] != "cc@example.com" {
472+
t.Errorf("cc = %v, want [cc@example.com]", resp.Cc)
473+
}
474+
if len(resp.Bcc) != 1 || resp.Bcc[0] != "bcc@example.com" {
475+
t.Errorf("bcc = %v, want [bcc@example.com]", resp.Bcc)
476+
}
477+
}
478+
378479
func TestHandleUploadToken(t *testing.T) {
379480
// Create temp directory for tokens
380481
tmpDir, err := os.MkdirTemp("", "msgvault-test-tokens-*")

internal/store/api.go

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ type APIMessage struct {
1414
Subject string
1515
From string
1616
To []string
17+
Cc []string
18+
Bcc []string
1719
SentAt time.Time
1820
Snippet string
1921
Labels []string
@@ -76,23 +78,11 @@ func (s *Store) ListMessages(offset, limit int) ([]APIMessage, int64, error) {
7678
return messages, total, nil
7779
}
7880

79-
// Batch-load recipients for all messages
80-
recipientMap, err := s.batchGetRecipients(ids, "to")
81-
if err != nil {
82-
return nil, 0, err
83-
}
84-
85-
// Batch-load labels for all messages
86-
labelMap, err := s.batchGetLabels(ids)
87-
if err != nil {
81+
// Batch-load recipients and labels for all messages
82+
if err := s.batchPopulate(messages, ids); err != nil {
8883
return nil, 0, err
8984
}
9085

91-
for i := range messages {
92-
messages[i].To = recipientMap[messages[i].ID]
93-
messages[i].Labels = labelMap[messages[i].ID]
94-
}
95-
9686
return messages, total, nil
9787
}
9888

@@ -139,6 +129,14 @@ func (s *Store) GetMessage(id int64) (*APIMessage, error) {
139129
if err != nil {
140130
return nil, err
141131
}
132+
m.Cc, err = s.getRecipients(m.ID, "cc")
133+
if err != nil {
134+
return nil, err
135+
}
136+
m.Bcc, err = s.getRecipients(m.ID, "bcc")
137+
if err != nil {
138+
return nil, err
139+
}
142140

143141
// Get labels (single message, per-row is fine)
144142
m.Labels, err = s.getLabels(m.ID)
@@ -353,12 +351,22 @@ func (s *Store) batchPopulate(messages []APIMessage, ids []int64) error {
353351
if err != nil {
354352
return err
355353
}
354+
ccMap, err := s.batchGetRecipients(ids, "cc")
355+
if err != nil {
356+
return err
357+
}
358+
bccMap, err := s.batchGetRecipients(ids, "bcc")
359+
if err != nil {
360+
return err
361+
}
356362
labelMap, err := s.batchGetLabels(ids)
357363
if err != nil {
358364
return err
359365
}
360366
for i := range messages {
361367
messages[i].To = recipientMap[messages[i].ID]
368+
messages[i].Cc = ccMap[messages[i].ID]
369+
messages[i].Bcc = bccMap[messages[i].ID]
362370
messages[i].Labels = labelMap[messages[i].ID]
363371
}
364372
return nil

internal/store/api_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package store
33
import (
44
"database/sql"
55
"path/filepath"
6+
"slices"
67
"testing"
78
"time"
89
)
@@ -163,6 +164,139 @@ func TestParseSQLiteTime(t *testing.T) {
163164
}
164165
}
165166

167+
func TestGetMessageCcBcc(t *testing.T) {
168+
st := openTestStore(t)
169+
170+
source, err := st.GetOrCreateSource("gmail", "test@example.com")
171+
if err != nil {
172+
t.Fatalf("GetOrCreateSource: %v", err)
173+
}
174+
convID, err := st.EnsureConversation(source.ID, "thread-1", "Thread")
175+
if err != nil {
176+
t.Fatalf("EnsureConversation: %v", err)
177+
}
178+
msgID := seedMessage(t, st, source.ID, convID, "msg-cc-bcc", "CC/BCC test", "snippet")
179+
180+
db := st.DB()
181+
182+
// Insert participants
183+
for _, p := range []struct {
184+
id int
185+
email string
186+
}{
187+
{1, "to@example.com"},
188+
{2, "cc1@example.com"},
189+
{3, "cc2@example.com"},
190+
{4, "bcc@example.com"},
191+
} {
192+
if _, err := db.Exec(
193+
`INSERT INTO participants (id, email_address, domain, created_at, updated_at)
194+
VALUES (?, ?, 'example.com', datetime('now'), datetime('now'))`,
195+
p.id, p.email,
196+
); err != nil {
197+
t.Fatalf("insert participant %s: %v", p.email, err)
198+
}
199+
}
200+
201+
// Insert message_recipients
202+
for _, r := range []struct {
203+
participantID int
204+
recipientType string
205+
}{
206+
{1, "to"},
207+
{2, "cc"},
208+
{3, "cc"},
209+
{4, "bcc"},
210+
} {
211+
if _, err := db.Exec(
212+
`INSERT INTO message_recipients (message_id, participant_id, recipient_type)
213+
VALUES (?, ?, ?)`,
214+
msgID, r.participantID, r.recipientType,
215+
); err != nil {
216+
t.Fatalf("insert recipient %s: %v", r.recipientType, err)
217+
}
218+
}
219+
220+
// Test GetMessage
221+
m, err := st.GetMessage(msgID)
222+
if err != nil {
223+
t.Fatalf("GetMessage: %v", err)
224+
}
225+
if len(m.To) != 1 || m.To[0] != "to@example.com" {
226+
t.Errorf("To = %v, want [to@example.com]", m.To)
227+
}
228+
gotCc := slices.Clone(m.Cc)
229+
slices.Sort(gotCc)
230+
wantCc := []string{"cc1@example.com", "cc2@example.com"}
231+
if !slices.Equal(gotCc, wantCc) {
232+
t.Errorf("Cc = %v, want %v", m.Cc, wantCc)
233+
}
234+
if len(m.Bcc) != 1 || m.Bcc[0] != "bcc@example.com" {
235+
t.Errorf("Bcc = %v, want [bcc@example.com]", m.Bcc)
236+
}
237+
}
238+
239+
func TestListMessagesCcBcc(t *testing.T) {
240+
st := openTestStore(t)
241+
242+
source, err := st.GetOrCreateSource("gmail", "test@example.com")
243+
if err != nil {
244+
t.Fatalf("GetOrCreateSource: %v", err)
245+
}
246+
convID, err := st.EnsureConversation(source.ID, "thread-1", "Thread")
247+
if err != nil {
248+
t.Fatalf("EnsureConversation: %v", err)
249+
}
250+
msgID := seedMessage(t, st, source.ID, convID, "msg-list-cc", "List CC test", "snippet")
251+
252+
db := st.DB()
253+
254+
// Insert CC and BCC participants
255+
for _, p := range []struct {
256+
id int
257+
email string
258+
}{
259+
{10, "cc@example.com"},
260+
{11, "bcc@example.com"},
261+
} {
262+
if _, err := db.Exec(
263+
`INSERT INTO participants (id, email_address, domain, created_at, updated_at)
264+
VALUES (?, ?, 'example.com', datetime('now'), datetime('now'))`,
265+
p.id, p.email,
266+
); err != nil {
267+
t.Fatalf("insert participant %s: %v", p.email, err)
268+
}
269+
}
270+
for _, r := range []struct {
271+
participantID int
272+
recipientType string
273+
}{
274+
{10, "cc"},
275+
{11, "bcc"},
276+
} {
277+
if _, err := db.Exec(
278+
`INSERT INTO message_recipients (message_id, participant_id, recipient_type)
279+
VALUES (?, ?, ?)`, msgID, r.participantID, r.recipientType,
280+
); err != nil {
281+
t.Fatalf("insert recipient %s: %v", r.recipientType, err)
282+
}
283+
}
284+
285+
messages, total, err := st.ListMessages(0, 100)
286+
if err != nil {
287+
t.Fatalf("ListMessages: %v", err)
288+
}
289+
if total != 1 {
290+
t.Fatalf("total = %d, want 1", total)
291+
}
292+
if len(messages[0].Cc) != 1 || messages[0].Cc[0] != "cc@example.com" {
293+
t.Errorf("Cc = %v, want [cc@example.com]", messages[0].Cc)
294+
}
295+
if len(messages[0].Bcc) != 1 || messages[0].Bcc[0] != "bcc@example.com" {
296+
t.Errorf("Bcc = %v, want [bcc@example.com]", messages[0].Bcc)
297+
}
298+
}
299+
166300
func TestSearchMessagesLikeLiteralWildcards(t *testing.T) {
167301
st := openTestStore(t)
168302

0 commit comments

Comments
 (0)