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
4 changes: 2 additions & 2 deletions backend/internal/testutil/imap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
13 changes: 7 additions & 6 deletions e2e/tests/inbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,18 +173,17 @@ test.describe('Existing User Read-Only Flow', () => {
const firstEmail = emailLinks.first()

// Verify sender is displayed (not "Unknown")
// EmailListItem structure: <div class="flex-1 min-w-0"><div class="flex items-center gap-2"><span class="truncate text-sm text-gray-900">{sender}</span>
// 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()
expect(senderText?.trim()).not.toBe('Unknown')
expect(senderText?.trim().length).toBeGreaterThan(0)

// Verify subject is displayed
// EmailListItem structure: <div class="flex-1 min-w-0"><div class="truncate text-sm text-gray-600">{subject}</div>
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()
Expand Down Expand Up @@ -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<string | null>, so await is required
const firstEmailHref: string | null = await emailLinks.first().getAttribute('href')
expect(firstEmailHref).toBeTruthy()
expect(firstEmailHref).toMatch(/^\/thread\//)

Expand Down Expand Up @@ -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<string | null>, so await is required
const threadUrl = await emailLinks.first().getAttribute('href')
expect(threadUrl).toBeTruthy()

Expand Down
1 change: 0 additions & 1 deletion e2e/tests/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
fillSettingsForm,
navigateAndWait,
submitSettingsForm,
waitForAppReady,
} from '../utils/helpers'

/**
Expand Down
4 changes: 2 additions & 2 deletions e2e/tests/search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/settings-existing-user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file added frontend/public/dark-bg.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion frontend/public/vmail-logo-no-bg.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
75 changes: 51 additions & 24 deletions frontend/src/components/EmailListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as React from 'react'
import { Link } from 'react-router-dom'

import type { Thread } from '../lib/api'
Expand All @@ -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<HTMLAnchorElement>) => {
if (
event.key === 'j' ||
Expand All @@ -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()
Expand All @@ -40,33 +50,50 @@ export default function EmailListItem({ thread, isSelected }: EmailListItemProps
return (
<Link
to={`/thread/${encodeThreadIdForUrl(thread.stable_thread_id)}`}
className={`flex items-center gap-4 border-b border-gray-200 px-4 py-3 hover:bg-gray-50 ${
isSelected ? 'bg-blue-50' : ''
} ${isUnread ? 'font-semibold' : ''}`}
className={`flex items-center gap-3 px-4 py-2 sm:px-6 ${
isSelected
? 'bg-blue-500/20 text-white'
: isUnread
? 'bg-white/0 text-white'
: 'text-slate-300'
} hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-blue-400`}
onKeyDown={handleKeyDown}
tabIndex={isSelected ? -1 : 0}
>
<div className='flex-shrink-0'>
{isStarred ? (
<span className='text-yellow-500' aria-label='Starred'>
</span>
) : (
<span className='text-gray-300' aria-label='Not starred'>
</span>
)}
<div className='flex w-8 justify-center text-base'>
<span
className={isStarred ? 'text-amber-400' : 'text-slate-500'}
aria-hidden='true'
>
{isStarred ? '★' : '☆'}
</span>
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2'>
<span className='truncate text-sm text-gray-900'>{sender}</span>
<div className='min-w-0 flex-1'>
<div className='flex items-center gap-2 text-sm'>
<span
data-testid='email-sender'
className={`truncate ${isUnread ? 'font-semibold text-white' : 'text-slate-100'}`}
>
{sender}
</span>
{threadCount > 1 && (
<span className='text-xs text-gray-500'>({threadCount})</span>
<span className='rounded-full border border-white/10 px-2 py-0.5 text-xs text-slate-300'>
{threadCount}
</span>
)}
<span className='text-slate-400'>—</span>
<span
data-testid='email-subject'
className={`truncate ${isUnread ? 'font-semibold text-white' : 'text-slate-300'}`}
>
{subject}
</span>
{snippet && <span className='truncate text-slate-400'> — {snippet}</span>}
</div>
<div className='truncate text-sm text-gray-600'>{subject}</div>
</div>
<div className='flex-shrink-0 text-xs text-gray-500'>{date}</div>
<div className='w-16 flex-shrink-0 text-right text-xs text-slate-400'>
{formattedDate}
</div>
</Link>
)
}
Loading
Loading