diff --git a/backend/internal/testutil/imap.go b/backend/internal/testutil/imap.go index 9b574de..f179756 100644 --- a/backend/internal/testutil/imap.go +++ b/backend/internal/testutil/imap.go @@ -61,7 +61,7 @@ func (u *specialUseUser) GetMailbox(name string) (backend.Mailbox, error) { } // Add SPECIAL-USE attributes based on mailbox name - attrs := []string{} + var attrs []string switch name { case "Sent": attrs = append(attrs, "\\Sent") @@ -94,7 +94,7 @@ func (u *specialUseUser) ListMailboxes(subscribed bool) ([]backend.Mailbox, erro } // Add SPECIAL-USE attributes based on mailbox name - attrs := []string{} + var attrs []string switch info.Name { case "Sent": attrs = append(attrs, "\\Sent") diff --git a/e2e/tests/inbox.spec.ts b/e2e/tests/inbox.spec.ts index 7d482fe..cb6e63f 100644 --- a/e2e/tests/inbox.spec.ts +++ b/e2e/tests/inbox.spec.ts @@ -173,9 +173,8 @@ test.describe('Existing User Read-Only Flow', () => { const firstEmail = emailLinks.first() // Verify sender is displayed (not "Unknown") - // EmailListItem structure:
{sender} - // We look for the sender span within the email link - const senderSpan = firstEmail.locator('div.flex-1 div.flex span.text-gray-900').first() + // Use data-testid for style-independent testing + const senderSpan = firstEmail.locator('[data-testid="email-sender"]').first() await expect(senderSpan).toBeVisible({ timeout: 5000 }) const senderText = await senderSpan.textContent() expect(senderText).toBeTruthy() @@ -183,8 +182,8 @@ test.describe('Existing User Read-Only Flow', () => { expect(senderText?.trim().length).toBeGreaterThan(0) // Verify subject is displayed - // EmailListItem structure:
{subject}
- const subjectDiv = firstEmail.locator('div.flex-1 div.text-gray-600').first() + // Use data-testid for style-independent testing + const subjectDiv = firstEmail.locator('[data-testid="email-subject"]').first() await expect(subjectDiv).toBeVisible({ timeout: 5000 }) const subjectText = await subjectDiv.textContent() expect(subjectText).toBeTruthy() @@ -215,7 +214,8 @@ test.describe('Existing User Read-Only Flow', () => { if (count > 0) { // Get the href of the first email to verify URL format - const firstEmailHref = await emailLinks.first().getAttribute('href') + // noinspection ES6RedundantAwait -- getAttribute returns Promise, so await is required + const firstEmailHref: string | null = await emailLinks.first().getAttribute('href') expect(firstEmailHref).toBeTruthy() expect(firstEmailHref).toMatch(/^\/thread\//) @@ -281,6 +281,7 @@ test.describe('Existing User Read-Only Flow', () => { if (count > 0) { // Get the thread URL from the first email link + // noinspection ES6RedundantAwait -- getAttribute returns Promise, so await is required const threadUrl = await emailLinks.first().getAttribute('href') expect(threadUrl).toBeTruthy() diff --git a/e2e/tests/onboarding.spec.ts b/e2e/tests/onboarding.spec.ts index 234e555..be47ed8 100644 --- a/e2e/tests/onboarding.spec.ts +++ b/e2e/tests/onboarding.spec.ts @@ -6,7 +6,6 @@ import { fillSettingsForm, navigateAndWait, submitSettingsForm, - waitForAppReady, } from '../utils/helpers' /** diff --git a/e2e/tests/search.spec.ts b/e2e/tests/search.spec.ts index 7ca7665..04c3f4a 100644 --- a/e2e/tests/search.spec.ts +++ b/e2e/tests/search.spec.ts @@ -44,8 +44,8 @@ test.describe('Search Functionality', () => { // Wait for results await waitForEmailList(page) - // Verify search results page shows query (use main content area to avoid sidebar h1) - await expect(page.locator('main h1, [role="main"] h1').first()).toContainText('Search results') + // Verify search results page shows query (use data-testid for style-independent testing) + await expect(page.locator('[data-testid="search-page-heading"]').first()).toContainText('Search results') // Verify we found the expected message from sampleMessages const expectedMessage = sampleMessages.find(m => m.subject.includes('Special Report')) diff --git a/e2e/tests/settings-existing-user.spec.ts b/e2e/tests/settings-existing-user.spec.ts index 490dc16..581b00c 100644 --- a/e2e/tests/settings-existing-user.spec.ts +++ b/e2e/tests/settings-existing-user.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test' import { setupAuth } from '../fixtures/auth' import { defaultTestUser } from '../fixtures/test-data' -import { fillSettingsForm, navigateAndWait, submitSettingsForm } from '../utils/helpers' +import { navigateAndWait } from '../utils/helpers' /** * Settings Page Tests for Existing Users diff --git a/frontend/public/dark-bg.jpg b/frontend/public/dark-bg.jpg new file mode 100644 index 0000000..25a61b4 Binary files /dev/null and b/frontend/public/dark-bg.jpg differ diff --git a/frontend/public/vmail-logo-no-bg.svg b/frontend/public/vmail-logo-no-bg.svg index 109aa36..246aff9 100644 --- a/frontend/public/vmail-logo-no-bg.svg +++ b/frontend/public/vmail-logo-no-bg.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/src/components/EmailListItem.tsx b/frontend/src/components/EmailListItem.tsx index 86b487d..430b344 100644 --- a/frontend/src/components/EmailListItem.tsx +++ b/frontend/src/components/EmailListItem.tsx @@ -1,3 +1,4 @@ +import * as React from 'react' import { Link } from 'react-router-dom' import type { Thread } from '../lib/api' @@ -8,18 +9,28 @@ interface EmailListItemProps { isSelected?: boolean } +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', +}) + export default function EmailListItem({ thread, isSelected }: EmailListItemProps) { - // Get the first message for display purposes const firstMessage = thread.messages?.[0] const sender = thread.first_message_from_address || firstMessage?.from_address || 'Unknown' const subject = thread.subject || '(No subject)' - const date = firstMessage?.sent_at ? new Date(firstMessage.sent_at).toLocaleDateString() : '' const threadCount = thread.messages?.length || 1 const isUnread = firstMessage ? !firstMessage.is_read : false const isStarred = firstMessage?.is_starred || false + const snippetSource = firstMessage?.body_text || '' + const snippetText = snippetSource.replace(/\s+/g, ' ').trim() + const snippet = + snippetText.length > 0 + ? snippetText.slice(0, 80) + (snippetText.length > 80 ? '...' : '') + : '' + const formattedDate = firstMessage?.sent_at + ? dateFormatter.format(new Date(firstMessage.sent_at)) + : '' - // Prevent navigation when 'j' or 'k' keys are pressed (handled by keyboard shortcuts) - // Also prevent the Link from receiving focus when selected to avoid accidental navigation const handleKeyDown = (event: React.KeyboardEvent) => { if ( event.key === 'j' || @@ -30,7 +41,6 @@ export default function EmailListItem({ thread, isSelected }: EmailListItemProps event.preventDefault() event.stopPropagation() } - // Prevent Enter from navigating when Link has focus (keyboard shortcuts handle 'o' and Enter) if (event.key === 'Enter' && isSelected) { event.preventDefault() event.stopPropagation() @@ -40,33 +50,50 @@ export default function EmailListItem({ thread, isSelected }: EmailListItemProps return ( -
- {isStarred ? ( - - ★ - - ) : ( - - ☆ - - )} +
+
-
-
- {sender} +
+
+ + {sender} + {threadCount > 1 && ( - ({threadCount}) + + {threadCount} + )} + + + {subject} + + {snippet && — {snippet}}
-
{subject}
-
{date}
+
+ {formattedDate} +
) } diff --git a/frontend/src/components/EmailListPagination.test.tsx b/frontend/src/components/EmailListPagination.test.tsx index 9d7a226..69b9ad2 100644 --- a/frontend/src/components/EmailListPagination.test.tsx +++ b/frontend/src/components/EmailListPagination.test.tsx @@ -55,7 +55,7 @@ describe('EmailListPagination', () => { expect(container.firstChild).toBeNull() }) - it('should render Previous and Next buttons', () => { + it('should render Prev and Next buttons', () => { render( { { wrapper: createWrapper(['/?folder=INBOX&page=2']) }, ) - expect(screen.getByText('Previous')).toBeInTheDocument() + expect(screen.getByText('Prev')).toBeInTheDocument() expect(screen.getByText('Next')).toBeInTheDocument() }) @@ -83,7 +83,7 @@ describe('EmailListPagination', () => { { wrapper: createWrapper(['/?folder=INBOX&page=1']) }, ) - const prevButton = screen.getByText('Previous') + const prevButton = screen.getByText('Prev') expect(prevButton).toBeDisabled() }) @@ -95,7 +95,6 @@ describe('EmailListPagination', () => { page: 3, per_page: 100, }} - folder='INBOX' />, { wrapper: createWrapper(['/?folder=INBOX&page=3']) }, ) @@ -156,7 +155,7 @@ describe('EmailListPagination', () => { , ) - const prevButton = screen.getByText('Previous') + const prevButton = screen.getByText('Prev') await user.click(prevButton) expect(prevButton).toBeInTheDocument() diff --git a/frontend/src/components/EmailListPagination.tsx b/frontend/src/components/EmailListPagination.tsx index 4043f3d..3cff2ce 100644 --- a/frontend/src/components/EmailListPagination.tsx +++ b/frontend/src/components/EmailListPagination.tsx @@ -47,8 +47,8 @@ export default function EmailListPagination({ pagination }: EmailListPaginationP } return ( -
-
+
+
Page {pagination.page} of {totalPages}
@@ -57,16 +57,16 @@ export default function EmailListPagination({ pagination }: EmailListPaginationP handlePageChange(pagination.page - 1) }} disabled={pagination.page <= 1} - className='rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50' + className='rounded-full border border-white/10 px-4 py-2 text-sm transition hover:border-white/40 disabled:cursor-not-allowed disabled:opacity-30' > - Previous + Prev diff --git a/frontend/src/components/Header.test.tsx b/frontend/src/components/Header.test.tsx index 4516029..7f5fbde 100644 --- a/frontend/src/components/Header.test.tsx +++ b/frontend/src/components/Header.test.tsx @@ -23,7 +23,7 @@ describe('Header', () => { it('should render the search input', () => { render( -
+
{}} /> , ) expect(screen.getByPlaceholderText('Search mail...')).toBeInTheDocument() @@ -33,7 +33,7 @@ describe('Header', () => { const user = userEvent.setup() render( -
+
{}} /> , ) @@ -47,7 +47,7 @@ describe('Header', () => { const user = userEvent.setup() render( -
+
{}} /> , ) @@ -62,7 +62,7 @@ describe('Header', () => { const user = userEvent.setup() render( -
+
{}} /> , ) @@ -78,7 +78,7 @@ describe('Header', () => { const user = userEvent.setup() render( -
+
{}} /> , ) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index b506dcd..a4b7e0e 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -4,7 +4,11 @@ import { useNavigate } from 'react-router-dom' import { validateSearchQuery } from '../lib/searchValidation' -export default function Header() { +interface HeaderProps { + onToggleSidebar: () => void +} + +export default function Header({ onToggleSidebar }: HeaderProps) { const [searchQuery, setSearchQuery] = useState('') const [validationError, setValidationError] = useState(null) const navigate = useNavigate() @@ -33,33 +37,50 @@ export default function Header() { } return ( -
-
-
+
+
+ +
+ V-Mail logo +
+

V-Mail

+

Personal mail hub

+
+
+
+ + ⌕ + -
- -
{validationError && ( +
+ + +
+ DV +
+
) diff --git a/frontend/src/components/Layout.test.tsx b/frontend/src/components/Layout.test.tsx index eb1b015..5764489 100644 --- a/frontend/src/components/Layout.test.tsx +++ b/frontend/src/components/Layout.test.tsx @@ -29,7 +29,8 @@ describe('Layout', () => { it('should render Sidebar component', () => { renderLayout(
Test
) - expect(screen.getByText('V-Mail')).toBeInTheDocument() + const navs = screen.getAllByLabelText('Sidebar navigation') + expect(navs.length).toBeGreaterThan(0) }) it('should render Header component', () => { @@ -37,9 +38,9 @@ describe('Layout', () => { expect(screen.getByPlaceholderText('Search mail...')).toBeInTheDocument() }) - it('should have correct layout structure', () => { + it('should wrap content with a background container', () => { const { container } = renderLayout(
Test
) const mainLayout = container.firstChild - expect(mainLayout).toHaveClass('flex', 'h-screen', 'overflow-hidden') + expect(mainLayout).toHaveClass('min-h-screen') }) }) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9c21dcb..f549cc9 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react' +import { useState, type ReactNode } from 'react' import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts' @@ -11,14 +11,24 @@ interface LayoutProps { export default function Layout({ children }: LayoutProps) { useKeyboardShortcuts() + const [isSidebarOpen, setIsSidebarOpen] = useState(false) return ( -
- -
-
-
-
{children}
+
+
{ + setIsSidebarOpen(true) + }} + /> +
+ { + setIsSidebarOpen(false) + }} + /> +
+
{children}
diff --git a/frontend/src/components/Message.tsx b/frontend/src/components/Message.tsx index d49c9b7..acdcda3 100644 --- a/frontend/src/components/Message.tsx +++ b/frontend/src/components/Message.tsx @@ -20,49 +20,65 @@ export default function Message({ message }: MessageProps) { return date.toLocaleString() } + const attachments = message.attachments?.filter((att) => !att.is_inline) ?? [] + return ( -
-
-
-
-
{message.from_address}
-
- To: {message.to_addresses.join(', ')} -
- {message.cc_addresses.length > 0 && ( -
- CC: {message.cc_addresses.join(', ')} -
- )} -
-
{formatDate(message.sent_at)}
+
+
+
+

From

+

{message.from_address}

+

+ To:{' '} + {message.to_addresses.join(', ')} +

+ {message.cc_addresses.length > 0 && ( +

+ CC:{' '} + + {message.cc_addresses.join(', ')} + +

+ )} +
+
{formatDate(message.sent_at)}
+
+ {message.subject && ( +
+ {message.subject}
- {message.subject && ( -
{message.subject}
- )} -
- {message.attachments && message.attachments.length > 0 && ( -
-
Attachments:
-
    - {message.attachments - .filter((att) => !att.is_inline) - .map((attachment) => ( -
  • - {attachment.filename} ({formatFileSize(attachment.size_bytes)}) -
  • - ))} + )} + {attachments.length > 0 && ( +
    +

    Attachments

    +
      + {attachments.map((attachment) => ( +
    • + {attachment.filename} + + {formatFileSize(attachment.size_bytes)} + +
    • + ))}
    )} -
    - {!sanitizedHTML && message.body_text && ( -
    {message.body_text}
    + {sanitizedHTML ? ( +
    + ) : ( + message.body_text && ( +
    + {message.body_text} +
    + ) )} -
    + ) } diff --git a/frontend/src/components/Sidebar.test.tsx b/frontend/src/components/Sidebar.test.tsx index d6ba5e8..85d9ed6 100644 --- a/frontend/src/components/Sidebar.test.tsx +++ b/frontend/src/components/Sidebar.test.tsx @@ -22,12 +22,12 @@ const createWrapper = () => { describe('Sidebar', () => { it('should render the V-Mail title', () => { render(, { wrapper: createWrapper() }) - expect(screen.getByText('V-Mail')).toBeInTheDocument() + expect(screen.getAllByText('V-Mail')[0]).toBeInTheDocument() }) it('should render a loading state', () => { render(, { wrapper: createWrapper() }) - expect(screen.getByText('Loading...')).toBeInTheDocument() + expect(screen.getAllByText('Loading...').length).toBeGreaterThan(0) }) it('should call GET /api/v1/folders', async () => { @@ -41,21 +41,20 @@ describe('Sidebar', () => { it('should render a list of links based on the mock API response', async () => { render(, { wrapper: createWrapper() }) - await waitFor(() => { - expect(screen.getByText('INBOX')).toBeInTheDocument() - expect(screen.getByText('Sent')).toBeInTheDocument() - expect(screen.getByText('Drafts')).toBeInTheDocument() - }) + const inboxMatches = await screen.findAllByText('INBOX') + const sentMatches = await screen.findAllByText('Sent') + const draftsMatches = await screen.findAllByText('Drafts') + + expect(inboxMatches.length).toBeGreaterThan(0) + expect(sentMatches.length).toBeGreaterThan(0) + expect(draftsMatches.length).toBeGreaterThan(0) }) it('should navigate to the correct folder when clicking a link', async () => { render(, { wrapper: createWrapper() }) - await waitFor(() => { - expect(screen.getByText('Sent')).toBeInTheDocument() - }) - - const sentLink = screen.getByText('Sent') + const sentItems = await screen.findAllByText('Sent') + const sentLink = sentItems[0] expect(sentLink.closest('a')).toHaveAttribute('href', '/?folder=Sent') }) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index d27295d..5e842c1 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,9 +1,25 @@ import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' import { Link, useLocation } from 'react-router-dom' -import { api } from '../lib/api' +import { api, type Folder } from '../lib/api' -export default function Sidebar() { +interface SidebarProps { + isMobileOpen?: boolean + onClose?: () => void +} + +const ROLE_ORDER: Record = { + inbox: 0, + sent: 1, + drafts: 2, + spam: 3, + trash: 4, + archive: 5, + other: 6, +} + +export default function Sidebar({ isMobileOpen = false, onClose }: SidebarProps) { const location = useLocation() const { data: folders, @@ -16,35 +32,55 @@ export default function Sidebar() { queryFn: () => api.getFolders(), }) - return ( -
    -
    -

    V-Mail

    + const sortedFolders = useMemo(() => { + if (!folders) { + return [] + } + return [...folders].sort((a, b) => { + const roleComparison = ROLE_ORDER[a.role] - ROLE_ORDER[b.role] + if (roleComparison !== 0) { + return roleComparison + } + return a.name.localeCompare(b.name) + }) + }, [folders]) + + const sidebarContent = ( +
    +
    +
    +

    V-Mail

    +

    Inbox zero, but make it cozy

    +
    +
    -