Skip to content

Commit 1031b18

Browse files
committed
EmailListItem fixes
- It looks pretty much like Gmail now! - Needed a few back-end changes - Tests added - Docs updated
1 parent 6af24ad commit 1031b18

7 files changed

Lines changed: 674 additions & 85 deletions

File tree

backend/internal/db/threads.go

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ func GetThreadByID(ctx context.Context, pool *pgxpool.Pool, threadID string) (*m
8989

9090
// GetThreadsForFolder returns threads for a specific folder.
9191
// It returns threads that have at least one message in the specified folder.
92+
// Each thread includes message_count (number of messages), last_sent_at (most recent message date),
93+
// preview_snippet, has_attachments, and first_message_from_address for efficient list view rendering.
9294
func GetThreadsForFolder(ctx context.Context, pool *pgxpool.Pool, userID, folderName string, limit, offset int) ([]*models.Thread, error) {
9395
rows, err := pool.Query(ctx, `
9496
SELECT
@@ -101,7 +103,20 @@ func GetThreadsForFolder(ctx context.Context, pool *pgxpool.Pool, userID, folder
101103
FROM messages m3
102104
WHERE m3.thread_id = t.id
103105
ORDER BY m3.sent_at NULLS LAST
104-
LIMIT 1) AS first_message_from_address
106+
LIMIT 1) AS first_message_from_address,
107+
(SELECT LEFT(m4.body_text, 100)
108+
FROM messages m4
109+
WHERE m4.thread_id = t.id
110+
ORDER BY m4.sent_at NULLS LAST
111+
LIMIT 1) AS preview_snippet,
112+
EXISTS (
113+
SELECT 1
114+
FROM attachments a
115+
INNER JOIN messages m5 ON a.message_id = m5.id
116+
WHERE m5.thread_id = t.id
117+
AND a.is_inline = false
118+
) AS has_attachments,
119+
COUNT(DISTINCT m.id) AS message_count
105120
FROM threads t
106121
INNER JOIN messages m ON t.id = m.thread_id
107122
LEFT JOIN messages m2 ON m2.thread_id = t.id
@@ -119,21 +134,33 @@ func GetThreadsForFolder(ctx context.Context, pool *pgxpool.Pool, userID, folder
119134
var threads []*models.Thread
120135
for rows.Next() {
121136
var thread models.Thread
122-
var _lastSentAt *time.Time
137+
var lastSentAt *time.Time
123138
var firstMessageFromAddress *string
139+
var previewSnippet *string
140+
var hasAttachments bool
141+
var messageCount int
124142
if err := rows.Scan(
125143
&thread.ID,
126144
&thread.UserID,
127145
&thread.StableThreadID,
128146
&thread.Subject,
129-
&_lastSentAt,
147+
&lastSentAt,
130148
&firstMessageFromAddress,
149+
&previewSnippet,
150+
&hasAttachments,
151+
&messageCount,
131152
); err != nil {
132153
return nil, fmt.Errorf("failed to scan thread: %w", err)
133154
}
134155
if firstMessageFromAddress != nil {
135156
thread.FirstMessageFromAddress = *firstMessageFromAddress
136157
}
158+
if previewSnippet != nil {
159+
thread.PreviewSnippet = *previewSnippet
160+
}
161+
thread.HasAttachments = hasAttachments
162+
thread.MessageCount = messageCount
163+
thread.LastSentAt = lastSentAt
137164
threads = append(threads, &thread)
138165
}
139166

@@ -295,3 +322,72 @@ func EnrichThreadsWithFirstMessageFromAddress(ctx context.Context, pool *pgxpool
295322

296323
return nil
297324
}
325+
326+
// EnrichThreadsWithPreviewAndAttachments enriches threads with preview snippet, attachment info,
327+
// message count, and last sent date. This is useful for search results and other cases where
328+
// threads don't have these fields populated.
329+
func EnrichThreadsWithPreviewAndAttachments(ctx context.Context, pool *pgxpool.Pool, threads []*models.Thread) error {
330+
if len(threads) == 0 {
331+
return nil
332+
}
333+
334+
// Build a map of thread IDs for efficient lookup
335+
threadIDMap := make(map[string]*models.Thread)
336+
threadIDs := make([]string, 0, len(threads))
337+
for _, thread := range threads {
338+
threadIDMap[thread.ID] = thread
339+
threadIDs = append(threadIDs, thread.ID)
340+
}
341+
342+
// Query preview snippets, attachment flags, message count, and last sent date in one query
343+
rows, err := pool.Query(ctx, `
344+
SELECT
345+
t.id,
346+
(SELECT LEFT(m.body_text, 100)
347+
FROM messages m
348+
WHERE m.thread_id = t.id
349+
ORDER BY m.sent_at NULLS LAST
350+
LIMIT 1) AS preview_snippet,
351+
EXISTS (
352+
SELECT 1
353+
FROM attachments a
354+
INNER JOIN messages m2 ON a.message_id = m2.id
355+
WHERE m2.thread_id = t.id
356+
AND a.is_inline = false
357+
) AS has_attachments,
358+
(SELECT COUNT(*) FROM messages m3 WHERE m3.thread_id = t.id) AS message_count,
359+
(SELECT MAX(m4.sent_at) FROM messages m4 WHERE m4.thread_id = t.id) AS last_sent_at
360+
FROM threads t
361+
WHERE t.id = ANY($1)
362+
`, threadIDs)
363+
364+
if err != nil {
365+
return fmt.Errorf("failed to get preview and attachment info: %w", err)
366+
}
367+
defer rows.Close()
368+
369+
for rows.Next() {
370+
var threadID string
371+
var previewSnippet *string
372+
var hasAttachments bool
373+
var messageCount int
374+
var lastSentAt *time.Time
375+
if err := rows.Scan(&threadID, &previewSnippet, &hasAttachments, &messageCount, &lastSentAt); err != nil {
376+
return fmt.Errorf("failed to scan preview and attachment info: %w", err)
377+
}
378+
if thread, exists := threadIDMap[threadID]; exists {
379+
if previewSnippet != nil {
380+
thread.PreviewSnippet = *previewSnippet
381+
}
382+
thread.HasAttachments = hasAttachments
383+
thread.MessageCount = messageCount
384+
thread.LastSentAt = lastSentAt
385+
}
386+
}
387+
388+
if err := rows.Err(); err != nil {
389+
return fmt.Errorf("error iterating preview and attachment info: %w", err)
390+
}
391+
392+
return nil
393+
}

backend/internal/imap/search.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,12 @@ func (s *Service) Search(ctx context.Context, userID string, query string, page,
415415
// Continue anyway - threads will work without the from_address
416416
}
417417

418+
// Enrich threads with preview snippet and attachment info
419+
if err := db.EnrichThreadsWithPreviewAndAttachments(ctx, s.dbPool, threads); err != nil {
420+
log.Printf("Warning: Failed to enrich threads with preview and attachment info: %v", err)
421+
// Continue anyway - threads will work without these fields
422+
}
423+
418424
return nil
419425
})
420426

backend/internal/models/email.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@ type Folder struct {
1313
// The StableThreadID is the Message-ID header of the root message, which allows
1414
// us to group messages from different folders (e.g., 'INBOX' and 'Sent') into a single thread.
1515
type Thread struct {
16-
ID string `json:"id"`
17-
StableThreadID string `json:"stable_thread_id"`
18-
Subject string `json:"subject"`
19-
UserID string `json:"user_id"`
20-
FirstMessageFromAddress string `json:"first_message_from_address,omitempty"`
21-
Messages []Message `json:"messages,omitempty"`
16+
ID string `json:"id"`
17+
StableThreadID string `json:"stable_thread_id"`
18+
Subject string `json:"subject"`
19+
UserID string `json:"user_id"`
20+
FirstMessageFromAddress string `json:"first_message_from_address,omitempty"`
21+
PreviewSnippet string `json:"preview_snippet,omitempty"`
22+
HasAttachments bool `json:"has_attachments"`
23+
MessageCount int `json:"message_count,omitempty"`
24+
LastSentAt *time.Time `json:"last_sent_at,omitempty"`
25+
Messages []Message `json:"messages,omitempty"`
2226
}
2327

2428
// Message represents a single email message.

docs/backend/threads.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ It's intentionally not organized into a single package so that API-level functio
4343
* If sync fails, continues and returns cached data (graceful degradation).
4444
* Sync errors are logged but don't fail the request.
4545

46+
## Thread Fields
47+
48+
The `GetThreadsForFolder` function returns threads with the following fields populated for list views:
49+
50+
* **`message_count`**: Number of messages in the thread. Always populated in list views to avoid needing to load the full messages array.
51+
* **`last_sent_at`**: Date/time of the most recent message in the thread. Used for date display in the email list (shows time if today, otherwise shows day).
52+
* **`preview_snippet`**: First 100 characters of the first message's body text, with whitespace normalized. Used for email preview in the list view.
53+
* **`has_attachments`**: Boolean indicating if any messages in the thread have non-inline attachments. Used to display attachment indicator (📎) in the list view.
54+
* **`first_message_from_address`**: Sender address of the first message in the thread. Used to display the sender name in the list view.
55+
56+
The `EnrichThreadsWithPreviewAndAttachments` function also populates these fields for search results and other cases where threads don't have them pre-populated.
57+
4658
## Error handling
4759

4860
* Returns 400 if folder parameter is missing.

0 commit comments

Comments
 (0)