This commit is contained in:
Lucas Gaitzsch
2026-05-08 22:37:16 +02:00
parent c458091698
commit f45b67fe19
10 changed files with 204 additions and 112 deletions
+12 -12
View File
@@ -630,12 +630,12 @@ export default function Home() {
useEffect(() => { useEffect(() => {
if (!pendingUndoSend) return; if (!pendingUndoSend) return;
const sendAt = new Date(pendingUndoSend.sendAt).getTime(); const pendingSendTime = new Date(pendingUndoSend.sendAt).getTime();
if (!Number.isFinite(sendAt) || sendAt <= Date.now()) { if (!Number.isFinite(pendingSendTime) || pendingSendTime <= Date.now()) {
clearPendingUndoSend(); clearPendingUndoSend();
return; return;
} }
const timer = setTimeout(clearPendingUndoSend, sendAt - Date.now()); const timer = setTimeout(clearPendingUndoSend, pendingSendTime - Date.now());
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [clearPendingUndoSend, pendingUndoSend]); }, [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 }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[]; inReplyTo?: string[];
references?: string[]; references?: string[];
sendAt?: string; delayedUntil?: string;
}) => { }) => {
if (!client) return; if (!client) return;
@@ -952,7 +952,7 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode; const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id; 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); setShowComposer(false);
if (result.scheduled) { if (result.scheduled) {
await refreshScheduledMetadata(client); await refreshScheduledMetadata(client);
@@ -1703,13 +1703,13 @@ export default function Home() {
const originalEmailId = selectedEmail.id; const originalEmailId = selectedEmail.id;
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds; const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
let sendAt: string | undefined; let delayedUntil: string | undefined;
if (sendDelaySeconds > 0) { if (sendDelaySeconds > 0) {
if (!client.hasDelayedSend()) { if (!client.hasDelayedSend()) {
const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm')); const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm'));
if (!confirmed) return; if (!confirmed) return;
} else { } 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, undefined,
threading?.inReplyTo, threading?.inReplyTo,
threading?.references, threading?.references,
sendAt, delayedUntil,
); );
if (result.scheduled) { if (result.scheduled) {
@@ -2312,9 +2312,9 @@ export default function Home() {
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer'); if (isMobile) setActiveView('viewer');
}} }}
onRescheduleScheduled={async (email, sendAt) => { onRescheduleScheduled={async (email, delayedUntil) => {
if (client && email.emailSubmissionId && email.scheduledIdentityId) { 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} onEmailSelect={handleEmailSelect}
@@ -2561,9 +2561,9 @@ export default function Home() {
} }
if (restored) await handleEditDraft(restored); if (restored) await handleEditDraft(restored);
}} }}
onRescheduleScheduled={async (sendAt) => { onRescheduleScheduled={async (delayedUntil) => {
if (client && selectedEmail?.emailSubmissionId && selectedEmail.scheduledIdentityId) { 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={() => { onCompose={() => {
+8 -5
View File
@@ -1551,16 +1551,19 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult {
} }
function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult { function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult {
const created: Record<string, { id: string }> = {}; const created: Record<string, { id: string; sendAt?: string }> = {};
const updated: Record<string, null> = {}; const updated: Record<string, null> = {};
const create = args.create as Record<string, { emailId?: string; identityId?: string; sendAt?: string }> | undefined; const create = args.create as Record<string, { emailId?: string; identityId?: string; envelope?: { mailFrom?: { parameters?: { HOLDUNTIL?: string } } } }> | undefined;
if (create) { if (create) {
for (const [key, value] of Object.entries(create)) { for (const [key, value] of Object.entries(create)) {
const id = `submission-${Date.now()}-${key}`; const id = `submission-${Date.now()}-${key}`;
created[key] = { id }; const holdUntil = value.envelope?.mailFrom?.parameters?.HOLDUNTIL;
if (value.sendAt && value.emailId && value.identityId) { 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; 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' });
} }
} }
} }
+23 -22
View File
@@ -72,7 +72,7 @@ interface EmailComposerProps {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[]; inReplyTo?: string[];
references?: string[]; references?: string[];
sendAt?: string; delayedUntil?: string;
}) => void | Promise<void>; }) => void | Promise<void>;
onScheduledSendCreated?: () => void | Promise<void>; onScheduledSendCreated?: () => void | Promise<void>;
onClose?: () => void; onClose?: () => void;
@@ -876,8 +876,8 @@ export function EmailComposer({
return null; return null;
}; };
const getEffectiveSendAt = async (explicitSendAt?: string): Promise<string | undefined> => { const resolveDelayedUntil = async (requestedDelayedUntil?: string): Promise<string | undefined> => {
if (explicitSendAt) return explicitSendAt; if (requestedDelayedUntil) return requestedDelayedUntil;
if (sendDelaySeconds === 0) return undefined; if (sendDelaySeconds === 0) return undefined;
if (client?.hasDelayedSend()) { if (client?.hasDelayedSend()) {
return new Date(Date.now() + sendDelaySeconds * 1000).toISOString(); 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 ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean);
const bccAddresses = bcc.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 ?? []; const inlineAttachments = rewritten?.attachments ?? [];
try { try {
const effectiveSendAt = await getEffectiveSendAt(sendAt); const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
// Let plugins veto the send (external-mail warning, mistyped-domain // Let plugins veto the send (external-mail warning, mistyped-domain
// guards, etc.). Returning false from any handler aborts before either // guards, etc.). Returning false from any handler aborts before either
// the S/MIME or standard JMAP path runs. // the S/MIME or standard JMAP path runs.
@@ -1151,8 +1151,8 @@ export function EmailComposer({
} }
// 7. Send via raw email path // 7. Send via raw email path
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveSendAt); const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil);
if (effectiveSendAt && finalDraftId) { if (effectiveDelayedUntil && finalDraftId) {
client.deleteEmail(finalDraftId).catch(err => { client.deleteEmail(finalDraftId).catch(err => {
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err); debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
toast.warning(t('schedule_send_cleanup_warning')); toast.warning(t('schedule_send_cleanup_warning'));
@@ -1199,7 +1199,7 @@ export function EmailComposer({
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo, inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references, references: threadingHeaders?.references,
sendAt: effectiveSendAt, delayedUntil: effectiveDelayedUntil,
}); });
if (mode === 'reply' || mode === 'replyAll') { if (mode === 'reply' || mode === 'replyAll') {
@@ -1700,20 +1700,21 @@ export function EmailComposer({
> >
<BookmarkPlus className="w-4 h-4" /> <BookmarkPlus className="w-4 h-4" />
</Button> </Button>
<Button {client?.hasDelayedSend() && (
variant="ghost" <Button
size="icon" variant="ghost"
onClick={() => { size="icon"
setScheduleError(''); onClick={() => {
setScheduleValue(''); setScheduleError('');
setShowScheduleDialog(true); setScheduleValue('');
}} setShowScheduleDialog(true);
disabled={!client?.hasDelayedSend()} }}
title={client?.hasDelayedSend() ? t('schedule_send') : t('schedule_send_unsupported')} title={t('schedule_send')}
className="h-9 w-9" className="h-9 w-9"
> >
<CalendarClock className="w-4 h-4" /> <CalendarClock className="w-4 h-4" />
</Button> </Button>
)}
{/* S/MIME toggles */} {/* S/MIME toggles */}
{canSmimeSign && ( {canSmimeSign && (
+6 -6
View File
@@ -43,7 +43,7 @@ interface EmailListProps {
onLoadMoreScheduled?: () => void; onLoadMoreScheduled?: () => void;
onCancelScheduled?: (email: Email) => void | Promise<void>; onCancelScheduled?: (email: Email) => void | Promise<void>;
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>; onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
onRescheduleScheduled?: (email: Email, sendAt: string) => void | Promise<void>; onRescheduleScheduled?: (email: Email, delayedUntil: string) => void | Promise<void>;
} }
export function EmailList({ export function EmailList({
@@ -235,7 +235,7 @@ export function EmailList({
} }
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]); }, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
const promptForRescheduleSendAt = useCallback((): string | null => { const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('reschedule_prompt')); const value = window.prompt(t('reschedule_prompt'));
if (!value) return null; if (!value) return null;
const time = new Date(value).getTime(); const time = new Date(value).getTime();
@@ -518,8 +518,8 @@ export function EmailList({
size="sm" size="sm"
className="h-7 px-2" className="h-7 px-2"
onClick={() => { onClick={() => {
const sendAt = promptForRescheduleSendAt(); const delayedUntil = promptForRescheduleDelayedUntil();
if (sendAt) onRescheduleScheduled?.(thread.latestEmail, sendAt); if (delayedUntil) onRescheduleScheduled?.(thread.latestEmail, delayedUntil);
}} }}
> >
<CalendarClock className="w-3.5 h-3.5 mr-1" /> <CalendarClock className="w-3.5 h-3.5 mr-1" />
@@ -581,8 +581,8 @@ export function EmailList({
onCancelScheduled={() => onCancelScheduled?.(contextMenu.data!)} onCancelScheduled={() => onCancelScheduled?.(contextMenu.data!)}
onCancelScheduledForEdit={() => onCancelScheduledForEdit?.(contextMenu.data!)} onCancelScheduledForEdit={() => onCancelScheduledForEdit?.(contextMenu.data!)}
onRescheduleScheduled={() => { onRescheduleScheduled={() => {
const sendAt = promptForRescheduleSendAt(); const delayedUntil = promptForRescheduleDelayedUntil();
if (sendAt) onRescheduleScheduled?.(contextMenu.data!, sendAt); if (delayedUntil) onRescheduleScheduled?.(contextMenu.data!, delayedUntil);
}} }}
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
onBatchDelete={() => client && batchDelete(client)} onBatchDelete={() => client && batchDelete(client)}
+6 -6
View File
@@ -121,7 +121,7 @@ interface EmailViewerProps {
onEditDraft?: () => void; onEditDraft?: () => void;
onCancelScheduled?: () => void; onCancelScheduled?: () => void;
onCancelScheduledForEdit?: () => void; onCancelScheduledForEdit?: () => void;
onRescheduleScheduled?: (sendAt: string) => void; onRescheduleScheduled?: (delayedUntil: string) => void;
onCompose?: () => void; onCompose?: () => void;
currentUserEmail?: string; currentUserEmail?: string;
currentUserName?: string; currentUserName?: string;
@@ -883,7 +883,7 @@ export function EmailViewer({
const { isTablet, isMobile } = useDeviceDetection(); const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore(); const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const promptForRescheduleSendAt = useCallback((): string | null => { const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('reschedule_prompt')); const value = window.prompt(t('reschedule_prompt'));
if (!value) return null; if (!value) return null;
const time = new Date(value).getTime(); const time = new Date(value).getTime();
@@ -3147,8 +3147,8 @@ export function EmailViewer({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
const sendAt = promptForRescheduleSendAt(); const delayedUntil = promptForRescheduleDelayedUntil();
if (sendAt) onRescheduleScheduled?.(sendAt); if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}} }}
className="hidden sm:flex sm:h-8" className="hidden sm:flex sm:h-8"
title={t('reschedule_send')} title={t('reschedule_send')}
@@ -4598,8 +4598,8 @@ export function EmailViewer({
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
const sendAt = promptForRescheduleSendAt(); const delayedUntil = promptForRescheduleDelayedUntil();
if (sendAt) onRescheduleScheduled?.(sendAt); if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
}} }}
> >
{t('reschedule_send')} {t('reschedule_send')}
+23 -8
View File
@@ -16,14 +16,14 @@ function enableDelayedSend(client: JMAPClient) {
capabilities: { capabilities: {
'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:core': {},
'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {}, 'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: ['FUTURERELEASE'] },
}, },
session: { session: {
accounts: { accounts: {
'account-1': { 'account-1': {
accountCapabilities: { accountCapabilities: {
'urn:ietf:params:jmap:mail': {}, '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 = { payload = {
methodResponses: [ methodResponses: [
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'], ['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).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(); 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(); const client = createClient();
enableDelayedSend(client); enableDelayedSend(client);
const captured = mockSendEmailFlow(); 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( const result = await client.sendEmail(
['recipient@example.com'], ['recipient@example.com'],
@@ -183,14 +183,26 @@ describe('JMAPClient.sendEmail threading headers', () => {
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
undefined, undefined,
undefined, undefined,
sendAt, delayedUntil,
); );
const identityRequest = captured[1]; const identityRequest = captured[1];
expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission'); expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission');
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set'); 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(submissionCall?.[1].create).toEqual({
expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt }); '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 () => { 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-drafts', name: 'Drafts', role: 'drafts' },
{ id: 'mb-sent', name: 'Sent', role: 'sent' }, { id: 'mb-sent', name: 'Sent', role: 'sent' },
] as never); ] 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') const requestSpy = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
.mockImplementation(async (methodCalls) => { .mockImplementation(async (methodCalls) => {
const args = methodCalls[0][1] as { create?: unknown; update?: Record<string, unknown> }; const args = methodCalls[0][1] as { create?: unknown; update?: Record<string, unknown> };
+17 -17
View File
@@ -58,7 +58,7 @@ export class DemoJMAPClient implements IJMAPClient {
return { return {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 }, 'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
'urn:ietf:params:jmap:mail': {}, '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:vacationresponse': {},
'urn:ietf:params:jmap:contacts': {}, 'urn:ietf:params:jmap:contacts': {},
'urn:ietf:params:jmap:calendars': {}, '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 }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[], inReplyTo?: string[],
references?: string[], references?: string[],
sendAt?: string, delayedUntil?: string,
): Promise<SendEmailResult> { ): Promise<SendEmailResult> {
// Remove draft if updating // Remove draft if updating
if (draftId) { if (draftId) {
@@ -463,8 +463,8 @@ export class DemoJMAPClient implements IJMAPClient {
const sentMb = this.data.mailboxes.find(m => m.role === 'sent'); const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = { const email: Email = {
id: generateDemoId('email'), threadId: generateDemoId('thread'), 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 }, mailboxIds: { [delayedUntil ? (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 }, keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
size: body.length + (htmlBody?.length || 0), size: body.length + (htmlBody?.length || 0),
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }], from: [{ name: 'Demo User', email: 'demo@example.com' }],
@@ -485,20 +485,20 @@ export class DemoJMAPClient implements IJMAPClient {
}; };
this.data.emails.push(email); this.data.emails.push(email);
let emailSubmissionId: string | undefined; let emailSubmissionId: string | undefined;
if (sendAt) { if (delayedUntil) {
emailSubmissionId = generateDemoId('submission'); emailSubmissionId = generateDemoId('submission');
this.scheduledSubmissions.set(emailSubmissionId, { this.scheduledSubmissions.set(emailSubmissionId, {
id: emailSubmissionId, id: emailSubmissionId,
emailId: email.id, emailId: email.id,
identityId: _identityId || 'demo-identity', identityId: _identityId || 'demo-identity',
sendAt, sendAt: delayedUntil,
undoStatus: 'pending', undoStatus: 'pending',
isSmime: false, isSmime: false,
}); });
} }
this.recalcMailboxCounts(); this.recalcMailboxCounts();
return sendAt return delayedUntil
? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt } ? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt: delayedUntil }
: { scheduled: false, emailId: email.id }; : { scheduled: false, emailId: email.id };
} }
@@ -919,15 +919,15 @@ export class DemoJMAPClient implements IJMAPClient {
async importRawEmail(): Promise<string> { return generateDemoId('email'); } async importRawEmail(): Promise<string> { return generateDemoId('email'); }
async submitEmail(): Promise<void> { /* no-op */ } async submitEmail(): Promise<void> { /* no-op */ }
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, sendAt?: string): Promise<SendEmailResult> { async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string): Promise<SendEmailResult> {
const emailId = generateDemoId('email'); const emailId = generateDemoId('email');
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts'); const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent'); const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = { const email: Email = {
id: emailId, id: emailId,
threadId: generateDemoId('thread'), threadId: generateDemoId('thread'),
mailboxIds: { [(sendAt ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true }, mailboxIds: { [(delayedUntil ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true },
keywords: sendAt ? { $seen: true, $draft: true } : { $seen: true }, keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
size: 1024, size: 1024,
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }], from: [{ name: 'Demo User', email: 'demo@example.com' }],
@@ -938,12 +938,12 @@ export class DemoJMAPClient implements IJMAPClient {
}; };
this.data.emails.push(email); this.data.emails.push(email);
let emailSubmissionId: string | undefined; let emailSubmissionId: string | undefined;
if (sendAt) { if (delayedUntil) {
emailSubmissionId = generateDemoId('submission'); 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(); 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 }> { 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'; if (submission) submission.undoStatus = 'canceled';
} }
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, sendAt: string): Promise<SendEmailResult> { async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
await this.cancelEmailSubmission(submissionId); await this.cancelEmailSubmission(submissionId);
const replacement = generateDemoId('submission'); const replacement = generateDemoId('submission');
this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt, undoStatus: 'pending', isSmime: false }); this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: false });
return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt }; return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil };
} }
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> { async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
+3 -3
View File
@@ -148,13 +148,13 @@ export interface IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[], inReplyTo?: string[],
references?: string[], references?: string[],
sendAt?: string, delayedUntil?: string,
): Promise<SendEmailResult>; ): Promise<SendEmailResult>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, sendAt?: string): Promise<SendEmailResult>; sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string): Promise<SendEmailResult>;
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number }>; getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number }>;
cancelEmailSubmission(submissionId: string): Promise<void>; cancelEmailSubmission(submissionId: string): Promise<void>;
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, sendAt: string): Promise<SendEmailResult>; rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>; restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>;
sendImipReply(opts: { sendImipReply(opts: {
+94 -21
View File
@@ -337,6 +337,27 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string {
return name.replace(/\s*<[^>]*>\s*$/, '').trim(); 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<string, unknown> | undefined {
if (!delayedUntil) return undefined;
return {
mailFrom: {
email: fromEmail,
parameters: {
HOLDUNTIL: formatHoldUntil(delayedUntil),
},
},
};
}
export class JMAPClient implements IJMAPClient { export class JMAPClient implements IJMAPClient {
private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000; 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 }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[], inReplyTo?: string[],
references?: string[], references?: string[],
sendAt?: string delayedUntil?: string
): Promise<SendEmailResult> { ): Promise<SendEmailResult> {
if (sendAt) this.validateSendAt(sendAt); if (delayedUntil) this.validateDelayedUntil(delayedUntil);
const emailId = `send-${Date.now()}`; const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
@@ -2189,9 +2210,14 @@ export class JMAPClient implements IJMAPClient {
accountId: this.accountId, accountId: this.accountId,
create: { [emailId]: emailCreate }, create: { [emailId]: emailCreate },
}, "1"]); }, "1"]);
const submissionCreate = {
emailId: `#${emailId}`,
identityId: finalIdentityId,
...(delayedUntil ? { envelope: createDelayedSubmissionEnvelope(fromEmail || this.username, delayedUntil) } : {}),
};
methodCalls.push(["EmailSubmission/set", { methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId, accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId, ...(sendAt ? { sendAt } : {}) } }, create: { "1": submissionCreate },
onSuccessUpdateEmail, onSuccessUpdateEmail,
}, "2"]); }, "2"]);
} else { } else {
@@ -2199,9 +2225,14 @@ export class JMAPClient implements IJMAPClient {
accountId: this.accountId, accountId: this.accountId,
create: { [emailId]: emailCreate }, create: { [emailId]: emailCreate },
}, "0"]); }, "0"]);
const submissionCreate = {
emailId: `#${emailId}`,
identityId: finalIdentityId,
...(delayedUntil ? { envelope: createDelayedSubmissionEnvelope(fromEmail || this.username, delayedUntil) } : {}),
};
methodCalls.push(["EmailSubmission/set", { methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId, accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId, ...(sendAt ? { sendAt } : {}) } }, create: { "1": submissionCreate },
onSuccessUpdateEmail, onSuccessUpdateEmail,
}, "1"]); }, "1"]);
} }
@@ -2210,6 +2241,7 @@ export class JMAPClient implements IJMAPClient {
let createdEmailId: string | undefined; let createdEmailId: string | undefined;
let emailSubmissionId: string | undefined; let emailSubmissionId: string | undefined;
let serverSendAt: string | undefined;
if (response.methodResponses) { if (response.methodResponses) {
for (const [methodName, result] of 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) { if (methodName === 'EmailSubmission/set' && result.created?.['1']?.id) {
emailSubmissionId = result.created['1'].id; emailSubmissionId = result.created['1'].id;
serverSendAt = result.created['1'].sendAt;
} }
} }
} }
return sendAt if (delayedUntil && emailSubmissionId && !serverSendAt) {
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt } serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
}
return delayedUntil
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt }
: { scheduled: false, emailId: createdEmailId, emailSubmissionId }; : { scheduled: false, emailId: createdEmailId, emailSubmissionId };
} }
@@ -2866,19 +2903,27 @@ export class JMAPClient implements IJMAPClient {
getMaxDelayedSend(accountId?: string): number { getMaxDelayedSend(accountId?: string): number {
const id = accountId || this.accountId; const id = accountId || this.accountId;
const submissionCapability = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:submission"] as { maxDelayedSend?: number } | undefined; const accountCapability = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:submission"] as { maxDelayedSend?: number } | undefined;
return typeof submissionCapability?.maxDelayedSend === 'number' ? submissionCapability.maxDelayedSend : 0; 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 { hasDelayedSend(accountId?: string): boolean {
const id = accountId || this.accountId; 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() return this.supportsEmailSubmission()
&& this.hasAccountCapability('urn:ietf:params:jmap:submission', id) && hasFutureRelease
&& this.getMaxDelayedSend(id) > 0; && this.getMaxDelayedSend(id) > 0;
} }
private validateSendAt(sendAt: string, accountId?: string): void { private validateDelayedUntil(delayedUntil: string, accountId?: string): void {
const time = new Date(sendAt).getTime(); const time = new Date(delayedUntil).getTime();
if (!Number.isFinite(time)) { if (!Number.isFinite(time)) {
throw new Error('Scheduled send time is invalid'); throw new Error('Scheduled send time is invalid');
} }
@@ -2895,6 +2940,18 @@ export class JMAPClient implements IJMAPClient {
} }
} }
private async getEmailSubmissionSendAt(submissionId: string): Promise<string | undefined> {
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 { getEventSourceUrl(): string | null {
if (!this.session) return null; if (!this.session) return null;
@@ -5361,9 +5418,9 @@ export class JMAPClient implements IJMAPClient {
identityId: string, identityId: string,
sentMailboxId: string, sentMailboxId: string,
draftMailboxId?: string, draftMailboxId?: string,
sendAt?: string, delayedUntil?: string,
): Promise<SendEmailResult> { ): Promise<SendEmailResult> {
if (sendAt) this.validateSendAt(sendAt); if (delayedUntil) this.validateDelayedUntil(delayedUntil);
// Upload the raw message // Upload the raw message
const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.uploadBlob(file); 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. // Import into Drafts first, then move to Sent after submission succeeds.
// This avoids encrypt-on-append affecting the SMTP send. See #188. // This avoids encrypt-on-append affecting the SMTP send. See #188.
const importMailboxId = draftMailboxId || sentMailboxId; 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, unknown>, string][] = [ const methodCalls: [string, Record<string, unknown>, string][] = [
['Email/import', { ['Email/import', {
accountId: this.accountId, accountId: this.accountId,
@@ -5388,7 +5449,7 @@ export class JMAPClient implements IJMAPClient {
'raw-submit': { 'raw-submit': {
emailId: '#raw-import', emailId: '#raw-import',
identityId, identityId,
...(sendAt ? { sendAt } : {}), ...(envelope ? { envelope } : {}),
}, },
}, },
...(draftMailboxId ? { ...(draftMailboxId ? {
@@ -5406,6 +5467,7 @@ export class JMAPClient implements IJMAPClient {
const response = await this.request(methodCalls); const response = await this.request(methodCalls);
let emailId: string | undefined; let emailId: string | undefined;
let emailSubmissionId: string | undefined; let emailSubmissionId: string | undefined;
let serverSendAt: string | undefined;
// Check for errors // Check for errors
for (const [methodName, result] of response.methodResponses ?? []) { for (const [methodName, result] of response.methodResponses ?? []) {
@@ -5421,12 +5483,18 @@ export class JMAPClient implements IJMAPClient {
emailId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-import']?.id; emailId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-import']?.id;
} }
if (methodName === 'EmailSubmission/set') { if (methodName === 'EmailSubmission/set') {
emailSubmissionId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-submit']?.id; const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['raw-submit'];
emailSubmissionId = created?.id;
serverSendAt = created?.sendAt;
} }
} }
return sendAt if (delayedUntil && emailSubmissionId && !serverSendAt) {
? { scheduled: true, emailId, emailSubmissionId, sendAt, isSmime: true } serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
}
return delayedUntil
? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, isSmime: true }
: { scheduled: false, emailId, emailSubmissionId, 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<SendEmailResult> { async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
this.validateSendAt(sendAt); this.validateDelayedUntil(delayedUntil);
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); 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([ const response = await this.request([
['EmailSubmission/set', { ['EmailSubmission/set', {
accountId: this.accountId, accountId: this.accountId,
create: { replacement: { emailId, identityId, sendAt } }, create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } },
...(draftsMailbox && sentMailbox ? { ...(draftsMailbox && sentMailbox ? {
onSuccessUpdateEmail: { onSuccessUpdateEmail: {
'#replacement': { '#replacement': {
@@ -5543,9 +5614,11 @@ export class JMAPClient implements IJMAPClient {
throw new Error(createError.description || createError.type || 'Failed to reschedule email'); throw new Error(createError.description || createError.type || 'Failed to reschedule email');
} }
const replacementId = result?.created?.replacement?.id; const replacementId = result?.created?.replacement?.id;
const serverSendAt = result?.created?.replacement?.sendAt;
if (!replacementId) { if (!replacementId) {
throw new Error('Server did not return a replacement scheduled send ID'); throw new Error('Server did not return a replacement scheduled send ID');
} }
const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId);
try { try {
await this.cancelEmailSubmission(submissionId); await this.cancelEmailSubmission(submissionId);
} catch (error) { } catch (error) {
@@ -5557,7 +5630,7 @@ export class JMAPClient implements IJMAPClient {
const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send'; 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}`); 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<void> { async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
+12 -12
View File
@@ -94,8 +94,8 @@ interface EmailStore {
loadMoreEmails: (client: IJMAPClient) => Promise<void>; loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>; fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>; fetchQuota: (client: IJMAPClient) => Promise<void>;
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<SendEmailResult>; 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<SendEmailResult>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, sendAt?: string) => Promise<SendEmailResult>; sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string) => Promise<SendEmailResult>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>; deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>; moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
@@ -153,7 +153,7 @@ interface EmailStore {
loadMoreScheduledEmails: (client: IJMAPClient) => Promise<void>; loadMoreScheduledEmails: (client: IJMAPClient) => Promise<void>;
cancelScheduledEmail: (client: IJMAPClient, submissionId: string) => Promise<void>; cancelScheduledEmail: (client: IJMAPClient, submissionId: string) => Promise<void>;
cancelScheduledEmailForEdit: (client: IJMAPClient, email: ScheduledEmail | Email) => Promise<Email | null>; cancelScheduledEmailForEdit: (client: IJMAPClient, email: ScheduledEmail | Email) => Promise<Email | null>;
rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, sendAt: string) => Promise<SendEmailResult>; rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, delayedUntil: string) => Promise<SendEmailResult>;
cancelUndoSend: (client: IJMAPClient, pending: PendingUndoSend) => Promise<Email | null>; cancelUndoSend: (client: IJMAPClient, pending: PendingUndoSend) => Promise<Email | null>;
clearPendingUndoSend: () => void; clearPendingUndoSend: () => void;
refreshScheduledMetadata: (client: IJMAPClient) => Promise<void>; refreshScheduledMetadata: (client: IJMAPClient) => Promise<void>;
@@ -219,8 +219,8 @@ function annotateScheduledEmail(
function shouldClearPendingUndoSend(pending: PendingUndoSend | null, scheduledEmails: ScheduledEmail[]): boolean { function shouldClearPendingUndoSend(pending: PendingUndoSend | null, scheduledEmails: ScheduledEmail[]): boolean {
if (!pending) return false; if (!pending) return false;
const sendAt = new Date(pending.sendAt).getTime(); const pendingSendTime = new Date(pending.sendAt).getTime();
if (Number.isFinite(sendAt) && sendAt <= Date.now()) return true; if (Number.isFinite(pendingSendTime) && pendingSendTime <= Date.now()) return true;
const scheduledEmail = scheduledEmails.find(email => email.emailSubmissionId === pending.submissionId); const scheduledEmail = scheduledEmails.find(email => email.emailSubmissionId === pending.submissionId);
return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending'; return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending';
} }
@@ -607,10 +607,10 @@ export const useEmailStore = create<EmailStore>((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 }); set({ isLoading: true, error: null });
try { 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 // Refresh handled by UI layer for immediate feedback
set({ set({
isLoading: false, isLoading: false,
@@ -628,14 +628,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} }
}, },
sendRawEmail: async (client, rawMimeBlob, identityId, sendAt) => { sendRawEmail: async (client, rawMimeBlob, identityId, delayedUntil) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const mailboxes = await client.getMailboxes(); const mailboxes = await client.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) throw new Error('No sent mailbox found'); if (!sentMailbox) throw new Error('No sent mailbox found');
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); 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({ set({
isLoading: false, isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
@@ -2152,12 +2152,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return restored; return restored;
}, },
rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, sendAt) => { rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, delayedUntil) => {
try { try {
const result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, sendAt); const result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, delayedUntil);
const pendingUndoSend = get().pendingUndoSend; const pendingUndoSend = get().pendingUndoSend;
if (pendingUndoSend?.submissionId === submissionId) { if (pendingUndoSend?.submissionId === submissionId) {
set({ pendingUndoSend: { ...pendingUndoSend, sendAt } }); set({ pendingUndoSend: { ...pendingUndoSend, sendAt: result.sendAt || delayedUntil } });
} }
return result; return result;
} finally { } finally {