Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 99 additions & 3 deletions backend/internal/db/threads.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ func GetThreadByID(ctx context.Context, pool *pgxpool.Pool, threadID string) (*m

// GetThreadsForFolder returns threads for a specific folder.
// It returns threads that have at least one message in the specified folder.
// Each thread includes message_count (number of messages), last_sent_at (most recent message date),
// preview_snippet, has_attachments, and first_message_from_address for efficient list view rendering.
func GetThreadsForFolder(ctx context.Context, pool *pgxpool.Pool, userID, folderName string, limit, offset int) ([]*models.Thread, error) {
rows, err := pool.Query(ctx, `
SELECT
Expand All @@ -101,7 +103,20 @@ func GetThreadsForFolder(ctx context.Context, pool *pgxpool.Pool, userID, folder
FROM messages m3
WHERE m3.thread_id = t.id
ORDER BY m3.sent_at NULLS LAST
LIMIT 1) AS first_message_from_address
LIMIT 1) AS first_message_from_address,
(SELECT LEFT(m4.body_text, 100)
FROM messages m4
WHERE m4.thread_id = t.id
ORDER BY m4.sent_at NULLS LAST
LIMIT 1) AS preview_snippet,
EXISTS (
SELECT 1
FROM attachments a
INNER JOIN messages m5 ON a.message_id = m5.id
WHERE m5.thread_id = t.id
AND a.is_inline = false
) AS has_attachments,
COUNT(DISTINCT m.id) AS message_count
FROM threads t
INNER JOIN messages m ON t.id = m.thread_id
LEFT JOIN messages m2 ON m2.thread_id = t.id
Expand All @@ -119,21 +134,33 @@ func GetThreadsForFolder(ctx context.Context, pool *pgxpool.Pool, userID, folder
var threads []*models.Thread
for rows.Next() {
var thread models.Thread
var _lastSentAt *time.Time
var lastSentAt *time.Time
var firstMessageFromAddress *string
var previewSnippet *string
var hasAttachments bool
var messageCount int
if err := rows.Scan(
&thread.ID,
&thread.UserID,
&thread.StableThreadID,
&thread.Subject,
&_lastSentAt,
&lastSentAt,
&firstMessageFromAddress,
&previewSnippet,
&hasAttachments,
&messageCount,
); err != nil {
return nil, fmt.Errorf("failed to scan thread: %w", err)
}
if firstMessageFromAddress != nil {
thread.FirstMessageFromAddress = *firstMessageFromAddress
}
if previewSnippet != nil {
thread.PreviewSnippet = *previewSnippet
}
thread.HasAttachments = hasAttachments
thread.MessageCount = messageCount
thread.LastSentAt = lastSentAt
threads = append(threads, &thread)
}

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

return nil
}

// EnrichThreadsWithPreviewAndAttachments enriches threads with preview snippet, attachment info,
// message count, and last sent date. This is useful for search results and other cases where
// threads don't have these fields populated.
func EnrichThreadsWithPreviewAndAttachments(ctx context.Context, pool *pgxpool.Pool, threads []*models.Thread) error {
if len(threads) == 0 {
return nil
}

// Build a map of thread IDs for efficient lookup
threadIDMap := make(map[string]*models.Thread)
threadIDs := make([]string, 0, len(threads))
for _, thread := range threads {
threadIDMap[thread.ID] = thread
threadIDs = append(threadIDs, thread.ID)
}

// Query preview snippets, attachment flags, message count, and last sent date in one query
rows, err := pool.Query(ctx, `
SELECT
t.id,
(SELECT LEFT(m.body_text, 100)
FROM messages m
WHERE m.thread_id = t.id
ORDER BY m.sent_at NULLS LAST
LIMIT 1) AS preview_snippet,
EXISTS (
SELECT 1
FROM attachments a
INNER JOIN messages m2 ON a.message_id = m2.id
WHERE m2.thread_id = t.id
AND a.is_inline = false
) AS has_attachments,
(SELECT COUNT(*) FROM messages m3 WHERE m3.thread_id = t.id) AS message_count,
(SELECT MAX(m4.sent_at) FROM messages m4 WHERE m4.thread_id = t.id) AS last_sent_at
FROM threads t
WHERE t.id = ANY($1)
`, threadIDs)

if err != nil {
return fmt.Errorf("failed to get preview and attachment info: %w", err)
}
defer rows.Close()

for rows.Next() {
var threadID string
var previewSnippet *string
var hasAttachments bool
var messageCount int
var lastSentAt *time.Time
if err := rows.Scan(&threadID, &previewSnippet, &hasAttachments, &messageCount, &lastSentAt); err != nil {
return fmt.Errorf("failed to scan preview and attachment info: %w", err)
}
if thread, exists := threadIDMap[threadID]; exists {
if previewSnippet != nil {
thread.PreviewSnippet = *previewSnippet
}
thread.HasAttachments = hasAttachments
thread.MessageCount = messageCount
thread.LastSentAt = lastSentAt
}
}

if err := rows.Err(); err != nil {
return fmt.Errorf("error iterating preview and attachment info: %w", err)
}

return nil
}
6 changes: 6 additions & 0 deletions backend/internal/imap/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,12 @@ func (s *Service) Search(ctx context.Context, userID string, query string, page,
// Continue anyway - threads will work without the from_address
}

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

return nil
})

Expand Down
16 changes: 10 additions & 6 deletions backend/internal/models/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ type Folder struct {
// The StableThreadID is the Message-ID header of the root message, which allows
// us to group messages from different folders (e.g., 'INBOX' and 'Sent') into a single thread.
type Thread struct {
ID string `json:"id"`
StableThreadID string `json:"stable_thread_id"`
Subject string `json:"subject"`
UserID string `json:"user_id"`
FirstMessageFromAddress string `json:"first_message_from_address,omitempty"`
Messages []Message `json:"messages,omitempty"`
ID string `json:"id"`
StableThreadID string `json:"stable_thread_id"`
Subject string `json:"subject"`
UserID string `json:"user_id"`
FirstMessageFromAddress string `json:"first_message_from_address,omitempty"`
PreviewSnippet string `json:"preview_snippet,omitempty"`
HasAttachments bool `json:"has_attachments"`
MessageCount int `json:"message_count,omitempty"`
LastSentAt *time.Time `json:"last_sent_at,omitempty"`
Messages []Message `json:"messages,omitempty"`
}

// Message represents a single email message.
Expand Down
12 changes: 12 additions & 0 deletions docs/backend/threads.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ It's intentionally not organized into a single package so that API-level functio
* If sync fails, continues and returns cached data (graceful degradation).
* Sync errors are logged but don't fail the request.

## Thread Fields

The `GetThreadsForFolder` function returns threads with the following fields populated for list views:

* **`message_count`**: Number of messages in the thread. Always populated in list views to avoid needing to load the full messages array.
* **`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).
* **`preview_snippet`**: First 100 characters of the first message's body text, with whitespace normalized. Used for email preview in the list view.
* **`has_attachments`**: Boolean indicating if any messages in the thread have non-inline attachments. Used to display attachment indicator (📎) in the list view.
* **`first_message_from_address`**: Sender address of the first message in the thread. Used to display the sender name in the list view.

The `EnrichThreadsWithPreviewAndAttachments` function also populates these fields for search results and other cases where threads don't have them pre-populated.

## Error handling

* Returns 400 if folder parameter is missing.
Expand Down
Loading
Loading