diff --git a/SCHEDULED_SEND_README.md b/SCHEDULED_SEND_README.md deleted file mode 100644 index c877c4de..00000000 --- a/SCHEDULED_SEND_README.md +++ /dev/null @@ -1,331 +0,0 @@ -# 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`; default `0`. -- Use JMAP `EmailSubmission.sendAt` as 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.tsx` `handleEmailSend` -> `stores/email-store.ts` `sendEmail` -> `lib/jmap/client.ts` `sendEmail`. -- S/MIME flow: composer builds raw MIME, signs/encrypts, then calls `stores/email-store.ts` `sendRawEmail` -> `lib/jmap/client.ts` `sendRawEmail`. -- `lib/jmap/client.ts` `request(methodCalls, using?)` defaults to Core + Mail only. Any request with `Identity/*` or `EmailSubmission/*` must pass submission capability explicitly. -- `lib/jmap/types.ts` already has `EmailSubmission` with `sendAt` and `undoStatus`; add only missing client-only `Email` fields and optional helper types. -- `sendEmail`, `sendRawEmail`, and store wrappers currently return `Promise`; scheduled/undo UX needs a structured result containing the created `EmailSubmission` ID. -- `app/[locale]/page.tsx` sets `$answered`/`$forwarded` immediately in `handleEmailSend`; `handleQuickReply` also sets `$answered` immediately. Scheduled sends must skip those immediate keyword updates. -- The composer already tracks a post-save `finalDraftId` before sending. S/MIME scheduled cleanup must delete that final plaintext draft, not stale component state. -- `stores/settings-store.ts` is the right place for the global delay preference. `components/settings/composing-settings.tsx` is the right settings UI location. -- `stores/email-store.ts` owns 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/*`: - -```ts -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: - -```ts -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: - -```json -{ - "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: - -```ts -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: - -```ts -scheduledSendAt?: string; -emailSubmissionId?: string; -scheduledIdentityId?: string; -scheduledUndoStatus?: 'pending' | 'final' | 'canceled'; -isScheduled?: boolean; -isSmimeScheduled?: boolean; -``` - -Extend `IJMAPClient` and both client implementations: - -```ts -sendEmail(..., sendAt?: string): Promise; -sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, sendAt?: 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; -restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise; -``` - -Implementation notes: - -- `sendEmail` adds `sendAt` to both draft and non-draft submission branches, returns `{ scheduled: true, emailId, emailSubmissionId, sendAt }` when `sendAt` is present, otherwise `{ scheduled: false }`. -- `sendRawEmail` imports into Drafts as today, adds `sendAt` to `raw-submit`, keeps `onSuccessUpdateEmail`, and returns `isSmime: true` for delayed/scheduled sends. -- `getScheduledEmails` queries `EmailSubmission` with `undoStatus: pending`, fetches submissions, drops any submission without a valid `sendAt`, fetches referenced emails, merges scheduling metadata, detects S/MIME with existing `detectSmime(...)`, and sorts by `scheduledSendAt` ascending. -- Do not rely only on `sendAt > now`; clock skew can hide near-term submissions. If an `after` filter is added for performance, use a small tolerance and treat `undoStatus: pending` as authoritative. -- Cancel uses `EmailSubmission/set.update` with `undoStatus: 'canceled'`. Do not use `destroy` for cancellation. -- Reschedule validates the new time, creates the replacement for the same `emailId` and `identityId` before 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. -- `restoreEmailToDraft` uses `Email/set.update` patches 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: SendDelaySeconds` under Composer settings with default `0`. -- 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: - -```ts -scheduledEmails: ScheduledEmail[]; -scheduledEmailIds: Set; -scheduledSubmissionByEmailId: Map; -scheduledTotal: number; -scheduledHasMore: boolean; -isLoadingScheduled: boolean; -isScheduledView: boolean; -pendingUndoSend: null | { submissionId: string; emailId?: string; sendAt: string; isSmime: boolean }; -``` - -Add actions: - -- `fetchScheduledEmails(client)` and `loadMoreScheduledEmails(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`/`scheduledSubmissionByEmailId` on app load and `EmailSubmission` push changes. - -Store behavior: - -- Scheduled view reads `scheduledEmails`; normal views read `emails`. -- 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. -- `cancelUndoSend` cancels the submission, refreshes scheduled metadata, clears `pendingUndoSend`, 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 delay` select 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: - -```ts -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 `effectiveSendAt` through standard `onSend` payload and S/MIME `sendRawEmail` path. -- After S/MIME scheduled send succeeds, clear autosave timers/state and delete `finalDraftId` if 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 calls `fetchScheduledEmails(client)`. -- Define an explicit active list once and use it for rendering, selection, keyboard navigation, mobile list/view behavior, and load-more: - -```ts -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`/`$forwarded` updates whenever `sendAt` is present. Apply this to both `handleEmailSend` and `handleQuickReply`. -- Auto mark-as-read must be disabled in Scheduled view. -- Push/state handling should refresh scheduled metadata when `StateChange.changed[accountId].EmailSubmission` changes. -- Browser restore and refresh for `__scheduled__` must call `fetchScheduledEmails`, not `fetchEmails`. - -### 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`: add `isScheduledView` and scheduled action callbacks; hide normal batch/mailbox actions in Scheduled view. -- `components/email/email-viewer.tsx`: render Scheduled banner before Draft banner when `email.isScheduled`; suppress normal reply/forward/archive/spam/move/delete unless explicitly supported. -- Viewer actions: `Reschedule`, `Cancel send`, non-S/MIME `Cancel and edit`, S/MIME `Cancel 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 again` may 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 with `maxDelayedSend`, and handle `EmailSubmission/query`, `EmailSubmission/get`, create with `sendAt`, and update `undoStatus: '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.scheduled` -- `email_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`. -- `getMaxDelayedSend` and `hasDelayedSend` use account-level capability and `maxDelayedSend`. -- `sendEmail` and `sendRawEmail` include/omit `sendAt` correctly and return `SendEmailResult`. -- `getScheduledEmails` queries pending submissions and merges metadata. -- `cancelEmailSubmission` updates `undoStatus` and handles `cannotUnsend`. -- `rescheduleEmailSubmission` cancels old and creates replacement. -- `restoreEmailToDraft` adds 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: - -```sh -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`/`$forwarded` before 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 - -1. JMAP foundation: capabilities, `SUBMISSION_USING`, structured send result, `sendAt`, scheduled query/cancel/reschedule/restore, client tests. -2. Store/settings: `sendDelaySeconds`, scheduled state/actions/index, push refresh, normal mailbox guards, store tests. -3. Composer/page: schedule dialog, automatic delay, undo snackbar, S/MIME final draft cleanup, reply/forward keyword guards. -4. Scheduled UI: sidebar row, active-list routing, list/viewer/context-menu actions, disabled invalid batch/mailbox actions. -5. Demo/mock/i18n: in-memory demo behavior, dev JMAP handlers, locale keys, translation test. -6. Verification: typecheck, lint, build, manual test matrix, screenshots/screen recording for PR. - -## Key Risks - -- Server differences around delayed `onSuccessUpdateEmail` timing. -- 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. diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index d4c90318..2fd325b9 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -62,6 +62,8 @@ import { useThemeStore } from "@/stores/theme-store"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import type { EmailReadView } from "@/lib/plugin-types"; +const SCHEDULED_MAILBOX_ID = '__scheduled__'; + function emailToReadView(email: Email): EmailReadView { return { id: email.id, @@ -268,6 +270,21 @@ export default function Home() { fetchTagCounts, fetchEmailContent, isUnifiedView, + scheduledEmails, + scheduledTotal, + scheduledHasMore, + isLoadingScheduled, + isScheduledView, + setScheduledView, + fetchScheduledEmails, + loadMoreScheduledEmails, + cancelScheduledEmail, + cancelScheduledEmailForEdit, + rescheduleScheduledEmail, + refreshScheduledMetadata, + cancelUndoSend, + clearPendingUndoSend, + pendingUndoSend, fetchUnifiedEmails: fetchUnifiedEmailsAction, refreshUnifiedCounts, exitUnifiedView, @@ -284,6 +301,9 @@ export default function Home() { } = useEmailStore(); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); + const activeEmails = isScheduledView ? scheduledEmails : emails; + const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails; + const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading; const accounts = useAccountStore((s) => s.accounts); const connectedAccountsSignature = useMemo( () => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","), @@ -337,7 +357,7 @@ export default function Home() { conversationThreadId: null as string | null, }); navRestoreStateRef.current.client = client; - navRestoreStateRef.current.emails = emails; + navRestoreStateRef.current.emails = activeEmails; navRestoreStateRef.current.mailboxes = mailboxes; navRestoreStateRef.current.selectedMailbox = selectedMailbox; navRestoreStateRef.current.selectedEmailId = selectedEmail?.id ?? null; @@ -363,7 +383,19 @@ export default function Home() { // Restore mailbox selection. selectMailbox clears the current email, // which is fine because we re-apply the saved email below. - if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) { + if (state.mailboxId === SCHEDULED_MAILBOX_ID) { + setScheduledView(true); + selectMailbox(SCHEDULED_MAILBOX_ID); + selectEmail(null); + if (ctx.client) { + try { + await fetchScheduledEmails(ctx.client); + } catch (error) { + debug.error('Failed to fetch scheduled emails on history restore:', error); + } + } + } else if (state.mailboxId && state.mailboxId !== ctx.selectedMailbox) { + setScheduledView(false); selectMailbox(state.mailboxId); if (ctx.client) { try { @@ -424,19 +456,19 @@ export default function Home() { // Keyboard shortcuts handlers const keyboardHandlers = useMemo(() => ({ onNextEmail: () => { - if (emails.length === 0) return; - const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1; - const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex; - if (nextIndex >= 0 && nextIndex < emails.length) { - handleEmailSelect(emails[nextIndex]); + if (activeEmails.length === 0) return; + const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1; + const nextIndex = currentIndex < activeEmails.length - 1 ? currentIndex + 1 : currentIndex; + if (nextIndex >= 0 && nextIndex < activeEmails.length) { + handleEmailSelect(activeEmails[nextIndex]); } }, onPreviousEmail: () => { - if (emails.length === 0) return; - const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length; + if (activeEmails.length === 0) return; + const currentIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : activeEmails.length; const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0; - if (prevIndex >= 0 && prevIndex < emails.length) { - handleEmailSelect(emails[prevIndex]); + if (prevIndex >= 0 && prevIndex < activeEmails.length) { + handleEmailSelect(activeEmails[prevIndex]); } }, onOpenEmail: () => { @@ -452,18 +484,23 @@ export default function Home() { } }, onReply: () => { + if (isScheduledView) return; if (selectedEmail) handleReply(); }, onReplyAll: () => { + if (isScheduledView) return; if (selectedEmail) handleReplyAll(); }, onForward: () => { + if (isScheduledView) return; if (selectedEmail) handleForward(); }, onToggleStar: () => { + if (isScheduledView) return; if (selectedEmail) handleToggleStar(); }, onArchive: async () => { + if (isScheduledView) return; if (selectedEmailIds.size > 0 && client) { try { await batchArchive(client); @@ -475,6 +512,7 @@ export default function Home() { } }, onDelete: async () => { + if (isScheduledView) return; if (selectedEmailIds.size > 0 && client) { const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const isInTrash = currentMailbox?.role === 'trash'; @@ -504,6 +542,7 @@ export default function Home() { } }, onMarkAsUnread: async () => { + if (isScheduledView) return; if (!client) return; if (selectedEmailIds.size > 0) { await batchMarkAsRead(client, false); @@ -512,6 +551,7 @@ export default function Home() { } }, onMarkAsRead: async () => { + if (isScheduledView) return; if (!client) return; if (selectedEmailIds.size > 0) { await batchMarkAsRead(client, true); @@ -520,6 +560,7 @@ export default function Home() { } }, onToggleSpam: async () => { + if (isScheduledView) return; const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const isInJunk = currentMailbox?.role === 'junk'; if (selectedEmailIds.size > 0 && client) { @@ -547,6 +588,7 @@ export default function Home() { if (isMobile) setActiveView('viewer'); }, onFocusSearch: () => { + if (isScheduledView) return; const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement; if (searchInput) { searchInput.focus(); @@ -558,22 +600,27 @@ export default function Home() { }, onRefresh: async () => { if (client && selectedMailbox) { - await fetchEmails(client, selectedMailbox); + if (selectedMailbox === SCHEDULED_MAILBOX_ID) { + await fetchScheduledEmails(client); + } else { + await fetchEmails(client, selectedMailbox); + } } }, onSelectAll: () => { + if (isScheduledView) return; selectAllEmails(); }, onDeselectAll: () => { clearSelection(); }, // eslint-disable-next-line react-hooks/exhaustive-deps - }), [emails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes]); + }), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailIds, mailboxes, isScheduledView]); // Initialize keyboard shortcuts useKeyboardShortcuts({ enabled: isAuthenticated && !showComposer, - emails, + emails: activeEmails, selectedEmailId: selectedEmail?.id, selectionCount: selectedEmailIds.size, handlers: keyboardHandlers, @@ -586,13 +633,30 @@ export default function Home() { onRefresh: async () => { if (!client) return; const state = useEmailStore.getState(); - await Promise.all([ - state.fetchMailboxes(client), - state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client), - ]); + if (state.isScheduledView || state.selectedMailbox === SCHEDULED_MAILBOX_ID) { + await Promise.all([ + state.fetchMailboxes(client), + state.fetchScheduledEmails(client), + ]); + } else { + await state.fetchMailboxes(client); + await state.refreshScheduledMetadata(client); + await (state.selectedMailbox ? state.fetchEmails(client, state.selectedMailbox) : state.fetchEmails(client)); + } }, }); + useEffect(() => { + if (!pendingUndoSend) return; + const sendAt = new Date(pendingUndoSend.sendAt).getTime(); + if (!Number.isFinite(sendAt) || sendAt <= Date.now()) { + clearPendingUndoSend(); + return; + } + const timer = setTimeout(clearPendingUndoSend, sendAt - Date.now()); + return () => clearTimeout(timer); + }, [clearPendingUndoSend, pendingUndoSend]); + // Update page title based on context useEffect(() => { let title = appName; @@ -694,7 +758,9 @@ export default function Home() { return; } - // Fetch emails for the selected mailbox + await refreshScheduledMetadata(client); + + // Fetch emails for the selected mailbox after scheduled metadata is available. if (selectedMailboxId) { await fetchEmails(client, selectedMailboxId); } else { @@ -741,7 +807,7 @@ export default function Home() { client.closePushNotifications(); } }; - }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]); + }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata, handleStateChange, setPushConnected]); // Keep unified mailbox counts in sync when the feature is enabled and more // than one account is connected. Runs whenever the set of connected accounts @@ -822,7 +888,7 @@ export default function Home() { } // Only set timeout if there's a selected email, it's unread, and we have a client - if (!selectedEmail || !client || selectedEmail.keywords?.$seen) { + if (!selectedEmail || !client || selectedEmail.keywords?.$seen || isScheduledView) { return; } @@ -856,7 +922,7 @@ export default function Home() { } }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedEmail?.id]); + }, [selectedEmail?.id, isScheduledView]); // Handle new email notifications - play sound useEffect(() => { @@ -896,6 +962,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; }) => { if (!client) return; @@ -903,8 +970,14 @@ export default function Home() { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = selectedEmail?.id; - 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); + 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); setShowComposer(false); + if (result.scheduled) { + await refreshScheduledMetadata(client); + if (isScheduledView) await fetchScheduledEmails(client); + toast.success(t('email_viewer.scheduled_send_created')); + return; + } // Mark the original email with $answered or $forwarded keyword if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) { @@ -922,7 +995,7 @@ export default function Home() { } // Refresh the current mailbox to update the UI - await fetchEmails(client, selectedMailbox); + if (!isScheduledView) await fetchEmails(client, selectedMailbox); } catch (error) { console.error("Failed to send email:", error); } @@ -1236,7 +1309,25 @@ export default function Home() { }; const handleMailboxSelect = async (mailboxId: string) => { + if (mailboxId === SCHEDULED_MAILBOX_ID) { + if (isUnifiedView) exitUnifiedView(); + setScheduledView(true); + selectMailbox(mailboxId); + selectEmail(null); + clearSelection(); + if (isMobile) { + setSidebarOpen(false); + setActiveView("list"); + } + if (isTablet) { + setTabletListVisible(true); + } + if (client) await fetchScheduledEmails(client); + return; + } + if (isUnifiedMailboxId(mailboxId)) { + setScheduledView(false); const role = UNIFIED_ROLE_BY_ID[mailboxId]; if (!role) return; @@ -1261,6 +1352,7 @@ export default function Home() { if (isUnifiedView) { exitUnifiedView(); } + setScheduledView(false); selectMailbox(mailboxId); selectEmail(null); // Clear selected email when switching mailboxes @@ -1287,6 +1379,7 @@ export default function Home() { }; const handleTagSelect = async (keywordId: string | null) => { + setScheduledView(false); selectKeyword(keywordId); // On mobile, close sidebar and go to list view @@ -1627,6 +1720,16 @@ export default function Home() { const finalBody = appendPlainTextSignature(body, primaryIdentity); const originalEmailId = selectedEmail.id; + const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds; + let sendAt: 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(); + } + } // RFC 5322 §3.6.4 threading — keep the conversation stitched together (#234). const threading = computeReplyThreadingHeaders({ @@ -1635,7 +1738,7 @@ export default function Home() { }); // Send reply with just the body text - await sendEmail( + const result = await sendEmail( client, [sender.email], `Re: ${selectedEmail.subject || "(no subject)"}`, @@ -1650,8 +1753,14 @@ export default function Home() { undefined, threading?.inReplyTo, threading?.references, + sendAt, ); + if (result.scheduled) { + await refreshScheduledMetadata(client); + return; + } + // Mark the original email as answered try { await client.setKeyword(originalEmailId, '$answered'); @@ -1676,7 +1785,7 @@ export default function Home() { } // Get current mailbox name for mobile header - const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox"; + const currentMailboxName = isScheduledView ? t('sidebar.scheduled') : mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox"; const isFocusedMailLayout = mailLayout === 'focus'; const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent); @@ -1693,7 +1802,7 @@ export default function Home() { // Show the list stub immediately so subject/sender render without // waiting for the body fetch — avoids the loading flicker. - const listEmail = emails.find(e => e.id === email.id); + const listEmail = activeEmails.find(e => e.id === email.id); if (listEmail) { selectEmail(listEmail); } @@ -1730,6 +1839,14 @@ export default function Home() { const fullEmail = await fetchClient.getEmail(email.id, accountId); if (fullEmail) { + if (listEmail?.isScheduled) { + fullEmail.scheduledSendAt = listEmail.scheduledSendAt; + fullEmail.emailSubmissionId = listEmail.emailSubmissionId; + fullEmail.scheduledIdentityId = listEmail.scheduledIdentityId; + fullEmail.scheduledUndoStatus = listEmail.scheduledUndoStatus; + fullEmail.isScheduled = true; + fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled; + } if (emailAccountId) { fullEmail.accountId = emailAccountId; fullEmail.accountLabel = listEmail?.accountLabel; @@ -1762,14 +1879,14 @@ export default function Home() { }; // Navigate to next/previous email in the list - const selectedEmailIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1; + const selectedEmailIndex = selectedEmail ? activeEmails.findIndex(e => e.id === selectedEmail.id) : -1; - const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < emails.length - 1 - ? () => handleEmailSelect(emails[selectedEmailIndex + 1]) + const handleNavigateNext = selectedEmailIndex >= 0 && selectedEmailIndex < activeEmails.length - 1 + ? () => handleEmailSelect(activeEmails[selectedEmailIndex + 1]) : undefined; const handleNavigatePrev = selectedEmailIndex > 0 - ? () => handleEmailSelect(emails[selectedEmailIndex - 1]) + ? () => handleEmailSelect(activeEmails[selectedEmailIndex - 1]) : undefined; // Handle opening conversation view on mobile @@ -1901,6 +2018,7 @@ export default function Home() { mailboxes={mailboxes} selectedMailbox={selectedMailbox} selectedKeyword={selectedKeyword} + scheduledTotal={scheduledTotal} onMailboxSelect={handleMailboxSelect} onTagSelect={handleTagSelect} onUnreadFilterClick={handleUnreadFilterClick} @@ -1968,26 +2086,27 @@ export default function Home() { type="button" onClick={() => { if (selectedEmailIds.size > 0) { - if (selectedEmailIds.size === emails.length) { + if (selectedEmailIds.size === activeEmails.length) { clearSelection(); } else { selectAllEmails(); } - } else if (emails.length > 0) { + } else if (activeEmails.length > 0) { const currentId = selectedEmail?.id; - const target = currentId && emails.some((e) => e.id === currentId) + const target = currentId && activeEmails.some((e) => e.id === currentId) ? currentId - : emails[0].id; + : activeEmails[0].id; toggleEmailSelection(target); } }} + disabled={isScheduledView} className={cn( "flex-shrink-0 p-2 rounded-md transition-colors", selectedEmailIds.size > 0 ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted" )} - title={selectedEmailIds.size > 0 ? (selectedEmailIds.size === emails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')} + title={isScheduledView ? t('email_viewer.scheduled_actions_only') : selectedEmailIds.size > 0 ? (selectedEmailIds.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')} > {selectedEmailIds.size > 0 ? ( @@ -2005,8 +2124,8 @@ export default function Home() { className={cn("pl-9 h-9", searchQuery && "pr-8")} data-search-input data-tour="search-input" - disabled={isUnifiedView} - title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined} + disabled={isUnifiedView || isScheduledView} + title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined} /> {searchQuery && ( + + )} diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index fde1f2af..cbbb1fda 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -10,6 +10,7 @@ import { NextRequest, NextResponse } from 'next/server'; */ const ACCOUNT_ID = 'dev-account-001'; +const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = []; // --------------------------------------------------------------------------- // Mailboxes @@ -1549,8 +1550,44 @@ function handleThreadGet(args: MethodArgs, callId: string): MethodResult { return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId]; } -function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult { - return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: { 'sub-1': { id: 'sub-mock-1' } }, notCreated: null }, callId]; +function handleEmailSubmissionSet(args: MethodArgs, callId: string): MethodResult { + const created: Record = {}; + const updated: Record = {}; + 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 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' }); + } + } + } + const update = args.update as Record | undefined; + if (update) { + for (const [id, patch] of Object.entries(update)) { + const submission = scheduledSubmissions.find(s => s.id === id); + if (submission && patch.undoStatus) { + submission.undoStatus = patch.undoStatus; + updated[id] = null; + } + } + } + return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, notCreated: null, notUpdated: null }, callId]; +} + +function handleEmailSubmissionQuery(args: MethodArgs, callId: string): MethodResult { + const position = Number(args.position || 0); + const limit = Number(args.limit || 50); + const pending = scheduledSubmissions.filter(s => s.undoStatus === 'pending').sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime()); + return ['EmailSubmission/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: pending.slice(position, position + limit).map(s => s.id), total: pending.length, position, canCalculateChanges: false }, callId]; +} + +function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResult { + const ids = args.ids as string[] | undefined; + const list = ids ? scheduledSubmissions.filter(s => ids.includes(s.id)) : scheduledSubmissions; + return ['EmailSubmission/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId]; } function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult { @@ -1613,6 +1650,8 @@ const METHOD_HANDLERS: Record Meth 'Identity/get': handleIdentityGet, 'Identity/set': handleIdentitySet, 'EmailSubmission/set': handleEmailSubmissionSet, + 'EmailSubmission/query': handleEmailSubmissionQuery, + 'EmailSubmission/get': handleEmailSubmissionGet, 'Quota/get': handleQuotaGet, 'VacationResponse/get': handleVacationResponseGet, 'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId], @@ -1750,7 +1789,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ isReadOnly: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, - 'urn:ietf:params:jmap:submission': {}, + 'urn:ietf:params:jmap:submission': { maxDelayedSend: 2592000 }, 'urn:ietf:params:jmap:quota': {}, 'urn:ietf:params:jmap:vacationresponse': {}, 'urn:ietf:params:jmap:contacts': {}, diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 083b2fc6..90601554 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -72,7 +72,9 @@ interface EmailComposerProps { attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; inReplyTo?: string[]; references?: string[]; + sendAt?: string; }) => void | Promise; + onScheduledSendCreated?: () => void | Promise; onClose?: () => void; onDiscardDraft?: (draftId: string) => void; onSaveState?: (data: ComposerDraftData) => void; @@ -113,6 +115,7 @@ type ComposerAttachment = { export function EmailComposer({ onSend, + onScheduledSendCreated, onClose, onDiscardDraft, onSaveState, @@ -130,6 +133,7 @@ export function EmailComposer({ const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); + const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); // Initialize with reply/forward data if provided const getInitialTo = () => { @@ -256,6 +260,9 @@ export function EmailComposer({ const [smimePassphraseError, setSmimePassphraseError] = useState(''); const [showAttachmentWarning, setShowAttachmentWarning] = useState(false); const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState(''); + const [showScheduleDialog, setShowScheduleDialog] = useState(false); + const [scheduleValue, setScheduleValue] = useState(''); + const [scheduleError, setScheduleError] = useState(''); const saveTemplateModalRef = useFocusTrap({ isActive: showSaveAsTemplate, @@ -855,6 +862,33 @@ export function EmailComposer({ return undefined; }; + const validateScheduleValue = (value: string): string | null => { + if (!value) return t('schedule_send_required'); + const time = new Date(value).getTime(); + if (!Number.isFinite(time)) return t('schedule_send_invalid'); + if (time <= Date.now()) return t('schedule_send_future'); + if (client) { + const maxDelayedSend = client.getMaxDelayedSend(); + if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) { + return t('schedule_send_too_late'); + } + } + return null; + }; + + const getEffectiveSendAt = async (explicitSendAt?: string): Promise => { + if (explicitSendAt) return explicitSendAt; + if (sendDelaySeconds === 0) return undefined; + if (client?.hasDelayedSend()) { + return new Date(Date.now() + sendDelaySeconds * 1000).toISOString(); + } + const confirmed = window.confirm(t('send_delay_unsupported_confirm')); + if (!confirmed) { + throw new Error(t('send_delay_unsupported')); + } + return undefined; + }; + // Rewrite data: URLs of dropped images (tagged with data-cid) into cid: // references so recipient clients that strip data URIs can still render them. const rewriteInlineImages = (html: string): { @@ -898,7 +932,7 @@ export function EmailComposer({ }; }; - const handleSend = async (skipAttachmentCheck = false) => { + const handleSend = async (skipAttachmentCheck = false, sendAt?: string) => { const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); @@ -980,6 +1014,7 @@ export function EmailComposer({ const inlineAttachments = rewritten?.attachments ?? []; try { + const effectiveSendAt = await getEffectiveSendAt(sendAt); // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { // 1. Resolve S/MIME key @@ -1096,7 +1131,16 @@ export function EmailComposer({ } // 7. Send via raw email path - await sendRawEmail(client, payload, currentIdentity.id); + const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveSendAt); + if (effectiveSendAt && 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')); + }); + } + if (result.scheduled) { + await onScheduledSendCreated?.(); + } } else { // Standard JMAP send path // Collect uploaded attachment blobIds for the send request @@ -1134,6 +1178,7 @@ export function EmailComposer({ attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, inReplyTo: threadingHeaders?.inReplyTo, references: threadingHeaders?.references, + sendAt: effectiveSendAt, }); if (mode === 'reply' || mode === 'replyAll') { @@ -1157,14 +1202,30 @@ export function EmailComposer({ setDraftId(null); setSubAddressTag(""); setValidationErrors({}); + setShowScheduleDialog(false); + setScheduleValue(''); + setScheduleError(''); // Clear ref so unmount effect doesn't re-save stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; } catch (err) { debug.error('Failed to send email:', err); - toast.error(t('send_failed')); + toast.error(err instanceof Error ? err.message : t('send_failed')); } }; + const handleScheduleSend = () => { + if (!client?.hasDelayedSend()) { + setScheduleError(t('schedule_send_unsupported')); + return; + } + const error = validateScheduleValue(scheduleValue); + if (error) { + setScheduleError(error); + return; + } + handleSend(false, new Date(scheduleValue).toISOString()); + }; + const cleanClose = () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); @@ -1583,6 +1644,20 @@ export function EmailComposer({ > + {/* S/MIME toggles */} {canSmimeSign && ( @@ -1668,6 +1743,29 @@ export function EmailComposer({ )} + {showScheduleDialog && ( +
+
+

{t('schedule_send')}

+

{t('schedule_send_description')}

+ { + setScheduleValue(e.target.value); + setScheduleError(''); + }} + className={cn(scheduleError && "border-destructive focus-visible:ring-destructive")} + /> + {scheduleError &&

{scheduleError}

} +
+ + +
+
+
+ )} + {/* S/MIME passphrase prompt */} {smimePassphrasePrompt && (
); -} \ No newline at end of file +} diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index bd9c41fa..a8a8d790 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -30,6 +30,8 @@ import { ShieldAlert, ShieldCheck, EditIcon, + CalendarClock, + XCircle, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; @@ -63,6 +65,9 @@ interface EmailContextMenuProps { onMarkAsSpam?: () => void; onUndoSpam?: () => void; onEditDraft?: () => void; + onCancelScheduled?: () => void; + onCancelScheduledForEdit?: () => void; + onRescheduleScheduled?: () => void; // Batch actions onBatchMarkAsRead?: (read: boolean) => void; onBatchDelete?: () => void; @@ -133,6 +138,9 @@ export function EmailContextMenu({ onBatchMarkAsSpam, onBatchUndoSpam, onEditDraft, + onCancelScheduled, + onCancelScheduledForEdit, + onRescheduleScheduled, }: EmailContextMenuProps) { const t = useTranslations("context_menu"); const _tColor = useTranslations("email_viewer.color_tag"); @@ -143,6 +151,7 @@ export function EmailContextMenu({ const currentColors = getCurrentColors(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; + const isScheduled = email.isScheduled === true; // Build color options from keyword definitions in settings const colorOptions = emailKeywords.map((kw) => ({ @@ -196,8 +205,36 @@ export function EmailContextMenu({ )} + {isScheduled && !showBatchActions && ( + <> + handleAction(onRescheduleScheduled!)} + disabled={!onRescheduleScheduled} + /> + handleAction(onCancelScheduled!)} + disabled={!onCancelScheduled} + /> + handleAction(onCancelScheduledForEdit!)} + disabled={!onCancelScheduledForEdit} + /> + + )} + + {isScheduled && } + + {!isScheduled && ( + <> + {/* Edit Draft - only for single draft emails */} - {!showBatchActions && isDraft && onEditDraft && ( + {!isScheduled && !showBatchActions && isDraft && onEditDraft && ( <> + + )} diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index d37c3c68..ab07f6ff 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types"; import { ThreadListItem } from "./thread-list-item"; import { EmailContextMenu } from "./email-context-menu"; import { cn } from "@/lib/utils"; -import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react"; +import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, XCircle, Edit3 } from "lucide-react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; @@ -18,6 +18,7 @@ import { useTranslations } from "next-intl"; import { useVirtualizer } from "@tanstack/react-virtual"; import { SearchChips } from "@/components/search/search-chips"; import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; +import { toast } from "@/stores/toast-store"; interface EmailListProps { emails: Email[]; @@ -38,6 +39,11 @@ interface EmailListProps { onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; onEditDraft?: (email: Email) => void; + isScheduledView?: boolean; + onLoadMoreScheduled?: () => void; + onCancelScheduled?: (email: Email) => void | Promise; + onCancelScheduledForEdit?: (email: Email) => void | Promise; + onRescheduleScheduled?: (email: Email, sendAt: string) => void | Promise; } export function EmailList({ @@ -59,8 +65,14 @@ export function EmailList({ onUndoSpam, onMoveToMailbox, onEditDraft, + isScheduledView = false, + onLoadMoreScheduled, + onCancelScheduled, + onCancelScheduledForEdit, + onRescheduleScheduled, }: EmailListProps) { const t = useTranslations('email_list'); + const tComposer = useTranslations('email_composer'); const { client } = useAuthStore(); const { selectedEmailIds, @@ -93,9 +105,9 @@ export function EmailList({ const disableThreading = useSettingsStore((state) => state.disableThreading); const threadGroups = useMemo(() => { - const groups = groupEmailsByThread(emails, disableThreading); + const groups = groupEmailsByThread(emails, disableThreading || isScheduledView); return sortThreadGroups(groups); - }, [emails, disableThreading]); + }, [emails, disableThreading, isScheduledView]); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); @@ -214,10 +226,38 @@ export function EmailList({ }; const handleLoadMore = useCallback(() => { + if (isScheduledView) { + onLoadMoreScheduled?.(); + return; + } if (client && hasMoreEmails && !isLoadingMore && !isLoading) { loadMoreEmails(client); } - }, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]); + }, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]); + + const promptForRescheduleSendAt = useCallback((): string | null => { + const value = window.prompt(t('reschedule_prompt')); + if (!value) return null; + const time = new Date(value).getTime(); + if (!Number.isFinite(time)) { + toast.error(tComposer('schedule_send_invalid')); + return null; + } + if (time <= Date.now()) { + toast.error(tComposer('schedule_send_future')); + return null; + } + if (!client?.hasDelayedSend()) { + toast.error(tComposer('schedule_send_unsupported')); + return null; + } + const maxDelayedSend = client.getMaxDelayedSend(); + if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) { + toast.error(tComposer('schedule_send_too_late')); + return null; + } + return new Date(time).toISOString(); + }, [client, t, tComposer]); const handleToggleThreadExpansion = useCallback(async (threadId: string) => { const isExpanded = expandedThreadIds.has(threadId); @@ -274,10 +314,15 @@ export function EmailList({ return (
{/* Batch Actions Toolbar */} + {isScheduledView && emails.length > 0 && ( +
+ {t('scheduled_actions_hint')} +
+ )}
@@ -401,17 +446,19 @@ export function EmailList({ ) : emails.length === 0 && !isLoading ? (
- {searchQuery || !isFilterEmpty(searchFilters) ? ( + {isScheduledView ? ( + + ) : searchQuery || !isFilterEmpty(searchFilters) ? ( ) : ( )}

- {searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')} + {isScheduledView ? t('no_scheduled_emails') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}

- {searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')} + {isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}

) : ( @@ -456,6 +503,34 @@ export function EmailList({ onSetColorTag={onSetColorTag} onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined} /> + {isScheduledView && thread.latestEmail.isScheduled && ( +
+ + + {new Date(thread.latestEmail.scheduledSendAt || '').toLocaleString()} + + + + +
+ )}
); })} @@ -503,6 +578,12 @@ export function EmailList({ onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} onEditDraft={() => onEditDraft?.(contextMenu.data!)} + onCancelScheduled={() => onCancelScheduled?.(contextMenu.data!)} + onCancelScheduledForEdit={() => onCancelScheduledForEdit?.(contextMenu.data!)} + onRescheduleScheduled={() => { + const sendAt = promptForRescheduleSendAt(); + if (sendAt) onRescheduleScheduled?.(contextMenu.data!, sendAt); + }} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={() => client && batchDelete(client)} onBatchArchive={async () => { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index f92b435a..9ac3dda1 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -62,6 +62,7 @@ import { EditIcon, PlayCircle, PenSquare, + CalendarClock, } from "lucide-react"; import { useTranslations } from "next-intl"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; @@ -117,6 +118,9 @@ interface EmailViewerProps { onNavigatePrev?: () => void; onShowShortcuts?: () => void; onEditDraft?: () => void; + onCancelScheduled?: () => void; + onCancelScheduledForEdit?: () => void; + onRescheduleScheduled?: (sendAt: string) => void; onCompose?: () => void; currentUserEmail?: string; currentUserName?: string; @@ -823,6 +827,9 @@ export function EmailViewer({ onNavigatePrev, onShowShortcuts, onEditDraft, + onCancelScheduled, + onCancelScheduledForEdit, + onRescheduleScheduled, onCompose, currentUserEmail, currentUserName, @@ -832,6 +839,7 @@ export function EmailViewer({ className, }: EmailViewerProps) { const t = useTranslations('email_viewer'); + const tComposer = useTranslations('email_composer'); const tNotifications = useTranslations('notifications'); const tCommon = useTranslations('common'); const tSmime = useTranslations('smime'); @@ -861,6 +869,7 @@ export function EmailViewer({ // Detect if the email is a draft const isDraft = email?.keywords?.['$draft'] === true; + const isScheduled = email?.isScheduled === true; // Color options for email tags (from user-defined keyword settings) const colorOptions = emailKeywords.map((kw) => ({ @@ -873,6 +882,29 @@ export function EmailViewer({ const { isTablet, isMobile } = useDeviceDetection(); const { tabletListVisible } = useUIStore(); const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); + const promptForRescheduleSendAt = useCallback((): string | null => { + const value = window.prompt(t('reschedule_prompt')); + if (!value) return null; + const time = new Date(value).getTime(); + if (!Number.isFinite(time)) { + toast.error(tComposer('schedule_send_invalid')); + return null; + } + if (time <= Date.now()) { + toast.error(tComposer('schedule_send_future')); + return null; + } + if (!client?.hasDelayedSend()) { + toast.error(tComposer('schedule_send_unsupported')); + return null; + } + const maxDelayedSend = client.getMaxDelayedSend(); + if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) { + toast.error(tComposer('schedule_send_too_late')); + return null; + } + return new Date(time).toISOString(); + }, [client, t, tComposer]); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const { startTour } = useTour(); const [showFullHeaders, setShowFullHeaders] = useState(false); @@ -3088,7 +3120,32 @@ export function EmailViewer({ )} - {isDraft && onEditDraft && ( + {isScheduled && ( + <> + + + + + )} + {!isScheduled && isDraft && onEditDraft && ( )} - {!isDraft && (<> + {!isScheduled && !isDraft && (<>
+ )} ); @@ -3609,13 +3668,13 @@ export function EmailViewer({ className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)} > {/* Mobile More menu sidebar overlay */} - {isMobile && moreMenuOpen && ( + {!isScheduled && isMobile && moreMenuOpen && (
setMoreMenuOpen(false)} /> )} - {isMobile && ( + {!isScheduled && isMobile && (
)} + {/* Scheduled Banner */} + {isScheduled && ( +
+
+
+ + + {t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })} + +
+
+ + + +
+
+
+ )} + {/* Draft Banner */} {isDraft && (
@@ -4932,7 +5021,7 @@ export function EmailViewer({ {/* Quick Reply Section - hidden for drafts and while loading a new email */} - {!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
@@ -5230,4 +5319,4 @@ export function EmailViewer({
); -} \ No newline at end of file +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index ae779cc2..b63ecebc 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -28,6 +28,7 @@ import { FlaskConical, PlayCircle, Loader2, + CalendarClock, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { Mailbox } from "@/lib/jmap/types"; @@ -67,6 +68,7 @@ interface SidebarProps { onRenameFolder?: (mailboxId: string) => void; onDeleteFolder?: (mailboxId: string) => void; onRefreshMailboxes?: () => void; + scheduledTotal?: number; className?: string; } @@ -637,6 +639,7 @@ export function Sidebar({ onRenameFolder, onDeleteFolder, onRefreshMailboxes, + scheduledTotal = 0, className, }: SidebarProps) { const router = useRouter(); @@ -927,20 +930,31 @@ export function Sidebar({ {!isCollapsed && t("loading_mailboxes")}
) : ( - ownTree.map((node) => ( - + {ownTree.map((node) => ( + + ))} + } + label={t('scheduled')} + depth={0} + isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'} + total={scheduledTotal} + onClick={() => onMailboxSelect?.('__scheduled__')} isCollapsed={isCollapsed} - onUnreadFilterClick={onUnreadFilterClick} - colorful={colorfulSidebarIcons} - onContextMenu={handleMailboxContextMenu} /> - )) + )} )} diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index 1c75ce05..dfb8cf08 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -4,6 +4,8 @@ import { useState, useCallback } from 'react'; import { useTranslations } from 'next-intl'; import { useConfig } from '@/hooks/use-config'; import { useSettingsStore } from '@/stores/settings-store'; +import type { SendDelaySeconds } from '@/stores/settings-store'; +import { useAuthStore } from '@/stores/auth-store'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { Mail, X } from 'lucide-react'; import { getPathPrefix } from '@/lib/browser-navigation'; @@ -26,9 +28,12 @@ export function ComposingSettings() { autoSelectReplyIdentity, attachmentReminderEnabled, attachmentReminderKeywords, + sendDelaySeconds, subAddressDelimiter, updateSetting, } = useSettingsStore(); + const { client } = useAuthStore(); + const delayedSendSupported = client?.hasDelayedSend() ?? false; const handleSetDefaultMailProgram = useCallback(() => { try { @@ -50,6 +55,24 @@ export function ComposingSettings() { /> + +
+