17 KiB
Scheduled Send Implementation Plan
Plan for adding server-side scheduled send and optional short "Undo send" delay to Bulwark Webmail.
Scope
- Add manual "Schedule send" from the composer.
- Add a global composing setting
sendDelaySeconds: 0 | 10 | 30 | 60; default0. - Use JMAP
EmailSubmission.sendAtas the only source of truth. - Add a virtual Scheduled view backed by
EmailSubmission/query, not a real mailbox. - Support cancel, reschedule, and non-S/MIME
Cancel and edit. - Support S/MIME scheduled send through the raw MIME path, but do not edit signed/encrypted raw messages.
- Do not add browser-local scheduling, per-account delay settings, recurring send, templates, or server compatibility fallbacks in the first implementation.
Current Code Facts
- Standard send flow:
components/email/email-composer.tsx->app/[locale]/page.tsxhandleEmailSend->stores/email-store.tssendEmail->lib/jmap/client.tssendEmail. - S/MIME flow: composer builds raw MIME, signs/encrypts, then calls
stores/email-store.tssendRawEmail->lib/jmap/client.tssendRawEmail. lib/jmap/client.tsrequest(methodCalls, using?)defaults to Core + Mail only. Any request withIdentity/*orEmailSubmission/*must pass submission capability explicitly.lib/jmap/types.tsalready hasEmailSubmissionwithsendAtandundoStatus; add only missing client-onlyEmailfields and optional helper types.sendEmail,sendRawEmail, and store wrappers currently returnPromise<void>; scheduled/undo UX needs a structured result containing the createdEmailSubmissionID.app/[locale]/page.tsxsets$answered/$forwardedimmediately inhandleEmailSend;handleQuickReplyalso sets$answeredimmediately. Scheduled sends must skip those immediate keyword updates.- The composer already tracks a post-save
finalDraftIdbefore sending. S/MIME scheduled cleanup must delete that final plaintext draft, not stale component state. stores/settings-store.tsis the right place for the global delay preference.components/settings/composing-settings.tsxis the right settings UI location.stores/email-store.tsowns the active email list, selection, loading, push handling, and batch operations. Scheduled view integration must not assume a second list is automatically respected everywhere.
JMAP Requirements
Use this capability list for every request containing Identity/* or EmailSubmission/*:
const SUBMISSION_USING = [
'urn:ietf:params:jmap:core',
'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:submission',
];
Delayed send is enabled only when all are true:
supportsEmailSubmission()is true.hasAccountCapability('urn:ietf:params:jmap:submission', accountId)is true.- Account capability
maxDelayedSend > 0.
Add client helpers:
getMaxDelayedSend(accountId?: string): number;
hasDelayedSend(accountId?: string): boolean;
Validate every sendAt:
- Valid ISO date.
- Strictly in the future.
- Not later than
Date.now() + maxDelayedSend * 1000.
When creating scheduled submissions, add sendAt to EmailSubmission/set.create and keep onSuccessUpdateEmail so the server can move Drafts to Sent when delivery succeeds:
{
"accountId": "account-id",
"create": {
"submit": {
"emailId": "#created-email-or-import",
"identityId": "identity-id",
"sendAt": "2026-04-29T08:30:00.000Z"
}
},
"onSuccessUpdateEmail": {
"#submit": {
"mailboxIds/drafts-id": null,
"mailboxIds/sent-id": true,
"keywords/$draft": null
}
}
}
Verify server behavior: if onSuccessUpdateEmail is applied immediately instead of at release time, Scheduled must still use EmailSubmission/query as truth and normal Drafts/Sent UI must guard or hide pending scheduled messages.
Data Model And Client API
Add a send result and scheduled email shape:
export interface SendEmailResult {
scheduled: boolean;
emailId?: string;
emailSubmissionId?: string;
sendAt?: string;
isSmime?: boolean;
}
export interface ScheduledEmail extends Email {
scheduledSendAt: string;
emailSubmissionId: string;
scheduledIdentityId: string;
scheduledUndoStatus: 'pending' | 'final' | 'canceled';
isScheduled: true;
isSmimeScheduled: boolean;
}
Also add optional client-only fields to Email for messages that appear in normal mailbox queries:
scheduledSendAt?: string;
emailSubmissionId?: string;
scheduledIdentityId?: string;
scheduledUndoStatus?: 'pending' | 'final' | 'canceled';
isScheduled?: boolean;
isSmimeScheduled?: boolean;
Extend IJMAPClient and both client implementations:
sendEmail(..., sendAt?: string): Promise<SendEmailResult>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, sendAt?: string): Promise<SendEmailResult>;
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number }>;
cancelEmailSubmission(submissionId: string): Promise<void>;
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, sendAt: string): Promise<SendEmailResult>;
restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>;
Implementation notes:
sendEmailaddssendAtto both draft and non-draft submission branches, returns{ scheduled: true, emailId, emailSubmissionId, sendAt }whensendAtis present, otherwise{ scheduled: false }.sendRawEmailimports into Drafts as today, addssendAttoraw-submit, keepsonSuccessUpdateEmail, and returnsisSmime: truefor delayed/scheduled sends.getScheduledEmailsqueriesEmailSubmissionwithundoStatus: pending, fetches submissions, drops any submission without a validsendAt, fetches referenced emails, merges scheduling metadata, detects S/MIME with existingdetectSmime(...), and sorts byscheduledSendAtascending.- Do not rely only on
sendAt > now; clock skew can hide near-term submissions. If anafterfilter is added for performance, use a small tolerance and treatundoStatus: pendingas authoritative. - Cancel uses
EmailSubmission/set.updatewithundoStatus: 'canceled'. Do not usedestroyfor cancellation. - Reschedule validates the new time, creates the replacement for the same
emailIdandidentityIdbefore canceling the old submission when the server allows multiple pending submissions for the same email, then refreshes scheduled state. If the server requires cancel-first semantics, surface cancel-succeeded/create-failed partial failures, refresh scheduled state, and leave or restore the email as a draft where possible so the user can recover. restoreEmailToDraftusesEmail/set.updatepatches to add Drafts, set$draft, and remove Sent only when the Sent mailbox ID is known.
Store Changes
stores/settings-store.ts
- Add
export type SendDelaySeconds = 0 | 10 | 30 | 60. - Add
sendDelaySeconds: SendDelaySecondsunder Composer settings with default0. - Ensure import/migration tolerates missing or invalid values by falling back to
0. - Keep runtime pending submissions out of settings state.
stores/email-store.ts
Add scheduled state:
scheduledEmails: ScheduledEmail[];
scheduledEmailIds: Set<string>;
scheduledSubmissionByEmailId: Map<string, {
submissionId: string;
sendAt: string;
identityId: string;
undoStatus: 'pending' | 'final' | 'canceled';
}>;
scheduledTotal: number;
scheduledHasMore: boolean;
isLoadingScheduled: boolean;
isScheduledView: boolean;
pendingUndoSend: null | { submissionId: string; emailId?: string; sendAt: string; isSmime: boolean };
Add actions:
fetchScheduledEmails(client)andloadMoreScheduledEmails(client).cancelScheduledEmail(client, submissionId).cancelScheduledEmailForEdit(client, email); cancel first, restore draft, then let page open composer.rescheduleScheduledEmail(client, submissionId, emailId, identityId, sendAt).cancelUndoSend(client, pending).- A lightweight refresh action for
scheduledEmailIds/scheduledSubmissionByEmailIdon app load andEmailSubmissionpush changes.
Store behavior:
- Scheduled view reads
scheduledEmails; normal views reademails. - Batch archive/delete/spam/move must no-op or be disabled in Scheduled view.
- Normal Drafts/Sent fetches should annotate or hide emails found in
scheduledEmailIds. Minimal safe behavior is to show a Scheduled banner and disable normal draft/edit/mailbox actions. cancelUndoSendcancels the submission, refreshes scheduled metadata, clearspendingUndoSend, and returns enough data for the page to reopen non-S/MIME drafts.
UI Changes
Settings
File: components/settings/composing-settings.tsx
- Add compact
Undo send/Send delayselect near existing composing settings. - Options:
Aus,10 seconds,30 seconds,60 seconds. - Persist through
updateSetting('sendDelaySeconds', value). - If the active account lacks delayed-send support, show a non-blocking warning; the saved global preference may still apply to other accounts.
Composer
File: components/email/email-composer.tsx
- Add a Schedule send button/dialog using native
datetime-local. - Validate required, valid, future, and within
maxDelayedSend. - Change
handleSend(skipAttachmentCheck = false, sendAt?: string). - For normal Send, compute automatic delay only when no explicit schedule exists:
const effectiveSendAt = sendAt ?? (
sendDelaySeconds > 0
? new Date(Date.now() + sendDelaySeconds * 1000).toISOString()
: undefined
);
- If send delay is configured but unsupported for the active account, do not silently send immediately. Show feedback and require explicit immediate-send confirmation.
- Forward
effectiveSendAtthrough standardonSendpayload and S/MIMEsendRawEmailpath. - After S/MIME scheduled send succeeds, clear autosave timers/state and delete
finalDraftIdif present. Cleanup failure should show/log a warning but must not fail the already-created scheduled send.
Page Integration
File: app/[locale]/page.tsx
- Add
const SCHEDULED_MAILBOX_ID = '__scheduled__'. - Selecting it exits unified mode, clears selected email, sets
isScheduledView, and callsfetchScheduledEmails(client). - Define an explicit active list once and use it for rendering, selection, keyboard navigation, mobile list/view behavior, and load-more:
const activeEmails = isScheduledView ? scheduledEmails : emails;
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
- After scheduled/manual-delay send succeeds, close composer, refresh scheduled metadata, and do not refresh the normal mailbox as if delivery already happened unless already in Scheduled view.
- Skip immediate
$answered/$forwardedupdates wheneversendAtis present. Apply this to bothhandleEmailSendandhandleQuickReply. - Auto mark-as-read must be disabled in Scheduled view.
- Push/state handling should refresh scheduled metadata when
StateChange.changed[accountId].EmailSubmissionchanges. - Browser restore and refresh for
__scheduled__must callfetchScheduledEmails, notfetchEmails.
Sidebar, List, Viewer, Context Menu
components/layout/sidebar.tsx: add virtual Scheduled row near Drafts/Sent with pending count. Do not create a JMAP mailbox.components/email/email-list.tsx: addisScheduledViewand scheduled action callbacks; hide normal batch/mailbox actions in Scheduled view.components/email/email-viewer.tsx: render Scheduled banner before Draft banner whenemail.isScheduled; suppress normal reply/forward/archive/spam/move/delete unless explicitly supported.- Viewer actions:
Reschedule,Cancel send, non-S/MIMECancel and edit, S/MIMECancel and compose again. components/email/email-context-menu.tsx: show scheduled-specific actions and hide irrelevant mailbox actions for scheduled messages.
S/MIME Rules
- Scheduled S/MIME messages are final raw MIME after signing/encryption.
- Allow Scheduled view, cancel, and reschedule.
- Do not offer direct edit.
Cancel and compose againmay open a fresh composer, but must not decrypt/reuse the scheduled raw payload.- Never log raw MIME, plaintext body, certificates, private keys, passphrases, or decrypted content.
- Delete the final plaintext autosaved draft after successful S/MIME scheduling.
Demo, Dev Mock, I18n
lib/demo/demo-client.ts: implement delayed send methods with in-memory pending submissions.app/api/dev-jmap/[...path]/route.ts: advertise submission account capability withmaxDelayedSend, and handleEmailSubmission/query,EmailSubmission/get, create withsendAt, and updateundoStatus: 'canceled'.- Add translation keys to every
locales/{lang}/common.json; this repo enforces full locale key parity.
Suggested key groups:
email_composer.schedule_send*settings.email_behavior.send_delay.*sidebar.scheduledemail_viewer.scheduled_*,cancel_scheduled_send,reschedule_send,cancel_and_edit,cancel_and_compose_again,undo_send*email_list.no_scheduled_emails*
Error Handling
- Scheduled send failure: keep composer open and show a toast.
- Unsupported manual scheduling: hide or disable the schedule action.
- Unsupported automatic delay: require explicit immediate-send confirmation; do not silently bypass the saved delay.
- Cancel failure
cannotUnsend: show that the message has already been sent and refresh scheduled state. - Reschedule partial failure: surface the error, refresh scheduled state, and leave the email as draft where possible.
- Scheduled list failure: show an error/empty state in the list area.
Tests
Unit tests for lib/jmap/client.ts:
- Submission/Identity requests pass
SUBMISSION_USING. getMaxDelayedSendandhasDelayedSenduse account-level capability andmaxDelayedSend.sendEmailandsendRawEmailinclude/omitsendAtcorrectly and returnSendEmailResult.getScheduledEmailsqueries pending submissions and merges metadata.cancelEmailSubmissionupdatesundoStatusand handlescannotUnsend.rescheduleEmailSubmissioncancels old and creates replacement.restoreEmailToDraftadds Drafts, sets$draft, and removes Sent when provided.
Store/component tests where existing test setup supports them:
- Settings default/persistence for
sendDelaySeconds. - Scheduled store load/cancel/reschedule/undo paths and loading/error resets.
- Normal mailbox guard/annotation for pending scheduled IDs.
- Composer date validation, manual
sendAt, automatic delay, unsupported delay feedback, S/MIME path forwarding, and final draft cleanup. - Scheduled view disables normal batch actions.
- Scheduled reply/forward and delayed quick reply do not immediately set
$answered/$forwarded. - Undo snackbar cancels and restores non-S/MIME drafts where possible.
Run:
npm run test:translations
npm run typecheck && npm run lint
npm run build
Manual Test Matrix
- Standard plain text, HTML, attachments.
- Reply and forward: no immediate
$answered/$forwardedbefore scheduled release. - Open Scheduled, Drafts, and Sent before release; pending message is visible only/primarily as scheduled and guarded from normal draft/mailbox actions.
- Cancel, reschedule, and cancel-and-edit standard messages, including server states where the email is still in Drafts or already in Sent.
- Signed, encrypted, and signed+encrypted S/MIME scheduling; no plaintext draft remains; direct edit unavailable.
- Send delay default
Aus; 10/30/60 second delays; Undo before release; no action until server release; browser closed during delay. - Account without delayed-send support: schedule hidden/disabled and automatic delay warns instead of silently sending immediately.
- Desktop, tablet, mobile composer and Scheduled view.
Implementation Phases
- JMAP foundation: capabilities,
SUBMISSION_USING, structured send result,sendAt, scheduled query/cancel/reschedule/restore, client tests. - Store/settings:
sendDelaySeconds, scheduled state/actions/index, push refresh, normal mailbox guards, store tests. - Composer/page: schedule dialog, automatic delay, undo snackbar, S/MIME final draft cleanup, reply/forward keyword guards.
- Scheduled UI: sidebar row, active-list routing, list/viewer/context-menu actions, disabled invalid batch/mailbox actions.
- Demo/mock/i18n: in-memory demo behavior, dev JMAP handlers, locale keys, translation test.
- Verification: typecheck, lint, build, manual test matrix, screenshots/screen recording for PR.
Key Risks
- Server differences around delayed
onSuccessUpdateEmailtiming. - Pending scheduled emails leaking into Drafts/Sent without scheduled index refresh.
- Cancel/reschedule racing with release time.
- Global delay setting on accounts without delayed-send support.
- S/MIME duplicate/plaintext drafts if final draft cleanup uses stale IDs.
- Central page/list changes can regress normal sending, selection, shortcuts, and batch actions.