diff --git a/backend/internal/db/threads.go b/backend/internal/db/threads.go index a082932..0ed8120 100644 --- a/backend/internal/db/threads.go +++ b/backend/internal/db/threads.go @@ -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 @@ -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 @@ -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) } @@ -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 +} diff --git a/backend/internal/imap/search.go b/backend/internal/imap/search.go index 069edbf..ce00a23 100644 --- a/backend/internal/imap/search.go +++ b/backend/internal/imap/search.go @@ -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 }) diff --git a/backend/internal/models/email.go b/backend/internal/models/email.go index a1db1ac..685d6e8 100644 --- a/backend/internal/models/email.go +++ b/backend/internal/models/email.go @@ -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. diff --git a/docs/backend/threads.md b/docs/backend/threads.md index baacd76..1bdd5a1 100644 --- a/docs/backend/threads.md +++ b/docs/backend/threads.md @@ -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. diff --git a/frontend/src/components/EmailListItem.test.tsx b/frontend/src/components/EmailListItem.test.tsx new file mode 100644 index 0000000..d159dfd --- /dev/null +++ b/frontend/src/components/EmailListItem.test.tsx @@ -0,0 +1,420 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import { BrowserRouter } from 'react-router-dom' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +import type { Thread } from '../lib/api' + +import EmailListItem from './EmailListItem' + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) +} + +describe('EmailListItem', () => { + beforeEach(() => { + // Mock current date to be 2025-01-15 for consistent date formatting tests + vi.useFakeTimers() + vi.setSystemTime(new Date('2025-01-15T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('displays thread count when message_count > 1', () => { + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: false, + message_count: 3, + last_sent_at: '2025-01-15T10:00:00Z', + } + + render(, { wrapper: createWrapper() }) + + const senderElement = screen.getByTestId('email-sender') + expect(senderElement).toHaveTextContent('sender@example.com') + expect(senderElement).toHaveTextContent('3') + }) + + it('does not display thread count when message_count = 1', () => { + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: false, + message_count: 1, + last_sent_at: '2025-01-15T10:00:00Z', + } + + render(, { wrapper: createWrapper() }) + + const senderElement = screen.getByTestId('email-sender') + expect(senderElement).toHaveTextContent('sender@example.com') + expect(senderElement).not.toHaveTextContent('1') + }) + + it('displays formatted date from last_sent_at (time if today)', () => { + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: false, + message_count: 1, + last_sent_at: '2025-01-15T10:30:00Z', // Today + } + + const { container } = render(, { + wrapper: createWrapper(), + }) + + // Date should be in the last column (text-right text-xs text-slate-400) + // Should show time format - check that date column has content + const dateColumn = container.querySelector('.text-right.text-xs.text-slate-400') + expect(dateColumn).toBeInTheDocument() + expect(dateColumn?.textContent.trim()).toBeTruthy() + // Should contain time-related content (either "10" or "30" or ":" or "AM"/"PM") + expect(dateColumn?.textContent).toMatch(/10|30|:|AM|PM/i) + }) + + it('displays formatted date from last_sent_at (day if not today)', () => { + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: false, + message_count: 1, + last_sent_at: '2025-01-10T10:00:00Z', // 5 days ago + } + + render(, { wrapper: createWrapper() }) + + // Should show day format (e.g., "Jan 10", "10 Jan", "Jan. 10", etc.) + // Check that it contains "10" and month abbreviation + const dateElement = screen.getByText(/Jan.*10|10.*Jan/i) + expect(dateElement).toBeInTheDocument() + }) + + it('falls back to first message sent_at when last_sent_at is missing', () => { + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: false, + message_count: 1, + messages: [ + { + id: 'msg-1', + thread_id: '1', + user_id: 'user-1', + imap_uid: 1, + imap_folder_name: 'INBOX', + message_id_header: '', + from_address: 'sender@example.com', + to_addresses: [], + cc_addresses: [], + sent_at: '2025-01-15T11:00:00Z', + subject: 'Test Thread', + unsafe_body_html: '', + body_text: '', + is_read: false, + is_starred: false, + }, + ], + } + + const { container } = render(, { + wrapper: createWrapper(), + }) + + // Should show time from first message + const dateColumn = container.querySelector('.text-right.text-xs.text-slate-400') + expect(dateColumn).toBeInTheDocument() + expect(dateColumn?.textContent.trim()).toBeTruthy() + // Should contain time-related content (either "11" or ":" or "AM"/"PM") + expect(dateColumn?.textContent).toMatch(/11|:|AM|PM/i) + }) + + it('displays attachment marker when thread has attachments', () => { + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: true, + message_count: 3, + last_sent_at: '2025-01-15T10:00:00Z', + messages: [ + { + id: 'msg-1', + thread_id: '1', + user_id: 'user-1', + imap_uid: 1, + imap_folder_name: 'INBOX', + message_id_header: '', + from_address: 'sender@example.com', + to_addresses: [], + cc_addresses: [], + sent_at: '2025-01-15T08:00:00Z', + subject: 'Test Thread', + unsafe_body_html: '', + body_text: '', + is_read: false, + is_starred: false, + attachments: [], + }, + { + id: 'msg-2', + thread_id: '1', + user_id: 'user-1', + imap_uid: 2, + imap_folder_name: 'INBOX', + message_id_header: '', + from_address: 'sender@example.com', + to_addresses: [], + cc_addresses: [], + sent_at: '2025-01-15T09:00:00Z', + subject: 'Test Thread', + unsafe_body_html: '', + body_text: '', + is_read: false, + is_starred: false, + attachments: [ + { + id: 'att-1', + message_id: 'msg-2', + filename: 'document.pdf', + mime_type: 'application/pdf', + size_bytes: 1024, + is_inline: false, + }, + ], + }, + { + id: 'msg-3', + thread_id: '1', + user_id: 'user-1', + imap_uid: 3, + imap_folder_name: 'INBOX', + message_id_header: '', + from_address: 'sender@example.com', + to_addresses: [], + cc_addresses: [], + sent_at: '2025-01-15T10:00:00Z', + subject: 'Test Thread', + unsafe_body_html: '', + body_text: '', + is_read: false, + is_starred: false, + attachments: [], + }, + ], + } + + render(, { wrapper: createWrapper() }) + + // Should show attachment marker (paperclip emoji) + const attachmentMarker = screen.getByLabelText('Has attachments') + expect(attachmentMarker).toBeInTheDocument() + expect(attachmentMarker).toHaveTextContent('📎') + }) + + it('does not display attachment marker when thread has no attachments', () => { + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: false, + message_count: 1, + last_sent_at: '2025-01-15T10:00:00Z', + } + + render(, { wrapper: createWrapper() }) + + // Should not show attachment marker + const attachmentMarker = screen.queryByLabelText('Has attachments') + expect(attachmentMarker).not.toBeInTheDocument() + }) + + it('displays sender name with exactly 20 characters fully', () => { + const name20Chars = 'A'.repeat(20) + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: `${name20Chars} `, + has_attachments: false, + message_count: 1, + last_sent_at: '2025-01-15T10:00:00Z', + } + + render(, { wrapper: createWrapper() }) + + const senderElement = screen.getByTestId('email-sender') + expect(senderElement).toHaveTextContent(name20Chars) + // Should not have a period at the end + expect(senderElement.textContent).not.toContain('.') + }) + + it('truncates sender name with 21 characters to 19 + period', () => { + const name21Chars = 'A'.repeat(21) + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: `${name21Chars} `, + has_attachments: false, + message_count: 1, + last_sent_at: '2025-01-15T10:00:00Z', + } + + render(, { wrapper: createWrapper() }) + + const senderElement = screen.getByTestId('email-sender') + const expectedText = 'A'.repeat(19) + '.' + expect(senderElement).toHaveTextContent(expectedText) + }) + + it('displays preview snippet from backend preview_snippet', () => { + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: false, + message_count: 1, + last_sent_at: '2025-01-15T10:00:00Z', + preview_snippet: 'This is a preview snippet from the backend', + } + + render(, { wrapper: createWrapper() }) + + expect(screen.getByText(/This is a preview snippet from the backend/i)).toBeInTheDocument() + }) + + it('displays preview snippet from first message body_text when preview_snippet is missing', () => { + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: false, + message_count: 1, + last_sent_at: '2025-01-15T10:00:00Z', + messages: [ + { + id: 'msg-1', + thread_id: '1', + user_id: 'user-1', + imap_uid: 1, + imap_folder_name: 'INBOX', + message_id_header: '', + from_address: 'sender@example.com', + to_addresses: [], + cc_addresses: [], + sent_at: '2025-01-15T10:00:00Z', + subject: 'Test Thread', + unsafe_body_html: '', + body_text: 'This is the body text from the first message', + is_read: false, + is_starred: false, + }, + ], + } + + render(, { wrapper: createWrapper() }) + + expect( + screen.getByText(/This is the body text from the first message/i), + ).toBeInTheDocument() + }) + + it('displays preview snippet from HTML email body (extracted text)', () => { + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: false, + message_count: 1, + last_sent_at: '2025-01-15T10:00:00Z', + preview_snippet: 'This is extracted text from HTML email', + } + + render(, { wrapper: createWrapper() }) + + expect(screen.getByText(/This is extracted text from HTML email/i)).toBeInTheDocument() + }) + + it('normalizes whitespace in preview snippet', () => { + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: false, + message_count: 1, + last_sent_at: '2025-01-15T10:00:00Z', + preview_snippet: 'This has multiple spaces', + } + + render(, { wrapper: createWrapper() }) + + // Whitespace should be normalized to single spaces + const snippetElement = screen.getByText(/This has multiple spaces/i) + expect(snippetElement).toBeInTheDocument() + }) + + it('truncates long preview snippets to 100 characters', () => { + const longSnippet = 'A'.repeat(150) + const thread: Thread = { + id: '1', + stable_thread_id: 'thread-1', + subject: 'Test Thread', + user_id: 'user-1', + first_message_from_address: 'sender@example.com', + has_attachments: false, + message_count: 1, + last_sent_at: '2025-01-15T10:00:00Z', + preview_snippet: longSnippet, + } + + render(, { wrapper: createWrapper() }) + + // Should be truncated to 100 chars + '...' + const expectedText = 'A'.repeat(100) + '...' + const snippetElement = screen.getByText(expectedText) + expect(snippetElement).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/EmailListItem.tsx b/frontend/src/components/EmailListItem.tsx index f54cb04..9076950 100644 --- a/frontend/src/components/EmailListItem.tsx +++ b/frontend/src/components/EmailListItem.tsx @@ -9,32 +9,75 @@ interface EmailListItemProps { isSelected?: boolean } +const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', +}) + const dateFormatter = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', }) -function getSnippet(bodyText: string | undefined): string { - if (!bodyText) { +function formatDate(sentAt: string | null | undefined): string { + if (!sentAt) { return '' } - const snippetText = bodyText.replace(/\s+/g, ' ').trim() + const date = new Date(sentAt) + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()) + + if (messageDate.getTime() === today.getTime()) { + return timeFormatter.format(date) + } + return dateFormatter.format(date) +} + +function getPreviewSnippet( + previewSnippet: string | undefined, + bodyText: string | undefined, +): string { + // Prefer backend preview_snippet, fallback to first message body_text + const source = previewSnippet || bodyText + if (!source) { + return '' + } + const snippetText = source.replace(/\s+/g, ' ').trim() if (snippetText.length === 0) { return '' } - return snippetText.length > 80 ? snippetText.slice(0, 80) + '...' : snippetText + // Backend already limits to 100 chars, but we'll truncate further if needed for display + return snippetText.length > 100 ? snippetText.slice(0, 100) + '...' : snippetText } -function getFormattedDate(sentAt: string | undefined): string { - if (!sentAt) { - return '' +function extractSenderName(fromAddress: string): string { + // Extract name from "Name " format + // If no angle brackets, assume it's just an email address + const match = fromAddress.match(/^(.+?)\s*<.+>$/) + if (match) { + return match[1].trim() } - return dateFormatter.format(new Date(sentAt)) + // If no name found, return the original (might be just email) + return fromAddress +} + +function truncateSenderName(name: string): string { + // If exactly 20 characters, display it fully + if (name.length === 20) { + return name + } + // If longer than 19, truncate to 19 and add "." + if (name.length > 19) { + return name.slice(0, 19) + '.' + } + // Otherwise, return as-is + return name } function getLinkClassName(isSelected: boolean, isUnread: boolean): string { const baseClasses = - 'flex items-center gap-3 px-4 py-2 sm:px-6 hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-blue-400' + 'grid grid-cols-[24px_32px_200px_1fr_24px_80px] items-center gap-2 px-4 py-2 sm:px-6 hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-blue-400' if (isSelected) { return `${baseClasses} bg-blue-500/20 text-white` } @@ -44,58 +87,6 @@ function getLinkClassName(isSelected: boolean, isUnread: boolean): string { return `${baseClasses} text-slate-300` } -function getTextClassName(isUnread: boolean, variant: 'sender' | 'subject'): string { - const baseClass = 'truncate' - if (isUnread) { - return `${baseClass} font-semibold text-white` - } - return variant === 'sender' ? `${baseClass} text-slate-100` : `${baseClass} text-slate-300` -} - -function StarIcon({ isStarred }: { isStarred: boolean }) { - return ( -
- -
- ) -} - -function EmailContent({ - sender, - subject, - threadCount, - snippet, - isUnread, -}: { - sender: string - subject: string - threadCount: number - snippet: string - isUnread: boolean -}) { - return ( -
-
- - {sender} - - {threadCount > 1 && ( - - {threadCount} - - )} - — - - {subject} - - {snippet && — {snippet}} -
-
- ) -} - function isNavigationKey(key: string): boolean { return key === 'j' || key === 'k' || key === 'ArrowDown' || key === 'ArrowUp' } @@ -112,16 +103,23 @@ function handleKeyDown(event: React.KeyboardEvent, isSelected } } -// eslint-disable-next-line complexity -- It's okay. +// eslint-disable-next-line complexity -- It's okay to have a lot of logic here export default function EmailListItem({ thread, isSelected }: EmailListItemProps) { const firstMessage = thread.messages?.[0] - const sender = thread.first_message_from_address || firstMessage?.from_address || 'Unknown' + const fromAddress = thread.first_message_from_address || firstMessage?.from_address || 'Unknown' + const senderName = truncateSenderName(extractSenderName(fromAddress)) const subject = thread.subject || '(No subject)' - const threadCount = thread.messages?.length || 1 + const threadCount = thread.message_count || 1 const isUnread = firstMessage ? !firstMessage.is_read : false const isStarred = firstMessage?.is_starred || false - const snippet = getSnippet(firstMessage?.body_text) - const formattedDate = getFormattedDate(firstMessage?.sent_at || undefined) + const previewSnippet = getPreviewSnippet(thread.preview_snippet, firstMessage?.body_text) + // Use last_sent_at from thread (for list views) or fallback to first message sent_at (for detail views) + const dateToFormat = thread.last_sent_at || firstMessage?.sent_at || null + const formattedDate = formatDate(dateToFormat) + const hasAttachments = thread.has_attachments + + const senderClassName = `truncate text-sm ${isUnread ? 'font-semibold text-white' : 'text-slate-100'}` + const subjectClassName = `truncate text-sm ${isUnread ? 'font-semibold text-white' : 'text-slate-300'}` return ( - - -
- {formattedDate} + {/* Checkbox column */} +
+ { + e.preventDefault() + e.stopPropagation() + }} + aria-label='Select email' + /> +
+ + {/* Star column */} +
+ +
+ + {/* Sender column with thread count */} +
+ + {senderName} + {threadCount > 1 && ( + <> + {' '} + {threadCount} + + )} +
+ + {/* Subject and preview column */} +
+
+ + {subject} + + {previewSnippet && ( + <> + - + + {previewSnippet} + + + )} +
+
+ + {/* Attachment indicator column */} +
+ {hasAttachments && 📎} +
+ + {/* Date column */} +
{formattedDate}
) } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 105077c..43089de 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -93,6 +93,10 @@ export interface Thread { subject: string user_id: string first_message_from_address?: string + preview_snippet?: string + has_attachments: boolean + message_count?: number + last_sent_at?: string messages?: Message[] }