diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index f5718c451..a3424e3a3 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -755,6 +755,7 @@ function mvSenderDate(date: Date | string, timeFormat: '12h' | '24h'): string { * snippet. Clicking it unfolds the message in place. */ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Email; onOpen?: (email: Email) => void; timeFormat: '12h' | '24h'; compact?: boolean }) { + const tSidebar = useTranslations('sidebar'); const sender = email.from?.[0]; const unread = !email.keywords?.$seen; return ( @@ -771,6 +772,12 @@ function MvConversationCard({ email, onOpen, timeFormat, compact }: { email: Ema {sender?.name || sender?.email || ''} + {email.isScheduled && ( + + + {email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')} + + )} {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} @@ -847,6 +854,7 @@ export function MvExpandedConversationCard({ blobSource?: EmailBlobContext; }) { const t = useTranslations('email_viewer'); + const tSidebar = useTranslations('sidebar'); const externalContent = useConversationExternalContent(); const sender = email.from?.[0]; const hasBody = !!email.bodyValues; @@ -885,6 +893,12 @@ export function MvExpandedConversationCard({ {sender?.name || sender?.email || ''} + {email.isScheduled && ( + + + {email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : tSidebar('scheduled')} + + )} {!compact && sender?.name && sender.email && ( <{sender.email}> )} @@ -920,6 +934,14 @@ export function MvExpandedConversationCard({ )} + {email.isScheduled && ( +
+ + + {t('scheduled_banner', { date: email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : '' })} + +
+ )} {showDetails && } {hasBody ? ( state.density); + const timeFormat = useSettingsStore((state) => state.timeFormat); const plugin = useConversationPluginProps(email); const sender = email.from?.[0]; const formatAddress = useOwnDomainAddress(); @@ -402,6 +405,12 @@ function EmailCard({ {sender?.name || (sender?.email && formatAddress(sender.email)) || t('email_viewer.unknown_sender')}
+ {email.isScheduled && ( + + + {t('sidebar.scheduled')} + + )} {isStarred && ( )} @@ -410,7 +419,14 @@ function EmailCard({ )}
- {formatDate(emailDisplayDate(email))} + {email.isScheduled && email.scheduledSendAt ? ( + + + {formatScheduledSendAt(email.scheduledSendAt, timeFormat)} + + ) : ( + formatDate(emailDisplayDate(email)) + )}
{!isExpanded && density !== 'extra-compact' && (

@@ -430,6 +446,14 @@ function EmailCard({ {/* Expanded Content */} {isExpanded && (

+ {email.isScheduled && ( +
+ + + {t('email_viewer.scheduled_banner', { date: email.scheduledSendAt ? formatScheduledSendAt(email.scheduledSendAt, timeFormat) : '' })} + +
+ )}
+ {email.isScheduled && } {isStarred && } {isAnswered && } {isForwarded && } @@ -278,6 +279,9 @@ export function ThreadEmailItem({ {/* Indicators */}
+ {email.isScheduled && ( + + )} {isStarred && ( )} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 1cf6058ad..81598d06d 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -37,7 +37,7 @@ import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; import { messagePriorityFromHeaders, type MessagePriority } from "@/lib/message-priority"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder, restoreBatchesFor, restoreBatchesForSelection, TrashMailboxNotFoundError, type EmailRestoreBatch , type CrossAccountMoveReceipt } from "@/stores/email-store"; +import { useEmailStore, annotateScheduledEmails, annotateScheduledEmail, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, resolveEmailActionContext, emailWriteSource, emailLinkSource, type EmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete, isAggregateListView, removalFolder, canRemoveFromFolder, restoreBatchesFor, restoreBatchesForSelection, TrashMailboxNotFoundError, type EmailRestoreBatch , type CrossAccountMoveReceipt } from "@/stores/email-store"; import { findDraftIdentityIdForLogin, listRowSource, resolveDraftSource, type DraftSource } from "@/lib/draft-source"; import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder"; import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; @@ -2182,27 +2182,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } } - if (result.scheduled) { - await refreshScheduledMetadata(client); - if (isScheduledView) await fetchScheduledEmails(client); - return; - } - - // Delayed sends get the undo toast instead; a filing error or a refused - // recipient already warned. - if (!result.filingError && !result.rejectedRecipients?.length) toast.success(t('notifications.email_sent')); - - // Refresh the current mailbox to update the UI - if (!isScheduledView) { - await refreshCurrentMailbox(client); - // Re-fetch the replied thread's cross-folder data so the expanded - // view shows the newly sent reply without collapsing. - if (originalEmailId) { - const emailState = useEmailStore.getState(); - const repliedEmail = emailState.emails.find(e => e.id === originalEmailId); - if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) { - // Route to the email's own account so shared/group threads refresh - // from the right server, not the active one. (#281, #814) + const refreshRepliedThreadAfterSend = async (emailId: string | undefined) => { + if (!emailId || !client) return; + const emailState = useEmailStore.getState(); + const repliedEmail = emailState.emails.find(e => e.id === emailId); + if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) { + try { const route = resolveThreadRoute({ isUnifiedView: emailState.isUnifiedView, ref: repliedEmail, @@ -2223,8 +2208,31 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return { threadEmailsCache: c }; }); } + } catch (e) { + debug.error('Failed to refresh replied thread after send:', e); } } + }; + + if (result.scheduled) { + await refreshScheduledMetadata(client); + if (isScheduledView) { + await fetchScheduledEmails(client); + } else { + await refreshCurrentMailbox(client); + } + await refreshRepliedThreadAfterSend(originalEmailId); + return; + } + + // Delayed sends get the undo toast instead; a filing error or a refused + // recipient already warned. + if (!result.filingError && !result.rejectedRecipients?.length) toast.success(t('notifications.email_sent')); + + // Refresh the current mailbox to update the UI + if (!isScheduledView) { + await refreshCurrentMailbox(client); + await refreshRepliedThreadAfterSend(originalEmailId); } } catch (error) { console.error("Failed to send email:", error); @@ -4017,8 +4025,46 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } } + const refreshInlineRepliedThread = async () => { + const emailState = useEmailStore.getState(); + const repliedEmail = emailState.emails.find(e => e.id === originalEmailId); + if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) { + const route = resolveThreadRoute({ + isUnifiedView: emailState.isUnifiedView, + ref: repliedEmail, + mailboxes: viewMailboxes, + selectedMailbox: emailState.selectedMailbox, + }); + const threadClient = route.clientAccountId + ? (useAuthStore.getState().getClientForAccount(route.clientAccountId) ?? client) + : client; + try { + const fullEmails = await threadClient.getThreadEmails( + repliedEmail.threadId, + route.accountId ?? client.getAccountId(), + ); + if (fullEmails.length > 0) { + useEmailStore.setState((state) => { + const c = new Map(state.threadEmailsCache); + c.set(threadKeyFor(repliedEmail), fullEmails); + return { threadEmailsCache: c }; + }); + } + } catch (e) { + debug.error('Failed to refresh replied thread after send:', e); + } + } + }; + if (result.scheduled) { await refreshScheduledMetadata(client); + // As below: a failed refresh must not reach the quick-reply box. + try { + await refreshCurrentMailbox(client); + await refreshInlineRepliedThread(); + } catch (error) { + console.error('Quick reply scheduled, but refreshing the view failed:', error); + } return true; } @@ -4030,34 +4076,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { try { // Refresh emails to show the sent reply await refreshCurrentMailbox(client); - // Re-fetch the replied thread's cross-folder data so the expanded - // view shows the newly sent reply without collapsing. - const emailState = useEmailStore.getState(); - const repliedEmail = emailState.emails.find(e => e.id === originalEmailId); - if (repliedEmail?.threadId && emailState.expandedThreadIds.has(threadKeyFor(repliedEmail))) { - // Route to the email's own account so shared/group threads refresh from - // the right server, not the active one. (#281, #814) - const route = resolveThreadRoute({ - isUnifiedView: emailState.isUnifiedView, - ref: repliedEmail, - mailboxes: viewMailboxes, - selectedMailbox: emailState.selectedMailbox, - }); - const threadClient = route.clientAccountId - ? (useAuthStore.getState().getClientForAccount(route.clientAccountId) ?? client) - : client; - const fullEmails = await threadClient.getThreadEmails( - repliedEmail.threadId, - route.accountId ?? client.getAccountId(), - ); - if (fullEmails.length > 0) { - useEmailStore.setState((state) => { - const c = new Map(state.threadEmailsCache); - c.set(threadKeyFor(repliedEmail), fullEmails); - return { threadEmailsCache: c }; - }); - } - } + await refreshInlineRepliedThread(); } catch (error) { console.error('Quick reply sent, but refreshing the view failed:', error); } @@ -4102,7 +4121,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { e.sourceClientAccountId = ref.sourceClientAccountId; e.sourceAccountId = ref.sourceAccountId; } - return emails + const scheduledSubmissionByEmailId = useEmailStore.getState().scheduledSubmissionByEmailId; + const annotated = annotateScheduledEmails(emails, scheduledSubmissionByEmailId); + return annotated .filter((e) => e.id === ref.id || !e.keywords?.$draft) .sort((a, b) => a.receivedAt.localeCompare(b.receivedAt)); })().catch(() => null); @@ -4403,7 +4424,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const fullEmail = await requestEmailContent(fetchClient, email.id, accountId); if (openSeq !== emailOpenSeqRef.current) return; if (fullEmail) { - if (listEmail?.isScheduled) { + const scheduledSubmissionByEmailId = useEmailStore.getState().scheduledSubmissionByEmailId; + const annotated = annotateScheduledEmail(fullEmail, scheduledSubmissionByEmailId); + if (annotated.isScheduled) { + fullEmail.scheduledSendAt = annotated.scheduledSendAt; + fullEmail.emailSubmissionId = annotated.emailSubmissionId; + fullEmail.scheduledIdentityId = annotated.scheduledIdentityId; + fullEmail.scheduledUndoStatus = annotated.scheduledUndoStatus; + fullEmail.scheduledAccountId = annotated.scheduledAccountId; + fullEmail.isScheduled = true; + } else if (listEmail?.isScheduled) { fullEmail.scheduledSendAt = listEmail.scheduledSendAt; fullEmail.emailSubmissionId = listEmail.emailSubmissionId; fullEmail.scheduledIdentityId = listEmail.scheduledIdentityId; diff --git a/stores/__tests__/email-store-scheduled-annotation.test.ts b/stores/__tests__/email-store-scheduled-annotation.test.ts new file mode 100644 index 000000000..62eb532e7 --- /dev/null +++ b/stores/__tests__/email-store-scheduled-annotation.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useEmailStore, annotateScheduledEmail, annotateScheduledEmails } from '../email-store'; +import type { Email } from '@/lib/jmap/types'; + +function makeEmail(id: string, threadId = `thread-${id}`): Email { + return { + id, + threadId, + mailboxIds: { inbox: true }, + keywords: {}, + size: 100, + receivedAt: new Date().toISOString(), + from: [{ name: 'Test', email: 'test@example.com' }], + to: [{ name: 'User', email: 'user@example.com' }], + subject: `Email ${id}`, + preview: 'preview', + hasAttachment: false, + textBody: [], + htmlBody: [], + bodyValues: {}, + sourceClientAccountId: 'login-1', + sourceAccountId: 'acc-1', + }; +} + +describe('scheduled email annotation in email-store', () => { + // Keyed like the store's map: login, account, then the message id. + const metadataMap = new Map([ + ['login-1|acc-1|email-scheduled', { + submissionId: 'sub-1', + sendAt: '2026-10-30T10:00:00Z', + identityId: 'id-1', + undoStatus: 'pending' as const, + accountId: 'acc-1', + }], + ]); + + beforeEach(() => { + vi.restoreAllMocks(); + useEmailStore.setState({ + emails: [makeEmail('email-1'), makeEmail('email-scheduled')], + selectedEmail: null, + scheduledSubmissionByEmailId: metadataMap, + scheduledEmailIds: new Set(['email-scheduled']), + }); + }); + + it('annotates email with scheduled properties when present in scheduledSubmissionByEmailId', () => { + const email = makeEmail('email-scheduled'); + const annotated = annotateScheduledEmail(email, metadataMap); + + expect(annotated.isScheduled).toBe(true); + expect(annotated.scheduledSendAt).toBe('2026-10-30T10:00:00Z'); + expect(annotated.emailSubmissionId).toBe('sub-1'); + expect(annotated.scheduledUndoStatus).toBe('pending'); + expect(annotated.scheduledAccountId).toBe('acc-1'); + }); + + it('returns identical reference when email is not scheduled and not in metadata', () => { + const email = makeEmail('email-1'); + const annotated = annotateScheduledEmail(email, metadataMap); + + expect(annotated).toBe(email); + expect(annotated.isScheduled).toBeUndefined(); + }); + + it('removes scheduled properties when email is no longer in scheduled metadata', () => { + const previouslyScheduled: Email = { + ...makeEmail('email-no-longer-scheduled'), + isScheduled: true, + scheduledSendAt: '2026-10-30T10:00:00Z', + emailSubmissionId: 'sub-old', + }; + const cleaned = annotateScheduledEmail(previouslyScheduled, new Map()); + + expect(cleaned.isScheduled).toBeUndefined(); + expect(cleaned.scheduledSendAt).toBeUndefined(); + expect(cleaned.emailSubmissionId).toBeUndefined(); + }); + + it('annotates a list of emails in annotateScheduledEmails', () => { + const emails = [makeEmail('email-1'), makeEmail('email-scheduled')]; + const annotatedList = annotateScheduledEmails(emails, metadataMap); + + expect(annotatedList[0].isScheduled).toBeUndefined(); + expect(annotatedList[0]).toBe(emails[0]); + expect(annotatedList[1].isScheduled).toBe(true); + expect(annotatedList[1].scheduledSendAt).toBe('2026-10-30T10:00:00Z'); + }); + + it('returns original array reference if scheduledSubmissionByEmailId is empty', () => { + const emails = [makeEmail('email-1')]; + const result = annotateScheduledEmails(emails, new Map()); + expect(result).toBe(emails); + }); + + it('preserves email object reference across selectEmail, beginOpenEmail, and finishOpenEmail', () => { + // Ensuring reference identity is preserved for plugin open/close hooks + const email = makeEmail('email-1'); + useEmailStore.getState().selectEmail(email); + expect(useEmailStore.getState().selectedEmail).toBe(email); + + useEmailStore.getState().beginOpenEmail(email); + expect(useEmailStore.getState().selectedEmail).toBe(email); + + useEmailStore.getState().finishOpenEmail(email); + expect(useEmailStore.getState().selectedEmail).toBe(email); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 2d3f7d854..384df23f6 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -714,7 +714,7 @@ function getNextSelectedEmailAfterRemovingRow( return null; } -function annotateScheduledEmails( +export function annotateScheduledEmails( emails: Email[], scheduledSubmissionByEmailId: Map ): Email[] { @@ -745,14 +745,26 @@ function scheduledSubmissionMap(client: IJMAPClient, emails: ScheduledEmail[]): }])); } -function annotateScheduledEmail( +export function annotateScheduledEmail( email: Email, scheduledSubmissionByEmailId: Map ): Email { // The row's login and account: its stamps, else the folder on screen. const { client, clientAccountId, accountId } = resolveEmailBlobContext(email, useAuthStore.getState().client); const scheduled = scheduledSubmissionByEmailId.get(scheduledEmailKey(clientAccountId, accountId ?? client?.getAccountId(), email.id)); - if (!scheduled) return email; + if (!scheduled) { + if (email.isScheduled) { + const copy = { ...email }; + delete copy.isScheduled; + delete copy.scheduledSendAt; + delete copy.emailSubmissionId; + delete copy.scheduledIdentityId; + delete copy.scheduledUndoStatus; + delete copy.scheduledAccountId; + return copy; + } + return email; + } return { ...email, scheduledSendAt: scheduled.sendAt, @@ -7952,6 +7964,7 @@ export const useEmailStore = create((set, get) => ({ scheduledEmails: result.emails, scheduledEmailIds, scheduledSubmissionByEmailId, + emails: annotateScheduledEmails(get().emails, scheduledSubmissionByEmailId), scheduledTotal: result.total, scheduledTotalByAccount: result.totalByAccount ?? {}, scheduledHasMore: result.hasMore,