Scheduld Send

This commit is contained in:
Lucas Gaitzsch
2026-05-05 20:11:11 +02:00
parent 16f719066f
commit 154ae84247
32 changed files with 2135 additions and 530 deletions
-331
View File
@@ -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<void>`; 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<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:
- `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<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)` 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.
+240 -47
View File
@@ -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 ? (
<CheckSquare className="w-4 h-4" />
@@ -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 && (
<button
@@ -2022,15 +2141,15 @@ export default function Home() {
<button
type="button"
onClick={toggleAdvancedSearch}
disabled={isUnifiedView}
disabled={isUnifiedView || isScheduledView}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
isUnifiedView && "opacity-50 cursor-not-allowed",
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
>
<Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
@@ -2172,11 +2291,17 @@ export default function Home() {
)}
</div>
{(searchQuery || !isFilterEmpty(searchFilters)) && !isLoading && (
{(searchQuery || !isFilterEmpty(searchFilters)) && !activeIsLoading && !isScheduledView && (
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
{hasMoreEmails
? t("advanced_search.results_found_more", { count: emails.length })
: t("advanced_search.results_found", { count: emails.length })}
{activeHasMore
? t("advanced_search.results_found_more", { count: activeEmails.length })
: t("advanced_search.results_found", { count: activeEmails.length })}
</div>
)}
{isScheduledView && !activeIsLoading && (
<div className="px-4 py-1.5 text-xs text-muted-foreground border-b border-border bg-muted/20">
{t('email_list.scheduled_count', { count: scheduledTotal })}
</div>
)}
@@ -2185,9 +2310,32 @@ export default function Home() {
<ErrorBoundary fallback={EmailListErrorFallback}>
<EmailList
emails={emails}
emails={activeEmails}
selectedEmailId={selectedEmail?.id}
isLoading={isLoading}
isLoading={activeIsLoading}
isScheduledView={isScheduledView}
onLoadMoreScheduled={() => client && loadMoreScheduledEmails(client)}
onCancelScheduled={async (email) => {
if (client && email.emailSubmissionId) await cancelScheduledEmail(client, email.emailSubmissionId);
}}
onCancelScheduledForEdit={async (email) => {
if (!client) return;
const restored = await cancelScheduledEmailForEdit(client, email);
if (email.isSmimeScheduled) {
setComposerMode('compose');
setPendingDraft(null);
} else if (restored) {
await handleEditDraft(restored);
return;
}
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
onRescheduleScheduled={async (email, sendAt) => {
if (client && email.emailSubmissionId && email.scheduledIdentityId) {
await rescheduleScheduledEmail(client, email.emailSubmissionId, email.id, email.scheduledIdentityId, sendAt);
}
}}
onEmailSelect={handleEmailSelect}
onOpenConversation={handleOpenConversation}
// Context menu handlers
@@ -2316,6 +2464,15 @@ export default function Home() {
await handleEmailSend(data);
setPendingDraft(null);
}}
onScheduledSendCreated={async () => {
if (client) {
await refreshScheduledMetadata(client);
if (isScheduledView) await fetchScheduledEmails(client);
}
toast.success(t('email_viewer.scheduled_send_created'));
setShowComposer(false);
setPendingDraft(null);
}}
onClose={() => {
setShowComposer(false);
setComposerMode('compose');
@@ -2410,6 +2567,24 @@ export default function Home() {
onNavigatePrev={handleNavigatePrev}
onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft}
onCancelScheduled={async () => {
if (client && selectedEmail?.emailSubmissionId) await cancelScheduledEmail(client, selectedEmail.emailSubmissionId);
}}
onCancelScheduledForEdit={async () => {
if (!client || !selectedEmail) return;
const restored = await cancelScheduledEmailForEdit(client, selectedEmail);
if (selectedEmail.isSmimeScheduled) {
setComposerMode('compose');
setShowComposer(true);
return;
}
if (restored) await handleEditDraft(restored);
}}
onRescheduleScheduled={async (sendAt) => {
if (client && selectedEmail?.emailSubmissionId && selectedEmail.scheduledIdentityId) {
await rescheduleScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id, selectedEmail.scheduledIdentityId, sendAt);
}
}}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
@@ -2468,6 +2643,24 @@ export default function Home() {
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
<ConfirmDialog {...confirmDialogProps} />
<PromptDialog {...promptDialogProps} />
{pendingUndoSend && (
<div className="fixed bottom-4 left-1/2 z-[70] flex -translate-x-1/2 items-center gap-3 rounded-lg border border-border bg-background px-4 py-3 shadow-lg">
<span className="text-sm text-foreground">{t('email_viewer.undo_send_scheduled')}</span>
<Button
size="sm"
variant="outline"
onClick={async () => {
if (!client) return;
const restored = await cancelUndoSend(client, pendingUndoSend);
if (restored && !pendingUndoSend.isSmime) {
await handleEditDraft(restored);
}
}}
>
{t('email_viewer.undo_send')}
</Button>
</div>
)}
<TotpReauthDialog />
</div>
</DragDropProvider>
+42 -3
View File
@@ -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<string, { id: string }> = {};
const updated: Record<string, null> = {};
const create = args.create as Record<string, { emailId?: string; identityId?: string; sendAt?: string }> | 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<string, { undoStatus?: 'pending' | 'final' | 'canceled' }> | 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<string, (args: MethodArgs, callId: string) => 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': {},
+103 -5
View File
@@ -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<void>;
onScheduledSendCreated?: () => void | Promise<void>;
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<string | undefined> => {
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({
>
<BookmarkPlus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
setScheduleError('');
setScheduleValue('');
setShowScheduleDialog(true);
}}
disabled={!client?.hasDelayedSend()}
title={client?.hasDelayedSend() ? t('schedule_send') : t('schedule_send_unsupported')}
className="h-9 w-9"
>
<CalendarClock className="w-4 h-4" />
</Button>
{/* S/MIME toggles */}
{canSmimeSign && (
@@ -1668,6 +1743,29 @@ export function EmailComposer({
</div>
)}
{showScheduleDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md p-6 animate-in zoom-in-95 duration-200">
<h3 className="text-lg font-semibold text-foreground mb-2">{t('schedule_send')}</h3>
<p className="text-sm text-muted-foreground mb-4">{t('schedule_send_description')}</p>
<Input
type="datetime-local"
value={scheduleValue}
onChange={(e) => {
setScheduleValue(e.target.value);
setScheduleError('');
}}
className={cn(scheduleError && "border-destructive focus-visible:ring-destructive")}
/>
{scheduleError && <p className="mt-2 text-sm text-destructive">{scheduleError}</p>}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowScheduleDialog(false)}>{tCommon('cancel')}</Button>
<Button onClick={handleScheduleSend} disabled={!canSend}>{t('schedule_send')}</Button>
</div>
</div>
</div>
)}
{/* S/MIME passphrase prompt */}
{smimePassphrasePrompt && (
<div
@@ -2007,4 +2105,4 @@ function RecipientChipInput({
)}
</div>
);
}
}
+41 -2
View File
@@ -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({
</ContextMenuHeader>
)}
{isScheduled && !showBatchActions && (
<>
<ContextMenuItem
icon={CalendarClock}
label={t("reschedule_send")}
onClick={() => handleAction(onRescheduleScheduled!)}
disabled={!onRescheduleScheduled}
/>
<ContextMenuItem
icon={XCircle}
label={t("cancel_scheduled_send")}
onClick={() => handleAction(onCancelScheduled!)}
disabled={!onCancelScheduled}
/>
<ContextMenuItem
icon={EditIcon}
label={email.isSmimeScheduled ? t("cancel_and_compose_again") : t("cancel_and_edit")}
onClick={() => handleAction(onCancelScheduledForEdit!)}
disabled={!onCancelScheduledForEdit}
/>
</>
)}
{isScheduled && <ContextMenuSeparator />}
{!isScheduled && (
<>
{/* Edit Draft - only for single draft emails */}
{!showBatchActions && isDraft && onEditDraft && (
{!isScheduled && !showBatchActions && isDraft && onEditDraft && (
<>
<ContextMenuItem
icon={EditIcon}
@@ -209,7 +246,7 @@ export function EmailContextMenu({
)}
{/* Single email actions - Reply, Reply All, Forward */}
{!showBatchActions && (
{!isScheduled && !showBatchActions && (
<>
<ContextMenuItem
icon={Reply}
@@ -375,6 +412,8 @@ export function EmailContextMenu({
)
}
/>
</>
)}
<PluginSlot name="context-menu-email" />
</ContextMenu>
+89 -8
View File
@@ -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<void>;
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
onRescheduleScheduled?: (email: Email, sendAt: string) => void | Promise<void>;
}
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<Email>();
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 (
<div className={cn("flex flex-col min-h-0", className)}>
{/* Batch Actions Toolbar */}
{isScheduledView && emails.length > 0 && (
<div className="border-b border-border bg-muted/20 px-4 py-2 text-xs text-muted-foreground">
{t('scheduled_actions_hint')}
</div>
)}
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
hasSelection ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
hasSelection && !isScheduledView ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
)}
>
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
@@ -401,17 +446,19 @@ export function EmailList({
) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full py-12">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted shadow-lg flex items-center justify-center">
{searchQuery || !isFilterEmpty(searchFilters) ? (
{isScheduledView ? (
<CalendarClock className="w-10 h-10 text-muted-foreground" />
) : searchQuery || !isFilterEmpty(searchFilters) ? (
<SearchX className="w-10 h-10 text-muted-foreground" />
) : (
<MailX className="w-10 h-10 text-muted-foreground" />
)}
</div>
<p className="text-base font-medium text-foreground">
{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')}
</p>
<p className="text-sm mt-1 text-muted-foreground">
{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')}
</p>
</div>
) : (
@@ -456,6 +503,34 @@ export function EmailList({
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
/>
{isScheduledView && thread.latestEmail.isScheduled && (
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/10 px-4 py-2 text-xs">
<span className="flex items-center gap-1 text-muted-foreground">
<CalendarClock className="w-3.5 h-3.5" />
{new Date(thread.latestEmail.scheduledSendAt || '').toLocaleString()}
</span>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => onCancelScheduled?.(thread.latestEmail)}>
<XCircle className="w-3.5 h-3.5 mr-1" />
{t('cancel_scheduled_send')}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={() => {
const sendAt = promptForRescheduleSendAt();
if (sendAt) onRescheduleScheduled?.(thread.latestEmail, sendAt);
}}
>
<CalendarClock className="w-3.5 h-3.5 mr-1" />
{t('reschedule_send')}
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => onCancelScheduledForEdit?.(thread.latestEmail)}>
<Edit3 className="w-3.5 h-3.5 mr-1" />
{thread.latestEmail.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
</div>
)}
</div>
);
})}
@@ -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 () => {
+95 -6
View File
@@ -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({
<ChevronLeft className="w-5 h-5" />
</Button>
)}
{isDraft && onEditDraft && (
{isScheduled && (
<>
<Button variant="default" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
<X className="w-4 h-4" />
<span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const sendAt = promptForRescheduleSendAt();
if (sendAt) onRescheduleScheduled?.(sendAt);
}}
className="hidden sm:flex sm:h-8"
title={t('reschedule_send')}
>
<CalendarClock className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reschedule_send')}</span>}
</Button>
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
<EditIcon className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
</Button>
</>
)}
{!isScheduled && isDraft && onEditDraft && (
<Button
variant="default"
size="sm"
@@ -3100,7 +3157,7 @@ export function EmailViewer({
<span className="text-sm">{t('edit_draft')}</span>
</Button>
)}
{!isDraft && (<>
{!isScheduled && !isDraft && (<>
<Button
variant="ghost"
size="sm"
@@ -3142,6 +3199,7 @@ export function EmailViewer({
</div>
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
{!isScheduled && (
<div className="flex items-center gap-0 sm:gap-0.5">
{/* Archive */}
<Button
@@ -3599,6 +3657,7 @@ export function EmailViewer({
)}
</div>
</div>
)}
</>
);
@@ -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 && (
<div
className="fixed inset-0 bg-black/50 z-[60] sm:hidden"
onClick={() => setMoreMenuOpen(false)}
/>
)}
{isMobile && (
{!isScheduled && isMobile && (
<div className={cn(
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
"transform transition-transform duration-300 ease-in-out",
@@ -4797,6 +4856,36 @@ export function EmailViewer({
</div>
)}
{/* Scheduled Banner */}
{isScheduled && (
<div className="border-b border-border bg-primary/10">
<div className="max-w-4xl mx-auto px-6 py-2.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-primary">
<CalendarClock className="w-4 h-4" />
<span className="text-sm font-medium">
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const sendAt = promptForRescheduleSendAt();
if (sendAt) onRescheduleScheduled?.(sendAt);
}}
>
{t('reschedule_send')}
</Button>
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
</Button>
</div>
</div>
</div>
)}
{/* Draft Banner */}
{isDraft && (
<div className="border-b border-border bg-warning/10">
@@ -4932,7 +5021,7 @@ export function EmailViewer({
<PluginSlot name="email-footer" />
{/* Quick Reply Section - hidden for drafts and while loading a new email */}
{!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
{!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (<div className={cn(
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
)}>
@@ -5230,4 +5319,4 @@ export function EmailViewer({
</div>
);
}
}
+26 -12
View File
@@ -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")}
</div>
) : (
ownTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
<>
{ownTree.map((node) => (
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))}
<SidebarRow
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('scheduled')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
total={scheduledTotal}
onClick={() => onMailboxSelect?.('__scheduled__')}
isCollapsed={isCollapsed}
onUnreadFilterClick={onUnreadFilterClick}
colorful={colorfulSidebarIcons}
onContextMenu={handleMailboxContextMenu}
/>
))
</>
)}
</>
)}
@@ -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() {
/>
</SettingItem>
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
<div className="flex flex-col items-end gap-1">
<Select
value={String(sendDelaySeconds)}
onChange={(value) => updateSetting('sendDelaySeconds', Number(value) as SendDelaySeconds)}
options={[
{ value: '0', label: t('send_delay.off') },
{ value: '10', label: t('send_delay.seconds', { seconds: 10 }) },
{ value: '30', label: t('send_delay.seconds', { seconds: 30 }) },
{ value: '60', label: t('send_delay.seconds', { seconds: 60 }) },
]}
/>
{sendDelaySeconds > 0 && !delayedSendSupported && (
<p className="max-w-64 text-right text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
)}
</div>
</SettingItem>
<SettingItem
label={t('sub_address_delimiter.label')}
description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })}
+71
View File
@@ -11,6 +11,26 @@ function createClient(): JMAPClient {
return client;
}
function enableDelayedSend(client: JMAPClient) {
Object.assign(client, {
capabilities: {
'urn:ietf:params:jmap:core': {},
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
},
session: {
accounts: {
'account-1': {
accountCapabilities: {
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600 },
},
},
},
},
});
}
interface JMAPMethodCall {
0: string;
1: Record<string, unknown>;
@@ -148,4 +168,55 @@ describe('JMAPClient.sendEmail threading headers', () => {
expect(draft.inReplyTo).toEqual(['real@example.com']);
expect(draft.references).toBeUndefined();
});
it('includes sendAt and submission capability for scheduled sends', async () => {
const client = createClient();
enableDelayedSend(client);
const captured = mockSendEmailFlow();
const sendAt = new Date(Date.now() + 60_000).toISOString();
const result = await client.sendEmail(
['recipient@example.com'],
'Scheduled test',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
undefined,
undefined,
sendAt,
);
const identityRequest = captured[1];
expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission');
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set');
expect(submissionCall?.[1].create).toEqual({ '1': { emailId: expect.stringMatching(/^#send-/), identityId: 'identity-1', sendAt } });
expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt });
});
it('cleans up replacement submission if canceling the original fails during reschedule', async () => {
const client = createClient();
enableDelayedSend(client);
vi.spyOn(client, 'getMailboxes').mockResolvedValue([
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
] as never);
const requestSpy = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
.mockImplementation(async (methodCalls) => {
const args = methodCalls[0][1] as { create?: unknown; update?: Record<string, unknown> };
if (args.create) {
return { methodResponses: [['EmailSubmission/set', { created: { replacement: { id: 'sub-new' } } }, '0']] };
}
if (args.update?.['sub-old']) {
return { methodResponses: [['EmailSubmission/set', { notUpdated: { 'sub-old': { type: 'cannotUnsend' } } }, '0']] };
}
return { methodResponses: [['EmailSubmission/set', { updated: { 'sub-new': null } }, '0']] };
});
await expect(client.rescheduleEmailSubmission('sub-old', 'email-1', 'identity-1', new Date(Date.now() + 60_000).toISOString()))
.rejects.toThrow('could not cancel the original');
expect(requestSpy).toHaveBeenCalledWith(expect.arrayContaining([
expect.arrayContaining(['EmailSubmission/set', expect.objectContaining({ update: { 'sub-new': { undoStatus: 'canceled' } } })]),
]));
});
});
+95 -7
View File
@@ -1,5 +1,5 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from '@/lib/jmap/types';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult } from '@/lib/jmap/types';
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { getDemoData, type DemoData } from './demo-data';
import { generateDemoId } from './demo-utils';
@@ -11,6 +11,7 @@ import { generateDemoId } from './demo-utils';
export class DemoJMAPClient implements IJMAPClient {
private data: DemoData;
private blobStore = new Map<string, Blob>();
private scheduledSubmissions = new Map<string, { id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled'; isSmime: boolean }>();
private connectionCallback: ((connected: boolean) => void) | null = null;
private stateChangeCallback: ((change: StateChange) => void) | null = null;
private lastStates: AccountStates = {};
@@ -49,8 +50,8 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Capabilities ──────────────────────────────────────────────
hasAccountCapability(_capability: string, _accountId?: string): boolean {
return false;
hasAccountCapability(capability: string, _accountId?: string): boolean {
return capability === 'urn:ietf:params:jmap:submission';
}
getCapabilities(): Record<string, unknown> {
@@ -70,6 +71,8 @@ export class DemoJMAPClient implements IJMAPClient {
getMaxSizeUpload(): number { return 50_000_000; }
getMaxCallsInRequest(): number { return 16; }
getMaxObjectsInGet(): number { return 500; }
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
hasDelayedSend(): boolean { return true; }
getEventSourceUrl(): string | null { return null; }
supportsEmailSubmission(): boolean { return true; }
supportsQuota(): boolean { return true; }
@@ -289,6 +292,8 @@ export class DemoJMAPClient implements IJMAPClient {
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
_existingMailboxes: Mailbox[],
_accountId?: string,
): Promise<void> {
if (emails.length === 0) return;
if (mode === 'single') {
@@ -449,7 +454,8 @@ export class DemoJMAPClient implements IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
): Promise<void> {
sendAt?: string,
): Promise<SendEmailResult> {
// Remove draft if updating
if (draftId) {
this.data.emails = this.data.emails.filter(e => e.id !== draftId);
@@ -457,8 +463,8 @@ export class DemoJMAPClient implements IJMAPClient {
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = {
id: generateDemoId('email'), threadId: generateDemoId('thread'),
mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true },
keywords: { $seen: true },
mailboxIds: { [sendAt ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true },
keywords: sendAt ? { $seen: true, $draft: true } : { $seen: true },
size: body.length + (htmlBody?.length || 0),
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
@@ -478,7 +484,22 @@ export class DemoJMAPClient implements IJMAPClient {
references: references?.length ? references : undefined,
};
this.data.emails.push(email);
let emailSubmissionId: string | undefined;
if (sendAt) {
emailSubmissionId = generateDemoId('submission');
this.scheduledSubmissions.set(emailSubmissionId, {
id: emailSubmissionId,
emailId: email.id,
identityId: _identityId || 'demo-identity',
sendAt,
undoStatus: 'pending',
isSmime: false,
});
}
this.recalcMailboxCounts();
return sendAt
? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt }
: { scheduled: false, emailId: email.id };
}
async sendImipReply(): Promise<void> { /* no-op in demo */ }
@@ -898,7 +919,74 @@ export class DemoJMAPClient implements IJMAPClient {
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
async submitEmail(): Promise<void> { /* no-op */ }
async sendRawEmail(): Promise<void> { /* no-op */ }
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, sendAt?: string): Promise<SendEmailResult> {
const emailId = generateDemoId('email');
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = {
id: emailId,
threadId: generateDemoId('thread'),
mailboxIds: { [(sendAt ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true },
keywords: sendAt ? { $seen: true, $draft: true } : { $seen: true },
size: 1024,
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [],
subject: 'S/MIME message',
preview: 'Signed/encrypted demo message',
hasAttachment: false,
};
this.data.emails.push(email);
let emailSubmissionId: string | undefined;
if (sendAt) {
emailSubmissionId = generateDemoId('submission');
this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt, undoStatus: 'pending', isSmime: true });
}
this.recalcMailboxCounts();
return sendAt ? { scheduled: true, emailId, emailSubmissionId, sendAt, isSmime: true } : { scheduled: false, emailId, isSmime: true };
}
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number }> {
const pending = Array.from(this.scheduledSubmissions.values())
.filter(s => s.undoStatus === 'pending')
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
const page = pending.slice(position, position + limit);
const emails = page.map((submission) => {
const email = this.data.emails.find(e => e.id === submission.emailId);
if (!email) return null;
return {
...email,
scheduledSendAt: submission.sendAt,
emailSubmissionId: submission.id,
scheduledIdentityId: submission.identityId,
scheduledUndoStatus: submission.undoStatus,
isScheduled: true,
isSmimeScheduled: submission.isSmime,
} satisfies ScheduledEmail;
}).filter((email): email is ScheduledEmail => email !== null);
return { emails, hasMore: position + emails.length < pending.length, total: pending.length };
}
async cancelEmailSubmission(submissionId: string): Promise<void> {
const submission = this.scheduledSubmissions.get(submissionId);
if (submission) submission.undoStatus = 'canceled';
}
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, sendAt: string): Promise<SendEmailResult> {
await this.cancelEmailSubmission(submissionId);
const replacement = generateDemoId('submission');
this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt, undoStatus: 'pending', isSmime: false });
return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
email.mailboxIds[draftMailboxId] = true;
if (sentMailboxId) delete email.mailboxIds[sentMailboxId];
email.keywords.$draft = true;
this.recalcMailboxCounts();
}
// ── Internal helpers ──────────────────────────────────────────
+11 -3
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
/**
@@ -31,6 +31,8 @@ export interface IJMAPClient {
getMaxSizeUpload(): number;
getMaxCallsInRequest(): number;
getMaxObjectsInGet(): number;
getMaxDelayedSend(accountId?: string): number;
hasDelayedSend(accountId?: string): boolean;
getEventSourceUrl(): string | null;
supportsEmailSubmission(): boolean;
supportsQuota(): boolean;
@@ -146,7 +148,14 @@ export interface IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[],
): Promise<void>;
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>;
sendImipReply(opts: {
organizerEmail: string;
@@ -275,5 +284,4 @@ export interface IJMAPClient {
// ── S/MIME raw-email helpers ──────────────────────────────────
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>): Promise<string>;
submitEmail(emailId: string, identityId: string): Promise<void>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise<void>;
}
+248 -8
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -61,6 +61,12 @@ interface JMAPEmailHeader {
type JMAPMethodCall = [string, Record<string, unknown>, string];
const SUBMISSION_USING = [
'urn:ietf:params:jmap:core',
'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:submission',
] as const;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type JMAPResponseResult = Record<string, any>;
@@ -275,6 +281,27 @@ function computeHasMore(position: number, emailCount: number, total: number, lim
return emailCount === limit;
}
function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean {
return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/'));
}
function isSmimeEmail(email: Email): boolean {
const types: string[] = [];
const collect = (part: Email['bodyStructure']): void => {
if (!part) return;
if (part.type) types.push(part.type.toLowerCase());
part.subParts?.forEach(collect);
};
collect(email.bodyStructure);
email.attachments?.forEach(att => types.push((att.type || '').toLowerCase()));
return types.some(type =>
type.includes('pkcs7') ||
type.includes('x-pkcs7') ||
type === 'application/pkcs7-mime' ||
type === 'application/pkcs7-signature'
);
}
/**
* Fold a single iCalendar content line per RFC 5545 §3.1.
* Lines longer than 75 octets MUST be split with CRLF + a single linear white space character.
@@ -666,7 +693,7 @@ export class JMAPClient implements IJMAPClient {
}
const requestBody = {
using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
using: using || (hasSubmissionMethod(methodCalls) ? [...SUBMISSION_USING] : ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]),
methodCalls,
};
@@ -2054,8 +2081,10 @@ export class JMAPClient implements IJMAPClient {
htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[],
references?: string[]
): Promise<void> {
references?: string[],
sendAt?: string
): Promise<SendEmailResult> {
if (sendAt) this.validateSendAt(sendAt);
const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
@@ -2162,7 +2191,7 @@ export class JMAPClient implements IJMAPClient {
}, "1"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId, ...(sendAt ? { sendAt } : {}) } },
onSuccessUpdateEmail,
}, "2"]);
} else {
@@ -2172,13 +2201,16 @@ export class JMAPClient implements IJMAPClient {
}, "0"]);
methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId, ...(sendAt ? { sendAt } : {}) } },
onSuccessUpdateEmail,
}, "1"]);
}
const response = await this.request(methodCalls);
let createdEmailId: string | undefined;
let emailSubmissionId: string | undefined;
if (response.methodResponses) {
for (const [methodName, result] of response.methodResponses) {
if (methodName.endsWith('/error')) {
@@ -2192,8 +2224,19 @@ export class JMAPClient implements IJMAPClient {
console.error('Email send error:', firstError);
throw new Error(firstError?.description || firstError?.type || 'Failed to send email');
}
if (methodName === 'Email/set' && result.created?.[emailId]?.id) {
createdEmailId = result.created[emailId].id;
}
if (methodName === 'EmailSubmission/set' && result.created?.['1']?.id) {
emailSubmissionId = result.created['1'].id;
}
}
}
return sendAt
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt }
: { scheduled: false, emailId: createdEmailId, emailSubmissionId };
}
/**
@@ -2821,6 +2864,37 @@ export class JMAPClient implements IJMAPClient {
return coreCapability?.maxObjectsInGet || 500;
}
getMaxDelayedSend(accountId?: string): number {
const id = accountId || this.accountId;
const submissionCapability = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:submission"] as { maxDelayedSend?: number } | undefined;
return typeof submissionCapability?.maxDelayedSend === 'number' ? submissionCapability.maxDelayedSend : 0;
}
hasDelayedSend(accountId?: string): boolean {
const id = accountId || this.accountId;
return this.supportsEmailSubmission()
&& this.hasAccountCapability('urn:ietf:params:jmap:submission', id)
&& this.getMaxDelayedSend(id) > 0;
}
private validateSendAt(sendAt: string, accountId?: string): void {
const time = new Date(sendAt).getTime();
if (!Number.isFinite(time)) {
throw new Error('Scheduled send time is invalid');
}
const now = Date.now();
if (time <= now) {
throw new Error('Scheduled send time must be in the future');
}
const maxDelayedSend = this.getMaxDelayedSend(accountId);
if (!this.hasDelayedSend(accountId) || maxDelayedSend <= 0) {
throw new Error('Scheduled send is not supported for this account');
}
if (time > now + maxDelayedSend * 1000) {
throw new Error('Scheduled send time is later than the server allows');
}
}
getEventSourceUrl(): string | null {
if (!this.session) return null;
@@ -5287,7 +5361,9 @@ export class JMAPClient implements IJMAPClient {
identityId: string,
sentMailboxId: string,
draftMailboxId?: string,
): Promise<void> {
sendAt?: string,
): Promise<SendEmailResult> {
if (sendAt) this.validateSendAt(sendAt);
// Upload the raw message
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.uploadBlob(file);
@@ -5312,6 +5388,7 @@ export class JMAPClient implements IJMAPClient {
'raw-submit': {
emailId: '#raw-import',
identityId,
...(sendAt ? { sendAt } : {}),
},
},
...(draftMailboxId ? {
@@ -5327,6 +5404,8 @@ export class JMAPClient implements IJMAPClient {
];
const response = await this.request(methodCalls);
let emailId: string | undefined;
let emailSubmissionId: string | undefined;
// Check for errors
for (const [methodName, result] of response.methodResponses ?? []) {
@@ -5338,6 +5417,167 @@ export class JMAPClient implements IJMAPClient {
const firstErr = Object.values(r.notCreated)[0];
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
}
if (methodName === 'Email/import') {
emailId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-import']?.id;
}
if (methodName === 'EmailSubmission/set') {
emailSubmissionId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-submit']?.id;
}
}
return sendAt
? { scheduled: true, emailId, emailSubmissionId, sendAt, isSmime: true }
: { scheduled: false, emailId, emailSubmissionId, isSmime: true };
}
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number }> {
if (!this.hasDelayedSend()) {
return { emails: [], hasMore: false, total: 0 };
}
const queryResponse = await this.request([
['EmailSubmission/query', {
accountId: this.accountId,
filter: { undoStatus: 'pending' },
sort: [{ property: 'sendAt', isAscending: true }],
limit,
position,
}, '0'],
]);
const query = queryResponse.methodResponses?.[0]?.[1] as { ids?: string[]; total?: number; position?: number } | undefined;
const ids = query?.ids ?? [];
if (ids.length === 0) {
return { emails: [], hasMore: false, total: query?.total ?? 0 };
}
const submissionResponse = await this.request([
['EmailSubmission/get', {
accountId: this.accountId,
ids,
properties: ['id', 'emailId', 'identityId', 'sendAt', 'undoStatus'],
}, '0'],
]);
const submissions = ((submissionResponse.methodResponses?.[0]?.[1]?.list ?? []) as EmailSubmission[])
.filter(submission => submission.sendAt && Number.isFinite(new Date(submission.sendAt).getTime()));
if (submissions.length === 0) {
return { emails: [], hasMore: false, total: query?.total ?? 0 };
}
const emailResponse = await this.request([
['Email/get', {
accountId: this.accountId,
ids: submissions.map(submission => submission.emailId),
properties: [
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'bcc', 'replyTo',
'subject', 'preview', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'hasAttachment', 'sentAt',
'messageId', 'inReplyTo', 'references', 'headers', 'blobId', 'bodyStructure',
],
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 256000,
}, '0'],
]);
const emailById = new Map(((emailResponse.methodResponses?.[0]?.[1]?.list ?? []) as Email[]).map(email => [email.id, email]));
const emails = submissions
.map((submission): ScheduledEmail | null => {
const email = emailById.get(submission.emailId);
if (!email || !submission.sendAt) return null;
return {
...email,
scheduledSendAt: submission.sendAt,
emailSubmissionId: submission.id,
scheduledIdentityId: submission.identityId,
scheduledUndoStatus: submission.undoStatus,
isScheduled: true,
isSmimeScheduled: isSmimeEmail(email),
};
})
.filter((email): email is ScheduledEmail => email !== null)
.sort((a, b) => new Date(a.scheduledSendAt).getTime() - new Date(b.scheduledSendAt).getTime());
const total = query?.total ?? emails.length;
return { emails, hasMore: computeHasMore(position, ids.length, total, limit), total };
}
async cancelEmailSubmission(submissionId: string): Promise<void> {
const response = await this.request([
['EmailSubmission/set', {
accountId: this.accountId,
update: { [submissionId]: { undoStatus: 'canceled' } },
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const error = result?.notUpdated?.[submissionId];
if (error) {
throw new Error(error.description || error.type || 'Failed to cancel scheduled send');
}
}
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, sendAt: string): Promise<SendEmailResult> {
this.validateSendAt(sendAt);
const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
const response = await this.request([
['EmailSubmission/set', {
accountId: this.accountId,
create: { replacement: { emailId, identityId, sendAt } },
...(draftsMailbox && sentMailbox ? {
onSuccessUpdateEmail: {
'#replacement': {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
'keywords/$draft': null,
},
},
} : {}),
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const createError = result?.notCreated?.replacement;
if (createError) {
throw new Error(createError.description || createError.type || 'Failed to reschedule email');
}
const replacementId = result?.created?.replacement?.id;
if (!replacementId) {
throw new Error('Server did not return a replacement scheduled send ID');
}
try {
await this.cancelEmailSubmission(submissionId);
} catch (error) {
try {
await this.cancelEmailSubmission(replacementId);
} catch (cleanupError) {
console.error('Failed to clean up replacement scheduled send after reschedule failure:', cleanupError);
}
const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send';
throw new Error(`Reschedule created a replacement but could not cancel the original: ${message}`);
}
return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
const update: Record<string, unknown> = {
[`mailboxIds/${draftMailboxId}`]: true,
'keywords/$draft': true,
};
if (sentMailboxId) {
update[`mailboxIds/${sentMailboxId}`] = null;
}
const response = await this.request([
['Email/set', {
accountId: this.accountId,
update: { [emailId]: update },
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
const error = result?.notUpdated?.[emailId];
if (error) {
throw new Error(error.description || error.type || 'Failed to restore email to drafts');
}
}
@@ -5416,4 +5656,4 @@ export class JMAPClient implements IJMAPClient {
['urn:ietf:params:jmap:core'],
);
}
}
}
+25 -1
View File
@@ -42,6 +42,30 @@ export interface Email {
// Unified mailbox support - set when displaying emails from multiple accounts
accountId?: string;
accountLabel?: string;
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
scheduledSendAt?: string;
emailSubmissionId?: string;
scheduledIdentityId?: string;
scheduledUndoStatus?: 'pending' | 'final' | 'canceled';
isScheduled?: boolean;
isSmimeScheduled?: boolean;
}
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;
}
export interface AuthenticationResults {
@@ -767,4 +791,4 @@ export const UNIFIED_ROLE_BY_ID: Record<string, UnifiedMailboxRole> = Object.fro
export function isUnifiedMailboxId(id: string): boolean {
return id in UNIFIED_ROLE_BY_ID;
}
}
+48 -5
View File
@@ -130,7 +130,8 @@
"shared": "Sdílené",
"mail": "Pošta",
"nav_label": "Navigace",
"add_app": "Aplikace"
"add_app": "Aplikace",
"scheduled": "Naplánováno"
},
"sidebar_apps": {
"modal_title": "Aplikace postranního panelu",
@@ -203,7 +204,16 @@
"confirm_button": "Vyprázdnit složku",
"junk_hint": "Můžete vyprázdnit složku Spam a trvale odstranit všechny zprávy.",
"trash_hint": "Můžete vyprázdnit Koš a trvale odstranit všechny zprávy."
}
},
"no_scheduled_emails": "Žádné naplánované e-maily",
"no_scheduled_emails_description": "Zprávy naplánované na později se zobrazí zde.",
"scheduled_count": "{count, plural, one {1 naplánovaný e-mail} few {# naplánované e-maily} other {# naplánovaných e-mailů}}",
"scheduled_actions_hint": "Naplánované zprávy můžete zrušit, přeplánovat nebo upravit pomocí akcí plánování.",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu",
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Není vybrána žádná zpráva",
@@ -475,7 +485,17 @@
"ai_verdict": "Verdikt AI",
"account": "Účet",
"no_subject": "(bez předmětu)"
}
},
"scheduled_banner": "Naplánováno k odeslání {date}",
"scheduled_send_created": "E-mail byl naplánován k odeslání",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu",
"reschedule_prompt": "Zadejte nové datum/čas, např. 2026-05-04T15:30",
"scheduled_actions_only": "Zobrazení Naplánováno podporuje pouze akce plánování",
"undo_send_scheduled": "Zpráva je naplánována k odeslání",
"undo_send": "Vrátit odeslání"
},
"email_composer": {
"new_message": "Nová zpráva",
@@ -557,7 +577,19 @@
},
"add_link": "Přidat odkaz",
"link_url_prompt": "Zadejte URL",
"sending": "Odesílání..."
"sending": "Odesílání...",
"schedule_send": "Naplánovat odeslání",
"schedule_send_description": "Vyberte, kdy má server tuto zprávu odeslat.",
"schedule_send_required": "Vyberte datum a čas.",
"schedule_send_invalid": "Zadejte platné datum a čas.",
"schedule_send_future": "Vyberte budoucí datum a čas.",
"schedule_send_too_late": "Tento čas je později, než server dovoluje.",
"schedule_send_unsupported": "Naplánované odeslání není pro tento účet podporováno.",
"schedule_send_cleanup_warning": "Naplánované odeslání bylo vytvořeno, ale vyčištění konceptu selhalo.",
"send_delay_unsupported": "Prodleva odeslání není pro tento účet podporována.",
"send_delay_unsupported_confirm": "Tento účet nepodporuje prodlevu odeslání. Odeslat ihned?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Potvrdit",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Zobrazit náhledy obrázků v přílohách",
"description": "Zobrazovat obrázkové přílohy jako miniatury místo obecných ikon souborů"
},
"send_delay": {
"label": "Vrátit odeslání / prodleva odeslání",
"description": "Pozdržet běžné odeslání krátkým oknem na serveru.",
"off": "Vypnuto",
"seconds": "{seconds} sekund",
"unsupported": "Aktuální účet neoznamuje podporu odloženého odeslání. Nastavení zůstane uloženo pro jiné účty."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Štítek",
"remove_color": "Odebrat štítek",
"items_selected": "{count} vybraných zpráv",
"edit_draft": "Upravit koncept"
"edit_draft": "Upravit koncept",
"cancel_scheduled_send": "Zrušit odeslání",
"reschedule_send": "Přeplánovat",
"cancel_and_edit": "Zrušit a upravit",
"cancel_and_compose_again": "Zrušit a napsat znovu"
},
"mailbox_context_menu": {
"mark_folder_read": "Označit složku jako přečtenou",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "E-Mail",
"nav_label": "Navigation",
"add_app": "Apps",
"shared": "Geteilt"
"shared": "Geteilt",
"scheduled": "Geplant"
},
"sidebar_apps": {
"modal_title": "Sidebar-Apps",
@@ -203,7 +204,16 @@
"confirm_button": "Ordner leeren",
"junk_hint": "Sie können den Spam-Ordner leeren, um alle Nachrichten dauerhaft zu entfernen.",
"trash_hint": "Sie können den Papierkorb leeren, um alle Nachrichten dauerhaft zu entfernen."
}
},
"no_scheduled_emails": "Keine geplanten E-Mails",
"no_scheduled_emails_description": "Nachrichten, die für später geplant sind, erscheinen hier.",
"scheduled_count": "{count, plural, one {1 geplante E-Mail} other {# geplante E-Mails}}",
"scheduled_actions_hint": "Geplante Nachrichten können über die Planungsaktionen abgebrochen, neu geplant oder bearbeitet werden.",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen",
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Keine E-Mail ausgewählt",
@@ -475,7 +485,17 @@
"ai_verdict": "KI-Urteil",
"account": "Konto",
"no_subject": "(kein Betreff)"
}
},
"scheduled_banner": "Geplant zum Senden um {date}",
"scheduled_send_created": "E-Mail wurde zum Senden geplant",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen",
"reschedule_prompt": "Neues Datum/Uhrzeit eingeben, z. B. 2026-05-04T15:30",
"scheduled_actions_only": "Die Geplant-Ansicht unterstützt nur Planungsaktionen",
"undo_send_scheduled": "Nachricht ist zum Senden geplant",
"undo_send": "Senden rückgängig"
},
"email_composer": {
"new_message": "Neue Nachricht",
@@ -557,7 +577,19 @@
},
"add_link": "Link hinzufügen",
"link_url_prompt": "URL eingeben",
"sending": "Wird gesendet..."
"sending": "Wird gesendet...",
"schedule_send": "Senden planen",
"schedule_send_description": "Wählen Sie, wann der Server diese Nachricht freigeben soll.",
"schedule_send_required": "Wählen Sie Datum und Uhrzeit.",
"schedule_send_invalid": "Geben Sie ein gültiges Datum und eine gültige Uhrzeit ein.",
"schedule_send_future": "Wählen Sie ein Datum und eine Uhrzeit in der Zukunft.",
"schedule_send_too_late": "Dieser Zeitpunkt liegt später, als der Server erlaubt.",
"schedule_send_unsupported": "Geplantes Senden wird für dieses Konto nicht unterstützt.",
"schedule_send_cleanup_warning": "Geplantes Senden wurde erstellt, aber das Bereinigen des Entwurfs ist fehlgeschlagen.",
"send_delay_unsupported": "Sendeverzögerung wird für dieses Konto nicht unterstützt.",
"send_delay_unsupported_confirm": "Dieses Konto unterstützt keine Sendeverzögerung. Stattdessen sofort senden?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Bestätigen",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Bildvorschau in Anhängen anzeigen",
"description": "Bildanhänge als Miniaturansichten statt als generische Dateisymbole anzeigen"
},
"send_delay": {
"label": "Senden rückgängig / Sendeverzögerung",
"description": "Normales Senden serverseitig kurz verzögern.",
"off": "Aus",
"seconds": "{seconds} Sekunden",
"unsupported": "Das aktuelle Konto meldet keine Unterstützung für verzögertes Senden. Die Einstellung bleibt für andere Konten gespeichert."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Label",
"remove_color": "Label entfernen",
"items_selected": "{count} E-Mails ausgewählt",
"edit_draft": "Entwurf bearbeiten"
"edit_draft": "Entwurf bearbeiten",
"cancel_scheduled_send": "Senden abbrechen",
"reschedule_send": "Neu planen",
"cancel_and_edit": "Abbrechen und bearbeiten",
"cancel_and_compose_again": "Abbrechen und neu verfassen"
},
"mailbox_context_menu": {
"mark_folder_read": "Ordner als gelesen markieren",
+46 -5
View File
@@ -130,7 +130,8 @@
"shared": "Shared",
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps"
"add_app": "Apps",
"scheduled": "Scheduled"
},
"sidebar_apps": {
"modal_title": "Sidebar Apps",
@@ -203,7 +204,16 @@
"confirm_button": "Empty folder",
"junk_hint": "You can empty the Junk folder to permanently remove all messages.",
"trash_hint": "You can empty the Trash folder to permanently remove all messages."
}
},
"no_scheduled_emails": "No scheduled emails",
"no_scheduled_emails_description": "Messages scheduled for later will appear here.",
"scheduled_count": "{count, plural, one {1 scheduled email} other {# scheduled emails}}",
"scheduled_actions_hint": "Scheduled messages can be canceled, rescheduled, or edited from their scheduled actions.",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again",
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "No email selected",
@@ -475,7 +485,17 @@
"collapse": "Hide details"
},
"send": "Send",
"more": "more"
"more": "more",
"scheduled_banner": "Scheduled to send at {date}",
"scheduled_send_created": "Email scheduled for sending",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again",
"reschedule_prompt": "Enter a new date/time, e.g. 2026-05-04T15:30",
"scheduled_actions_only": "Scheduled view supports scheduled actions only",
"undo_send_scheduled": "Message scheduled for sending",
"undo_send": "Undo send"
},
"email_composer": {
"new_message": "New Message",
@@ -559,7 +579,17 @@
"message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"send_anyway": "Send anyway",
"back": "Back to editing"
}
},
"schedule_send": "Schedule send",
"schedule_send_description": "Choose when the server should release this message.",
"schedule_send_required": "Choose a date and time.",
"schedule_send_invalid": "Enter a valid date and time.",
"schedule_send_future": "Choose a future date and time.",
"schedule_send_too_late": "This time is later than the server allows.",
"schedule_send_unsupported": "Scheduled send is not supported for this account.",
"schedule_send_cleanup_warning": "Scheduled send was created, but draft cleanup failed.",
"send_delay_unsupported": "Send delay is not supported for this account.",
"send_delay_unsupported_confirm": "This account does not support send delay. Send immediately instead?"
},
"confirm_dialog": {
"confirm": "Confirm",
@@ -1059,6 +1089,13 @@
"attachment_image_previews": {
"label": "Show image previews in attachments",
"description": "Render image attachments as thumbnail cards instead of generic file icons"
},
"send_delay": {
"label": "Undo send / send delay",
"description": "Delay normal sends by a short server-side window.",
"off": "Off",
"seconds": "{seconds} seconds",
"unsupported": "The current account does not advertise delayed-send support. The setting is still saved for other accounts."
}
},
"composer": {
@@ -1669,7 +1706,11 @@
"color_tag": "Tag",
"remove_color": "Remove tag",
"items_selected": "{count} emails selected",
"edit_draft": "Edit Draft"
"edit_draft": "Edit Draft",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
"cancel_and_compose_again": "Cancel and compose again"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "Correo",
"nav_label": "Navegación",
"add_app": "Apps",
"shared": "Compartido"
"shared": "Compartido",
"scheduled": "Programados"
},
"sidebar_apps": {
"modal_title": "Aplicaciones de la barra lateral",
@@ -203,7 +204,16 @@
"confirm_button": "Vaciar carpeta",
"junk_hint": "Puede vaciar la carpeta de Spam para eliminar permanentemente todos los mensajes.",
"trash_hint": "Puede vaciar la Papelera para eliminar permanentemente todos los mensajes."
}
},
"no_scheduled_emails": "No hay correos programados",
"no_scheduled_emails_description": "Los mensajes programados para más tarde aparecerán aquí.",
"scheduled_count": "{count, plural, one {1 correo programado} other {# correos programados}}",
"scheduled_actions_hint": "Los mensajes programados se pueden cancelar, reprogramar o editar desde sus acciones.",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo",
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Ningún correo seleccionado",
@@ -475,7 +485,17 @@
"ai_verdict": "Veredicto de IA",
"account": "Cuenta",
"no_subject": "(sin asunto)"
}
},
"scheduled_banner": "Programado para enviarse el {date}",
"scheduled_send_created": "Correo programado para envío",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo",
"reschedule_prompt": "Introduce una nueva fecha/hora, p. ej. 2026-05-04T15:30",
"scheduled_actions_only": "La vista Programados solo admite acciones de programación",
"undo_send_scheduled": "Mensaje programado para envío",
"undo_send": "Deshacer envío"
},
"email_composer": {
"new_message": "Nuevo Mensaje",
@@ -557,7 +577,19 @@
},
"add_link": "Añadir enlace",
"link_url_prompt": "Introduce la URL",
"sending": "Enviando..."
"sending": "Enviando...",
"schedule_send": "Programar envío",
"schedule_send_description": "Elige cuándo debe liberar el servidor este mensaje.",
"schedule_send_required": "Elige una fecha y hora.",
"schedule_send_invalid": "Introduce una fecha y hora válidas.",
"schedule_send_future": "Elige una fecha y hora futuras.",
"schedule_send_too_late": "Esta hora es posterior a lo que permite el servidor.",
"schedule_send_unsupported": "El envío programado no es compatible con esta cuenta.",
"schedule_send_cleanup_warning": "Se creó el envío programado, pero falló la limpieza del borrador.",
"send_delay_unsupported": "La demora de envío no es compatible con esta cuenta.",
"send_delay_unsupported_confirm": "Esta cuenta no admite demora de envío. ¿Enviar inmediatamente?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Mostrar vistas previas de imágenes en adjuntos",
"description": "Mostrar las imágenes adjuntas como tarjetas de miniatura en lugar de iconos de archivo genéricos"
},
"send_delay": {
"label": "Deshacer envío / demora de envío",
"description": "Retrasa envíos normales con una breve ventana del servidor.",
"off": "Desactivado",
"seconds": "{seconds} segundos",
"unsupported": "La cuenta actual no anuncia compatibilidad con envío demorado. La opción seguirá guardada para otras cuentas."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Etiqueta",
"remove_color": "Eliminar etiqueta",
"items_selected": "{count} correos seleccionados",
"edit_draft": "Editar borrador"
"edit_draft": "Editar borrador",
"cancel_scheduled_send": "Cancelar envío",
"reschedule_send": "Reprogramar",
"cancel_and_edit": "Cancelar y editar",
"cancel_and_compose_again": "Cancelar y redactar de nuevo"
},
"mailbox_context_menu": {
"mark_folder_read": "Marcar carpeta como leída",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "Messagerie",
"nav_label": "Navigation",
"add_app": "Apps",
"shared": "Partagé"
"shared": "Partagé",
"scheduled": "Planifiés"
},
"sidebar_apps": {
"modal_title": "Applications de la barre latérale",
@@ -203,7 +204,16 @@
"confirm_button": "Vider le dossier",
"junk_hint": "Vous pouvez vider le dossier Indésirables pour supprimer définitivement tous les messages.",
"trash_hint": "Vous pouvez vider la corbeille pour supprimer définitivement tous les messages."
}
},
"no_scheduled_emails": "Aucun e-mail planifié",
"no_scheduled_emails_description": "Les messages planifiés pour plus tard apparaîtront ici.",
"scheduled_count": "{count, plural, one {1 e-mail planifié} other {# e-mails planifiés}}",
"scheduled_actions_hint": "Les messages planifiés peuvent être annulés, replanifiés ou modifiés depuis leurs actions.",
"cancel_scheduled_send": "Annuler lenvoi",
"reschedule_send": "Replanifier",
"cancel_and_edit": "Annuler et modifier",
"cancel_and_compose_again": "Annuler et rédiger à nouveau",
"reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Aucun email sélectionné",
@@ -475,7 +485,17 @@
"ai_verdict": "Verdict IA",
"account": "Compte",
"no_subject": "(sans objet)"
}
},
"scheduled_banner": "Envoi planifié à {date}",
"scheduled_send_created": "E-mail planifié pour envoi",
"cancel_scheduled_send": "Annuler lenvoi",
"reschedule_send": "Replanifier",
"cancel_and_edit": "Annuler et modifier",
"cancel_and_compose_again": "Annuler et rédiger à nouveau",
"reschedule_prompt": "Saisissez une nouvelle date/heure, p. ex. 2026-05-04T15:30",
"scheduled_actions_only": "La vue Planifiés prend uniquement en charge les actions de planification",
"undo_send_scheduled": "Message planifié pour envoi",
"undo_send": "Annuler lenvoi"
},
"email_composer": {
"new_message": "Nouveau message",
@@ -557,7 +577,19 @@
},
"add_link": "Ajouter un lien",
"link_url_prompt": "Saisissez l'URL",
"sending": "Envoi..."
"sending": "Envoi...",
"schedule_send": "Planifier lenvoi",
"schedule_send_description": "Choisissez quand le serveur doit libérer ce message.",
"schedule_send_required": "Choisissez une date et une heure.",
"schedule_send_invalid": "Saisissez une date et une heure valides.",
"schedule_send_future": "Choisissez une date et une heure futures.",
"schedule_send_too_late": "Cette heure est au-delà de ce que le serveur autorise.",
"schedule_send_unsupported": "Lenvoi planifié nest pas pris en charge pour ce compte.",
"schedule_send_cleanup_warning": "Lenvoi planifié a été créé, mais le nettoyage du brouillon a échoué.",
"send_delay_unsupported": "Le délai denvoi nest pas pris en charge pour ce compte.",
"send_delay_unsupported_confirm": "Ce compte ne prend pas en charge le délai denvoi. Envoyer immédiatement ?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Confirmer",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Afficher les aperçus d'images dans les pièces jointes",
"description": "Afficher les images jointes sous forme de vignettes plutôt qu'avec des icônes de fichier génériques"
},
"send_delay": {
"label": "Annuler lenvoi / délai denvoi",
"description": "Retarde les envois normaux avec une courte fenêtre côté serveur.",
"off": "Désactivé",
"seconds": "{seconds} secondes",
"unsupported": "Le compte actuel nannonce pas la prise en charge de lenvoi différé. Le réglage reste enregistré pour dautres comptes."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Étiquette",
"remove_color": "Supprimer l'étiquette",
"items_selected": "{count} emails sélectionnés",
"edit_draft": "Modifier le brouillon"
"edit_draft": "Modifier le brouillon",
"cancel_scheduled_send": "Annuler lenvoi",
"reschedule_send": "Replanifier",
"cancel_and_edit": "Annuler et modifier",
"cancel_and_compose_again": "Annuler et rédiger à nouveau"
},
"mailbox_context_menu": {
"mark_folder_read": "Marquer le dossier comme lu",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "Posta",
"nav_label": "Navigazione",
"add_app": "App",
"shared": "Condiviso"
"shared": "Condiviso",
"scheduled": "Programmate"
},
"sidebar_apps": {
"modal_title": "App della barra laterale",
@@ -203,7 +204,16 @@
"confirm_button": "Svuota cartella",
"junk_hint": "Puoi svuotare la cartella Spam per rimuovere definitivamente tutti i messaggi.",
"trash_hint": "Puoi svuotare il Cestino per rimuovere definitivamente tutti i messaggi."
}
},
"no_scheduled_emails": "Nessuna e-mail programmata",
"no_scheduled_emails_description": "I messaggi programmati per più tardi appariranno qui.",
"scheduled_count": "{count, plural, one {1 e-mail programmata} other {# e-mail programmate}}",
"scheduled_actions_hint": "I messaggi programmati possono essere annullati, riprogrammati o modificati dalle azioni di programmazione.",
"cancel_scheduled_send": "Annulla invio",
"reschedule_send": "Riprogramma",
"cancel_and_edit": "Annulla e modifica",
"cancel_and_compose_again": "Annulla e componi di nuovo",
"reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nessun messaggio selezionato",
@@ -475,7 +485,17 @@
"ai_verdict": "Verdetto IA",
"account": "Account",
"no_subject": "(nessun oggetto)"
}
},
"scheduled_banner": "Programmato per linvio il {date}",
"scheduled_send_created": "E-mail programmata per linvio",
"cancel_scheduled_send": "Annulla invio",
"reschedule_send": "Riprogramma",
"cancel_and_edit": "Annulla e modifica",
"cancel_and_compose_again": "Annulla e componi di nuovo",
"reschedule_prompt": "Inserisci una nuova data/ora, ad es. 2026-05-04T15:30",
"scheduled_actions_only": "La vista Programmate supporta solo azioni di programmazione",
"undo_send_scheduled": "Messaggio programmato per linvio",
"undo_send": "Annulla invio"
},
"email_composer": {
"new_message": "Nuovo messaggio",
@@ -557,7 +577,19 @@
},
"add_link": "Aggiungi link",
"link_url_prompt": "Inserisci l'URL",
"sending": "Invio in corso..."
"sending": "Invio in corso...",
"schedule_send": "Programma invio",
"schedule_send_description": "Scegli quando il server deve rilasciare questo messaggio.",
"schedule_send_required": "Scegli una data e unora.",
"schedule_send_invalid": "Inserisci una data e unora valide.",
"schedule_send_future": "Scegli una data e unora future.",
"schedule_send_too_late": "Questo orario supera il limite consentito dal server.",
"schedule_send_unsupported": "Linvio programmato non è supportato per questo account.",
"schedule_send_cleanup_warning": "Linvio programmato è stato creato, ma la pulizia della bozza non è riuscita.",
"send_delay_unsupported": "Il ritardo di invio non è supportato per questo account.",
"send_delay_unsupported_confirm": "Questo account non supporta il ritardo di invio. Inviare subito?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Conferma",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Mostra anteprime delle immagini negli allegati",
"description": "Mostra gli allegati immagine come miniature anziché con icone di file generiche"
},
"send_delay": {
"label": "Annulla invio / ritardo di invio",
"description": "Ritarda gli invii normali con una breve finestra lato server.",
"off": "Disattivato",
"seconds": "{seconds} secondi",
"unsupported": "Laccount corrente non dichiara il supporto allinvio ritardato. Limpostazione resta salvata per altri account."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Etichetta",
"remove_color": "Rimuovi etichetta",
"items_selected": "{count} messaggi selezionati",
"edit_draft": "Modifica bozza"
"edit_draft": "Modifica bozza",
"cancel_scheduled_send": "Annulla invio",
"reschedule_send": "Riprogramma",
"cancel_and_edit": "Annulla e modifica",
"cancel_and_compose_again": "Annulla e componi di nuovo"
},
"mailbox_context_menu": {
"mark_folder_read": "Segna cartella come letta",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "メール",
"nav_label": "ナビゲーション",
"add_app": "アプリ",
"shared": "共有"
"shared": "共有",
"scheduled": "予約済み"
},
"sidebar_apps": {
"modal_title": "サイドバーアプリ",
@@ -203,7 +204,16 @@
"confirm_button": "フォルダを空にする",
"junk_hint": "迷惑メールフォルダを空にして、すべてのメールを完全に削除できます。",
"trash_hint": "ゴミ箱を空にして、すべてのメールを完全に削除できます。"
}
},
"no_scheduled_emails": "予約送信はありません",
"no_scheduled_emails_description": "後で送信するよう予約したメッセージがここに表示されます。",
"scheduled_count": "{count, plural, one {1 件の予約送信} other {# 件の予約送信}}",
"scheduled_actions_hint": "予約メッセージは予約アクションからキャンセル、再予約、編集できます。",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成",
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "メールが選択されていません",
@@ -475,7 +485,17 @@
"ai_verdict": "AIの判定",
"account": "アカウント",
"no_subject": "(件名なし)"
}
},
"scheduled_banner": "{date} に送信予定",
"scheduled_send_created": "メールを送信予約しました",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成",
"reschedule_prompt": "新しい日時を入力してください。例: 2026-05-04T15:30",
"scheduled_actions_only": "予約済みビューでは予約アクションのみ利用できます",
"undo_send_scheduled": "メッセージは送信予約されています",
"undo_send": "送信を取り消す"
},
"email_composer": {
"new_message": "新規メッセージ",
@@ -557,7 +577,19 @@
},
"add_link": "リンクを追加",
"link_url_prompt": "URLを入力してください",
"sending": "送信中..."
"sending": "送信中...",
"schedule_send": "送信を予約",
"schedule_send_description": "サーバーがこのメッセージを送信する日時を選択します。",
"schedule_send_required": "日時を選択してください。",
"schedule_send_invalid": "有効な日時を入力してください。",
"schedule_send_future": "未来の日時を選択してください。",
"schedule_send_too_late": "この日時はサーバーが許可する範囲を超えています。",
"schedule_send_unsupported": "このアカウントでは予約送信はサポートされていません。",
"schedule_send_cleanup_warning": "予約送信は作成されましたが、下書きのクリーンアップに失敗しました。",
"send_delay_unsupported": "このアカウントでは送信遅延はサポートされていません。",
"send_delay_unsupported_confirm": "このアカウントは送信遅延に対応していません。すぐに送信しますか?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "確認",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "添付ファイル内の画像プレビューを表示",
"description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します"
},
"send_delay": {
"label": "送信取り消し / 送信遅延",
"description": "通常の送信をサーバー側で短時間遅延します。",
"off": "オフ",
"seconds": "{seconds} 秒",
"unsupported": "現在のアカウントは遅延送信のサポートを通知していません。この設定は他のアカウント用に保存されます。"
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "ラベル",
"remove_color": "ラベルを削除",
"items_selected": "{count}件のメールを選択",
"edit_draft": "下書きを編集"
"edit_draft": "下書きを編集",
"cancel_scheduled_send": "送信をキャンセル",
"reschedule_send": "再予約",
"cancel_and_edit": "キャンセルして編集",
"cancel_and_compose_again": "キャンセルして新規作成"
},
"mailbox_context_menu": {
"mark_folder_read": "フォルダーを既読にする",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "메일",
"nav_label": "내비게이션",
"add_app": "앱",
"shared": "공유됨"
"shared": "공유됨",
"scheduled": "예약됨"
},
"sidebar_apps": {
"modal_title": "사이드바 앱",
@@ -203,7 +204,16 @@
"confirm_button": "폴더 비우기",
"junk_hint": "스팸함을 비워 모든 메시지를 영구적으로 삭제할 수 있어요.",
"trash_hint": "휴지통을 비워 모든 메시지를 영구적으로 삭제할 수 있어요."
}
},
"no_scheduled_emails": "예약된 메일 없음",
"no_scheduled_emails_description": "나중에 보내도록 예약한 메시지가 여기에 표시됩니다.",
"scheduled_count": "{count, plural, one {예약된 메일 1개} other {예약된 메일 #개}}",
"scheduled_actions_hint": "예약된 메시지는 예약 작업에서 취소, 재예약 또는 편집할 수 있습니다.",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성",
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "메일이 선택되지 않았어요",
@@ -475,7 +485,17 @@
"ai_verdict": "AI 판정",
"account": "계정",
"no_subject": "(제목 없음)"
}
},
"scheduled_banner": "{date}에 보내도록 예약됨",
"scheduled_send_created": "메일 보내기가 예약되었습니다",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성",
"reschedule_prompt": "새 날짜/시간을 입력하세요. 예: 2026-05-04T15:30",
"scheduled_actions_only": "예약됨 보기에서는 예약 작업만 사용할 수 있습니다",
"undo_send_scheduled": "메시지가 보내기로 예약되었습니다",
"undo_send": "보내기 실행 취소"
},
"email_composer": {
"new_message": "새 메시지",
@@ -557,7 +577,19 @@
},
"add_link": "링크 추가",
"link_url_prompt": "URL을 입력하세요",
"sending": "전송 중..."
"sending": "전송 중...",
"schedule_send": "보내기 예약",
"schedule_send_description": "서버가 이 메시지를 보낼 시간을 선택하세요.",
"schedule_send_required": "날짜와 시간을 선택하세요.",
"schedule_send_invalid": "유효한 날짜와 시간을 입력하세요.",
"schedule_send_future": "미래의 날짜와 시간을 선택하세요.",
"schedule_send_too_late": "이 시간은 서버가 허용하는 범위를 벗어납니다.",
"schedule_send_unsupported": "이 계정은 예약 보내기를 지원하지 않습니다.",
"schedule_send_cleanup_warning": "예약 보내기는 생성되었지만 초안 정리에 실패했습니다.",
"send_delay_unsupported": "이 계정은 보내기 지연을 지원하지 않습니다.",
"send_delay_unsupported_confirm": "이 계정은 보내기 지연을 지원하지 않습니다. 바로 보낼까요?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "확인",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "첨부 파일에 이미지 미리보기 표시",
"description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다"
},
"send_delay": {
"label": "보내기 취소 / 보내기 지연",
"description": "일반 보내기를 서버 측 짧은 시간 동안 지연합니다.",
"off": "끔",
"seconds": "{seconds}초",
"unsupported": "현재 계정은 지연 보내기 지원을 알리지 않습니다. 설정은 다른 계정을 위해 계속 저장됩니다."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "태그",
"remove_color": "태그 제거",
"items_selected": "{count}개의 메일 선택됨",
"edit_draft": "임시보관 메일 수정"
"edit_draft": "임시보관 메일 수정",
"cancel_scheduled_send": "보내기 취소",
"reschedule_send": "다시 예약",
"cancel_and_edit": "취소하고 편집",
"cancel_and_compose_again": "취소하고 다시 작성"
},
"mailbox_context_menu": {
"mark_folder_read": "폴더를 읽음으로 표시",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "Pasts",
"nav_label": "Navigācija",
"add_app": "Lietotnes",
"shared": "Koplietots"
"shared": "Koplietots",
"scheduled": "Ieplānots"
},
"sidebar_apps": {
"modal_title": "Sānu joslas lietotnes",
@@ -203,7 +204,16 @@
"confirm_button": "Iztīrīt mapi",
"junk_hint": "Jūs varat iztīrīt mapi Mēstules, lai neatgriezeniski dzēstu visus ziņojumus.",
"trash_hint": "Jūs varat iztīrīt Atkritni, lai neatgriezeniski dzēstu visus ziņojumus."
}
},
"no_scheduled_emails": "Nav ieplānotu e-pastu",
"no_scheduled_emails_description": "Ziņojumi, kas ieplānoti vēlākai nosūtīšanai, būs redzami šeit.",
"scheduled_count": "{count, plural, one {1 ieplānots e-pasts} other {# ieplānoti e-pasti}}",
"scheduled_actions_hint": "Ieplānotos ziņojumus var atcelt, pārplānot vai rediģēt no ieplānotajām darbībām.",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna",
"reschedule_prompt": "Ievadiet jaunu datumu/laiku, piem. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nav atlasīta neviena vēstule",
@@ -475,7 +485,17 @@
"ai_verdict": "AI vērtējums",
"account": "Konts",
"no_subject": "(bez temata)"
}
},
"scheduled_banner": "Ieplānots nosūtīšanai {date}",
"scheduled_send_created": "E-pasts ieplānots nosūtīšanai",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna",
"reschedule_prompt": "Ievadiet jaunu datumu/laiku, piem. 2026-05-04T15:30",
"scheduled_actions_only": "Ieplānoto skatā ir pieejamas tikai ieplānošanas darbības",
"undo_send_scheduled": "Ziņojums ir ieplānots nosūtīšanai",
"undo_send": "Atsaukt sūtīšanu"
},
"email_composer": {
"new_message": "Jauns ziņojums",
@@ -557,7 +577,19 @@
},
"add_link": "Pievienot saiti",
"link_url_prompt": "Ievadiet URL",
"sending": "Sūta..."
"sending": "Sūta...",
"schedule_send": "Ieplānot sūtīšanu",
"schedule_send_description": "Izvēlieties, kad serverim jānosūta šis ziņojums.",
"schedule_send_required": "Izvēlieties datumu un laiku.",
"schedule_send_invalid": "Ievadiet derīgu datumu un laiku.",
"schedule_send_future": "Izvēlieties datumu un laiku nākotnē.",
"schedule_send_too_late": "Šis laiks ir vēlāk, nekā serveris atļauj.",
"schedule_send_unsupported": "Šim kontam ieplānota sūtīšana netiek atbalstīta.",
"schedule_send_cleanup_warning": "Ieplānotā sūtīšana tika izveidota, bet melnraksta tīrīšana neizdevās.",
"send_delay_unsupported": "Šim kontam sūtīšanas aizture netiek atbalstīta.",
"send_delay_unsupported_confirm": "Šis konts neatbalsta sūtīšanas aizturi. Sūtīt uzreiz?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Apstiprināt",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Rādīt attēlu priekšskatījumus pielikumos",
"description": "Rādīt attēlu pielikumus kā sīktēlu kartītes, nevis vispārīgas failu ikonas"
},
"send_delay": {
"label": "Atsaukt sūtīšanu / sūtīšanas aizture",
"description": "Aizturiet parastu sūtīšanu īsā servera puses logā.",
"off": "Izslēgts",
"seconds": "{seconds} sekundes",
"unsupported": "Pašreizējais konts neziņo par aizturētas sūtīšanas atbalstu. Iestatījums joprojām tiks saglabāts citiem kontiem."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Tags",
"remove_color": "Noņemt tagu",
"items_selected": "{count} vēstules atlasītas",
"edit_draft": "Rediģēt melnrakstu"
"edit_draft": "Rediģēt melnrakstu",
"cancel_scheduled_send": "Atcelt sūtīšanu",
"reschedule_send": "Pārplānot",
"cancel_and_edit": "Atcelt un rediģēt",
"cancel_and_compose_again": "Atcelt un rakstīt no jauna"
},
"mailbox_context_menu": {
"mark_folder_read": "Atzīmēt mapi kā lasītu",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "E-mail",
"nav_label": "Navigatie",
"add_app": "Apps",
"shared": "Gedeeld"
"shared": "Gedeeld",
"scheduled": "Gepland"
},
"sidebar_apps": {
"modal_title": "Zijbalk-apps",
@@ -203,7 +204,16 @@
"confirm_button": "Map legen",
"junk_hint": "U kunt de map Spam legen om alle berichten permanent te verwijderen.",
"trash_hint": "U kunt de prullenbak legen om alle berichten permanent te verwijderen."
}
},
"no_scheduled_emails": "Geen geplande e-mails",
"no_scheduled_emails_description": "Berichten die voor later zijn gepland verschijnen hier.",
"scheduled_count": "{count, plural, one {1 geplande e-mail} other {# geplande e-mails}}",
"scheduled_actions_hint": "Geplande berichten kunnen worden geannuleerd, opnieuw gepland of bewerkt via hun geplande acties.",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen",
"reschedule_prompt": "Voer een nieuwe datum/tijd in, bijv. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Geen e-mail geselecteerd",
@@ -475,7 +485,17 @@
"ai_verdict": "AI-oordeel",
"account": "Account",
"no_subject": "(geen onderwerp)"
}
},
"scheduled_banner": "Gepland om te verzenden op {date}",
"scheduled_send_created": "E-mail gepland voor verzending",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen",
"reschedule_prompt": "Voer een nieuwe datum/tijd in, bijv. 2026-05-04T15:30",
"scheduled_actions_only": "De geplande weergave ondersteunt alleen geplande acties",
"undo_send_scheduled": "Bericht gepland voor verzending",
"undo_send": "Verzenden ongedaan maken"
},
"email_composer": {
"new_message": "Nieuw bericht",
@@ -557,7 +577,19 @@
},
"add_link": "Link toevoegen",
"link_url_prompt": "Voer de URL in",
"sending": "Bezig met verzenden..."
"sending": "Bezig met verzenden...",
"schedule_send": "Verzenden plannen",
"schedule_send_description": "Kies wanneer de server dit bericht moet vrijgeven.",
"schedule_send_required": "Kies een datum en tijd.",
"schedule_send_invalid": "Voer een geldige datum en tijd in.",
"schedule_send_future": "Kies een toekomstige datum en tijd.",
"schedule_send_too_late": "Dit tijdstip is later dan de server toestaat.",
"schedule_send_unsupported": "Gepland verzenden wordt niet ondersteund voor dit account.",
"schedule_send_cleanup_warning": "Gepland verzenden is aangemaakt, maar het opschonen van de conceptversie is mislukt.",
"send_delay_unsupported": "Verzendvertraging wordt niet ondersteund voor dit account.",
"send_delay_unsupported_confirm": "Dit account ondersteunt geen verzendvertraging. Meteen verzenden?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Bevestigen",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Voorbeelden van afbeeldingen tonen in bijlagen",
"description": "Toon afbeeldingsbijlagen als miniatuurkaarten in plaats van generieke bestandspictogrammen"
},
"send_delay": {
"label": "Verzenden ongedaan maken / verzendvertraging",
"description": "Vertraag normale verzending met een korte server-side periode.",
"off": "Uit",
"seconds": "{seconds} seconden",
"unsupported": "Het huidige account meldt geen ondersteuning voor vertraagd verzenden. De instelling blijft opgeslagen voor andere accounts."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Label",
"remove_color": "Label verwijderen",
"items_selected": "{count} e-mails geselecteerd",
"edit_draft": "Concept bewerken"
"edit_draft": "Concept bewerken",
"cancel_scheduled_send": "Verzenden annuleren",
"reschedule_send": "Opnieuw plannen",
"cancel_and_edit": "Annuleren en bewerken",
"cancel_and_compose_again": "Annuleren en opnieuw opstellen"
},
"mailbox_context_menu": {
"mark_folder_read": "Map markeren als gelezen",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "Poczta",
"nav_label": "Nawigacja",
"add_app": "Aplikacje",
"shared": "Udostępnione"
"shared": "Udostępnione",
"scheduled": "Zaplanowane"
},
"sidebar_apps": {
"modal_title": "Aplikacje paska bocznego",
@@ -203,7 +204,16 @@
"confirm_button": "Opróżnij folder",
"junk_hint": "Możesz opróżnić folder Spam, aby trwale usunąć wszystkie wiadomości.",
"trash_hint": "Możesz opróżnić Kosz, aby trwale usunąć wszystkie wiadomości."
}
},
"no_scheduled_emails": "Brak zaplanowanych e-maili",
"no_scheduled_emails_description": "Wiadomości zaplanowane na później pojawią się tutaj.",
"scheduled_count": "{count, plural, one {1 zaplanowany e-mail} few {# zaplanowane e-maile} other {# zaplanowanych e-maili}}",
"scheduled_actions_hint": "Zaplanowane wiadomości można anulować, przełożyć lub edytować z poziomu akcji planowania.",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie",
"reschedule_prompt": "Wprowadź nową datę/godzinę, np. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nie wybrano wiadomości",
@@ -475,7 +485,17 @@
"ai_verdict": "Werdykt AI",
"account": "Konto",
"no_subject": "(brak tematu)"
}
},
"scheduled_banner": "Zaplanowano wysyłkę na {date}",
"scheduled_send_created": "E-mail zaplanowany do wysłania",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie",
"reschedule_prompt": "Wprowadź nową datę/godzinę, np. 2026-05-04T15:30",
"scheduled_actions_only": "Widok Zaplanowane obsługuje tylko akcje planowania",
"undo_send_scheduled": "Wiadomość zaplanowana do wysłania",
"undo_send": "Cofnij wysyłkę"
},
"email_composer": {
"new_message": "Nowa wiadomość",
@@ -557,7 +577,19 @@
},
"add_link": "Dodaj link",
"link_url_prompt": "Wprowadź adres URL",
"sending": "Wysyłanie..."
"sending": "Wysyłanie...",
"schedule_send": "Zaplanuj wysyłkę",
"schedule_send_description": "Wybierz, kiedy serwer ma wysłać tę wiadomość.",
"schedule_send_required": "Wybierz datę i godzinę.",
"schedule_send_invalid": "Wprowadź prawidłową datę i godzinę.",
"schedule_send_future": "Wybierz datę i godzinę w przyszłości.",
"schedule_send_too_late": "Ten czas jest późniejszy, niż pozwala serwer.",
"schedule_send_unsupported": "Zaplanowana wysyłka nie jest obsługiwana dla tego konta.",
"schedule_send_cleanup_warning": "Zaplanowana wysyłka została utworzona, ale czyszczenie wersji roboczej nie powiodło się.",
"send_delay_unsupported": "Opóźnienie wysyłki nie jest obsługiwane dla tego konta.",
"send_delay_unsupported_confirm": "To konto nie obsługuje opóźnienia wysyłki. Wysłać natychmiast?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Potwierdź",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Pokaż podgląd obrazów w załącznikach",
"description": "Wyświetlaj załączone obrazy jako miniatury zamiast ogólnych ikon plików"
},
"send_delay": {
"label": "Cofnij wysyłkę / opóźnienie wysyłki",
"description": "Opóźnia normalne wysyłanie krótkim oknem po stronie serwera.",
"off": "Wyłączone",
"seconds": "{seconds} sekund",
"unsupported": "Bieżące konto nie zgłasza obsługi opóźnionej wysyłki. Ustawienie pozostanie zapisane dla innych kont."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Etykieta",
"remove_color": "Usuń etykietę",
"items_selected": "{count} zaznaczonych wiadomości",
"edit_draft": "Edytuj szkic"
"edit_draft": "Edytuj szkic",
"cancel_scheduled_send": "Anuluj wysyłkę",
"reschedule_send": "Przełóż",
"cancel_and_edit": "Anuluj i edytuj",
"cancel_and_compose_again": "Anuluj i napisz ponownie"
},
"mailbox_context_menu": {
"mark_folder_read": "Oznacz folder jako przeczytany",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "E-mail",
"nav_label": "Navegação",
"add_app": "Apps",
"shared": "Compartilhado"
"shared": "Compartilhado",
"scheduled": "Agendados"
},
"sidebar_apps": {
"modal_title": "Apps da barra lateral",
@@ -203,7 +204,16 @@
"confirm_button": "Esvaziar pasta",
"junk_hint": "Você pode esvaziar a pasta de Spam para remover permanentemente todas as mensagens.",
"trash_hint": "Você pode esvaziar a Lixeira para remover permanentemente todas as mensagens."
}
},
"no_scheduled_emails": "Nenhum e-mail agendado",
"no_scheduled_emails_description": "Mensagens agendadas para mais tarde aparecerão aqui.",
"scheduled_count": "{count, plural, one {1 e-mail agendado} other {# e-mails agendados}}",
"scheduled_actions_hint": "Mensagens agendadas podem ser canceladas, reagendadas ou editadas pelas ações de agendamento.",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente",
"reschedule_prompt": "Informe uma nova data/hora, ex. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Nenhum e-mail selecionado",
@@ -475,7 +485,17 @@
"ai_verdict": "Veredito da IA",
"account": "Conta",
"no_subject": "(sem assunto)"
}
},
"scheduled_banner": "Agendado para enviar em {date}",
"scheduled_send_created": "E-mail agendado para envio",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente",
"reschedule_prompt": "Informe uma nova data/hora, ex. 2026-05-04T15:30",
"scheduled_actions_only": "A visualização Agendados permite apenas ações de agendamento",
"undo_send_scheduled": "Mensagem agendada para envio",
"undo_send": "Desfazer envio"
},
"email_composer": {
"new_message": "Nova Mensagem",
@@ -557,7 +577,19 @@
},
"add_link": "Adicionar link",
"link_url_prompt": "Insira a URL",
"sending": "Enviando..."
"sending": "Enviando...",
"schedule_send": "Agendar envio",
"schedule_send_description": "Escolha quando o servidor deve liberar esta mensagem.",
"schedule_send_required": "Escolha uma data e hora.",
"schedule_send_invalid": "Informe uma data e hora válidas.",
"schedule_send_future": "Escolha uma data e hora no futuro.",
"schedule_send_too_late": "Este horário é posterior ao permitido pelo servidor.",
"schedule_send_unsupported": "Envio agendado não é compatível com esta conta.",
"schedule_send_cleanup_warning": "O envio agendado foi criado, mas a limpeza do rascunho falhou.",
"send_delay_unsupported": "Atraso de envio não é compatível com esta conta.",
"send_delay_unsupported_confirm": "Esta conta não oferece atraso de envio. Enviar imediatamente?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Mostrar prévias de imagens em anexos",
"description": "Renderizar anexos de imagem como miniaturas em vez de ícones genéricos de arquivo"
},
"send_delay": {
"label": "Desfazer envio / atraso de envio",
"description": "Atrase envios normais com uma breve janela no servidor.",
"off": "Desativado",
"seconds": "{seconds} segundos",
"unsupported": "A conta atual não anuncia suporte a envio atrasado. A configuração continuará salva para outras contas."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Etiqueta",
"remove_color": "Remover etiqueta",
"items_selected": "{count} e-mails selecionados",
"edit_draft": "Editar rascunho"
"edit_draft": "Editar rascunho",
"cancel_scheduled_send": "Cancelar envio",
"reschedule_send": "Reagendar",
"cancel_and_edit": "Cancelar e editar",
"cancel_and_compose_again": "Cancelar e escrever novamente"
},
"mailbox_context_menu": {
"mark_folder_read": "Marcar pasta como lida",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "Почта",
"nav_label": "Навигация",
"add_app": "Приложения",
"shared": "Общие"
"shared": "Общие",
"scheduled": "Запланировано"
},
"sidebar_apps": {
"modal_title": "Приложения боковой панели",
@@ -203,7 +204,16 @@
"confirm_button": "Очистить папку",
"junk_hint": "Вы можете очистить папку Спам для постоянного удаления всех сообщений.",
"trash_hint": "Вы можете очистить Корзину для постоянного удаления всех сообщений."
}
},
"no_scheduled_emails": "Нет запланированных писем",
"no_scheduled_emails_description": "Сообщения, запланированные на позже, появятся здесь.",
"scheduled_count": "{count, plural, one {# запланированное письмо} few {# запланированных письма} other {# запланированных писем}}",
"scheduled_actions_hint": "Запланированные сообщения можно отменить, перенести или изменить через действия планирования.",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново",
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Письмо не выбрано",
@@ -475,7 +485,17 @@
"ai_verdict": "Вердикт ИИ",
"account": "Аккаунт",
"no_subject": "(без темы)"
}
},
"scheduled_banner": "Запланировано к отправке {date}",
"scheduled_send_created": "Письмо запланировано к отправке",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново",
"reschedule_prompt": "Введите новую дату/время, например 2026-05-04T15:30",
"scheduled_actions_only": "В представлении запланированных доступны только действия планирования",
"undo_send_scheduled": "Сообщение запланировано к отправке",
"undo_send": "Отменить отправку"
},
"email_composer": {
"new_message": "Новое письмо",
@@ -557,7 +577,19 @@
},
"add_link": "Добавить ссылку",
"link_url_prompt": "Введите URL",
"sending": "Отправка..."
"sending": "Отправка...",
"schedule_send": "Запланировать отправку",
"schedule_send_description": "Выберите, когда сервер должен отправить это сообщение.",
"schedule_send_required": "Выберите дату и время.",
"schedule_send_invalid": "Введите действительные дату и время.",
"schedule_send_future": "Выберите дату и время в будущем.",
"schedule_send_too_late": "Это время позже, чем разрешает сервер.",
"schedule_send_unsupported": "Запланированная отправка не поддерживается для этой учетной записи.",
"schedule_send_cleanup_warning": "Запланированная отправка создана, но очистка черновика не удалась.",
"send_delay_unsupported": "Задержка отправки не поддерживается для этой учетной записи.",
"send_delay_unsupported_confirm": "Эта учетная запись не поддерживает задержку отправки. Отправить сразу?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Подтвердить",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Показывать миниатюры изображений во вложениях",
"description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов"
},
"send_delay": {
"label": "Отмена отправки / задержка отправки",
"description": "Задерживает обычную отправку на короткий серверный интервал.",
"off": "Выкл.",
"seconds": "{seconds} сек.",
"unsupported": "Текущая учетная запись не заявляет поддержку отложенной отправки. Настройка сохранится для других учетных записей."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Тег",
"remove_color": "Удалить тег",
"items_selected": "{count} писем выбрано",
"edit_draft": "Редактировать черновик"
"edit_draft": "Редактировать черновик",
"cancel_scheduled_send": "Отменить отправку",
"reschedule_send": "Перенести",
"cancel_and_edit": "Отменить и изменить",
"cancel_and_compose_again": "Отменить и написать заново"
},
"mailbox_context_menu": {
"mark_folder_read": "Отметить папку как прочитанную",
+48 -5
View File
@@ -130,7 +130,8 @@
"shared": "Paylaşılan",
"mail": "Posta",
"nav_label": "Gezinme",
"add_app": "Uygulamalar"
"add_app": "Uygulamalar",
"scheduled": "Zamanlandı"
},
"sidebar_apps": {
"modal_title": "Kenar Çubuğu Uygulamaları",
@@ -203,7 +204,16 @@
"confirm_button": "Klasörü boşalt",
"junk_hint": "Tüm iletileri kalıcı olarak kaldırmak için Önemsiz klasörünü boşaltabilirsiniz.",
"trash_hint": "Tüm iletileri kalıcı olarak kaldırmak için Çöp Kutusu klasörünü boşaltabilirsiniz."
}
},
"no_scheduled_emails": "Zamanlanmış e-posta yok",
"no_scheduled_emails_description": "Daha sonra gönderilecek iletiler burada görünür.",
"scheduled_count": "{count, plural, one {1 zamanlanmış e-posta} other {# zamanlanmış e-posta}}",
"scheduled_actions_hint": "Zamanlanmış iletiler zamanlama işlemlerinden iptal edilebilir, yeniden zamanlanabilir veya düzenlenebilir.",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz",
"reschedule_prompt": "Yeni tarih/saat girin, ör. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "E-posta seçilmedi",
@@ -475,7 +485,17 @@
"ai_verdict": "Yapay zeka kararı",
"account": "Hesap",
"no_subject": "(konu yok)"
}
},
"scheduled_banner": "{date} tarihinde gönderilmek üzere zamanlandı",
"scheduled_send_created": "E-posta gönderim için zamanlandı",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz",
"reschedule_prompt": "Yeni tarih/saat girin, ör. 2026-05-04T15:30",
"scheduled_actions_only": "Zamanlandı görünümü yalnızca zamanlama işlemlerini destekler",
"undo_send_scheduled": "İleti gönderim için zamanlandı",
"undo_send": "Göndermeyi geri al"
},
"email_composer": {
"new_message": "Yeni İleti",
@@ -557,7 +577,19 @@
"message": "İletinizde \"{keyword}\" geçiyor ancak hiçbir dosya eklenmemiş. Yine de gönderilsin mi?",
"send_anyway": "Yine de gönder",
"back": "Düzenlemeye dön"
}
},
"schedule_send": "Göndermeyi zamanla",
"schedule_send_description": "Sunucunun bu iletiyi ne zaman göndereceğini seçin.",
"schedule_send_required": "Bir tarih ve saat seçin.",
"schedule_send_invalid": "Geçerli bir tarih ve saat girin.",
"schedule_send_future": "Gelecekte bir tarih ve saat seçin.",
"schedule_send_too_late": "Bu zaman sunucunun izin verdiğinden daha geç.",
"schedule_send_unsupported": "Bu hesap için zamanlanmış gönderim desteklenmiyor.",
"schedule_send_cleanup_warning": "Zamanlanmış gönderim oluşturuldu, ancak taslak temizliği başarısız oldu.",
"send_delay_unsupported": "Bu hesap için gönderim gecikmesi desteklenmiyor.",
"send_delay_unsupported_confirm": "Bu hesap gönderim gecikmesini desteklemiyor. Hemen gönderilsin mi?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Onayla",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Eklerde resim önizlemelerini göster",
"description": "Resim eklerini, genel dosya simgeleri yerine küçük resim kartları olarak göster"
},
"send_delay": {
"label": "Göndermeyi geri al / gönderim gecikmesi",
"description": "Normal gönderimleri sunucu tarafında kısa bir süre geciktirin.",
"off": "Kapalı",
"seconds": "{seconds} saniye",
"unsupported": "Geçerli hesap gecikmeli gönderim desteği bildirmiyor. Ayar diğer hesaplar için kaydedilmeye devam eder."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Etiket",
"remove_color": "Etiketi kaldır",
"items_selected": "{count} e-posta seçildi",
"edit_draft": "Taslağı Düzenle"
"edit_draft": "Taslağı Düzenle",
"cancel_scheduled_send": "Göndermeyi iptal et",
"reschedule_send": "Yeniden zamanla",
"cancel_and_edit": "İptal et ve düzenle",
"cancel_and_compose_again": "İptal et ve yeniden yaz"
},
"mailbox_context_menu": {
"mark_folder_read": "Klasörü okundu olarak işaretle",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "Пошта",
"nav_label": "Навігація",
"add_app": "програми",
"shared": "Спільні"
"shared": "Спільні",
"scheduled": "Заплановано"
},
"sidebar_apps": {
"modal_title": "Програми бічної панелі",
@@ -203,7 +204,16 @@
"confirm_button": "Порожня папка",
"junk_hint": "Ви можете очистити папку «Сміття», щоб остаточно видалити всі повідомлення.",
"trash_hint": "Ви можете очистити папку «Кошик», щоб остаточно видалити всі повідомлення."
}
},
"no_scheduled_emails": "Немає запланованих листів",
"no_scheduled_emails_description": "Повідомлення, заплановані на пізніше, з’являться тут.",
"scheduled_count": "{count, plural, one {# запланований лист} few {# заплановані листи} other {# запланованих листів}}",
"scheduled_actions_hint": "Заплановані повідомлення можна скасувати, перенести або редагувати через дії планування.",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову",
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "Електронна адреса не вибрана",
@@ -475,7 +485,17 @@
"ai_verdict": "Висновок ШІ",
"account": "Обліковий запис",
"no_subject": "(без теми)"
}
},
"scheduled_banner": "Заплановано на надсилання {date}",
"scheduled_send_created": "Лист заплановано до надсилання",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову",
"reschedule_prompt": "Введіть нову дату/час, напр. 2026-05-04T15:30",
"scheduled_actions_only": "У поданні Заплановано доступні лише дії планування",
"undo_send_scheduled": "Повідомлення заплановано до надсилання",
"undo_send": "Скасувати надсилання"
},
"email_composer": {
"new_message": "Нове повідомлення",
@@ -557,7 +577,19 @@
},
"add_link": "Додати посилання",
"link_url_prompt": "Введіть URL",
"sending": "Надсилання..."
"sending": "Надсилання...",
"schedule_send": "Запланувати надсилання",
"schedule_send_description": "Виберіть, коли сервер має надіслати це повідомлення.",
"schedule_send_required": "Виберіть дату й час.",
"schedule_send_invalid": "Введіть дійсні дату й час.",
"schedule_send_future": "Виберіть дату й час у майбутньому.",
"schedule_send_too_late": "Цей час пізніший, ніж дозволяє сервер.",
"schedule_send_unsupported": "Заплановане надсилання не підтримується для цього облікового запису.",
"schedule_send_cleanup_warning": "Заплановане надсилання створено, але очищення чернетки не вдалося.",
"send_delay_unsupported": "Затримка надсилання не підтримується для цього облікового запису.",
"send_delay_unsupported_confirm": "Цей обліковий запис не підтримує затримку надсилання. Надіслати негайно?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "Підтвердити",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "Показувати попередній перегляд зображень у вкладеннях",
"description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів"
},
"send_delay": {
"label": "Скасувати надсилання / затримка надсилання",
"description": "Затримує звичайне надсилання на короткий серверний інтервал.",
"off": "Вимкнено",
"seconds": "{seconds} с",
"unsupported": "Поточний обліковий запис не повідомляє про підтримку відкладеного надсилання. Налаштування залишиться збереженим для інших облікових записів."
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "Мітка",
"remove_color": "Видалити мітку",
"items_selected": "Вибрано електронних листів: {count}",
"edit_draft": "Редагувати чернетку"
"edit_draft": "Редагувати чернетку",
"cancel_scheduled_send": "Скасувати надсилання",
"reschedule_send": "Перенести",
"cancel_and_edit": "Скасувати й редагувати",
"cancel_and_compose_again": "Скасувати й написати знову"
},
"mailbox_context_menu": {
"mark_folder_read": "Позначити папку як прочитану",
+48 -5
View File
@@ -130,7 +130,8 @@
"mail": "邮件",
"nav_label": "导航",
"add_app": "应用",
"shared": "共享"
"shared": "共享",
"scheduled": "已计划"
},
"sidebar_apps": {
"modal_title": "侧边栏应用",
@@ -203,7 +204,16 @@
"confirm_button": "清空文件夹",
"junk_hint": "您可以清空垃圾邮件文件夹以永久删除所有邮件。",
"trash_hint": "您可以清空已删除文件夹以永久删除所有邮件。"
}
},
"no_scheduled_emails": "没有计划发送的邮件",
"no_scheduled_emails_description": "计划稍后发送的邮件会显示在这里。",
"scheduled_count": "{count, plural, one {1 封计划发送邮件} other {# 封计划发送邮件}}",
"scheduled_actions_hint": "计划发送的邮件可以通过计划操作取消、重新计划或编辑。",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写",
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30"
},
"email_viewer": {
"no_email_selected": "未选择邮件",
@@ -475,7 +485,17 @@
"ai_verdict": "AI 判断",
"account": "账户",
"no_subject": "(无主题)"
}
},
"scheduled_banner": "计划于 {date} 发送",
"scheduled_send_created": "邮件已计划发送",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写",
"reschedule_prompt": "输入新的日期/时间,例如 2026-05-04T15:30",
"scheduled_actions_only": "计划发送视图仅支持计划操作",
"undo_send_scheduled": "邮件已计划发送",
"undo_send": "撤销发送"
},
"email_composer": {
"new_message": "新邮件",
@@ -557,7 +577,19 @@
},
"add_link": "添加链接",
"link_url_prompt": "输入 URL",
"sending": "发送中..."
"sending": "发送中...",
"schedule_send": "计划发送",
"schedule_send_description": "选择服务器应何时发送此邮件。",
"schedule_send_required": "请选择日期和时间。",
"schedule_send_invalid": "请输入有效的日期和时间。",
"schedule_send_future": "请选择未来的日期和时间。",
"schedule_send_too_late": "该时间晚于服务器允许的范围。",
"schedule_send_unsupported": "此账户不支持计划发送。",
"schedule_send_cleanup_warning": "已创建计划发送,但清理草稿失败。",
"send_delay_unsupported": "此账户不支持发送延迟。",
"send_delay_unsupported_confirm": "此账户不支持发送延迟。是否立即发送?",
"attach_photos": "Photos & Videos",
"attach_files": "Files"
},
"confirm_dialog": {
"confirm": "确认",
@@ -1057,6 +1089,13 @@
"attachment_image_previews": {
"label": "在附件中显示图片预览",
"description": "将图片附件显示为缩略图卡片,而不是通用文件图标"
},
"send_delay": {
"label": "撤销发送 / 发送延迟",
"description": "通过服务器端短暂窗口延迟普通发送。",
"off": "关闭",
"seconds": "{seconds} 秒",
"unsupported": "当前账户未声明支持延迟发送。该设置仍会为其他账户保存。"
}
},
"composer": {
@@ -1667,7 +1706,11 @@
"color_tag": "标签",
"remove_color": "删除标签",
"items_selected": "已选择 {count} 封邮件",
"edit_draft": "编辑草稿"
"edit_draft": "编辑草稿",
"cancel_scheduled_send": "取消发送",
"reschedule_send": "重新计划",
"cancel_and_edit": "取消并编辑",
"cancel_and_compose_again": "取消并重新撰写"
},
"mailbox_context_menu": {
"mark_folder_read": "将文件夹标记为已读",
+249 -17
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult } from "@/lib/jmap/types";
import type { UnifiedMailboxRole } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store";
@@ -11,6 +11,15 @@ import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, type UnifiedAccountClien
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
type ScheduledSubmissionMetadata = {
submissionId: string;
sendAt: string;
identityId: string;
undoStatus: 'pending' | 'final' | 'canceled';
};
type PendingUndoSend = { submissionId: string; emailId?: string; sendAt: string; isSmime: boolean };
interface EmailStore {
emails: Email[];
mailboxes: Mailbox[];
@@ -52,6 +61,16 @@ interface EmailStore {
unifiedErrors: Map<string, string>; // accountId -> error message
unifiedCounts: UnifiedMailboxCounts[];
// Scheduled send state
scheduledEmails: ScheduledEmail[];
scheduledEmailIds: Set<string>;
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>;
scheduledTotal: number;
scheduledHasMore: boolean;
isLoadingScheduled: boolean;
isScheduledView: boolean;
pendingUndoSend: PendingUndoSend | null;
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void;
selectEmail: (email: Email | null) => void;
@@ -75,8 +94,8 @@ interface EmailStore {
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
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[]) => Promise<void>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => 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>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, sendAt?: string) => Promise<SendEmailResult>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
@@ -130,6 +149,16 @@ interface EmailStore {
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>;
exitUnifiedView: () => void;
fetchScheduledEmails: (client: IJMAPClient) => Promise<void>;
loadMoreScheduledEmails: (client: IJMAPClient) => Promise<void>;
cancelScheduledEmail: (client: IJMAPClient, submissionId: string) => Promise<void>;
cancelScheduledEmailForEdit: (client: IJMAPClient, email: ScheduledEmail | Email) => Promise<Email | null>;
rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, sendAt: string) => Promise<SendEmailResult>;
cancelUndoSend: (client: IJMAPClient, pending: PendingUndoSend) => Promise<Email | null>;
clearPendingUndoSend: () => void;
refreshScheduledMetadata: (client: IJMAPClient) => Promise<void>;
setScheduledView: (isScheduledView: boolean) => void;
// Mock data for demo
loadMockData: () => void;
}
@@ -164,6 +193,38 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
}
function annotateScheduledEmails(
emails: Email[],
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
): Email[] {
if (scheduledSubmissionByEmailId.size === 0) return emails;
return emails.map(email => annotateScheduledEmail(email, scheduledSubmissionByEmailId));
}
function annotateScheduledEmail(
email: Email,
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
): Email {
const scheduled = scheduledSubmissionByEmailId.get(email.id);
if (!scheduled) return email;
return {
...email,
scheduledSendAt: scheduled.sendAt,
emailSubmissionId: scheduled.submissionId,
scheduledIdentityId: scheduled.identityId,
scheduledUndoStatus: scheduled.undoStatus,
isScheduled: true,
};
}
function shouldClearPendingUndoSend(pending: PendingUndoSend | null, scheduledEmails: ScheduledEmail[]): boolean {
if (!pending) return false;
const sendAt = new Date(pending.sendAt).getTime();
if (Number.isFinite(sendAt) && sendAt <= Date.now()) return true;
const scheduledEmail = scheduledEmails.find(email => email.emailSubmissionId === pending.submissionId);
return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending';
}
// Find the trash mailbox for a given account scope. Prefers JMAP role, but
// falls back to name matching ("trash" / "deleted") so users with custom or
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
@@ -227,6 +288,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
unifiedErrors: new Map(),
unifiedCounts: [],
// Scheduled send state
scheduledEmails: [],
scheduledEmailIds: new Set(),
scheduledSubmissionByEmailId: new Map(),
scheduledTotal: 0,
scheduledHasMore: false,
isLoadingScheduled: false,
isScheduledView: false,
pendingUndoSend: null,
// Spam undo cache
spamUndoCache: new Map(),
@@ -374,7 +445,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// all folders that carry the tag are returned.
const result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
set({
emails: result.emails,
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
@@ -484,7 +555,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Deduplicate: the server may return overlapping results if new emails
// arrived between paginated requests and shifted positions.
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter((e: Email) => !existingIds.has(e.id));
const newEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId).filter((e: Email) => !existingIds.has(e.id));
set({
emails: [...currentEmails, ...newEmails],
@@ -514,7 +585,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const email = await client.getEmail(emailId, accountId);
if (email) {
set({ selectedEmail: email });
const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId);
set({ selectedEmail: annotatedEmail });
return annotatedEmail;
}
return email;
} catch (error) {
@@ -534,12 +607,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references) => {
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, sendAt) => {
set({ isLoading: true, error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references);
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, sendAt);
// Refresh handled by UI layer for immediate feedback
set({ isLoading: false });
set({
isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: false }
: get().pendingUndoSend,
});
return result;
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to send email",
@@ -549,15 +628,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
sendRawEmail: async (client, rawMimeBlob, identityId) => {
sendRawEmail: async (client, rawMimeBlob, identityId, sendAt) => {
set({ isLoading: true, error: null });
try {
const mailboxes = await client.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (!sentMailbox) throw new Error('No sent mailbox found');
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id);
set({ isLoading: false });
const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, sendAt);
set({
isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: true }
: get().pendingUndoSend,
});
return result;
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to send email",
@@ -977,7 +1062,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
@@ -1026,7 +1111,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
@@ -1539,6 +1624,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
get().fetchTagCounts(client);
}
if (accountChanges.EmailSubmission) {
await get().refreshScheduledMetadata(client);
if (get().isScheduledView) {
await get().fetchScheduledEmails(client);
}
}
// Handle Mailbox state changes - refresh mailbox list
if (accountChanges.Mailbox) {
await get().fetchMailboxes(client);
@@ -1628,13 +1720,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Merge the refreshed first page with the existing loaded emails.
// This avoids discarding already-loaded pages which would cause the
// virtual list to shrink and then rapidly re-load (scroll bounce).
const freshMap = new Map(result.emails.map((e: Email) => [e.id, e]));
const refreshedEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId);
// Build the merged list: start with the fresh first page, then append
// existing emails beyond that page (if any), skipping duplicates and
// emails removed from the first page (e.g. deleted or moved).
const merged: Email[] = [...result.emails];
const mergedIds = new Set(result.emails.map((e: Email) => e.id));
const merged: Email[] = [...refreshedEmails];
const mergedIds = new Set(refreshedEmails.map((e: Email) => e.id));
for (const email of currentEmails) {
if (!mergedIds.has(email.id)) {
@@ -1948,6 +2040,146 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
},
setScheduledView: (isScheduledView) => set({ isScheduledView }),
clearPendingUndoSend: () => set({ pendingUndoSend: null }),
fetchScheduledEmails: async (client) => {
set({ isLoadingScheduled: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getScheduledEmails(emailsPerPage, 0);
const scheduledEmailIds = new Set(result.emails.map(email => email.id));
const scheduledSubmissionByEmailId = new Map(result.emails.map(email => [email.id, {
submissionId: email.emailSubmissionId,
sendAt: email.scheduledSendAt,
identityId: email.scheduledIdentityId,
undoStatus: email.scheduledUndoStatus,
}]));
const pendingUndoSend = get().pendingUndoSend;
set({
scheduledEmails: result.emails,
scheduledEmailIds,
scheduledSubmissionByEmailId,
scheduledTotal: result.total,
scheduledHasMore: result.hasMore,
isLoadingScheduled: false,
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, result.emails) ? null : pendingUndoSend,
});
} catch (error) {
console.error('Failed to fetch scheduled emails:', error);
set({
error: error instanceof Error ? error.message : 'Failed to fetch scheduled emails',
scheduledEmails: [],
scheduledEmailIds: new Set(),
scheduledSubmissionByEmailId: new Map(),
scheduledTotal: 0,
scheduledHasMore: false,
isLoadingScheduled: false,
});
}
},
loadMoreScheduledEmails: async (client) => {
const { isLoadingScheduled, scheduledHasMore, scheduledEmails } = get();
if (isLoadingScheduled || !scheduledHasMore) return;
set({ isLoadingScheduled: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getScheduledEmails(emailsPerPage, scheduledEmails.length);
const merged = [...scheduledEmails, ...result.emails.filter(email => !scheduledEmails.some(existing => existing.id === email.id))];
const pendingUndoSend = get().pendingUndoSend;
set({
scheduledEmails: merged,
scheduledEmailIds: new Set(merged.map(email => email.id)),
scheduledSubmissionByEmailId: new Map(merged.map(email => [email.id, {
submissionId: email.emailSubmissionId,
sendAt: email.scheduledSendAt,
identityId: email.scheduledIdentityId,
undoStatus: email.scheduledUndoStatus,
}])),
scheduledTotal: result.total,
scheduledHasMore: result.hasMore,
isLoadingScheduled: false,
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, merged) ? null : pendingUndoSend,
});
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to load scheduled emails', isLoadingScheduled: false });
}
},
refreshScheduledMetadata: async (client) => {
try {
const result = await client.getScheduledEmails(useSettingsStore.getState().emailsPerPage, 0);
const pendingUndoSend = get().pendingUndoSend;
set({
scheduledEmails: get().isScheduledView ? result.emails : get().scheduledEmails,
scheduledEmailIds: new Set(result.emails.map(email => email.id)),
scheduledSubmissionByEmailId: new Map(result.emails.map(email => [email.id, {
submissionId: email.emailSubmissionId,
sendAt: email.scheduledSendAt,
identityId: email.scheduledIdentityId,
undoStatus: email.scheduledUndoStatus,
}])),
scheduledTotal: result.total,
scheduledHasMore: result.hasMore,
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, result.emails) ? null : pendingUndoSend,
});
} catch (error) {
console.error('Failed to refresh scheduled metadata:', error);
}
},
cancelScheduledEmail: async (client, submissionId) => {
await client.cancelEmailSubmission(submissionId);
if (get().pendingUndoSend?.submissionId === submissionId) {
set({ pendingUndoSend: null });
}
await get().fetchScheduledEmails(client);
},
cancelScheduledEmailForEdit: async (client, email) => {
const submissionId = email.emailSubmissionId;
if (!submissionId) return null;
await client.cancelEmailSubmission(submissionId);
const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (draftsMailbox) {
await client.restoreEmailToDraft(email.id, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id);
}
await get().fetchScheduledEmails(client);
const restored = await client.getEmail(email.id);
return restored;
},
rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, sendAt) => {
try {
const result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, sendAt);
const pendingUndoSend = get().pendingUndoSend;
if (pendingUndoSend?.submissionId === submissionId) {
set({ pendingUndoSend: { ...pendingUndoSend, sendAt } });
}
return result;
} finally {
await get().fetchScheduledEmails(client);
}
},
cancelUndoSend: async (client, pending) => {
await client.cancelEmailSubmission(pending.submissionId);
if (pending.emailId && !pending.isSmime) {
const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
if (draftsMailbox) {
await client.restoreEmailToDraft(pending.emailId, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id);
}
}
await get().refreshScheduledMetadata(client);
set({ pendingUndoSend: null });
return pending.emailId ? client.getEmail(pending.emailId) : null;
},
loadMockData: () => {
const mockEmails: Email[] = [
{
+11
View File
@@ -40,6 +40,7 @@ export type ToolbarPosition = 'top' | 'below-subject';
export type ArchiveMode = 'single' | 'year' | 'month';
export type MailLayout = 'split' | 'focus';
export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s';
export type SendDelaySeconds = 0 | 10 | 30 | 60;
export type HoverAction = 'delete' | 'star' | 'markRead' | 'archive' | 'tag' | 'spam';
export type HoverActionsMode = 'inline' | 'floating';
@@ -143,6 +144,7 @@ interface SettingsState {
autoSelectReplyIdentity: boolean;
plainTextMode: boolean; // Send plain text only (no rich text editor)
subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@")
sendDelaySeconds: SendDelaySeconds;
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
@@ -296,6 +298,7 @@ const DEFAULT_SETTINGS = {
autoSelectReplyIdentity: false,
plainTextMode: false,
subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER,
sendDelaySeconds: 0 as SendDelaySeconds,
// Privacy & Security
sessionTimeout: 0, // Never
@@ -468,6 +471,7 @@ export const useSettingsStore = create<SettingsState>()(
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode,
subAddressDelimiter: state.subAddressDelimiter,
sendDelaySeconds: state.sendDelaySeconds,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,
emailNotificationSound: state.emailNotificationSound,
@@ -524,6 +528,10 @@ export const useSettingsStore = create<SettingsState>()(
if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) {
return;
}
if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(settings[key])) {
set({ sendDelaySeconds: 0 });
return;
}
set({ [key]: settings[key] });
}
});
@@ -699,6 +707,9 @@ export const useSettingsStore = create<SettingsState>()(
state.density = state.listDensity;
delete state.listDensity;
}
if (![0, 10, 30, 60].includes(state.sendDelaySeconds as number)) {
state.sendDelaySeconds = 0;
}
return state as unknown as SettingsState;
},
onRehydrateStorage: () => {