From f45b67fe19c5efd3dee1176f877c7c2234868d85 Mon Sep 17 00:00:00 2001 From: Lucas Gaitzsch Date: Fri, 8 May 2026 22:37:16 +0200 Subject: [PATCH] fix --- app/[locale]/page.tsx | 24 ++--- app/api/dev-jmap/[...path]/route.ts | 13 ++- components/email/email-composer.tsx | 45 ++++----- components/email/email-list.tsx | 12 +-- components/email/email-viewer.tsx | 12 +-- lib/__tests__/jmap-send-threading.test.ts | 31 ++++-- lib/demo/demo-client.ts | 34 +++---- lib/jmap/client-interface.ts | 6 +- lib/jmap/client.ts | 115 ++++++++++++++++++---- stores/email-store.ts | 24 ++--- 10 files changed, 204 insertions(+), 112 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index efc35fa9..2006807c 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -630,12 +630,12 @@ export default function Home() { useEffect(() => { if (!pendingUndoSend) return; - const sendAt = new Date(pendingUndoSend.sendAt).getTime(); - if (!Number.isFinite(sendAt) || sendAt <= Date.now()) { + const pendingSendTime = new Date(pendingUndoSend.sendAt).getTime(); + if (!Number.isFinite(pendingSendTime) || pendingSendTime <= Date.now()) { clearPendingUndoSend(); return; } - const timer = setTimeout(clearPendingUndoSend, sendAt - Date.now()); + const timer = setTimeout(clearPendingUndoSend, pendingSendTime - Date.now()); return () => clearTimeout(timer); }, [clearPendingUndoSend, pendingUndoSend]); @@ -944,7 +944,7 @@ export default function Home() { attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; inReplyTo?: string[]; references?: string[]; - sendAt?: string; + delayedUntil?: string; }) => { if (!client) return; @@ -952,7 +952,7 @@ export default function Home() { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = selectedEmail?.id; - const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.sendAt); + const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil); setShowComposer(false); if (result.scheduled) { await refreshScheduledMetadata(client); @@ -1703,13 +1703,13 @@ export default function Home() { const originalEmailId = selectedEmail.id; const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds; - let sendAt: string | undefined; + let delayedUntil: string | undefined; if (sendDelaySeconds > 0) { if (!client.hasDelayedSend()) { const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm')); if (!confirmed) return; } else { - sendAt = new Date(Date.now() + sendDelaySeconds * 1000).toISOString(); + delayedUntil = new Date(Date.now() + sendDelaySeconds * 1000).toISOString(); } } @@ -1735,7 +1735,7 @@ export default function Home() { undefined, threading?.inReplyTo, threading?.references, - sendAt, + delayedUntil, ); if (result.scheduled) { @@ -2312,9 +2312,9 @@ export default function Home() { setShowComposer(true); if (isMobile) setActiveView('viewer'); }} - onRescheduleScheduled={async (email, sendAt) => { + onRescheduleScheduled={async (email, delayedUntil) => { if (client && email.emailSubmissionId && email.scheduledIdentityId) { - await rescheduleScheduledEmail(client, email.emailSubmissionId, email.id, email.scheduledIdentityId, sendAt); + await rescheduleScheduledEmail(client, email.emailSubmissionId, email.id, email.scheduledIdentityId, delayedUntil); } }} onEmailSelect={handleEmailSelect} @@ -2561,9 +2561,9 @@ export default function Home() { } if (restored) await handleEditDraft(restored); }} - onRescheduleScheduled={async (sendAt) => { + onRescheduleScheduled={async (delayedUntil) => { if (client && selectedEmail?.emailSubmissionId && selectedEmail.scheduledIdentityId) { - await rescheduleScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id, selectedEmail.scheduledIdentityId, sendAt); + await rescheduleScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id, selectedEmail.scheduledIdentityId, delayedUntil); } }} onCompose={() => { diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index cbbb1fda..1b0bd535 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -1551,16 +1551,19 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult { } function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult { - const created: Record = {}; + const created: Record = {}; const updated: Record = {}; - const create = args.create as Record | undefined; + const create = args.create as Record | undefined; if (create) { for (const [key, value] of Object.entries(create)) { const id = `submission-${Date.now()}-${key}`; - created[key] = { id }; - if (value.sendAt && value.emailId && value.identityId) { + const holdUntil = value.envelope?.mailFrom?.parameters?.HOLDUNTIL; + const holdUntilTime = holdUntil ? new Date(holdUntil).getTime() : Number.NaN; + const delayedUntil = Number.isFinite(holdUntilTime) ? new Date(holdUntilTime).toISOString() : undefined; + created[key] = { id, ...(delayedUntil ? { sendAt: delayedUntil } : {}) }; + if (delayedUntil && value.emailId && value.identityId) { const emailId = value.emailId.startsWith('#') ? emails[emails.length - 1]?.id || value.emailId : value.emailId; - scheduledSubmissions.push({ id, emailId, identityId: value.identityId, sendAt: value.sendAt, undoStatus: 'pending' }); + scheduledSubmissions.push({ id, emailId, identityId: value.identityId, sendAt: delayedUntil, undoStatus: 'pending' }); } } } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 4014b937..82c417e1 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -72,7 +72,7 @@ interface EmailComposerProps { attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; inReplyTo?: string[]; references?: string[]; - sendAt?: string; + delayedUntil?: string; }) => void | Promise; onScheduledSendCreated?: () => void | Promise; onClose?: () => void; @@ -876,8 +876,8 @@ export function EmailComposer({ return null; }; - const getEffectiveSendAt = async (explicitSendAt?: string): Promise => { - if (explicitSendAt) return explicitSendAt; + const resolveDelayedUntil = async (requestedDelayedUntil?: string): Promise => { + if (requestedDelayedUntil) return requestedDelayedUntil; if (sendDelaySeconds === 0) return undefined; if (client?.hasDelayedSend()) { return new Date(Date.now() + sendDelaySeconds * 1000).toISOString(); @@ -932,7 +932,7 @@ export function EmailComposer({ }; }; - const handleSend = async (skipAttachmentCheck = false, sendAt?: string) => { + const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => { const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); @@ -1014,7 +1014,7 @@ export function EmailComposer({ const inlineAttachments = rewritten?.attachments ?? []; try { - const effectiveSendAt = await getEffectiveSendAt(sendAt); + const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil); // Let plugins veto the send (external-mail warning, mistyped-domain // guards, etc.). Returning false from any handler aborts before either // the S/MIME or standard JMAP path runs. @@ -1151,8 +1151,8 @@ export function EmailComposer({ } // 7. Send via raw email path - const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveSendAt); - if (effectiveSendAt && finalDraftId) { + const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil); + if (effectiveDelayedUntil && finalDraftId) { client.deleteEmail(finalDraftId).catch(err => { debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err); toast.warning(t('schedule_send_cleanup_warning')); @@ -1199,7 +1199,7 @@ export function EmailComposer({ attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, inReplyTo: threadingHeaders?.inReplyTo, references: threadingHeaders?.references, - sendAt: effectiveSendAt, + delayedUntil: effectiveDelayedUntil, }); if (mode === 'reply' || mode === 'replyAll') { @@ -1700,20 +1700,21 @@ export function EmailComposer({ > - + {client?.hasDelayedSend() && ( + + )} {/* S/MIME toggles */} {canSmimeSign && ( diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index ab07f6ff..f9f4679f 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -43,7 +43,7 @@ interface EmailListProps { onLoadMoreScheduled?: () => void; onCancelScheduled?: (email: Email) => void | Promise; onCancelScheduledForEdit?: (email: Email) => void | Promise; - onRescheduleScheduled?: (email: Email, sendAt: string) => void | Promise; + onRescheduleScheduled?: (email: Email, delayedUntil: string) => void | Promise; } export function EmailList({ @@ -235,7 +235,7 @@ export function EmailList({ } }, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]); - const promptForRescheduleSendAt = useCallback((): string | null => { + const promptForRescheduleDelayedUntil = useCallback((): string | null => { const value = window.prompt(t('reschedule_prompt')); if (!value) return null; const time = new Date(value).getTime(); @@ -518,8 +518,8 @@ export function EmailList({ size="sm" className="h-7 px-2" onClick={() => { - const sendAt = promptForRescheduleSendAt(); - if (sendAt) onRescheduleScheduled?.(thread.latestEmail, sendAt); + const delayedUntil = promptForRescheduleDelayedUntil(); + if (delayedUntil) onRescheduleScheduled?.(thread.latestEmail, delayedUntil); }} > @@ -581,8 +581,8 @@ export function EmailList({ onCancelScheduled={() => onCancelScheduled?.(contextMenu.data!)} onCancelScheduledForEdit={() => onCancelScheduledForEdit?.(contextMenu.data!)} onRescheduleScheduled={() => { - const sendAt = promptForRescheduleSendAt(); - if (sendAt) onRescheduleScheduled?.(contextMenu.data!, sendAt); + const delayedUntil = promptForRescheduleDelayedUntil(); + if (delayedUntil) onRescheduleScheduled?.(contextMenu.data!, delayedUntil); }} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={() => client && batchDelete(client)} diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index b7344d57..f4d4de79 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -121,7 +121,7 @@ interface EmailViewerProps { onEditDraft?: () => void; onCancelScheduled?: () => void; onCancelScheduledForEdit?: () => void; - onRescheduleScheduled?: (sendAt: string) => void; + onRescheduleScheduled?: (delayedUntil: string) => void; onCompose?: () => void; currentUserEmail?: string; currentUserName?: string; @@ -883,7 +883,7 @@ export function EmailViewer({ const { isTablet, isMobile } = useDeviceDetection(); const { tabletListVisible } = useUIStore(); const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); - const promptForRescheduleSendAt = useCallback((): string | null => { + const promptForRescheduleDelayedUntil = useCallback((): string | null => { const value = window.prompt(t('reschedule_prompt')); if (!value) return null; const time = new Date(value).getTime(); @@ -3147,8 +3147,8 @@ export function EmailViewer({ variant="ghost" size="sm" onClick={() => { - const sendAt = promptForRescheduleSendAt(); - if (sendAt) onRescheduleScheduled?.(sendAt); + const delayedUntil = promptForRescheduleDelayedUntil(); + if (delayedUntil) onRescheduleScheduled?.(delayedUntil); }} className="hidden sm:flex sm:h-8" title={t('reschedule_send')} @@ -4598,8 +4598,8 @@ export function EmailViewer({ size="sm" variant="outline" onClick={() => { - const sendAt = promptForRescheduleSendAt(); - if (sendAt) onRescheduleScheduled?.(sendAt); + const delayedUntil = promptForRescheduleDelayedUntil(); + if (delayedUntil) onRescheduleScheduled?.(delayedUntil); }} > {t('reschedule_send')} diff --git a/lib/__tests__/jmap-send-threading.test.ts b/lib/__tests__/jmap-send-threading.test.ts index 26e2dca1..8524af8b 100644 --- a/lib/__tests__/jmap-send-threading.test.ts +++ b/lib/__tests__/jmap-send-threading.test.ts @@ -16,14 +16,14 @@ function enableDelayedSend(client: JMAPClient) { capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, - 'urn:ietf:params:jmap:submission': {}, + 'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: ['FUTURERELEASE'] }, }, session: { accounts: { 'account-1': { accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, - 'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600 }, + 'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: ['FUTURERELEASE'] }, }, }, }, @@ -82,7 +82,7 @@ function mockSendEmailFlow() { payload = { methodResponses: [ ['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record }).create)[0]]: { id: 'sent-id-1' } } }, '0'], - ['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'], + ['EmailSubmission/set', { created: { '1': { id: 'sub-1', sendAt: '2026-05-08T18:00:00Z' } } }, '1'], ], }; } @@ -169,11 +169,11 @@ describe('JMAPClient.sendEmail threading headers', () => { expect(draft.references).toBeUndefined(); }); - it('includes sendAt and submission capability for scheduled sends', async () => { + it('uses FUTURERELEASE envelope and submission capability for scheduled sends', async () => { const client = createClient(); enableDelayedSend(client); const captured = mockSendEmailFlow(); - const sendAt = new Date(Date.now() + 60_000).toISOString(); + const delayedUntil = new Date(Date.now() + 60_000).toISOString(); const result = await client.sendEmail( ['recipient@example.com'], @@ -183,14 +183,26 @@ describe('JMAPClient.sendEmail threading headers', () => { undefined, undefined, undefined, undefined, undefined, undefined, - sendAt, + delayedUntil, ); const identityRequest = captured[1]; expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission'); const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set'); - expect(submissionCall?.[1].create).toEqual({ '1': { emailId: expect.stringMatching(/^#send-/), identityId: 'identity-1', sendAt } }); - expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt }); + expect(submissionCall?.[1].create).toEqual({ + '1': { + emailId: expect.stringMatching(/^#send-/), + identityId: 'identity-1', + envelope: { + mailFrom: { + email: 'user@example.com', + parameters: { HOLDUNTIL: expect.stringMatching(/^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} \+0000$/) }, + }, + }, + }, + }); + expect(JSON.stringify(submissionCall?.[1].create)).not.toContain('sendAt'); + expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: '2026-05-08T18:00:00Z' }); }); it('cleans up replacement submission if canceling the original fails during reschedule', async () => { @@ -200,6 +212,9 @@ describe('JMAPClient.sendEmail threading headers', () => { { id: 'mb-drafts', name: 'Drafts', role: 'drafts' }, { id: 'mb-sent', name: 'Sent', role: 'sent' }, ] as never); + vi.spyOn(client, 'getIdentities').mockResolvedValue([ + { id: 'identity-1', name: 'User', email: 'user@example.com', mayDelete: false }, + ]); const requestSpy = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request') .mockImplementation(async (methodCalls) => { const args = methodCalls[0][1] as { create?: unknown; update?: Record }; diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 3c2ee4a5..bbda377e 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -58,7 +58,7 @@ export class DemoJMAPClient implements IJMAPClient { return { 'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 }, 'urn:ietf:params:jmap:mail': {}, - 'urn:ietf:params:jmap:submission': {}, + 'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: ['FUTURERELEASE'] }, 'urn:ietf:params:jmap:vacationresponse': {}, 'urn:ietf:params:jmap:contacts': {}, 'urn:ietf:params:jmap:calendars': {}, @@ -454,7 +454,7 @@ export class DemoJMAPClient implements IJMAPClient { attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], - sendAt?: string, + delayedUntil?: string, ): Promise { // Remove draft if updating if (draftId) { @@ -463,8 +463,8 @@ export class DemoJMAPClient implements IJMAPClient { const sentMb = this.data.mailboxes.find(m => m.role === 'sent'); const email: Email = { id: generateDemoId('email'), threadId: generateDemoId('thread'), - mailboxIds: { [sendAt ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true }, - keywords: sendAt ? { $seen: true, $draft: true } : { $seen: true }, + mailboxIds: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true }, + keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true }, size: body.length + (htmlBody?.length || 0), receivedAt: new Date().toISOString(), from: [{ name: 'Demo User', email: 'demo@example.com' }], @@ -485,20 +485,20 @@ export class DemoJMAPClient implements IJMAPClient { }; this.data.emails.push(email); let emailSubmissionId: string | undefined; - if (sendAt) { + if (delayedUntil) { emailSubmissionId = generateDemoId('submission'); this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId: email.id, identityId: _identityId || 'demo-identity', - sendAt, + sendAt: delayedUntil, undoStatus: 'pending', isSmime: false, }); } this.recalcMailboxCounts(); - return sendAt - ? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt } + return delayedUntil + ? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt: delayedUntil } : { scheduled: false, emailId: email.id }; } @@ -919,15 +919,15 @@ export class DemoJMAPClient implements IJMAPClient { async importRawEmail(): Promise { return generateDemoId('email'); } async submitEmail(): Promise { /* no-op */ } - async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, sendAt?: string): Promise { + async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string): Promise { const emailId = generateDemoId('email'); const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts'); const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent'); const email: Email = { id: emailId, threadId: generateDemoId('thread'), - mailboxIds: { [(sendAt ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true }, - keywords: sendAt ? { $seen: true, $draft: true } : { $seen: true }, + mailboxIds: { [(delayedUntil ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true }, + keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true }, size: 1024, receivedAt: new Date().toISOString(), from: [{ name: 'Demo User', email: 'demo@example.com' }], @@ -938,12 +938,12 @@ export class DemoJMAPClient implements IJMAPClient { }; this.data.emails.push(email); let emailSubmissionId: string | undefined; - if (sendAt) { + if (delayedUntil) { emailSubmissionId = generateDemoId('submission'); - this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt, undoStatus: 'pending', isSmime: true }); + this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true }); } this.recalcMailboxCounts(); - return sendAt ? { scheduled: true, emailId, emailSubmissionId, sendAt, isSmime: true } : { scheduled: false, emailId, isSmime: true }; + return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true }; } async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number }> { @@ -972,11 +972,11 @@ export class DemoJMAPClient implements IJMAPClient { if (submission) submission.undoStatus = 'canceled'; } - async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, sendAt: string): Promise { + async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise { await this.cancelEmailSubmission(submissionId); const replacement = generateDemoId('submission'); - this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt, undoStatus: 'pending', isSmime: false }); - return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt }; + this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: false }); + return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil }; } async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise { diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index fe581122..daf0b721 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -148,13 +148,13 @@ export interface IJMAPClient { attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], - sendAt?: string, + delayedUntil?: string, ): Promise; - sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, sendAt?: string): Promise; + sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string): Promise; getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number }>; cancelEmailSubmission(submissionId: string): Promise; - rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, sendAt: string): Promise; + rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise; restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise; sendImipReply(opts: { diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 39b48920..4a2ae37d 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -337,6 +337,27 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string { return name.replace(/\s*<[^>]*>\s*$/, '').trim(); } +function formatHoldUntil(delayedUntil: string): string { + const date = new Date(delayedUntil); + const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const pad = (value: number) => String(value).padStart(2, '0'); + + return `${weekdays[date.getUTCDay()]}, ${pad(date.getUTCDate())} ${months[date.getUTCMonth()]} ${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} +0000`; +} + +function createDelayedSubmissionEnvelope(fromEmail: string, delayedUntil?: string): Record | undefined { + if (!delayedUntil) return undefined; + return { + mailFrom: { + email: fromEmail, + parameters: { + HOLDUNTIL: formatHoldUntil(delayedUntil), + }, + }, + }; +} + export class JMAPClient implements IJMAPClient { private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000; @@ -2082,9 +2103,9 @@ export class JMAPClient implements IJMAPClient { attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], - sendAt?: string + delayedUntil?: string ): Promise { - if (sendAt) this.validateSendAt(sendAt); + if (delayedUntil) this.validateDelayedUntil(delayedUntil); const emailId = `send-${Date.now()}`; const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); @@ -2189,9 +2210,14 @@ export class JMAPClient implements IJMAPClient { accountId: this.accountId, create: { [emailId]: emailCreate }, }, "1"]); + const submissionCreate = { + emailId: `#${emailId}`, + identityId: finalIdentityId, + ...(delayedUntil ? { envelope: createDelayedSubmissionEnvelope(fromEmail || this.username, delayedUntil) } : {}), + }; methodCalls.push(["EmailSubmission/set", { accountId: this.accountId, - create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId, ...(sendAt ? { sendAt } : {}) } }, + create: { "1": submissionCreate }, onSuccessUpdateEmail, }, "2"]); } else { @@ -2199,9 +2225,14 @@ export class JMAPClient implements IJMAPClient { accountId: this.accountId, create: { [emailId]: emailCreate }, }, "0"]); + const submissionCreate = { + emailId: `#${emailId}`, + identityId: finalIdentityId, + ...(delayedUntil ? { envelope: createDelayedSubmissionEnvelope(fromEmail || this.username, delayedUntil) } : {}), + }; methodCalls.push(["EmailSubmission/set", { accountId: this.accountId, - create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId, ...(sendAt ? { sendAt } : {}) } }, + create: { "1": submissionCreate }, onSuccessUpdateEmail, }, "1"]); } @@ -2210,6 +2241,7 @@ export class JMAPClient implements IJMAPClient { let createdEmailId: string | undefined; let emailSubmissionId: string | undefined; + let serverSendAt: string | undefined; if (response.methodResponses) { for (const [methodName, result] of response.methodResponses) { @@ -2230,12 +2262,17 @@ export class JMAPClient implements IJMAPClient { } if (methodName === 'EmailSubmission/set' && result.created?.['1']?.id) { emailSubmissionId = result.created['1'].id; + serverSendAt = result.created['1'].sendAt; } } } - return sendAt - ? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt } + if (delayedUntil && emailSubmissionId && !serverSendAt) { + serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId); + } + + return delayedUntil + ? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt } : { scheduled: false, emailId: createdEmailId, emailSubmissionId }; } @@ -2866,19 +2903,27 @@ export class JMAPClient implements IJMAPClient { getMaxDelayedSend(accountId?: string): number { const id = accountId || this.accountId; - const submissionCapability = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:submission"] as { maxDelayedSend?: number } | undefined; - return typeof submissionCapability?.maxDelayedSend === 'number' ? submissionCapability.maxDelayedSend : 0; + const accountCapability = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:submission"] as { maxDelayedSend?: number } | undefined; + const sessionCapability = this.session?.capabilities?.["urn:ietf:params:jmap:submission"] as { maxDelayedSend?: number } | undefined; + const maxDelayedSend = accountCapability?.maxDelayedSend ?? sessionCapability?.maxDelayedSend; + return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0; } hasDelayedSend(accountId?: string): boolean { const id = accountId || this.accountId; + const accountCapability = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:submission"] as { submissionExtensions?: unknown } | undefined; + const sessionCapability = this.session?.capabilities?.["urn:ietf:params:jmap:submission"] as { submissionExtensions?: unknown } | undefined; + const submissionExtensions = accountCapability?.submissionExtensions ?? sessionCapability?.submissionExtensions; + const hasFutureRelease = Array.isArray(submissionExtensions) + && submissionExtensions.some(extension => typeof extension === 'string' && extension.toUpperCase() === 'FUTURERELEASE'); + return this.supportsEmailSubmission() - && this.hasAccountCapability('urn:ietf:params:jmap:submission', id) + && hasFutureRelease && this.getMaxDelayedSend(id) > 0; } - private validateSendAt(sendAt: string, accountId?: string): void { - const time = new Date(sendAt).getTime(); + private validateDelayedUntil(delayedUntil: string, accountId?: string): void { + const time = new Date(delayedUntil).getTime(); if (!Number.isFinite(time)) { throw new Error('Scheduled send time is invalid'); } @@ -2895,6 +2940,18 @@ export class JMAPClient implements IJMAPClient { } } + private async getEmailSubmissionSendAt(submissionId: string): Promise { + const response = await this.request([ + ['EmailSubmission/get', { + accountId: this.accountId, + ids: [submissionId], + properties: ['sendAt', 'undoStatus'], + }, '0'], + ]); + const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined; + return submission?.sendAt; + } + getEventSourceUrl(): string | null { if (!this.session) return null; @@ -5361,9 +5418,9 @@ export class JMAPClient implements IJMAPClient { identityId: string, sentMailboxId: string, draftMailboxId?: string, - sendAt?: string, + delayedUntil?: string, ): Promise { - if (sendAt) this.validateSendAt(sendAt); + if (delayedUntil) this.validateDelayedUntil(delayedUntil); // Upload the raw message const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); const { blobId } = await this.uploadBlob(file); @@ -5371,6 +5428,10 @@ export class JMAPClient implements IJMAPClient { // Import into Drafts first, then move to Sent after submission succeeds. // This avoids encrypt-on-append affecting the SMTP send. See #188. const importMailboxId = draftMailboxId || sentMailboxId; + const identities = await this.getIdentities(); + const identity = identities.find(item => item.id === identityId); + const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, delayedUntil); + const methodCalls: [string, Record, string][] = [ ['Email/import', { accountId: this.accountId, @@ -5388,7 +5449,7 @@ export class JMAPClient implements IJMAPClient { 'raw-submit': { emailId: '#raw-import', identityId, - ...(sendAt ? { sendAt } : {}), + ...(envelope ? { envelope } : {}), }, }, ...(draftMailboxId ? { @@ -5406,6 +5467,7 @@ export class JMAPClient implements IJMAPClient { const response = await this.request(methodCalls); let emailId: string | undefined; let emailSubmissionId: string | undefined; + let serverSendAt: string | undefined; // Check for errors for (const [methodName, result] of response.methodResponses ?? []) { @@ -5421,12 +5483,18 @@ export class JMAPClient implements IJMAPClient { emailId = (result as { created?: Record }).created?.['raw-import']?.id; } if (methodName === 'EmailSubmission/set') { - emailSubmissionId = (result as { created?: Record }).created?.['raw-submit']?.id; + const created = (result as { created?: Record }).created?.['raw-submit']; + emailSubmissionId = created?.id; + serverSendAt = created?.sendAt; } } - return sendAt - ? { scheduled: true, emailId, emailSubmissionId, sendAt, isSmime: true } + if (delayedUntil && emailSubmissionId && !serverSendAt) { + serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId); + } + + return delayedUntil + ? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, isSmime: true } : { scheduled: false, emailId, emailSubmissionId, isSmime: true }; } @@ -5517,15 +5585,18 @@ export class JMAPClient implements IJMAPClient { } } - async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, sendAt: string): Promise { - this.validateSendAt(sendAt); + async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise { + this.validateDelayedUntil(delayedUntil); const mailboxes = await this.getMailboxes(); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); + const identities = await this.getIdentities(); + const identity = identities.find(item => item.id === identityId); + const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, delayedUntil); const response = await this.request([ ['EmailSubmission/set', { accountId: this.accountId, - create: { replacement: { emailId, identityId, sendAt } }, + create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } }, ...(draftsMailbox && sentMailbox ? { onSuccessUpdateEmail: { '#replacement': { @@ -5543,9 +5614,11 @@ export class JMAPClient implements IJMAPClient { throw new Error(createError.description || createError.type || 'Failed to reschedule email'); } const replacementId = result?.created?.replacement?.id; + const serverSendAt = result?.created?.replacement?.sendAt; if (!replacementId) { throw new Error('Server did not return a replacement scheduled send ID'); } + const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId); try { await this.cancelEmailSubmission(submissionId); } catch (error) { @@ -5557,7 +5630,7 @@ export class JMAPClient implements IJMAPClient { const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send'; throw new Error(`Reschedule created a replacement but could not cancel the original: ${message}`); } - return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt }; + return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt }; } async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise { diff --git a/stores/email-store.ts b/stores/email-store.ts index fbad284f..e85192c9 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -94,8 +94,8 @@ interface EmailStore { loadMoreEmails: (client: IJMAPClient) => Promise; fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise; fetchQuota: (client: IJMAPClient) => Promise; - sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], sendAt?: string) => Promise; - sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, sendAt?: string) => Promise; + sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string) => Promise; + sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string) => Promise; deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise; moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; @@ -153,7 +153,7 @@ interface EmailStore { loadMoreScheduledEmails: (client: IJMAPClient) => Promise; cancelScheduledEmail: (client: IJMAPClient, submissionId: string) => Promise; cancelScheduledEmailForEdit: (client: IJMAPClient, email: ScheduledEmail | Email) => Promise; - rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, sendAt: string) => Promise; + rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, delayedUntil: string) => Promise; cancelUndoSend: (client: IJMAPClient, pending: PendingUndoSend) => Promise; clearPendingUndoSend: () => void; refreshScheduledMetadata: (client: IJMAPClient) => Promise; @@ -219,8 +219,8 @@ function annotateScheduledEmail( function shouldClearPendingUndoSend(pending: PendingUndoSend | null, scheduledEmails: ScheduledEmail[]): boolean { if (!pending) return false; - const sendAt = new Date(pending.sendAt).getTime(); - if (Number.isFinite(sendAt) && sendAt <= Date.now()) return true; + const pendingSendTime = new Date(pending.sendAt).getTime(); + if (Number.isFinite(pendingSendTime) && pendingSendTime <= Date.now()) return true; const scheduledEmail = scheduledEmails.find(email => email.emailSubmissionId === pending.submissionId); return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending'; } @@ -607,10 +607,10 @@ export const useEmailStore = create((set, get) => ({ } }, - sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, sendAt) => { + sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil) => { set({ isLoading: true, error: null }); try { - const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, sendAt); + const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil); // Refresh handled by UI layer for immediate feedback set({ isLoading: false, @@ -628,14 +628,14 @@ export const useEmailStore = create((set, get) => ({ } }, - sendRawEmail: async (client, rawMimeBlob, identityId, sendAt) => { + sendRawEmail: async (client, rawMimeBlob, identityId, delayedUntil) => { set({ isLoading: true, error: null }); try { const mailboxes = await client.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) throw new Error('No sent mailbox found'); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); - const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, sendAt); + const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil); set({ isLoading: false, pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt @@ -2152,12 +2152,12 @@ export const useEmailStore = create((set, get) => ({ return restored; }, - rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, sendAt) => { + rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, delayedUntil) => { try { - const result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, sendAt); + const result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, delayedUntil); const pendingUndoSend = get().pendingUndoSend; if (pendingUndoSend?.submissionId === submissionId) { - set({ pendingUndoSend: { ...pendingUndoSend, sendAt } }); + set({ pendingUndoSend: { ...pendingUndoSend, sendAt: result.sendAt || delayedUntil } }); } return result; } finally {