From e93dd4411190d9ff432f65702796f3c6cb4c39ae Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Thu, 28 May 2026 11:20:22 -0500 Subject: [PATCH 001/437] fix: report real upload progress; XHR with progress events #333 The Files page UI sat at 0% throughout an upload because uploadBlob() uses fetch(), which does not surface upload progress events. The store set loaded=0 before the call and loaded=file.size after it, so users saw the progress bar jump from 0% straight to 100% on completion -- and on slow connections (or large files) it appeared frozen. Switch uploadBlob() to XHR when the caller passes onProgress or an AbortSignal, so progress events from xhr.upload.onprogress can drive the UI. Callers that don't pass either keep the fetch path so we preserve the existing 401-retry behaviour in authenticatedFetch(). Wire the file store to pass both onProgress (updates uploadProgress in real time) and the existing AbortController's signal (so cancel now actually aborts the network request, not just the post-upload createFileNode step). uploadBlob() is part of IJMAPClient so the signature change is also applied to the demo client (synthesises 0% then 100%). --- lib/demo/demo-client.ts | 10 +++- lib/jmap/client-interface.ts | 11 +++- lib/jmap/client.ts | 101 ++++++++++++++++++++++++++++++----- stores/file-store.ts | 15 +++++- 4 files changed, 121 insertions(+), 16 deletions(-) diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 18e003603..b83c2148a 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -494,9 +494,17 @@ export class DemoJMAPClient implements IJMAPClient { // ── Blobs ───────────────────────────────────────────────────── - async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> { + async uploadBlob( + file: File, + opts?: { onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal }, + ): Promise<{ blobId: string; size: number; type: string }> { + if (opts?.signal?.aborted) { + throw new DOMException('Upload aborted', 'AbortError'); + } + opts?.onProgress?.(0, file.size); const blobId = generateDemoId('blob'); this.blobStore.set(blobId, file); + opts?.onProgress?.(file.size, file.size); return { blobId, size: file.size, type: file.type }; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 1cfc8b560..05165ec85 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -169,7 +169,16 @@ export interface IJMAPClient { sendImipCancellation(event: CalendarEvent): Promise; // ── Blobs ───────────────────────────────────────────────────── - uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }>; + uploadBlob( + file: File, + optsOrAccountId?: + | string + | { + accountId?: string; + onProgress?: (loaded: number, total: number) => void; + signal?: AbortSignal; + }, + ): Promise<{ blobId: string; size: number; type: string }>; getBlobDownloadUrl(blobId: string, name?: string, type?: string): string; fetchBlob(blobId: string, name?: string, type?: string): Promise; fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 03524cca3..399d2413f 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2788,7 +2788,71 @@ export class JMAPClient implements IJMAPClient { } } - async uploadBlob(file: File, accountId?: string): Promise<{ blobId: string; size: number; type: string }> { + private xhrUpload( + url: string, + file: File, + onProgress?: (loaded: number, total: number) => void, + signal?: AbortSignal, + ): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Upload aborted', 'AbortError')); + return; + } + const xhr = new XMLHttpRequest(); + xhr.open('POST', url, true); + xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); + xhr.setRequestHeader('Authorization', this.authHeader); + xhr.responseType = 'text'; + + const onAbort = () => xhr.abort(); + if (signal) signal.addEventListener('abort', onAbort, { once: true }); + const cleanup = () => signal?.removeEventListener('abort', onAbort); + + if (onProgress) { + // Fire 0% immediately so the UI leaves its initial state even + // before the first network packet flushes. + onProgress(0, file.size); + xhr.upload.onprogress = (ev) => { + // ev.total is only meaningful when lengthComputable; fall back + // to file.size so callers always get a usable denominator. + const total = ev.lengthComputable ? ev.total : file.size; + onProgress(ev.loaded, total); + }; + } + + xhr.onload = () => { + cleanup(); + if (xhr.status >= 200 && xhr.status < 300) { + resolve(xhr.responseText); + } else { + reject(new Error(`Failed to upload file: ${xhr.status} - ${xhr.responseText}`)); + } + }; + xhr.onerror = () => { cleanup(); reject(new Error('Upload network error')); }; + xhr.onabort = () => { cleanup(); reject(new DOMException('Upload aborted', 'AbortError')); }; + + xhr.send(file); + }); + } + + // Signature accepts either the legacy positional accountId string OR + // the options bag introduced for progress / signal so existing + // call-sites keep compiling without touching every plugin. + async uploadBlob( + file: File, + optsOrAccountId?: + | string + | { + accountId?: string; + onProgress?: (loaded: number, total: number) => void; + signal?: AbortSignal; + }, + ): Promise<{ blobId: string; size: number; type: string }> { + const opts = + typeof optsOrAccountId === 'string' + ? { accountId: optsOrAccountId } + : optsOrAccountId ?? {}; if (!this.session) { throw new Error('Not connected. Call connect() first.'); } @@ -2798,20 +2862,33 @@ export class JMAPClient implements IJMAPClient { throw new Error('Upload URL not available'); } - const targetAccountId = accountId || this.accountId; + const targetAccountId = opts.accountId || this.accountId; const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId)); - const response = await this.authenticatedFetch(finalUploadUrl, { - method: 'POST', - headers: { 'Content-Type': file.type || 'application/octet-stream' }, - body: file, - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to upload file: ${response.status} - ${errorText}`); + // XHR path: fetch() does not expose upload progress events, so when the + // caller wants progress (or an AbortSignal) we use XMLHttpRequest. The + // fetch path is kept for callers that don't need either, to preserve + // existing 401/retry behaviour through authenticatedFetch(). + let responseText: string; + if (opts.onProgress || opts.signal) { + responseText = await this.xhrUpload( + finalUploadUrl, + file, + opts.onProgress, + opts.signal, + ); + } else { + const response = await this.authenticatedFetch(finalUploadUrl, { + method: 'POST', + headers: { 'Content-Type': file.type || 'application/octet-stream' }, + body: file, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to upload file: ${response.status} - ${errorText}`); + } + responseText = await response.text(); } - - const responseText = await response.text(); let result; try { result = JSON.parse(responseText); diff --git a/stores/file-store.ts b/stores/file-store.ts index 115252e26..e643e6344 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -333,7 +333,12 @@ export const useFileStore = create((set, get) => ({ try { if (abortController.signal.aborted) return; - const { blobId, type } = await client.uploadBlob(file); + const { blobId, type } = await client.uploadBlob(file, { + signal: abortController.signal, + onProgress: (loaded, total) => { + set({ uploadProgress: { name: file.name, loaded, total, current: 1, totalFiles: 1 } }); + }, + }); if (abortController.signal.aborted) return; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } }); await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null); @@ -361,7 +366,13 @@ export const useFileStore = create((set, get) => ({ set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } }); try { - const { blobId, type } = await client.uploadBlob(file); + const idx = i; + const { blobId, type } = await client.uploadBlob(file, { + signal: abortController.signal, + onProgress: (loaded, total) => { + set({ uploadProgress: { name: file.name, loaded, total, current: idx + 1, totalFiles } }); + }, + }); if (abortController.signal.aborted) break; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null); From 2818a16f06d3a569877ef97fe185e2588d416d35 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Tue, 26 May 2026 20:01:48 +0300 Subject: [PATCH 002/437] feat(composer): Ctrl+Enter / Cmd+Enter sends the open draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the universal "send with the platform modifier" shortcut every mainstream mail client (Gmail, Outlook, Apple Mail, Proton, Tutanota, Fastmail, Thunderbird) supports. Closes #343. Behaviour: * Window-level keydown listener registered while the composer is mounted. Fires when focus is anywhere inside the composer — chip inputs, subject, body textarea, or the rich-text contentEditable. * Plain Enter is untouched; only Enter + Ctrl (Win/Linux) or Cmd (macOS) triggers send. Shift/Alt modifiers are ignored so existing autocomplete-confirm / chip-commit Enters are not hijacked. * Routes through a ref so handleSend's per-render rebind doesn't re-register the listener every render. * All existing send-time validation, attachment-warning, draft-save and undo-send flows still apply — the shortcut just calls the same handleSend() as the toolbar button. * Listed in the Keyboard Shortcuts dialog under the existing Composer section. Tested: * Compose -> type body -> Ctrl+Enter -> Outbox. * Cc/Bcc autocomplete suggestion + Enter still selects (alt-free Enter without Ctrl, so the new listener bails). * Subject input -> Ctrl+Enter -> sends. * Body Enter without modifier -> newline. --- components/email/email-composer.tsx | 23 +++++++++++++++++++++++ hooks/use-keyboard-shortcuts.ts | 1 + locales/en/common.json | 3 ++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 90704e69a..2401ca5cf 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -854,6 +854,7 @@ export function EmailComposer({ return () => window.removeEventListener('keydown', handleTemplateKey); }, []); + const addFiles = useCallback(async (files: File[]) => { if (!client || files.length === 0) return; @@ -1574,6 +1575,28 @@ export function EmailComposer({ } }; + // Ctrl+Enter (Windows/Linux) / Cmd+Enter (macOS) sends the open + // compose draft — same as every other major mail client. Fires + // even when focus is inside the To/Cc/Bcc chips, subject input, + // body textarea, or the rich-text editor's contentEditable region. + // Plain Enter in the body still inserts a newline; only Enter + + // the platform modifier sends. handleSend is rebound every render, + // so we route through a ref to keep the window listener stable. + const handleSendRef = useRef<(skipAttachmentCheck?: boolean) => Promise>(); + handleSendRef.current = handleSend; + useEffect(() => { + const handleSendShortcut = (e: KeyboardEvent) => { + if (e.key !== 'Enter') return; + if (!(e.ctrlKey || e.metaKey)) return; + // Don't hijack autocomplete-confirm or chip-commit Enters. + if (e.altKey || e.shiftKey) return; + e.preventDefault(); + void handleSendRef.current?.(); + }; + window.addEventListener('keydown', handleSendShortcut); + return () => window.removeEventListener('keydown', handleSendShortcut); + }, []); + const cleanClose = () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts index 0a27942cc..0dd8a8414 100644 --- a/hooks/use-keyboard-shortcuts.ts +++ b/hooks/use-keyboard-shortcuts.ts @@ -290,5 +290,6 @@ export const KEYBOARD_SHORTCUTS = { ], composer: [ { key: "t", description: "shortcuts.composer.template_picker" }, + { key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" }, ], } as const; diff --git a/locales/en/common.json b/locales/en/common.json index 072169df9..0468b27e8 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1882,7 +1882,8 @@ "expand_collapse": "Expand/collapse thread" }, "composer": { - "template_picker": "Open template picker" + "template_picker": "Open template picker", + "send": "Send email" } }, "threads": { From 7d1fb732907d984d6c3598d95f26e05523f5aad7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 28 May 2026 18:28:07 +0200 Subject: [PATCH 003/437] fix: scope Ctrl/Cmd+Enter send to focused composer --- components/email/email-composer.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 2401ca5cf..0dee84d25 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -854,7 +854,6 @@ export function EmailComposer({ return () => window.removeEventListener('keydown', handleTemplateKey); }, []); - const addFiles = useCallback(async (files: File[]) => { if (!client || files.length === 0) return; @@ -1575,21 +1574,23 @@ export function EmailComposer({ } }; - // Ctrl+Enter (Windows/Linux) / Cmd+Enter (macOS) sends the open - // compose draft — same as every other major mail client. Fires - // even when focus is inside the To/Cc/Bcc chips, subject input, - // body textarea, or the rich-text editor's contentEditable region. - // Plain Enter in the body still inserts a newline; only Enter + - // the platform modifier sends. handleSend is rebound every render, - // so we route through a ref to keep the window listener stable. - const handleSendRef = useRef<(skipAttachmentCheck?: boolean) => Promise>(); + // Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose + // draft. Scoped to events whose target lives inside this composer's + // DOM tree — in Pro mode multiple composer tabs can be mounted at + // once (inactive tabs are CSS-hidden, not unmounted), so a window + // listener would otherwise fire every mounted composer's handleSend + // on a single keystroke. handleSend is rebound every render, so we + // route through a ref to keep the listener stable. + const composerRootRef = useRef(null); + const handleSendRef = useRef<((skipAttachmentCheck?: boolean) => Promise) | undefined>(undefined); handleSendRef.current = handleSend; useEffect(() => { const handleSendShortcut = (e: KeyboardEvent) => { if (e.key !== 'Enter') return; if (!(e.ctrlKey || e.metaKey)) return; - // Don't hijack autocomplete-confirm or chip-commit Enters. if (e.altKey || e.shiftKey) return; + const root = composerRootRef.current; + if (!root || !(e.target instanceof Node) || !root.contains(e.target)) return; e.preventDefault(); void handleSendRef.current?.(); }; @@ -1636,7 +1637,7 @@ export function EmailComposer({ }; return ( -
+
Date: Tue, 26 May 2026 19:33:48 +0300 Subject: [PATCH 004/437] fix(email-viewer): stop shattering table cells with word-break: break-word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global rule td, th { word-break: break-word; } was breaking HTML-email tables one glyph per row whenever a column was narrow, especially for Hebrew/Arabic/CJK headers and long English strings. The non-standard `word-break: break-word` keyword behaves like `break-all` in some engines, splitting words at arbitrary character boundaries even when the word would fit if the column auto- expanded. `overflow-wrap: break-word` is already set on body/table, so the rule only needs to add min-content relaxation for cells. `overflow-wrap: anywhere` does exactly that without re-introducing break-all behaviour. Repro: any transactional Hebrew/RTL order-summary email — each header (`מוצר`, `כמות`, `מחיר`) collapses to one glyph per row. After the fix they render on a single line. Closes #341. --- components/email/email-viewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 1be0569b1..210eb5787 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2866,7 +2866,7 @@ export function EmailViewer({ img { max-width: 100% !important; height: auto !important; } a { color: #1a73e8; } table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; } - td, th { word-break: break-word; } + td, th { overflow-wrap: anywhere; } pre { white-space: pre-wrap; word-wrap: break-word; } ${wordHtmlCSS} ${darkModeCSS} From 31e96d6a462c2788f6a367a85e78023acffd84cf Mon Sep 17 00:00:00 2001 From: Lucas Gaitzsch Date: Thu, 28 May 2026 18:46:49 +0200 Subject: [PATCH 005/437] Feat: Scheduled send and send delay #322 * ADD DOC * Scheduld Send * add new shortcuts * fix * fix * fix bugs * rework * fix draft duplicating * fix err * some fixes * fixes from review * fixes from review * fixes from review * disable password managers for recipients * fix email store lazy load * add translations * fix styling * fixes --------- Co-authored-by: Linus Rath <139418639+rathlinus@users.noreply.github.com> --- app/(main)/[locale]/page.tsx | 439 +++++++++++++++++---- app/api/dev-jmap/[...path]/route.ts | 54 ++- components/email/email-composer.tsx | 237 ++++++++++- components/email/email-context-menu.tsx | 44 ++- components/email/email-list.tsx | 45 ++- components/email/email-viewer.tsx | 106 ++++- components/email/thread-list-item.tsx | 152 ++++--- components/layout/sidebar.tsx | 90 +++-- components/pro/pro-compose-tab-body.tsx | 22 +- components/settings/composing-settings.tsx | 23 ++ hooks/use-keyboard-shortcuts.ts | 2 + lib/__tests__/jmap-send-threading.test.ts | 98 ++++- lib/demo/demo-client.ts | 106 ++++- lib/jmap/client-interface.ts | 14 +- lib/jmap/client.ts | 404 ++++++++++++++++++- lib/jmap/types.ts | 28 +- locales/cs/common.json | 55 ++- locales/da/common.json | 53 ++- locales/de/common.json | 55 ++- locales/en/common.json | 56 ++- locales/es/common.json | 55 ++- locales/fr/common.json | 55 ++- locales/it/common.json | 55 ++- locales/ja/common.json | 55 ++- locales/ko/common.json | 55 ++- locales/lv/common.json | 55 ++- locales/nl/common.json | 55 ++- locales/pl/common.json | 55 ++- locales/pt/common.json | 55 ++- locales/ru/common.json | 55 ++- locales/tr/common.json | 55 ++- locales/uk/common.json | 55 ++- locales/zh/common.json | 55 ++- package-lock.json | 12 - stores/email-store.ts | 374 ++++++++++++++++-- stores/settings-store.ts | 11 + 36 files changed, 2835 insertions(+), 360 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 526a74f81..24db868df 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -75,6 +75,8 @@ import { buildQuoteHeader } from "@/lib/quote-header"; import { useLocaleStore } from "@/stores/locale-store"; import type { QuoteHeader } from "@/lib/plugin-types"; +const SCHEDULED_MAILBOX_ID = '__scheduled__'; + export default function Home() { const t = useTranslations(); @@ -108,12 +110,39 @@ export default function Home() { const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(null); const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(null); + const lastUndoToastSubmissionRef = useRef(null); + const initialMailLoadClientRef = useRef(null); const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); useIdentitySync(); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); + const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore(); + const promptForRescheduleDelayedUntil = useCallback((): string | null => { + const value = window.prompt(t('email_viewer.reschedule_prompt')); + if (!value) return null; + const time = new Date(value).getTime(); + if (!Number.isFinite(time)) { + toast.error(t('email_composer.schedule_send_invalid')); + return null; + } + if (time <= Date.now()) { + toast.error(t('email_composer.schedule_send_future')); + return null; + } + if (!client?.hasDelayedSend()) { + toast.error(t('email_composer.schedule_send_unsupported')); + return null; + } + const maxDelayedSend = client.getMaxDelayedSend(); + if (maxDelayedSend > 0 && time > Date.now() + maxDelayedSend * 1000) { + toast.error(t('email_composer.schedule_send_too_late')); + return null; + } + return new Date(time).toISOString(); + }, [client, t]); + // Load trusted senders address book when feature is enabled useEffect(() => { if (trustedSendersAddressBook && client && !trustedSendersLoaded) { @@ -270,6 +299,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, @@ -294,6 +338,10 @@ export default function Home() { useProMultiAccountMailboxes(); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); + const delayedSendSupported = client?.hasDelayedSend() ?? true; + const activeEmails = isScheduledView ? scheduledEmails : emails; + const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails; + const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading; const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified); const accounts = useAccountStore((s) => s.accounts); const connectedAccountsSignature = useMemo( @@ -329,7 +377,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; @@ -355,7 +403,24 @@ 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) { + if (!ctx.client?.hasDelayedSend()) { + setScheduledView(false); + selectEmail(null); + return; + } + 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 { @@ -416,19 +481,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: () => { @@ -444,18 +509,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); @@ -467,6 +537,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'; @@ -496,6 +567,7 @@ export default function Home() { } }, onMarkAsUnread: async () => { + if (isScheduledView) return; if (!client) return; if (selectedEmailIds.size > 0) { await batchMarkAsRead(client, false); @@ -504,6 +576,7 @@ export default function Home() { } }, onMarkAsRead: async () => { + if (isScheduledView) return; if (!client) return; if (selectedEmailIds.size > 0) { await batchMarkAsRead(client, true); @@ -512,6 +585,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) { @@ -539,6 +613,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(); @@ -550,22 +625,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, @@ -578,13 +658,36 @@ 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 (!delayedSendSupported && isScheduledView) { + setScheduledView(false); + } + }, [delayedSendSupported, isScheduledView, setScheduledView]); + + useEffect(() => { + if (!pendingUndoSend) return; + const pendingSendTime = new Date(pendingUndoSend.sendAt).getTime(); + if (!Number.isFinite(pendingSendTime) || pendingSendTime <= Date.now()) { + clearPendingUndoSend(); + return; + } + const timer = setTimeout(clearPendingUndoSend, pendingSendTime - Date.now()); + return () => clearTimeout(timer); + }, [clearPendingUndoSend, pendingUndoSend]); + // Update page title based on context useEffect(() => { let title = appName; @@ -788,50 +891,61 @@ export default function Home() { }, [isAuthenticated, client, handleMailtoProtocolRequest]); // Fallback fetch for paths that didn't go through login()'s prefetch - // (notably checkAuth on page refresh). The prefetch in auth-store/login() - // populates mailboxes before this effect first runs, so on the post-login - // path this block is a no-op. + // (notably checkAuth on page refresh). Settings pages can also prefill + // mailboxes without emails, so bootstrap emails once per client when the + // mail route mounts even if mailbox data is already present. useEffect(() => { - if (isAuthenticated && client && mailboxes.length === 0) { - let retryTimer: ReturnType | null = null; - let cancelled = false; - - const loadData = async (attempt = 1) => { - try { - await Promise.all([ - fetchMailboxes(client), - fetchQuota(client) - ]); - - const state = useEmailStore.getState(); - const selectedMailboxId = state.selectedMailbox; - - if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) { - const delay = Math.min(1000 * attempt, 5000); - debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`); - retryTimer = setTimeout(() => loadData(attempt + 1), delay); - return; - } - - if (selectedMailboxId) { - await fetchEmails(client, selectedMailboxId); - } else { - await fetchEmails(client); - } - - fetchTagCounts(client); - } catch (error) { - console.error('Error loading email data:', error); - } - }; - loadData(); - - return () => { - cancelled = true; - if (retryTimer) clearTimeout(retryTimer); - }; + if (!isAuthenticated || !client) { + initialMailLoadClientRef.current = null; + return; } - }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]); + + if (initialMailLoadClientRef.current === client) return; + initialMailLoadClientRef.current = client; + + let retryTimer: ReturnType | null = null; + let cancelled = false; + + const loadData = async (attempt = 1) => { + try { + const needsMailboxes = useEmailStore.getState().mailboxes.length === 0; + await Promise.all([ + needsMailboxes ? fetchMailboxes(client) : Promise.resolve(), + fetchQuota(client) + ]); + + const state = useEmailStore.getState(); + const selectedMailboxId = state.selectedMailbox; + + if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) { + const delay = Math.min(1000 * attempt, 5000); + debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`); + retryTimer = setTimeout(() => loadData(attempt + 1), delay); + return; + } + + await refreshScheduledMetadata(client); + + // Fetch emails for the selected mailbox after scheduled metadata is available. + if (selectedMailboxId) { + await fetchEmails(client, selectedMailboxId); + } else { + await fetchEmails(client); + } + + fetchTagCounts(client); + } catch (error) { + console.error('Error loading email data:', error); + initialMailLoadClientRef.current = null; + } + }; + loadData(); + + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + }; + }, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]); // Push notifications: set up once per client and tear down when the client // goes away (logout or account switch). Kept separate from the fetch effect @@ -938,7 +1052,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; } @@ -972,7 +1086,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(() => { @@ -1013,6 +1127,7 @@ export default function Home() { attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; inReplyTo?: string[]; references?: string[]; + delayedUntil?: string; }) => { if (!client) return; @@ -1020,8 +1135,13 @@ 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, data.envelopeMailFrom); + const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom); setShowComposer(false); + if (result.scheduled) { + await refreshScheduledMetadata(client); + if (isScheduledView) await fetchScheduledEmails(client); + return; + } // Mark the original email with $answered or $forwarded keyword if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) { @@ -1039,7 +1159,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); } @@ -1169,6 +1289,45 @@ export default function Home() { if (isMobile) setActiveView('viewer'); }; + useEffect(() => { + if (!pendingUndoSend || !client) return; + if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return; + + lastUndoToastSubmissionRef.current = pendingUndoSend.submissionId; + const pending = pendingUndoSend; + const undoDurationMs = Math.max(sendDelaySeconds, 8) * 1000; + + toast.success(t('email_viewer.scheduled_send_created'), { + duration: undoDurationMs, + action: { + label: t('email_viewer.undo_send'), + onClick: () => { + void (async () => { + try { + const restored = await cancelUndoSend(client, pending); + if (restored && !pending.isSmime) { + await handleEditDraft(restored); + } + if (isScheduledView) await fetchScheduledEmails(client); + } catch (error) { + console.error('Failed to undo scheduled send:', error); + } + })(); + }, + }, + }); + + const timer = setTimeout(() => { + const current = useEmailStore.getState().pendingUndoSend; + if (current?.submissionId === pending.submissionId) { + clearPendingUndoSend(); + } + }, undoDurationMs); + + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t]); + const handleReplyAll = async () => { if (selectedEmail) { const ok = await emailHooks.onBeforeReplyAll.intercept({ @@ -1435,7 +1594,29 @@ export default function Home() { }; const handleMailboxSelect = async (mailboxId: string) => { + if (mailboxId === SCHEDULED_MAILBOX_ID) { + if (!delayedSendSupported) { + setScheduledView(false); + return; + } + 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; @@ -1459,6 +1640,7 @@ export default function Home() { if (isUnifiedView) { exitUnifiedView(); } + setScheduledView(false); selectMailbox(mailboxId); selectEmail(null); // Clear selected email when switching mailboxes @@ -1485,6 +1667,7 @@ export default function Home() { }; const handleTagSelect = async (keywordId: string | null) => { + setScheduledView(false); selectKeyword(keywordId); // On mobile, close sidebar and go to list view @@ -1899,6 +2082,16 @@ export default function Home() { }); const originalEmailId = selectedEmail.id; + const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds; + let delayedUntil: string | undefined; + if (sendDelaySeconds > 0) { + if (!client.hasDelayedSend()) { + const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm')); + if (!confirmed) return; + } else { + delayedUntil = new Date(Date.now() + sendDelaySeconds * 1000).toISOString(); + } + } // RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234). const threading = computeReplyThreadingHeaders({ @@ -1907,7 +2100,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)"}`, @@ -1922,9 +2115,15 @@ export default function Home() { undefined, threading?.inReplyTo, threading?.references, + delayedUntil, envelopeMailFrom, ); + if (result.scheduled) { + await refreshScheduledMetadata(client); + return; + } + // Mark the original email as answered try { await client.setKeyword(originalEmailId, '$answered'); @@ -1949,7 +2148,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 isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); @@ -1966,9 +2165,8 @@ export default function Home() { setShowComposer(false); } - // 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); } @@ -2005,6 +2203,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; @@ -2037,14 +2243,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 @@ -2199,6 +2405,8 @@ export default function Home() { mailboxes={mailboxes} selectedMailbox={selectedMailbox} selectedKeyword={selectedKeyword} + scheduledTotal={scheduledTotal} + showScheduledMailbox={delayedSendSupported} onMailboxSelect={handleMailboxSelect} onTagSelect={handleTagSelect} onUnreadFilterClick={handleUnreadFilterClick} @@ -2280,26 +2488,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 ? ( @@ -2317,6 +2526,8 @@ export default function Home() { className={cn("pl-9 h-9", searchQuery && "pr-8")} data-search-input data-tour="search-input" + disabled={isUnifiedView || isScheduledView} + title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined} /> {searchQuery && ( - {/* S/MIME toggles */} {canSmimeSign && ( <> @@ -2115,15 +2251,56 @@ export function EmailComposer({ > {t('discard')} - + {composerClient?.hasDelayedSend() ? ( +
+ + + {showSendMenu && ( +
+ +
+ )} +
+ ) : ( + + )} @@ -2162,6 +2339,29 @@ export function EmailComposer({ )} + {showScheduleDialog && ( +
+
+

{t('schedule_send')}

+

{t('schedule_send_description')}

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

{scheduleError}

} +
+ + +
+
+
+ )} + {/* S/MIME passphrase prompt */} {smimePassphrasePrompt && (
setShowAttachmentWarning(false)}> {t('forgot_attachment.back')} -
@@ -2485,6 +2685,11 @@ function RecipientChipInput({ aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined} aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} aria-invalid={validationError || undefined} + data-bwignore="true" + data-1p-ignore + data-op-ignore + data-lpignore="true" + data-form-type="other" /> {validationError && validationMessage && ( @@ -2501,4 +2706,4 @@ function RecipientChipInput({ )} ); -} \ No newline at end of file +} diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index bd9c41fa7..fbcce94db 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -30,6 +30,8 @@ import { ShieldAlert, ShieldCheck, EditIcon, + CalendarClock, + XCircle, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; @@ -63,6 +65,9 @@ interface EmailContextMenuProps { onMarkAsSpam?: () => void; onUndoSpam?: () => void; onEditDraft?: () => void; + onCancelScheduled?: () => void; + onCancelScheduledForEdit?: () => void; + onRescheduleScheduled?: () => void; // Batch actions onBatchMarkAsRead?: (read: boolean) => void; onBatchDelete?: () => void; @@ -133,6 +138,9 @@ export function EmailContextMenu({ onBatchMarkAsSpam, onBatchUndoSpam, onEditDraft, + onCancelScheduled, + onCancelScheduledForEdit, + onRescheduleScheduled, }: EmailContextMenuProps) { const t = useTranslations("context_menu"); const _tColor = useTranslations("email_viewer.color_tag"); @@ -143,6 +151,8 @@ export function EmailContextMenu({ const currentColors = getCurrentColors(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; + const isScheduled = email.isScheduled === true; + const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending'; // Build color options from keyword definitions in settings const colorOptions = emailKeywords.map((kw) => ({ @@ -196,8 +206,36 @@ export function EmailContextMenu({ )} + {isScheduled && !showBatchActions && canCancelScheduled && ( + <> + handleAction(onRescheduleScheduled!)} + disabled={!onRescheduleScheduled} + /> + handleAction(onCancelScheduled!)} + disabled={!onCancelScheduled} + /> + handleAction(onCancelScheduledForEdit!)} + disabled={!onCancelScheduledForEdit} + /> + + )} + + {canCancelScheduled && } + + {!isScheduled && ( + <> + {/* Edit Draft - only for single draft emails */} - {!showBatchActions && isDraft && onEditDraft && ( + {!isScheduled && !showBatchActions && isDraft && onEditDraft && ( <> + + )} diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 70245be74..80753f583 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types"; import { ThreadListItem } from "./thread-list-item"; import { EmailContextMenu } from "./email-context-menu"; import { cn } from "@/lib/utils"; -import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react"; +import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock } from "lucide-react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; @@ -27,6 +27,8 @@ interface EmailListProps { onEmailDoubleClick?: (email: Email) => void; className?: string; isLoading?: boolean; + hasMore?: boolean; + isLoadingMoreItems?: boolean; onOpenConversation?: (thread: ThreadGroup) => void; onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; @@ -40,6 +42,11 @@ interface EmailListProps { onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; onEditDraft?: (email: Email) => void; + isScheduledView?: boolean; + onLoadMoreScheduled?: () => void; + onCancelScheduled?: (email: Email) => void | Promise; + onCancelScheduledForEdit?: (email: Email) => void | Promise; + onRescheduleScheduled?: (email: Email) => void | Promise; } export function EmailList({ @@ -49,6 +56,8 @@ export function EmailList({ onEmailDoubleClick, className, isLoading = false, + hasMore, + isLoadingMoreItems, onOpenConversation, onReply, onReplyAll, @@ -62,6 +71,11 @@ export function EmailList({ onUndoSpam, onMoveToMailbox, onEditDraft, + isScheduledView = false, + onLoadMoreScheduled, + onCancelScheduled, + onCancelScheduledForEdit, + onRescheduleScheduled, }: EmailListProps) { const t = useTranslations('email_list'); const { client } = useAuthStore(); @@ -96,9 +110,9 @@ export function EmailList({ const disableThreading = useSettingsStore((state) => state.disableThreading); const threadGroups = useMemo(() => { - const groups = groupEmailsByThread(emails, disableThreading); + const groups = groupEmailsByThread(emails, disableThreading || isScheduledView); return sortThreadGroups(groups); - }, [emails, disableThreading]); + }, [emails, disableThreading, isScheduledView]); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); @@ -108,6 +122,8 @@ export function EmailList({ const density = useSettingsStore((state) => state.density); const showPreview = useSettingsStore((state) => state.showPreview); const mailLayout = useSettingsStore((state) => state.mailLayout); + const footerHasMore = hasMore ?? hasMoreEmails; + const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore; const isMobile = useUIStore((state) => state.isMobile); // Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match. const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; @@ -219,10 +235,14 @@ 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 handleToggleThreadExpansion = useCallback(async (threadId: string) => { const isExpanded = expandedThreadIds.has(threadId); @@ -282,7 +302,7 @@ export function EmailList({
@@ -406,17 +426,19 @@ export function EmailList({ ) : emails.length === 0 && !isLoading ? (
- {searchQuery || !isFilterEmpty(searchFilters) ? ( + {isScheduledView ? ( + + ) : searchQuery || !isFilterEmpty(searchFilters) ? ( ) : ( )}

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

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

) : ( @@ -468,13 +490,13 @@ export function EmailList({
- {isLoadingMore && hasMoreEmails && ( + {footerIsLoadingMore && footerHasMore && (
{t('loading_more')}
)} - {!hasMoreEmails && emails.length > 0 && ( + {!footerHasMore && emails.length > 0 && (
{t('no_more_emails')}
@@ -509,6 +531,9 @@ export function EmailList({ onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} onEditDraft={() => onEditDraft?.(contextMenu.data!)} + onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined} + onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined} + onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={() => client && batchDelete(client)} onBatchArchive={async () => { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 210eb5787..0f5b25e65 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -66,6 +66,7 @@ import { EditIcon, PlayCircle, PenSquare, + CalendarClock, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; @@ -126,6 +127,9 @@ interface EmailViewerProps { onNavigatePrev?: () => void; onShowShortcuts?: () => void; onEditDraft?: () => void; + onCancelScheduled?: () => void; + onCancelScheduledForEdit?: () => void; + onRescheduleScheduled?: (delayedUntil: string) => void; onCompose?: () => void; currentUserEmail?: string; currentUserName?: string; @@ -875,6 +879,9 @@ export function EmailViewer({ onNavigatePrev, onShowShortcuts, onEditDraft, + onCancelScheduled, + onCancelScheduledForEdit, + onRescheduleScheduled, onCompose, currentUserEmail, currentUserName, @@ -884,6 +891,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'); @@ -934,6 +942,8 @@ export function EmailViewer({ // Detect if the email is a draft const isDraft = email?.keywords?.['$draft'] === true; + const isScheduled = email?.isScheduled === true; + const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending'; // Color options for email tags (from user-defined keyword settings) const colorOptions = emailKeywords.map((kw) => ({ @@ -947,6 +957,29 @@ export function EmailViewer({ const { tabletListVisible } = useUIStore(); const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId)); + const promptForRescheduleDelayedUntil = 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 isEmbedded = useIsEmbedded(); @@ -3358,7 +3391,32 @@ export function EmailViewer({ )} - {isDraft && onEditDraft && ( + {isScheduled && canCancelScheduled && ( + <> + + + + + )} + {!isScheduled && isDraft && onEditDraft && ( )} - {!isDraft && (<> + {!isScheduled && !isDraft && (<>
+ )} ); @@ -3879,13 +3939,13 @@ export function EmailViewer({ className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)} > {/* Mobile More menu sidebar overlay */} - {isMobile && moreMenuOpen && ( + {!isScheduled && isMobile && moreMenuOpen && (
setMoreMenuOpen(false)} /> )} - {isMobile && ( + {!isScheduled && isMobile && (
)} + {/* Scheduled Banner */} + {isScheduled && ( +
+
+
+ + + {t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })} + +
+
+ {canCancelScheduled && ( + <> + + + + + )} +
+
+
+ )} + {/* Draft Banner */} {isDraft && (
-
+
{t('draft_banner')} @@ -5289,7 +5383,7 @@ export function EmailViewer({ {/* Quick Reply Section - hidden for drafts and while loading a new email */} - {!isDraft && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
+ {!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
( const emailKeywords = useSettingsStore((state) => state.emailKeywords); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); + const timeFormat = useSettingsStore((state) => state.timeFormat); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; const isUnifiedView = useEmailStore((state) => state.isUnifiedView); @@ -78,6 +79,9 @@ const SingleEmailItem = React.forwardRef( const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; const trimmedPreview = stripInvisibleLeading(email.preview ?? ''); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; + const scheduledSendLabel = email.isScheduled && email.scheduledSendAt + ? formatDateTime(email.scheduledSendAt, timeFormat) + : null; // Resolve color tags using keyword definitions; unknown tags fall back to gray const tagIds = getEmailColorTags(email.keywords); @@ -241,12 +245,22 @@ const SingleEmailItem = React.forwardRef( {resolvedKeywordDefs.map((kd) => ( ))} - - {formatDate(email.receivedAt)} - + {scheduledSendLabel ? ( + + + {scheduledSendLabel} + + ) : ( + + {formatDate(email.receivedAt)} + + )}
) : ( @@ -299,14 +313,24 @@ const SingleEmailItem = React.forwardRef( {kd.label} ))} - - {formatDate(email.receivedAt)} - + {scheduledSendLabel ? ( + + + {scheduledSendLabel} + + ) : ( + + {formatDate(email.receivedAt)} + + )}
@@ -335,16 +359,18 @@ const SingleEmailItem = React.forwardRef(
{/* Hover Quick Actions */} - + {!email.isScheduled && ( + + )}
); } @@ -374,6 +400,7 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); const mailLayout = useSettingsStore((state) => state.mailLayout); + const timeFormat = useSettingsStore((state) => state.timeFormat); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const isMobile = useUIStore((state) => state.isMobile); const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; @@ -381,6 +408,9 @@ export const ThreadListItem = React.forwardRef state.getAccountById); @@ -646,12 +676,22 @@ export const ThreadListItem = React.forwardRef )} - - {formatDate(latestEmail.receivedAt)} - + {scheduledSendLabel ? ( + + + {scheduledSendLabel} + + ) : ( + + {formatDate(latestEmail.receivedAt)} + + )}
) : ( @@ -716,14 +756,24 @@ export const ThreadListItem = React.forwardRef )} - - {formatDate(latestEmail.receivedAt)} - + {scheduledSendLabel ? ( + + + {scheduledSendLabel} + + ) : ( + + {formatDate(latestEmail.receivedAt)} + + )}
@@ -752,16 +802,18 @@ export const ThreadListItem = React.forwardRef {/* Hover Quick Actions for thread header */} - onToggleStar(latestEmail) : undefined} - onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} - onDelete={onDelete ? () => onDelete(latestEmail) : undefined} - onArchive={onArchive ? () => onArchive(latestEmail) : undefined} - onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} - onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} - /> + {!latestEmail.isScheduled && ( + onToggleStar(latestEmail) : undefined} + onMarkAsRead={onMarkAsRead ? (read) => onMarkAsRead(latestEmail, read) : undefined} + onDelete={onDelete ? () => onDelete(latestEmail) : undefined} + onArchive={onArchive ? () => onArchive(latestEmail) : undefined} + onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} + onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} + /> + )}
{isExpanded && !isMobile && !isFocusedMailLayout && ( diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index df38d387b..d76218f4c 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -74,6 +74,8 @@ interface SidebarProps { onDeleteFolder?: (mailboxId: string) => void; onImportEmail?: (mailboxId: string) => void; onRefreshMailboxes?: () => void; + scheduledTotal?: number; + showScheduledMailbox?: boolean; className?: string; /** * Multi-account (Pro) mode props. When `multiAccountMode` is true, the @@ -674,6 +676,8 @@ export function Sidebar({ onDeleteFolder, onImportEmail, onRefreshMailboxes, + scheduledTotal = 0, + showScheduledMailbox = false, className, multiAccountMode = false, accountMailboxes, @@ -1027,22 +1031,35 @@ export function Sidebar({ {!isCollapsed && t("loading_mailboxes")} ) : ( - tree.map((node) => ( - - onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId) - } - onToggleExpand={handleToggleExpand} - isCollapsed={isCollapsed} - onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined} - colorful={colorfulSidebarIcons} - onContextMenu={isActive ? handleMailboxContextMenu : undefined} - /> - )) + <> + {tree.map((node) => ( + + onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId) + } + onToggleExpand={handleToggleExpand} + isCollapsed={isCollapsed} + onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined} + colorful={colorfulSidebarIcons} + onContextMenu={isActive ? handleMailboxContextMenu : undefined} + /> + ))} + {isActive && showScheduledMailbox && ( + } + label={t('scheduled')} + depth={0} + isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'} + total={scheduledTotal} + onClick={() => onMailboxSelect?.('__scheduled__')} + isCollapsed={isCollapsed} + /> + )} + )} )} @@ -1067,20 +1084,33 @@ export function Sidebar({ {!isCollapsed && t("loading_mailboxes")} ) : ( - ownTree.map((node) => ( - - )) + <> + {ownTree.map((node) => ( + + ))} + {showScheduledMailbox && ( + } + label={t('scheduled')} + depth={0} + isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'} + total={scheduledTotal} + onClick={() => onMailboxSelect?.('__scheduled__')} + isCollapsed={isCollapsed} + /> + )} + )} )} diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index 5f24f5260..ecbca7515 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -26,7 +26,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { const client = useAuthStore((s) => s.client); const sendEmail = useEmailStore((s) => s.sendEmail); const fetchEmails = useEmailStore((s) => s.fetchEmails); + const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails); + const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata); const selectedMailbox = useEmailStore((s) => s.selectedMailbox); + const isScheduledView = useEmailStore((s) => s.isScheduledView); const closeTab = useProTabStore((s) => s.closeTab); const updateTabTitle = useProTabStore((s) => s.updateTabTitle); const updateComposeDraft = useProTabStore((s) => s.updateComposeDraft); @@ -36,10 +39,18 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { const tabIdRef = useRef(tabId); tabIdRef.current = tabId; + const handleScheduledSendCreated = useCallback(async () => { + if (client) { + await refreshScheduledMetadata(client); + if (isScheduledView) await fetchScheduledEmails(client); + } + closeTab(tabIdRef.current); + }, [client, refreshScheduledMetadata, isScheduledView, fetchScheduledEmails, closeTab]); + const handleSend = useCallback(async (sendData: Parameters['onSend']>>[0]) => { if (!client) return; try { - await sendEmail( + const result = await sendEmail( client, sendData.to, sendData.subject, @@ -54,9 +65,15 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { sendData.attachments, sendData.inReplyTo, sendData.references, + sendData.delayedUntil, sendData.envelopeMailFrom, ); + if (result.scheduled) { + await handleScheduledSendCreated(); + return; + } + // Mark the original message as $answered / $forwarded so the standard // viewer and list reflect the action (same behaviour as inline compose). if (data.sourceEmailId && (data.mode === 'reply' || data.mode === 'replyAll')) { @@ -81,7 +98,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { console.error('Failed to send email:', error); toast.error(t('notifications.error_sending')); } - }, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t]); + }, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated]); const handleClose = useCallback(() => { closeTab(tabIdRef.current); @@ -125,6 +142,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { initialDraftText={data.initialDraftText} initialData={data.initialData} onSend={handleSend} + onScheduledSendCreated={handleScheduledSendCreated} onClose={handleClose} onDiscardDraft={handleDiscardDraft} onSaveState={handleSaveState} diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index 93e17c985..c7787b6db 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -3,6 +3,8 @@ import { useState } from 'react'; import { useTranslations } from 'next-intl'; 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 { X } from 'lucide-react'; import { @@ -22,11 +24,14 @@ export function ComposingSettings() { autoSelectReplyIdentity, attachmentReminderEnabled, attachmentReminderKeywords, + sendDelaySeconds, subAddressDelimiter, signaturePosition, signatureSeparatorEnabled, updateSetting, } = useSettingsStore(); + const { client } = useAuthStore(); + const delayedSendSupported = client?.hasDelayedSend() ?? false; return ( @@ -37,6 +42,24 @@ export function ComposingSettings() { /> + +
+ ; @@ -62,7 +90,7 @@ function mockSendEmailFlow() { payload = { methodResponses: [ ['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record }).create)[0]]: { id: 'sent-id-1' } } }, '0'], - ['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'], + ['EmailSubmission/set', { created: { '1': { id: 'sub-1', sendAt: '2026-05-08T18:00:00Z' } } }, '1'], ], }; } @@ -167,4 +195,72 @@ describe('JMAPClient.sendEmail threading headers', () => { expect(draft.inReplyTo).toEqual(['real@example.com']); expect(draft.references).toBeUndefined(); }); + + it('uses FUTURERELEASE envelope and submission capability for scheduled sends', async () => { + const client = createClient(); + enableDelayedSend(client); + const captured = mockSendEmailFlow(); + const delayedUntil = 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, + delayedUntil, + ); + + const identityRequest = captured[1]; + expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission'); + const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set'); + expect(submissionCall?.[1].accountId).toBe('submission-account-1'); + expect(submissionCall?.[1].create).toEqual({ + '1': { + emailId: expect.stringMatching(/^#send-/), + identityId: 'identity-1', + envelope: { + mailFrom: { + email: 'user@example.com', + parameters: { HOLDFOR: expect.stringMatching(/^\d+$/) }, + }, + rcptTo: [{ email: 'recipient@example.com' }], + }, + }, + }); + expect(JSON.stringify(submissionCall?.[1].create)).not.toContain('sendAt'); + expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: '2026-05-08T18:00:00Z' }); + }); + + it('cleans up replacement submission if canceling the original fails during reschedule', async () => { + 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); + vi.spyOn(client, 'getIdentities').mockResolvedValue([ + { id: 'identity-1', name: 'User', email: 'user@example.com', mayDelete: false }, + ]); + const requestSpy = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request') + .mockImplementation(async (methodCalls) => { + const args = methodCalls[0][1] as { create?: unknown; update?: Record }; + 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' } } })]), + ])); + }); }); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index b83c2148a..298d17df7 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -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(); + private scheduledSubmissions = new Map(); private connectionCallback: ((connected: boolean) => void) | null = null; private stateChangeCallback: ((change: StateChange) => void) | null = null; private lastStates: AccountStates = {}; @@ -49,15 +50,15 @@ 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 { return { 'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 }, 'urn:ietf:params:jmap:mail': {}, - 'urn:ietf:params:jmap:submission': {}, + 'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } }, 'urn:ietf:params:jmap:vacationresponse': {}, 'urn:ietf:params:jmap:contacts': {}, 'urn:ietf:params:jmap:calendars': {}, @@ -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; } @@ -293,6 +296,8 @@ export class DemoJMAPClient implements IJMAPClient { emails: Array<{ id: string; receivedAt: string }>, archiveMailboxId: string, mode: 'single' | 'year' | 'month', + _existingMailboxes: Mailbox[], + _accountId?: string, ): Promise { if (emails.length === 0) return; if (mode === 'single') { @@ -456,7 +461,9 @@ 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 { + delayedUntil?: string, + _envelopeMailFrom?: string, + ): Promise { // Remove draft if updating if (draftId) { this.data.emails = this.data.emails.filter(e => e.id !== draftId); @@ -464,8 +471,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: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true }, + keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true }, size: body.length + (htmlBody?.length || 0), receivedAt: new Date().toISOString(), from: [{ name: 'Demo User', email: 'demo@example.com' }], @@ -485,7 +492,22 @@ export class DemoJMAPClient implements IJMAPClient { references: references?.length ? references : undefined, }; this.data.emails.push(email); + let emailSubmissionId: string | undefined; + if (delayedUntil) { + emailSubmissionId = generateDemoId('submission'); + this.scheduledSubmissions.set(emailSubmissionId, { + id: emailSubmissionId, + emailId: email.id, + identityId: _identityId || 'demo-identity', + sendAt: delayedUntil, + undoStatus: 'pending', + isSmime: false, + }); + } this.recalcMailboxCounts(); + return delayedUntil + ? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt: delayedUntil } + : { scheduled: false, emailId: email.id }; } async sendImipReply(): Promise { /* no-op in demo */ } @@ -918,7 +940,75 @@ export class DemoJMAPClient implements IJMAPClient { async importRawEmail(): Promise { return generateDemoId('email'); } async submitEmail(): Promise { /* no-op */ } - async sendRawEmail(): Promise { /* no-op */ } + async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise { + const emailId = generateDemoId('email'); + const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts'); + const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent'); + const email: Email = { + id: emailId, + threadId: generateDemoId('thread'), + mailboxIds: { [(delayedUntil ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true }, + keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true }, + size: 1024, + receivedAt: new Date().toISOString(), + from: [{ name: 'Demo User', email: 'demo@example.com' }], + to: [], + subject: 'S/MIME message', + preview: 'Signed/encrypted demo message', + hasAttachment: false, + }; + this.data.emails.push(email); + let emailSubmissionId: string | undefined; + if (delayedUntil) { + emailSubmissionId = generateDemoId('submission'); + this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true }); + } + this.recalcMailboxCounts(); + return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true }; + } + + async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: 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); + const nextPosition = position + page.length; + return { emails, hasMore: nextPosition < pending.length, total: pending.length, nextPosition }; + } + + async cancelEmailSubmission(submissionId: string): Promise { + const submission = this.scheduledSubmissions.get(submissionId); + if (submission) submission.undoStatus = 'canceled'; + } + + async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise { + await this.cancelEmailSubmission(submissionId); + const replacement = generateDemoId('submission'); + this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: false }); + return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil }; + } + + async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise { + 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 ────────────────────────────────────────── diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 05165ec85..88013cd37 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -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,8 +148,15 @@ export interface IJMAPClient { attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], + delayedUntil?: string, envelopeMailFrom?: string, - ): Promise; + ): Promise; + + sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise; + getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>; + cancelEmailSubmission(submissionId: string): Promise; + rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise; + restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise; sendImipReply(opts: { organizerEmail: string; @@ -285,5 +294,4 @@ export interface IJMAPClient { // ── S/MIME raw-email helpers ────────────────────────────────── importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record, accountId?: string): Promise; submitEmail(emailId: string, identityId: string): Promise; - sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 399d2413f..54da61485 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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]; +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; @@ -312,6 +318,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. @@ -347,6 +374,33 @@ function sanitizeIdentityDisplayName(name: string | undefined | null): string { return name.replace(/\s*<[^>]*>\s*$/, '').trim(); } +function normalizeEnvelopeRecipients(recipients?: Array): Array<{ email: string }> { + return (recipients || []) + .map((recipient) => typeof recipient === 'string' ? recipient : recipient.email) + .map((email) => email.trim()) + .filter(Boolean) + .map((email) => ({ email })); +} + +function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number, recipients?: Array): Record | undefined { + if (!holdForSeconds) return undefined; + const rcptTo = normalizeEnvelopeRecipients(recipients); + return { + mailFrom: { + email: fromEmail, + parameters: { + HOLDFOR: String(holdForSeconds), + }, + }, + rcptTo, + }; +} + +type SubmissionCapability = { + maxDelayedSend?: number; + submissionExtensions?: unknown; +}; + export class JMAPClient implements IJMAPClient { private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000; @@ -703,7 +757,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, }; @@ -2094,8 +2148,10 @@ export class JMAPClient implements IJMAPClient { attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], + delayedUntil?: string, envelopeMailFrom?: string - ): Promise { + ): Promise { + const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; const emailId = `send-${Date.now()}`; const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); @@ -2197,12 +2253,19 @@ export class JMAPClient implements IJMAPClient { // e.g. sending from a domain-catch-all alias without a dedicated Identity), // set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope` // is omitted the server derives mailFrom from the Identity. - const submissionCreate = (submissionId: string): Record => { + const buildSubmissionCreate = (submissionId: string): Record => { const create: Record = { emailId: `#${emailId}`, identityId: finalIdentityId }; - if (envelopeMailFrom) { + if (holdForSeconds || envelopeMailFrom) { + const envelopeRecipients = [...to, ...(cc || []), ...(bcc || [])] + .map((email) => email.trim()) + .filter(Boolean) + .map((email) => ({ email })); create.envelope = { - mailFrom: { email: envelopeMailFrom }, - rcptTo: [...to, ...(cc || []), ...(bcc || [])].map((email) => ({ email })), + mailFrom: { + email: envelopeMailFrom || fromEmail || this.username, + ...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}), + }, + rcptTo: envelopeRecipients, }; } return { [submissionId]: create }; @@ -2219,8 +2282,8 @@ export class JMAPClient implements IJMAPClient { create: { [emailId]: emailCreate }, }, "1"]); methodCalls.push(["EmailSubmission/set", { - accountId: this.accountId, - create: submissionCreate("1"), + accountId: this.getSubmissionAccountId(), + create: buildSubmissionCreate("1"), onSuccessUpdateEmail, }, "2"]); } else { @@ -2229,14 +2292,18 @@ export class JMAPClient implements IJMAPClient { create: { [emailId]: emailCreate }, }, "0"]); methodCalls.push(["EmailSubmission/set", { - accountId: this.accountId, - create: submissionCreate("1"), + accountId: this.getSubmissionAccountId(), + create: buildSubmissionCreate("1"), onSuccessUpdateEmail, }, "1"]); } const response = await this.request(methodCalls); + let createdEmailId: string | undefined; + let emailSubmissionId: string | undefined; + let serverSendAt: string | undefined; + if (response.methodResponses) { for (const [methodName, result] of response.methodResponses) { if (methodName.endsWith('/error')) { @@ -2268,8 +2335,24 @@ export class JMAPClient implements IJMAPClient { `${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`, ); } + + 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; + serverSendAt = result.created['1'].sendAt; + } } } + + if (delayedUntil && emailSubmissionId && !serverSendAt) { + serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId); + } + + return delayedUntil + ? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt } + : { scheduled: false, emailId: createdEmailId, emailSubmissionId }; } /** @@ -2431,7 +2514,7 @@ export class JMAPClient implements IJMAPClient { create: { [emailId]: emailCreate }, }, "0"], ["EmailSubmission/set", { - accountId: this.accountId, + accountId: this.getSubmissionAccountId(), create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, onSuccessUpdateEmail: { "#sub-1": { @@ -2609,7 +2692,7 @@ export class JMAPClient implements IJMAPClient { create: { [emailId]: emailCreate }, }, "0"], ["EmailSubmission/set", { - accountId: this.accountId, + accountId: this.getSubmissionAccountId(), create: { "sub-1": { emailId: `#${emailId}`, identityId } }, onSuccessUpdateEmail: { "#sub-1": { @@ -2761,7 +2844,7 @@ export class JMAPClient implements IJMAPClient { create: { [emailId]: emailCreate }, }, "0"], ["EmailSubmission/set", { - accountId: this.accountId, + accountId: this.getSubmissionAccountId(), create: { "sub-1": { emailId: `#${emailId}`, identityId } }, onSuccessUpdateEmail: { "#sub-1": { @@ -2975,6 +3058,85 @@ export class JMAPClient implements IJMAPClient { return coreCapability?.maxObjectsInGet || 500; } + getMaxDelayedSend(accountId?: string): number { + const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend; + return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0; + } + + hasDelayedSend(accountId?: string): boolean { + const submissionCapability = this.getSubmissionCapability(accountId); + const submissionExtensions = submissionCapability?.submissionExtensions; + const hasFutureRelease = this.hasSubmissionExtension(submissionExtensions, 'FUTURERELEASE'); + + return !!submissionCapability + && hasFutureRelease + && this.getMaxDelayedSend(accountId) > 0; + } + + private validateDelayedUntil(delayedUntil: string, accountId?: string): number { + const time = new Date(delayedUntil).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'); + } + return Math.ceil((time - now) / 1000); + } + + private async getEmailSubmissionSendAt(submissionId: string): Promise { + const response = await this.request([ + ['EmailSubmission/get', { + accountId: this.getSubmissionAccountId(), + ids: [submissionId], + properties: ['sendAt', 'undoStatus'], + }, '0'], + ]); + const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined; + return submission?.sendAt; + } + + private async getEmailSubmissionEnvelope(submissionId: string): Promise<{ rcptTo?: Array<{ email: string }> } | undefined> { + const response = await this.request([ + ['EmailSubmission/get', { + accountId: this.getSubmissionAccountId(), + ids: [submissionId], + properties: ['envelope'], + }, '0'], + ]); + const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { envelope?: { rcptTo?: Array<{ email: string }> } } | undefined; + return submission?.envelope; + } + + private getSubmissionAccountId(accountId?: string): string { + return accountId || this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'] || this.accountId; + } + + private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined { + const submissionAccountId = this.getSubmissionAccountId(accountId); + return this.session?.accounts?.[submissionAccountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission'] as SubmissionCapability | undefined; + } + + private hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean { + const target = extension.toUpperCase(); + if (Array.isArray(submissionExtensions)) { + return submissionExtensions.some(item => typeof item === 'string' && item.toUpperCase() === target); + } + if (submissionExtensions && typeof submissionExtensions === 'object') { + return Object.entries(submissionExtensions as Record) + .some(([key, value]) => key.toUpperCase() === target && value !== false && value != null); + } + return false; + } + getEventSourceUrl(): string | null { if (!this.session) return null; @@ -5430,7 +5592,7 @@ export class JMAPClient implements IJMAPClient { async submitEmail(emailId: string, identityId: string): Promise { const response = await this.request([ ['EmailSubmission/set', { - accountId: this.accountId, + accountId: this.getSubmissionAccountId(), create: { 'smime-submit': { emailId, identityId } }, }, '0'], ]); @@ -5451,7 +5613,10 @@ export class JMAPClient implements IJMAPClient { identityId: string, sentMailboxId: string, draftMailboxId?: string, - ): Promise { + delayedUntil?: string, + envelopeRecipients?: string[], + ): Promise { + const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; // Upload the raw message const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); const { blobId } = await this.uploadBlob(file); @@ -5459,6 +5624,10 @@ export class JMAPClient implements IJMAPClient { // Import into Drafts first, then move to Sent after submission succeeds. // This avoids encrypt-on-append affecting the SMTP send. See #188. const importMailboxId = draftMailboxId || sentMailboxId; + const identities = await this.getIdentities(); + const identity = identities.find(item => item.id === identityId); + const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients); + const methodCalls: [string, Record, string][] = [ ['Email/import', { accountId: this.accountId, @@ -5471,11 +5640,12 @@ export class JMAPClient implements IJMAPClient { }, }, '0'], ['EmailSubmission/set', { - accountId: this.accountId, + accountId: this.getSubmissionAccountId(), create: { 'raw-submit': { emailId: '#raw-import', identityId, + ...(envelope ? { envelope } : {}), }, }, ...(draftMailboxId ? { @@ -5491,6 +5661,9 @@ export class JMAPClient implements IJMAPClient { ]; const response = await this.request(methodCalls); + let emailId: string | undefined; + let emailSubmissionId: string | undefined; + let serverSendAt: string | undefined; // Check for errors for (const [methodName, result] of response.methodResponses ?? []) { @@ -5502,6 +5675,201 @@ 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 }).created?.['raw-import']?.id; + } + if (methodName === 'EmailSubmission/set') { + const created = (result as { created?: Record }).created?.['raw-submit']; + emailSubmissionId = created?.id; + serverSendAt = created?.sendAt; + } + } + + if (delayedUntil && emailSubmissionId && !serverSendAt) { + serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId); + } + + return delayedUntil + ? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, isSmime: true } + : { scheduled: false, emailId, emailSubmissionId, isSmime: true }; + } + + async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> { + if (!this.hasDelayedSend()) { + return { emails: [], hasMore: false, total: 0, nextPosition: position }; + } + + const now = Date.now(); + const pageSize = Math.max(limit, 50); + const submissions: EmailSubmission[] = []; + let rawPosition = 0; + let rawTotal = 0; + + do { + const queryResponse = await this.request([ + ['EmailSubmission/query', { + accountId: this.getSubmissionAccountId(), + limit: pageSize, + position: rawPosition, + }, '0'], + ]); + + const query = queryResponse.methodResponses?.[0]?.[1] as { ids?: string[]; total?: number; position?: number } | undefined; + const ids = query?.ids ?? []; + rawTotal = query?.total ?? rawPosition + ids.length; + if (ids.length === 0) break; + + const submissionResponse = await this.request([ + ['EmailSubmission/get', { + accountId: this.getSubmissionAccountId(), + ids, + properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus', 'deliveryStatus'], + }, '0'], + ]); + + submissions.push(...((submissionResponse.methodResponses?.[0]?.[1]?.list ?? []) as EmailSubmission[]) + .filter(submission => { + if (submission.undoStatus !== 'pending' || !submission.sendAt) return false; + const sendAtTime = new Date(submission.sendAt).getTime(); + return Number.isFinite(sendAtTime) && sendAtTime > now; + })); + + rawPosition += ids.length; + } while (rawPosition < rawTotal); + + submissions.sort((a, b) => new Date(a.sendAt || '').getTime() - new Date(b.sendAt || '').getTime()); + const total = submissions.length; + const pageSubmissions = submissions.slice(position, position + limit); + const nextPosition = position + pageSubmissions.length; + + if (pageSubmissions.length === 0) { + return { emails: [], hasMore: false, total, nextPosition }; + } + + const emailResponse = await this.request([ + ['Email/get', { + accountId: this.accountId, + ids: pageSubmissions.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 = pageSubmissions + .map((submission): ScheduledEmail | null => { + const email = emailById.get(submission.emailId); + if (!email || !submission.sendAt) return null; + return { + ...email, + threadId: submission.threadId || email.threadId, + scheduledSendAt: submission.sendAt, + emailSubmissionId: submission.id, + scheduledIdentityId: submission.identityId, + scheduledUndoStatus: submission.undoStatus, + scheduledDeliveryStatus: submission.deliveryStatus, + 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()); + + return { emails, hasMore: nextPosition < total, total, nextPosition }; + } + + async cancelEmailSubmission(submissionId: string): Promise { + const response = await this.request([ + ['EmailSubmission/set', { + accountId: this.getSubmissionAccountId(), + 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, delayedUntil: string): Promise { + const holdForSeconds = this.validateDelayedUntil(delayedUntil); + const mailboxes = await this.getMailboxes(); + const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); + const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); + const identities = await this.getIdentities(); + const identity = identities.find(item => item.id === identityId); + const existingEnvelope = await this.getEmailSubmissionEnvelope(submissionId); + const email = existingEnvelope?.rcptTo?.length ? undefined : await this.getEmail(emailId); + const envelopeRecipients = existingEnvelope?.rcptTo?.length + ? existingEnvelope.rcptTo + : [...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || [])]; + const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients); + const response = await this.request([ + ['EmailSubmission/set', { + accountId: this.getSubmissionAccountId(), + create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } }, + ...(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; + const serverSendAt = result?.created?.replacement?.sendAt; + if (!replacementId) { + throw new Error('Server did not return a replacement scheduled send ID'); + } + const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId); + try { + await this.cancelEmailSubmission(submissionId); + } catch (error) { + 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: finalSendAt }; + } + + async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise { + const update: Record = { + [`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'); } } @@ -5580,4 +5948,4 @@ export class JMAPClient implements IJMAPClient { ['urn:ietf:params:jmap:core'], ); } -} \ No newline at end of file +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 517f678d8..3eee60c4a 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -42,6 +42,32 @@ 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'; + scheduledDeliveryStatus?: Record; + 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'; + scheduledDeliveryStatus?: Record; + isScheduled: true; + isSmimeScheduled: boolean; } export interface AuthenticationResults { @@ -785,4 +811,4 @@ export const UNIFIED_ROLE_BY_ID: Record = Object.fro export function isUnifiedMailboxId(id: string): boolean { return id in UNIFIED_ROLE_BY_ID; -} \ No newline at end of file +} diff --git a/locales/cs/common.json b/locales/cs/common.json index 833f774a9..6a9894091 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -132,7 +132,8 @@ "shared": "Sdílené", "mail": "Pošta", "nav_label": "Navigace", - "add_app": "Aplikace" + "add_app": "Aplikace", + "scheduled": "Naplánováno" }, "protocol_handlers": { "title": "Výchozí aplikace", @@ -239,7 +240,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", @@ -514,7 +524,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", @@ -607,7 +627,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", @@ -1134,6 +1166,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": { @@ -1803,7 +1842,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", @@ -1881,6 +1924,8 @@ "expand_collapse": "Rozbalit/sbalit vlákno" }, "composer": { + "send": "Odeslat e-mail", + "schedule_send": "Naplánovat odeslání", "template_picker": "Otevřít výběr šablon" } }, diff --git a/locales/da/common.json b/locales/da/common.json index ba1aba8a7..5adbe4f2d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -132,7 +132,8 @@ "shared": "Delt", "mail": "Mail", "nav_label": "Navigation", - "add_app": "Apps" + "add_app": "Apps", + "scheduled": "Planlagt" }, "protocol_handlers": { "title": "Standardapps", @@ -239,7 +240,16 @@ "confirm_button": "Tøm mappe", "junk_hint": "Du kan tømme spam-mappen for permanent at fjerne alle beskeder.", "trash_hint": "Du kan tømme papirkurven for permanent at fjerne alle beskeder." - } + }, + "no_scheduled_emails": "Ingen planlagte e-mails", + "no_scheduled_emails_description": "Beskeder planlagt til senere vises her.", + "scheduled_count": "{count, plural, one {1 planlagt e-mail} other {# planlagte e-mails}}", + "scheduled_actions_hint": "Planlagte beskeder kan annulleres, planlægges om eller redigeres fra deres planlagte handlinger.", + "cancel_scheduled_send": "Annuller afsendelse", + "reschedule_send": "Planlæg om", + "cancel_and_edit": "Annuller og redigér", + "cancel_and_compose_again": "Annuller og skriv igen", + "reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30" }, "email_viewer": { "no_email_selected": "Ingen e-mail valgt", @@ -514,7 +524,17 @@ "collapse": "Skjul detaljer" }, "send": "Send", - "more": "mere" + "more": "mere", + "scheduled_banner": "Planlagt til afsendelse {date}", + "scheduled_send_created": "E-mail planlagt til afsendelse", + "cancel_scheduled_send": "Annuller afsendelse", + "reschedule_send": "Planlæg om", + "cancel_and_edit": "Annuller og redigér", + "cancel_and_compose_again": "Annuller og skriv igen", + "reschedule_prompt": "Indtast en ny dato/tid, f.eks. 2026-05-04T15:30", + "scheduled_actions_only": "Planlagt-visningen understøtter kun planlagte handlinger", + "undo_send_scheduled": "Besked planlagt til afsendelse", + "undo_send": "Fortryd afsendelse" }, "email_composer": { "new_message": "Ny besked", @@ -607,7 +627,17 @@ "send_anyway": "Send alligevel", "back": "Tilbage til redigering" }, - "link_url_prompt": "Indtast URL'en" + "link_url_prompt": "Indtast URL'en", + "schedule_send": "Planlæg afsendelse", + "schedule_send_description": "Vælg hvornår serveren skal frigive denne besked.", + "schedule_send_required": "Vælg en dato og et tidspunkt.", + "schedule_send_invalid": "Indtast en gyldig dato og tid.", + "schedule_send_future": "Vælg en fremtidig dato og tid.", + "schedule_send_too_late": "Dette tidspunkt er senere end serveren tillader.", + "schedule_send_unsupported": "Planlagt afsendelse understøttes ikke for denne konto.", + "schedule_send_cleanup_warning": "Planlagt afsendelse blev oprettet, men oprydning af kladde mislykkedes.", + "send_delay_unsupported": "Sendeforsinkelse understøttes ikke for denne konto.", + "send_delay_unsupported_confirm": "Denne konto understøtter ikke sendeforsinkelse. Send straks i stedet?" }, "confirm_dialog": { "confirm": "Bekræft", @@ -1137,6 +1167,13 @@ "attachment_image_previews": { "label": "Vis billedforhåndsvisninger i vedhæftninger", "description": "Vis billedvedhæftninger som miniaturekort i stedet for generiske filikoner" + }, + "send_delay": { + "label": "Fortryd afsendelse / sendeforsinkelse", + "description": "Forsink normale afsendelser med et kort serverstyret tidsrum.", + "off": "Fra", + "seconds": "{seconds} sekunder", + "unsupported": "Den aktuelle konto annoncerer ikke understøttelse af forsinket afsendelse. Indstillingen gemmes stadig for andre konti." } }, "composer": { @@ -1803,7 +1840,11 @@ "color_tag": "Tag", "remove_color": "Fjern tag", "items_selected": "{count} e-mails valgt", - "edit_draft": "Redigér kladde" + "edit_draft": "Redigér kladde", + "cancel_scheduled_send": "Annuller afsendelse", + "reschedule_send": "Planlæg om", + "cancel_and_edit": "Annuller og redigér", + "cancel_and_compose_again": "Annuller og skriv igen" }, "mailbox_context_menu": { "mark_folder_read": "Markér mappe som læst", @@ -1881,6 +1922,8 @@ "expand_collapse": "Udvid/skjul tråd" }, "composer": { + "send": "Send e-mail", + "schedule_send": "Planlæg afsendelse", "template_picker": "Åbn skabelonvælger" } }, diff --git a/locales/de/common.json b/locales/de/common.json index eb9a913f5..e80b5bd36 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -132,7 +132,8 @@ "mail": "E-Mail", "nav_label": "Navigation", "add_app": "Apps", - "shared": "Geteilt" + "shared": "Geteilt", + "scheduled": "Geplant" }, "protocol_handlers": { "title": "Standard-Apps", @@ -239,7 +240,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", @@ -514,7 +524,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", @@ -607,7 +627,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", @@ -1134,6 +1166,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": { @@ -1803,7 +1842,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", @@ -1881,6 +1924,8 @@ "expand_collapse": "Unterhaltung erweitern/einklappen" }, "composer": { + "send": "E-Mail senden", + "schedule_send": "Senden planen", "template_picker": "Vorlagenauswahl öffnen" } }, diff --git a/locales/en/common.json b/locales/en/common.json index 0468b27e8..a7d3a38c5 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -132,7 +132,8 @@ "shared": "Shared", "mail": "Mail", "nav_label": "Navigation", - "add_app": "Apps" + "add_app": "Apps", + "scheduled": "Scheduled" }, "protocol_handlers": { "title": "Default apps", @@ -239,7 +240,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", @@ -514,7 +524,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", @@ -607,7 +627,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", @@ -1138,6 +1168,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": { @@ -1804,7 +1841,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", @@ -1882,8 +1923,9 @@ "expand_collapse": "Expand/collapse thread" }, "composer": { - "template_picker": "Open template picker", - "send": "Send email" + "send": "Send email", + "schedule_send": "Schedule send", + "template_picker": "Open template picker" } }, "threads": { diff --git a/locales/es/common.json b/locales/es/common.json index 60687c161..e81aaccf3 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -132,7 +132,8 @@ "mail": "Correo", "nav_label": "Navegación", "add_app": "Apps", - "shared": "Compartido" + "shared": "Compartido", + "scheduled": "Programados" }, "protocol_handlers": { "title": "Aplicaciones predeterminadas", @@ -239,7 +240,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", @@ -514,7 +524,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", @@ -607,7 +627,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", @@ -1134,6 +1166,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": { @@ -1803,7 +1842,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", @@ -1881,6 +1924,8 @@ "expand_collapse": "Expandir/contraer conversación" }, "composer": { + "send": "Enviar correo", + "schedule_send": "Programar envío", "template_picker": "Abrir selector de plantillas" } }, diff --git a/locales/fr/common.json b/locales/fr/common.json index 41ca1cf05..8b26ae0c8 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -132,7 +132,8 @@ "mail": "Messagerie", "nav_label": "Navigation", "add_app": "Apps", - "shared": "Partagé" + "shared": "Partagé", + "scheduled": "Planifiés" }, "protocol_handlers": { "title": "Applications par défaut", @@ -239,7 +240,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 l’envoi", + "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é", @@ -514,7 +524,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 l’envoi", + "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 l’envoi" }, "email_composer": { "new_message": "Nouveau message", @@ -607,7 +627,19 @@ }, "add_link": "Ajouter un lien", "link_url_prompt": "Saisissez l'URL", - "sending": "Envoi..." + "sending": "Envoi...", + "schedule_send": "Planifier l’envoi", + "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": "L’envoi planifié n’est pas pris en charge pour ce compte.", + "schedule_send_cleanup_warning": "L’envoi planifié a été créé, mais le nettoyage du brouillon a échoué.", + "send_delay_unsupported": "Le délai d’envoi n’est pas pris en charge pour ce compte.", + "send_delay_unsupported_confirm": "Ce compte ne prend pas en charge le délai d’envoi. Envoyer immédiatement ?", + "attach_photos": "Photos & Videos", + "attach_files": "Files" }, "confirm_dialog": { "confirm": "Confirmer", @@ -1134,6 +1166,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 l’envoi / délai d’envoi", + "description": "Retarde les envois normaux avec une courte fenêtre côté serveur.", + "off": "Désactivé", + "seconds": "{seconds} secondes", + "unsupported": "Le compte actuel n’annonce pas la prise en charge de l’envoi différé. Le réglage reste enregistré pour d’autres comptes." } }, "composer": { @@ -1803,7 +1842,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 l’envoi", + "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", @@ -1881,6 +1924,8 @@ "expand_collapse": "Développer/réduire la conversation" }, "composer": { + "send": "Envoyer l'e-mail", + "schedule_send": "Planifier l'envoi", "template_picker": "Ouvrir le sélecteur de modèles" } }, diff --git a/locales/it/common.json b/locales/it/common.json index e7a0d7e50..4eaa318be 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -132,7 +132,8 @@ "mail": "Posta", "nav_label": "Navigazione", "add_app": "App", - "shared": "Condiviso" + "shared": "Condiviso", + "scheduled": "Programmate" }, "protocol_handlers": { "title": "App predefinite", @@ -239,7 +240,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", @@ -514,7 +524,17 @@ "ai_verdict": "Verdetto IA", "account": "Account", "no_subject": "(nessun oggetto)" - } + }, + "scheduled_banner": "Programmato per l’invio il {date}", + "scheduled_send_created": "E-mail programmata per l’invio", + "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 l’invio", + "undo_send": "Annulla invio" }, "email_composer": { "new_message": "Nuovo messaggio", @@ -607,7 +627,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 un’ora.", + "schedule_send_invalid": "Inserisci una data e un’ora valide.", + "schedule_send_future": "Scegli una data e un’ora future.", + "schedule_send_too_late": "Questo orario supera il limite consentito dal server.", + "schedule_send_unsupported": "L’invio programmato non è supportato per questo account.", + "schedule_send_cleanup_warning": "L’invio 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", @@ -1134,6 +1166,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": "L’account corrente non dichiara il supporto all’invio ritardato. L’impostazione resta salvata per altri account." } }, "composer": { @@ -1803,7 +1842,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", @@ -1881,6 +1924,8 @@ "expand_collapse": "Espandi/comprimi conversazione" }, "composer": { + "send": "Invia email", + "schedule_send": "Programma invio", "template_picker": "Apri selettore modelli" } }, diff --git a/locales/ja/common.json b/locales/ja/common.json index 85ecd13b5..6aa074039 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -132,7 +132,8 @@ "mail": "メール", "nav_label": "ナビゲーション", "add_app": "アプリ", - "shared": "共有" + "shared": "共有", + "scheduled": "予約済み" }, "protocol_handlers": { "title": "既定のアプリ", @@ -239,7 +240,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": "メールが選択されていません", @@ -514,7 +524,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": "新規メッセージ", @@ -607,7 +627,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": "確認", @@ -1134,6 +1166,13 @@ "attachment_image_previews": { "label": "添付ファイル内の画像プレビューを表示", "description": "画像の添付ファイルを汎用ファイルアイコンではなくサムネイルカードとして表示します" + }, + "send_delay": { + "label": "送信取り消し / 送信遅延", + "description": "通常の送信をサーバー側で短時間遅延します。", + "off": "オフ", + "seconds": "{seconds} 秒", + "unsupported": "現在のアカウントは遅延送信のサポートを通知していません。この設定は他のアカウント用に保存されます。" } }, "composer": { @@ -1803,7 +1842,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": "フォルダーを既読にする", @@ -1881,6 +1924,8 @@ "expand_collapse": "スレッドの展開/折りたたみ" }, "composer": { + "send": "メールを送信", + "schedule_send": "送信を予約", "template_picker": "テンプレートピッカーを開く" } }, diff --git a/locales/ko/common.json b/locales/ko/common.json index 5b34dd9a4..5a45aa988 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -132,7 +132,8 @@ "mail": "메일", "nav_label": "내비게이션", "add_app": "앱", - "shared": "공유됨" + "shared": "공유됨", + "scheduled": "예약됨" }, "protocol_handlers": { "title": "기본 앱", @@ -239,7 +240,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": "메일이 선택되지 않았어요", @@ -514,7 +524,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": "새 메시지", @@ -607,7 +627,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": "확인", @@ -1134,6 +1166,13 @@ "attachment_image_previews": { "label": "첨부 파일에 이미지 미리보기 표시", "description": "이미지 첨부 파일을 일반 파일 아이콘 대신 썸네일 카드로 표시합니다" + }, + "send_delay": { + "label": "보내기 취소 / 보내기 지연", + "description": "일반 보내기를 서버 측 짧은 시간 동안 지연합니다.", + "off": "끔", + "seconds": "{seconds}초", + "unsupported": "현재 계정은 지연 보내기 지원을 알리지 않습니다. 설정은 다른 계정을 위해 계속 저장됩니다." } }, "composer": { @@ -1803,7 +1842,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": "폴더를 읽음으로 표시", @@ -1881,6 +1924,8 @@ "expand_collapse": "대화 펼치기/접기" }, "composer": { + "send": "이메일 보내기", + "schedule_send": "보내기 예약", "template_picker": "템플릿 선택기 열기" } }, diff --git a/locales/lv/common.json b/locales/lv/common.json index eb405b2fd..ddc7e420e 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -132,7 +132,8 @@ "mail": "Pasts", "nav_label": "Navigācija", "add_app": "Lietotnes", - "shared": "Koplietots" + "shared": "Koplietots", + "scheduled": "Ieplānots" }, "protocol_handlers": { "title": "Noklusējuma lietotnes", @@ -239,7 +240,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", @@ -514,7 +524,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", @@ -607,7 +627,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", @@ -1134,6 +1166,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": { @@ -1803,7 +1842,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", @@ -1881,6 +1924,8 @@ "expand_collapse": "Izvērst/sairt sarunu" }, "composer": { + "send": "Nosūtīt e-pastu", + "schedule_send": "Ieplānot nosūtīšanu", "template_picker": "Atvērt veidņu izvēli" } }, diff --git a/locales/nl/common.json b/locales/nl/common.json index 653971086..351b56d7b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -132,7 +132,8 @@ "mail": "E-mail", "nav_label": "Navigatie", "add_app": "Apps", - "shared": "Gedeeld" + "shared": "Gedeeld", + "scheduled": "Gepland" }, "protocol_handlers": { "title": "Standaardapps", @@ -239,7 +240,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", @@ -514,7 +524,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", @@ -607,7 +627,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", @@ -1134,6 +1166,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": { @@ -1803,7 +1842,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", @@ -1881,6 +1924,8 @@ "expand_collapse": "Gesprek uitklappen/inklappen" }, "composer": { + "send": "E-mail verzenden", + "schedule_send": "Verzenden plannen", "template_picker": "Sjabloonkiezer openen" } }, diff --git a/locales/pl/common.json b/locales/pl/common.json index 151c4a063..bc3367522 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -132,7 +132,8 @@ "mail": "Poczta", "nav_label": "Nawigacja", "add_app": "Aplikacje", - "shared": "Udostępnione" + "shared": "Udostępnione", + "scheduled": "Zaplanowane" }, "protocol_handlers": { "title": "Aplikacje domyślne", @@ -239,7 +240,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", @@ -514,7 +524,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ść", @@ -607,7 +627,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ź", @@ -1134,6 +1166,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": { @@ -1803,7 +1842,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", @@ -1881,6 +1924,8 @@ "expand_collapse": "Rozwiń/zwiń wątek" }, "composer": { + "send": "Wyślij e-mail", + "schedule_send": "Zaplanuj wysyłkę", "template_picker": "Otwórz wybór szablonu" } }, diff --git a/locales/pt/common.json b/locales/pt/common.json index a6ce35966..9d2f65bf7 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -132,7 +132,8 @@ "mail": "E-mail", "nav_label": "Navegação", "add_app": "Apps", - "shared": "Compartilhado" + "shared": "Compartilhado", + "scheduled": "Agendados" }, "protocol_handlers": { "title": "Aplicativos padrão", @@ -239,7 +240,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", @@ -514,7 +524,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", @@ -607,7 +627,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", @@ -1134,6 +1166,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": { @@ -1803,7 +1842,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", @@ -1881,6 +1924,8 @@ "expand_collapse": "Expandir/recolher conversa" }, "composer": { + "send": "Enviar e-mail", + "schedule_send": "Agendar envio", "template_picker": "Abrir seletor de modelos" } }, diff --git a/locales/ru/common.json b/locales/ru/common.json index 45e2de45b..a2205d4d6 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -132,7 +132,8 @@ "mail": "Почта", "nav_label": "Навигация", "add_app": "Приложения", - "shared": "Общие" + "shared": "Общие", + "scheduled": "Запланировано" }, "protocol_handlers": { "title": "Приложения по умолчанию", @@ -239,7 +240,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": "Письмо не выбрано", @@ -514,7 +524,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": "Новое письмо", @@ -607,7 +627,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": "Подтвердить", @@ -1134,6 +1166,13 @@ "attachment_image_previews": { "label": "Показывать миниатюры изображений во вложениях", "description": "Отображать вложенные изображения в виде миниатюр вместо обычных значков файлов" + }, + "send_delay": { + "label": "Отмена отправки / задержка отправки", + "description": "Задерживает обычную отправку на короткий серверный интервал.", + "off": "Выкл.", + "seconds": "{seconds} сек.", + "unsupported": "Текущая учетная запись не заявляет поддержку отложенной отправки. Настройка сохранится для других учетных записей." } }, "composer": { @@ -1803,7 +1842,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": "Отметить папку как прочитанную", @@ -1881,6 +1924,8 @@ "expand_collapse": "Развернуть/свернуть цепочку" }, "composer": { + "send": "Отправить письмо", + "schedule_send": "Запланировать отправку", "template_picker": "Открыть выбор шаблонов" } }, diff --git a/locales/tr/common.json b/locales/tr/common.json index 7d2f50377..53b49aeb8 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -132,7 +132,8 @@ "shared": "Paylaşılan", "mail": "Posta", "nav_label": "Gezinme", - "add_app": "Uygulamalar" + "add_app": "Uygulamalar", + "scheduled": "Zamanlandı" }, "protocol_handlers": { "title": "Varsayılan uygulamalar", @@ -239,7 +240,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", @@ -514,7 +524,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", @@ -607,7 +627,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", @@ -1134,6 +1166,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": { @@ -1803,7 +1842,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", @@ -1881,6 +1924,8 @@ "expand_collapse": "İleti dizisini genişlet/daralt" }, "composer": { + "send": "E-posta gönder", + "schedule_send": "Göndermeyi planla", "template_picker": "Şablon seçiciyi aç" } }, diff --git a/locales/uk/common.json b/locales/uk/common.json index fd1a458db..5ee0ba1ac 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -132,7 +132,8 @@ "mail": "Пошта", "nav_label": "Навігація", "add_app": "програми", - "shared": "Спільні" + "shared": "Спільні", + "scheduled": "Заплановано" }, "protocol_handlers": { "title": "Програми за замовчуванням", @@ -239,7 +240,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": "Електронна адреса не вибрана", @@ -514,7 +524,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": "Нове повідомлення", @@ -607,7 +627,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": "Підтвердити", @@ -1134,6 +1166,13 @@ "attachment_image_previews": { "label": "Показувати попередній перегляд зображень у вкладеннях", "description": "Відображати вкладені зображення як ескізи замість загальних піктограм файлів" + }, + "send_delay": { + "label": "Скасувати надсилання / затримка надсилання", + "description": "Затримує звичайне надсилання на короткий серверний інтервал.", + "off": "Вимкнено", + "seconds": "{seconds} с", + "unsupported": "Поточний обліковий запис не повідомляє про підтримку відкладеного надсилання. Налаштування залишиться збереженим для інших облікових записів." } }, "composer": { @@ -1803,7 +1842,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": "Позначити папку як прочитану", @@ -1881,6 +1924,8 @@ "expand_collapse": "Розгорнути/згорнути ланцюжок" }, "composer": { + "send": "Надіслати лист", + "schedule_send": "Запланувати надсилання", "template_picker": "Відкрити засіб вибору шаблону" } }, diff --git a/locales/zh/common.json b/locales/zh/common.json index 3de1074e2..804bb0aab 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -132,7 +132,8 @@ "mail": "邮件", "nav_label": "导航", "add_app": "应用", - "shared": "共享" + "shared": "共享", + "scheduled": "已计划" }, "protocol_handlers": { "title": "默认应用", @@ -239,7 +240,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": "未选择邮件", @@ -514,7 +524,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": "新邮件", @@ -607,7 +627,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": "确认", @@ -1134,6 +1166,13 @@ "attachment_image_previews": { "label": "在附件中显示图片预览", "description": "将图片附件显示为缩略图卡片,而不是通用文件图标" + }, + "send_delay": { + "label": "撤销发送 / 发送延迟", + "description": "通过服务器端短暂窗口延迟普通发送。", + "off": "关闭", + "seconds": "{seconds} 秒", + "unsupported": "当前账户未声明支持延迟发送。该设置仍会为其他账户保存。" } }, "composer": { @@ -1803,7 +1842,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": "将文件夹标记为已读", @@ -1881,6 +1924,8 @@ "expand_collapse": "展开/折叠会话" }, "composer": { + "send": "发送邮件", + "schedule_send": "定时发送", "template_picker": "打开模板选择器" } }, diff --git a/package-lock.json b/package-lock.json index 243d9a05a..4a9d51409 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5971,7 +5971,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7643,17 +7642,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", - "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", diff --git a/stores/email-store.ts b/stores/email-store.ts index 4e7d7a86e..7c7ce7c0d 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { Email, Mailbox, StateChange } 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,17 @@ import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, adv import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; +type ScheduledSubmissionMetadata = { + submissionId: string; + sendAt: string; + identityId: string; + undoStatus: 'pending' | 'final' | 'canceled'; +}; + +const VIRTUAL_SCHEDULED_MAILBOX_ID = '__scheduled__'; + +type PendingUndoSend = { submissionId: string; emailId?: string; sendAt: string; isSmime: boolean }; + interface EmailStore { emails: Email[]; mailboxes: Mailbox[]; @@ -67,6 +78,17 @@ interface EmailStore { unifiedErrors: Map; // accountId -> error message unifiedCounts: UnifiedMailboxCounts[]; + // Scheduled send state + scheduledEmails: ScheduledEmail[]; + scheduledEmailIds: Set; + scheduledSubmissionByEmailId: Map; + scheduledTotal: number; + scheduledHasMore: boolean; + scheduledNextPosition: number; + isLoadingScheduled: boolean; + isScheduledView: boolean; + pendingUndoSend: PendingUndoSend | null; + setEmails: (emails: Email[]) => void; setMailboxes: (mailboxes: Mailbox[]) => void; /** Cache or update the mailbox list for a specific account. */ @@ -111,8 +133,8 @@ interface EmailStore { loadMoreEmails: (client: IJMAPClient) => Promise; fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise; fetchQuota: (client: IJMAPClient) => Promise; - sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], envelopeMailFrom?: string) => Promise; - sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise; + sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string) => Promise; + sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise; deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise; moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; @@ -185,6 +207,16 @@ interface EmailStore { refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise; exitUnifiedView: () => void; + fetchScheduledEmails: (client: IJMAPClient) => Promise; + loadMoreScheduledEmails: (client: IJMAPClient) => Promise; + cancelScheduledEmail: (client: IJMAPClient, submissionId: string, emailId?: string) => Promise; + cancelScheduledEmailForEdit: (client: IJMAPClient, email: ScheduledEmail | Email) => Promise; + rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, delayedUntil: string) => Promise; + cancelUndoSend: (client: IJMAPClient, pending: PendingUndoSend) => Promise; + clearPendingUndoSend: () => void; + refreshScheduledMetadata: (client: IJMAPClient) => Promise; + setScheduledView: (isScheduledView: boolean) => void; + // Mock data for demo loadMockData: () => void; } @@ -219,6 +251,38 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId])); } +function annotateScheduledEmails( + emails: Email[], + scheduledSubmissionByEmailId: Map +): Email[] { + if (scheduledSubmissionByEmailId.size === 0) return emails; + return emails.map(email => annotateScheduledEmail(email, scheduledSubmissionByEmailId)); +} + +function annotateScheduledEmail( + email: Email, + scheduledSubmissionByEmailId: Map +): 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 pendingSendTime = new Date(pending.sendAt).getTime(); + if (Number.isFinite(pendingSendTime) && pendingSendTime <= Date.now()) return true; + const scheduledEmail = scheduledEmails.find(email => email.emailSubmissionId === pending.submissionId); + return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending'; +} + /** * When the mail view is showing a non-active account (Pro shell's * Thunderbird-style sidebar), redirect read/write operations to that @@ -244,12 +308,6 @@ function resolveActionMailboxes(): Mailbox[] { return state.mailboxes; } -/** - * After a mailbox-list mutation (create/rename/delete/etc.), refresh the - * cache for whichever account we're operating on. Writes the result to the - * standard `mailboxes` slot for the active account, or the per-account - * cache for non-active accounts so the Pro sidebar stays in sync. - */ /** * Builds the `UnifiedAccountClient[]` list used by every unified fan-out * action (browse, load-more, search). Each entry has a JMAP client plus a @@ -305,6 +363,12 @@ export async function buildUnifiedAccountClients( return built; } +/** + * After a mailbox-list mutation (create/rename/delete/etc.), refresh the + * cache for whichever account we're operating on. Writes the result to the + * standard `mailboxes` slot for the active account, or the per-account + * cache for non-active accounts so the Pro sidebar stays in sync. + */ async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise { const viewingId = useEmailStore.getState().viewingAccountId; const client = resolveActionClient(fallbackClient); @@ -387,6 +451,17 @@ export const useEmailStore = create((set, get) => ({ unifiedErrors: new Map(), unifiedCounts: [], + // Scheduled send state + scheduledEmails: [], + scheduledEmailIds: new Set(), + scheduledSubmissionByEmailId: new Map(), + scheduledTotal: 0, + scheduledHasMore: false, + scheduledNextPosition: 0, + isLoadingScheduled: false, + isScheduledView: false, + pendingUndoSend: null, + // Spam undo cache spamUndoCache: new Map(), @@ -516,7 +591,8 @@ export const useEmailStore = create((set, get) => ({ // Auto-select inbox if no mailbox is selected or the current selection // doesn't exist in the fetched list (e.g. after an account switch) const currentSelectedMailbox = get().selectedMailbox; - const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox); + const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID + || (currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox)); const loadingPatch = isInitialLoad ? { isLoading: false } : {}; if (!selectionValid) { // Find inbox from PRIMARY account (not shared accounts) @@ -568,6 +644,11 @@ export const useEmailStore = create((set, get) => ({ set({ isLoading: true, error: null }); // Keep previous emails visible during transition try { const targetMailboxId = mailboxId || get().selectedMailbox; + if (targetMailboxId === VIRTUAL_SCHEDULED_MAILBOX_ID) { + set({ isLoading: false, emails: [], hasMoreEmails: false, totalEmails: 0 }); + await get().fetchScheduledEmails(client); + return; + } const effectiveClient = resolveActionClient(client); // Find the mailbox to get its accountId (for shared folder support) @@ -589,7 +670,7 @@ export const useEmailStore = create((set, get) => ({ // all folders that carry the tag are returned. const result = await effectiveClient.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 @@ -657,6 +738,12 @@ export const useEmailStore = create((set, get) => ({ set({ isLoadingMore: true, error: null }); try { + if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) { + set({ isLoadingMore: false }); + await get().loadMoreScheduledEmails(client); + return; + } + const effectiveClient = resolveActionClient(client); // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; @@ -702,7 +789,7 @@ export const useEmailStore = create((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], @@ -732,7 +819,9 @@ export const useEmailStore = create((set, get) => ({ const email = await resolveActionClient(client).getEmail(emailId, accountId); if (email) { - set({ selectedEmail: email }); + const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId); + set({ selectedEmail: annotatedEmail }); + return annotatedEmail; } return email; } catch (error) { @@ -752,12 +841,18 @@ export const useEmailStore = create((set, get) => ({ } }, - sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom) => { + sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom) => { set({ isLoading: true, error: null }); try { - await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom); + const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom); // 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", @@ -767,15 +862,21 @@ export const useEmailStore = create((set, get) => ({ } }, - sendRawEmail: async (client, rawMimeBlob, identityId) => { + sendRawEmail: async (client, rawMimeBlob, identityId, delayedUntil, envelopeRecipients) => { 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, delayedUntil, envelopeRecipients); + 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", @@ -1325,7 +1426,7 @@ export const useEmailStore = create((set, get) => ({ const result = await resolveActionClient(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, @@ -1400,7 +1501,7 @@ export const useEmailStore = create((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, @@ -1930,6 +2031,13 @@ export const useEmailStore = create((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); @@ -1977,6 +2085,11 @@ export const useEmailStore = create((set, get) => ({ // Only refresh if a mailbox is currently selected if (!selectedMailbox) return; + if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) { + await get().fetchScheduledEmails(client); + return; + } + try { // Fetch emails for the current mailbox without clearing the list first // This provides a smoother update experience @@ -2003,6 +2116,7 @@ export const useEmailStore = create((set, get) => ({ } const currentEmails = get().emails; + const previousTotal = get().totalEmails; // Only notify for genuinely new incoming mail in the Inbox. // Without these guards the toast/sound also fires when sending, @@ -2020,15 +2134,19 @@ export const useEmailStore = create((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)); + // existing emails beyond that page (if any), skipping duplicates. Do not + // append the whole previous list: drafts are saved as destroy+create, so + // the old draft can disappear from the refreshed first page and must not + // be reintroduced from stale local state. + const merged: Email[] = [...refreshedEmails]; + const mergedIds = new Set(refreshedEmails.map((e: Email) => e.id)); + const insertedCount = Math.max((result.total || 0) - previousTotal, 0); + const appendFromIndex = Math.max(refreshedEmails.length - insertedCount, 0); - for (const email of currentEmails) { + for (const email of currentEmails.slice(appendFromIndex)) { if (!mergedIds.has(email.id)) { merged.push(email); mergedIds.add(email.id); @@ -2384,6 +2502,208 @@ export const useEmailStore = create((set, get) => ({ }); }, + setScheduledView: (isScheduledView) => set(state => { + const leavingScheduled = !isScheduledView && state.selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID; + return { + isScheduledView, + selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox, + selectedEmail: leavingScheduled ? null : state.selectedEmail, + selectedEmailIds: leavingScheduled ? new Set() : state.selectedEmailIds, + }; + }), + 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, + scheduledNextPosition: result.nextPosition, + 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, + scheduledNextPosition: 0, + isLoadingScheduled: false, + }); + } + }, + + loadMoreScheduledEmails: async (client) => { + const { isLoadingScheduled, scheduledHasMore, scheduledEmails, scheduledNextPosition } = get(); + if (isLoadingScheduled || !scheduledHasMore) return; + set({ isLoadingScheduled: true, error: null }); + try { + const emailsPerPage = useSettingsStore.getState().emailsPerPage; + const result = await client.getScheduledEmails(emailsPerPage, scheduledNextPosition); + 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, + scheduledNextPosition: result.nextPosition, + 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 emailsPerPage = useSettingsStore.getState().emailsPerPage; + const allEmails: ScheduledEmail[] = []; + let position = 0; + let hasMore = true; + let total = 0; + while (hasMore) { + const page = await client.getScheduledEmails(emailsPerPage, position); + allEmails.push(...page.emails.filter(email => !allEmails.some(existing => existing.id === email.id))); + total = page.total; + hasMore = page.hasMore && page.nextPosition > position; + position = page.nextPosition; + } + const pendingUndoSend = get().pendingUndoSend; + set({ + scheduledEmails: get().isScheduledView ? allEmails : get().scheduledEmails, + scheduledEmailIds: new Set(allEmails.map(email => email.id)), + scheduledSubmissionByEmailId: new Map(allEmails.map(email => [email.id, { + submissionId: email.emailSubmissionId, + sendAt: email.scheduledSendAt, + identityId: email.scheduledIdentityId, + undoStatus: email.scheduledUndoStatus, + }])), + scheduledTotal: total, + scheduledHasMore: false, + scheduledNextPosition: position, + pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, allEmails) ? null : pendingUndoSend, + }); + } catch (error) { + console.error('Failed to refresh scheduled metadata:', error); + } + }, + + cancelScheduledEmail: async (client, submissionId, emailId) => { + await client.cancelEmailSubmission(submissionId); + if (emailId) { + await client.deleteEmail(emailId); + set(state => ({ + selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail, + selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== emailId)), + })); + } + 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); + if (get().pendingUndoSend?.submissionId === submissionId) { + set({ pendingUndoSend: null }); + } + if (email.isSmimeScheduled) { + await client.deleteEmail(email.id); + set(state => ({ + selectedEmail: state.selectedEmail?.id === email.id ? null : state.selectedEmail, + selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== email.id)), + })); + await get().fetchScheduledEmails(client); + return null; + } + 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, delayedUntil) => { + let result: SendEmailResult | undefined; + try { + result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, delayedUntil); + const pendingUndoSend = get().pendingUndoSend; + if (pendingUndoSend?.submissionId === submissionId) { + set({ pendingUndoSend: { ...pendingUndoSend, submissionId: result.emailSubmissionId || submissionId, sendAt: result.sendAt || delayedUntil } }); + } + return result; + } finally { + await get().fetchScheduledEmails(client); + if (result && get().selectedEmail?.id === emailId) { + const refreshed = get().scheduledEmails.find(email => email.id === emailId); + set(state => ({ + selectedEmail: refreshed || (state.selectedEmail ? { + ...state.selectedEmail, + emailSubmissionId: result?.emailSubmissionId || submissionId, + scheduledSendAt: result?.sendAt || delayedUntil, + scheduledIdentityId: identityId, + scheduledUndoStatus: 'pending' as const, + isScheduled: true, + } : state.selectedEmail), + })); + } + } + }, + + cancelUndoSend: async (client, pending) => { + await client.cancelEmailSubmission(pending.submissionId); + if (pending.emailId && pending.isSmime) { + await client.deleteEmail(pending.emailId); + set(state => ({ + selectedEmail: state.selectedEmail?.id === pending.emailId ? null : state.selectedEmail, + selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== pending.emailId)), + })); + } else if (pending.emailId) { + 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 && !pending.isSmime ? client.getEmail(pending.emailId) : null; + }, + loadMockData: () => { const mockEmails: Email[] = [ { diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 08bc4a056..bfc5010ab 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -41,6 +41,7 @@ export type ToolbarPosition = 'top' | 'below-subject'; export type ArchiveMode = 'single' | 'year' | 'month'; export type MailLayout = 'split' | 'focus' | 'horizontal'; export type CalendarHoverPreview = 'off' | 'instant' | 'delay-500ms' | 'delay-1s' | 'delay-2s'; +export type SendDelaySeconds = 0 | 10 | 30 | 60; export type ProtocolOpenMode = 'active-session' | 'new-tab'; /** @@ -153,6 +154,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; signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter @@ -327,6 +329,7 @@ const DEFAULT_SETTINGS = { autoSelectReplyIdentity: false, plainTextMode: false, subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, + sendDelaySeconds: 0 as SendDelaySeconds, signaturePosition: 'below_quote' as SignaturePosition, signatureSeparatorEnabled: true, @@ -520,6 +523,7 @@ export const useSettingsStore = create()( autoSelectReplyIdentity: state.autoSelectReplyIdentity, plainTextMode: state.plainTextMode, subAddressDelimiter: state.subAddressDelimiter, + sendDelaySeconds: state.sendDelaySeconds, signaturePosition: state.signaturePosition, signatureSeparatorEnabled: state.signatureSeparatorEnabled, sessionTimeout: state.sessionTimeout, @@ -589,6 +593,10 @@ export const useSettingsStore = create()( if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) { return; } + if (key === 'sendDelaySeconds' && ![0, 10, 30, 60].includes(settings[key])) { + set({ sendDelaySeconds: 0 }); + return; + } if (DEVICE_LOCAL_SETTING_KEYS.has(key)) { return; } @@ -767,6 +775,9 @@ export const useSettingsStore = create()( state.density = state.listDensity; delete state.listDensity; } + if (![0, 10, 30, 60].includes(state.sendDelaySeconds as number)) { + state.sendDelaySeconds = 0; + } if (version < 3 && typeof state.protocolOpenMode !== 'string' && typeof state.protocolMailtoOpenMode === 'string') { state.protocolOpenMode = state.protocolMailtoOpenMode; } From 4ff05bd4ecdebc400dfa684fa9838089cd785222 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 28 May 2026 19:21:44 +0200 Subject: [PATCH 006/437] fix: editable HTML signature in new mail; clean state on every compose entry #329 --- app/(main)/[locale]/page.tsx | 19 +++++++++ components/email/email-composer.tsx | 62 ++++++++++++++++++++++++----- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 24db868df..ae57e8ece 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -608,6 +608,7 @@ export default function Home() { } }, onCompose: () => { + startFreshComposerSession(); setComposerMode('compose'); setShowComposer(true); if (isMobile) setActiveView('viewer'); @@ -1219,6 +1220,15 @@ export default function Home() { } }, [tCommon]); + // Force a clean composer remount on every fresh entry point so prior + // compose state can't bleed into the new session (#329 C). The composer is + // keyed on composerSessionId, and pendingDraft would otherwise pin the + // composer to a stale draft from a discarded reply. + const startFreshComposerSession = useCallback(() => { + setComposerSessionId(id => id + 1); + setPendingDraft(null); + }, []); + const handleReply = async (draftText?: string) => { if (selectedEmail) { const ok = await emailHooks.onBeforeReply.intercept({ @@ -1231,6 +1241,7 @@ export default function Home() { } else { setComposerQuoteHeader(null); } + startFreshComposerSession(); setComposerDraftText(draftText || ""); setComposerMode('reply'); setShowComposer(true); @@ -1340,6 +1351,7 @@ export default function Home() { } else { setComposerQuoteHeader(null); } + startFreshComposerSession(); setComposerMode('replyAll'); setShowComposer(true); if (isMobile) setActiveView('viewer'); @@ -1357,6 +1369,7 @@ export default function Home() { } else { setComposerQuoteHeader(null); } + startFreshComposerSession(); setComposerMode('forward'); setShowComposer(true); if (isMobile) setActiveView('viewer'); @@ -2278,6 +2291,7 @@ export default function Home() { const handleConversationReply = async (email: Email) => { selectEmail(email); await prepareComposerQuoteHeader(email, 'reply'); + startFreshComposerSession(); setComposerMode('reply'); setShowComposer(true); if (isMobile) setActiveView('viewer'); @@ -2286,6 +2300,7 @@ export default function Home() { const handleConversationReplyAll = async (email: Email) => { selectEmail(email); await prepareComposerQuoteHeader(email, 'replyAll'); + startFreshComposerSession(); setComposerMode('replyAll'); setShowComposer(true); if (isMobile) setActiveView('viewer'); @@ -2294,6 +2309,7 @@ export default function Home() { const handleConversationForward = async (email: Email) => { selectEmail(email); await prepareComposerQuoteHeader(email, 'forward'); + startFreshComposerSession(); setComposerMode('forward'); setShowComposer(true); if (isMobile) setActiveView('viewer'); @@ -2421,6 +2437,7 @@ export default function Home() { onImportEmail={handleImportEmailFromContextMenu} onRefreshMailboxes={handleRefreshMailboxes} onCompose={() => { + startFreshComposerSession(); setComposerMode('compose'); setShowComposer(true); if (isMobile) { @@ -2805,6 +2822,7 @@ export default function Home() { {/* Floating Compose Button */}
+
+
+

Push Relay

+

Override the Web Push relay URL shown in user notification settings. Leave empty to use the built-in default.

+
+
+ setPushRelayUrl(e.target.value)} + placeholder="https://notifications.relay.example.com" + className="w-full rounded border border-input bg-background px-3 py-2 text-sm" + /> + +
+
+ {categories.map(category => (
diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index 56c83c3e0..f4874804c 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -7,7 +7,7 @@ import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-s import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; import { Button } from '@/components/ui/button'; -import { CheckCircle2, Volume2, XCircle } from 'lucide-react'; +import { CheckCircle2, Lock, Volume2, XCircle } from 'lucide-react'; import { usePolicyStore } from '@/stores/policy-store'; import { useAuthStore } from '@/stores/auth-store'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -40,16 +40,30 @@ export function NotificationSettings() { updateSetting, } = useSettingsStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore(); + const adminPushRelayUrl = usePolicyStore((s) => s.policy.pushRelayUrl); + const pushRelayLocked = usePolicyStore((s) => s.policy.pushRelayUrlLocked) === true; const client = useAuthStore((s) => s.client); const username = useAuthStore((s) => s.username); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const supported = typeof window !== 'undefined' && isWebPushSupported(); - const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL); + const adminUrl = (adminPushRelayUrl ?? '').trim(); + const [relayUrl, setRelayUrl] = useState(adminUrl || DEFAULT_RELAY_BASE_URL); const [pushStatus, setPushStatus] = useState( supported ? { kind: 'idle' } : { kind: 'unsupported' }, ); + // Pull the admin-configured URL into local state when policy loads/changes. + // When locked, the admin value always wins; when only set (not locked), use + // it as the initial default but let the user override. + useEffect(() => { + if (pushRelayLocked && adminUrl) { + setRelayUrl(adminUrl); + } else if (adminUrl) { + setRelayUrl((current) => (current === DEFAULT_RELAY_BASE_URL ? adminUrl : current)); + } + }, [adminUrl, pushRelayLocked]); + useEffect(() => { if (!supported) return; if (!client) return; @@ -125,12 +139,17 @@ export function NotificationSettings() {
-
-

{t('push.relay_desc')}

+

+ {pushRelayLocked ? t('push.relay_locked_desc') : t('push.relay_desc')} +

setRelayUrl(e.target.value)} placeholder={t('push.relay_placeholder')} - disabled={busy || pushStatus.kind === 'unsupported'} + disabled={busy || pushStatus.kind === 'unsupported' || pushRelayLocked} + readOnly={pushRelayLocked} className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50" />
diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 57e8535bd..09d496c95 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -106,6 +106,10 @@ export interface SettingsPolicy { approvedPlugins: string[]; /** Theme IDs that are force-enabled (users cannot deactivate) */ forceEnabledThemes: string[]; + /** Web Push relay base URL shown to users. Empty means the built-in default. */ + pushRelayUrl?: string; + /** When true, users cannot change pushRelayUrl in notification settings. */ + pushRelayUrlLocked?: boolean; } export const DEFAULT_POLICY: SettingsPolicy = { @@ -116,6 +120,8 @@ export const DEFAULT_POLICY: SettingsPolicy = { forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + pushRelayUrl: '', + pushRelayUrlLocked: false, }; export interface AuditEntry { diff --git a/locales/cs/common.json b/locales/cs/common.json index 6a9894091..7a9eb55df 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -955,6 +955,8 @@ "reenable": "Znovu zaregistrovat", "relay_desc": "Výchozí je hostovaný relay Bulwark. Změňte pouze pokud používáte vlastní hosting.", "relay_label": "Push relay", + "relay_locked": "Nastaveno administrátorem", + "relay_locked_desc": "URL push relay byla nastavena administrátorem a nelze ji změnit.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Aktivní na tomto zařízení", "status_busy": "Pracuji…", diff --git a/locales/en/common.json b/locales/en/common.json index a7d3a38c5..1dbba5934 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -927,6 +927,8 @@ "description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.", "relay_label": "Push relay", "relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.", + "relay_locked": "Set by administrator", + "relay_locked_desc": "The push relay URL has been set by your administrator and cannot be changed.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Active on this device", "status_inactive": "Not enabled on this device", diff --git a/locales/nl/common.json b/locales/nl/common.json index 351b56d7b..43679c438 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -955,6 +955,8 @@ "reenable": "Opnieuw registreren", "relay_desc": "Gebruikt standaard de gehoste Bulwark-relay. Wijzig alleen als je zelf hostt.", "relay_label": "Push-relay", + "relay_locked": "Ingesteld door beheerder", + "relay_locked_desc": "De push-relay-URL is ingesteld door je beheerder en kan niet worden gewijzigd.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Actief op dit apparaat", "status_busy": "Bezig…", From 1da04c254bf60d7b39899b3adf6a1d2fcc488ce5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 28 May 2026 20:06:47 +0200 Subject: [PATCH 008/437] feat: per-domain branding overrides on /api/config, manifest, pwa-icon #332 --- app/api/admin/config/route.ts | 20 ++++ app/api/config/route.ts | 102 +++++++++++------ app/api/pwa-icon/[size]/route.ts | 15 ++- app/manifest.ts | 42 +++++-- lib/__tests__/config-route.test.ts | 83 +++++++++++++- lib/__tests__/domain-branding.test.ts | 151 ++++++++++++++++++++++++++ lib/admin/domain-branding.ts | 144 ++++++++++++++++++++++++ lib/admin/types.ts | 1 + 8 files changed, 509 insertions(+), 49 deletions(-) create mode 100644 lib/__tests__/domain-branding.test.ts create mode 100644 lib/admin/domain-branding.ts diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index cf89f033f..a5c8a7744 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -4,6 +4,7 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { CONFIG_ENV_MAP, SENSITIVE_CONFIG_KEYS } from '@/lib/admin/types'; import { parseJmapServers } from '@/lib/admin/jmap-servers'; +import { parseDomainBranding } from '@/lib/admin/domain-branding'; import { logger } from '@/lib/logger'; // Strings that count as "no real secret configured" - used so the dashboard @@ -88,6 +89,25 @@ export async function PATCH(request: NextRequest) { updates.jmapServers = sanitized; } + // Normalize domainBranding: drop entries with an invalid/missing host or + // duplicate hosts before persisting. Each entry's branding field strings + // are passed through unchanged (URL/string content is the operator's + // responsibility, same as the flat branding fields). + if ('domainBranding' in updates) { + const incoming = updates.domainBranding; + if (incoming != null && !Array.isArray(incoming)) { + return NextResponse.json({ error: 'domainBranding must be an array' }, { status: 400 }); + } + const sanitized = parseDomainBranding(incoming); + const incomingCount = Array.isArray(incoming) ? incoming.length : 0; + if (sanitized.length !== incomingCount) { + return NextResponse.json({ + error: 'One or more domainBranding entries are invalid (each needs a unique, valid host).', + }, { status: 400 }); + } + updates.domainBranding = sanitized; + } + // Get old values for audit const oldValues: Record = {}; for (const key of Object.keys(updates)) { diff --git a/app/api/config/route.ts b/app/api/config/route.ts index bef6b80ab..d0c792557 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -1,9 +1,15 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { configManager } from '@/lib/admin/config-manager'; import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers'; import { hasSessionSecret } from '@/lib/auth/session-secret'; import { getOauthScopes } from '@/lib/oauth/tokens'; +import { + matchDomainBranding, + parseDomainBranding, + pickRequestHost, + type BrandingOverrideKey, +} from '@/lib/admin/domain-branding'; /** * Runtime configuration endpoint @@ -13,49 +19,73 @@ import { getOauthScopes } from '@/lib/oauth/tokens'; * post-build configuration for Docker deployments. * * Priority order: - * 1. Admin dashboard overrides (data/admin/config.json) - * 2. Runtime env vars (APP_NAME, JMAP_SERVER_URL) - * 3. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL) - * 4. Default values + * 1. Per-domain branding override (admin-configured, matched on request host) + * 2. Admin dashboard overrides (data/admin/config.json) + * 3. Runtime env vars (APP_NAME, JMAP_SERVER_URL) + * 4. Build-time env vars (NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_JMAP_SERVER_URL) + * 5. Default values */ -export async function GET() { +export async function GET(request: NextRequest) { logger.debug('Config requested'); await configManager.ensureLoaded(); - const appName = configManager.get('appName') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail'; + const host = pickRequestHost(request); + const domainOverrides = matchDomainBranding( + host, + parseDomainBranding(configManager.get('domainBranding', [])), + ); + + // Per-domain override wins over the global value, but only when the + // entry explicitly sets that key. Otherwise we fall through to the + // global admin/env/default chain. + const branded = (key: BrandingOverrideKey, fallback: T): T => { + const override = domainOverrides[key]; + if (typeof override === 'string' && override.length > 0) return override as T; + return configManager.get(key, fallback); + }; + + const appName = + branded('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail'; const jmapServerUrl = configManager.get('jmapServerUrl') || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || ''; const oauthEnabled = configManager.get('oauthEnabled', false); const oauthOnly = oauthEnabled && configManager.get('oauthOnly', false); const stalwartFeaturesEnabled = configManager.get('stalwartFeaturesEnabled', true); const allowedFrameAncestors = configManager.get('allowedFrameAncestors', ''); - return NextResponse.json({ - appName, - jmapServerUrl, - oauthEnabled, - oauthOnly, - oauthClientId: configManager.get('oauthClientId', ''), - oauthIssuerUrl: configManager.get('oauthIssuerUrl', ''), - oauthScopes: getOauthScopes(), - rememberMeEnabled: hasSessionSecret(), - settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && hasSessionSecret(), - stalwartFeaturesEnabled, - devMode: configManager.get('devMode', false), - faviconUrl: configManager.get('faviconUrl', '/branding/Bulwark_Favicon.svg'), - appLogoLightUrl: configManager.get('appLogoLightUrl', ''), - appLogoDarkUrl: configManager.get('appLogoDarkUrl', ''), - loginLogoLightUrl: configManager.get('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'), - loginLogoDarkUrl: configManager.get('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'), - loginCompanyName: configManager.get('loginCompanyName', ''), - loginImprintUrl: configManager.get('loginImprintUrl', ''), - loginPrivacyPolicyUrl: configManager.get('loginPrivacyPolicyUrl', ''), - loginWebsiteUrl: configManager.get('loginWebsiteUrl', ''), - demoMode: configManager.get('demoMode', false), - allowCustomJmapEndpoint: configManager.get('allowCustomJmapEndpoint', false), - jmapServers: redactJmapServers(parseJmapServers(configManager.get('jmapServers', []))), - jmapServerAutoPickByDomain: configManager.get('jmapServerAutoPickByDomain', false), - autoSsoEnabled: configManager.get('autoSsoEnabled', false), - embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", - parentOrigin: configManager.get('parentOrigin', ''), - }); + return NextResponse.json( + { + appName, + jmapServerUrl, + oauthEnabled, + oauthOnly, + oauthClientId: configManager.get('oauthClientId', ''), + oauthIssuerUrl: configManager.get('oauthIssuerUrl', ''), + oauthScopes: getOauthScopes(), + rememberMeEnabled: hasSessionSecret(), + settingsSyncEnabled: configManager.get('settingsSyncEnabled', false) && hasSessionSecret(), + stalwartFeaturesEnabled, + devMode: configManager.get('devMode', false), + faviconUrl: branded('faviconUrl', '/branding/Bulwark_Favicon.svg'), + appLogoLightUrl: branded('appLogoLightUrl', ''), + appLogoDarkUrl: branded('appLogoDarkUrl', ''), + loginLogoLightUrl: branded('loginLogoLightUrl', '/branding/Bulwark_Logo_Color.svg'), + loginLogoDarkUrl: branded('loginLogoDarkUrl', '/branding/Bulwark_Logo_White.svg'), + loginCompanyName: branded('loginCompanyName', ''), + loginImprintUrl: branded('loginImprintUrl', ''), + loginPrivacyPolicyUrl: branded('loginPrivacyPolicyUrl', ''), + loginWebsiteUrl: branded('loginWebsiteUrl', ''), + demoMode: configManager.get('demoMode', false), + allowCustomJmapEndpoint: configManager.get('allowCustomJmapEndpoint', false), + jmapServers: redactJmapServers(parseJmapServers(configManager.get('jmapServers', []))), + jmapServerAutoPickByDomain: configManager.get('jmapServerAutoPickByDomain', false), + autoSsoEnabled: configManager.get('autoSsoEnabled', false), + embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", + parentOrigin: configManager.get('parentOrigin', ''), + }, + { + // Branding varies by host, so any cache between us and the browser + // must key its entry by the host headers we consulted. + headers: { Vary: 'Host, X-Forwarded-Host' }, + }, + ); } diff --git a/app/api/pwa-icon/[size]/route.ts b/app/api/pwa-icon/[size]/route.ts index 54ad6c4a4..52870b185 100644 --- a/app/api/pwa-icon/[size]/route.ts +++ b/app/api/pwa-icon/[size]/route.ts @@ -4,6 +4,11 @@ import path from 'node:path'; import { readFile } from 'node:fs/promises'; import { configManager } from '@/lib/admin/config-manager'; import { getConfigDir } from '@/lib/admin/paths'; +import { + matchDomainBranding, + parseDomainBranding, + pickRequestHost, +} from '@/lib/admin/domain-branding'; const VALID_SIZES = new Set([192, 512]); @@ -33,7 +38,7 @@ async function fetchSourceImage(iconUrl: string): Promise { } export async function GET( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ size: string }> } ) { const { size: sizeParam } = await params; @@ -44,8 +49,15 @@ export async function GET( } await configManager.ensureLoaded(); + const host = pickRequestHost(req); + const domainOverrides = matchDomainBranding( + host, + parseDomainBranding(configManager.get('domainBranding', [])), + ); const sources = configManager.getAllWithSources(); const iconUrl = + domainOverrides.pwaIconUrl || + domainOverrides.faviconUrl || (sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') || (sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : ''); if (!iconUrl) { @@ -55,6 +67,7 @@ export async function GET( const pngHeaders = { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400', + Vary: 'Host, X-Forwarded-Host', }; const cacheKey = `${size}|${iconUrl}`; diff --git a/app/manifest.ts b/app/manifest.ts index 16c868819..d7046d5e4 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -1,5 +1,12 @@ import type { MetadataRoute } from "next"; +import { headers } from "next/headers"; import { configManager } from "@/lib/admin/config-manager"; +import { + matchDomainBranding, + parseDomainBranding, + pickRequestHost, + type BrandingOverrideKey, +} from "@/lib/admin/domain-branding"; export const dynamic = "force-dynamic"; @@ -25,25 +32,40 @@ const withBase = (p: string) => `${BASE_PATH}${p}`; export default async function manifest(): Promise { await configManager.ensureLoaded(); + const host = pickRequestHost(await headers()); + const domainOverrides = matchDomainBranding( + host, + parseDomainBranding(configManager.get("domainBranding", [])), + ); + const branded = (key: BrandingOverrideKey, fallback: T): T => { + const override = domainOverrides[key]; + if (typeof override === "string" && override.length > 0) return override as T; + return configManager.get(key, fallback); + }; + const appName = - configManager.get("appName") || + branded("appName", "") || process.env.NEXT_PUBLIC_APP_NAME || "Bulwark Webmail"; - const shortName = configManager.get("appShortName") || appName; + const shortName = branded("appShortName", "") || appName; const description = - configManager.get("appDescription") || + branded("appDescription", "") || "A modern webmail client built for Stalwart Mail Server"; - const themeColor = configManager.get("pwaThemeColor") || "#ffffff"; - const backgroundColor = configManager.get("pwaBackgroundColor") || "#ffffff"; + const themeColor = branded("pwaThemeColor", "") || "#ffffff"; + const backgroundColor = branded("pwaBackgroundColor", "") || "#ffffff"; - // If pwaIconUrl or faviconUrl was explicitly configured (admin override or - // env var), serve dynamically resized PNGs via /api/pwa-icon/[size]. - // Otherwise fall back to the static Bulwark PNGs - sources marked "default" - // are the built-in placeholder paths and not real custom icons. + // If pwaIconUrl or faviconUrl was explicitly configured (admin override, + // env var, or per-domain override), serve dynamically resized PNGs via + // /api/pwa-icon/[size]. Otherwise fall back to the static Bulwark PNGs - + // sources marked "default" are the built-in placeholder paths and not + // real custom icons. const sources = configManager.getAllWithSources(); const hasCustomIcon = - sources.pwaIconUrl?.source !== "default" || sources.faviconUrl?.source !== "default"; + !!domainOverrides.pwaIconUrl || + !!domainOverrides.faviconUrl || + sources.pwaIconUrl?.source !== "default" || + sources.faviconUrl?.source !== "default"; const icons: MetadataRoute.Manifest["icons"] = hasCustomIcon ? [ diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts index 414ae7ae8..7787ec1df 100644 --- a/lib/__tests__/config-route.test.ts +++ b/lib/__tests__/config-route.test.ts @@ -36,16 +36,29 @@ describe('config API route', () => { delete process.env.LOGIN_IMPRINT_URL; delete process.env.LOGIN_PRIVACY_POLICY_URL; delete process.env.LOGIN_WEBSITE_URL; + delete process.env.DOMAIN_BRANDING; }); afterEach(() => { process.env = { ...originalEnv }; }); - async function getConfig() { + function mockRequest(headers: Record = {}): unknown { + const lc: Record = {}; + for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v; + return { + headers: { + get(name: string) { + return lc[name.toLowerCase()] ?? null; + }, + }, + }; + } + + async function getConfig(headers?: Record) { // Re-import to pick up env changes const { GET } = await import('@/app/api/config/route'); - const response = await GET(); + const response = await GET(mockRequest(headers) as Parameters[0]); return response.json(); } @@ -192,4 +205,70 @@ describe('config API route', () => { expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg'); expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.svg'); }); + + describe('per-domain branding overrides', () => { + it('applies overrides for the matching host', async () => { + process.env.LOGIN_COMPANY_NAME = 'Default Co'; + process.env.LOGIN_WEBSITE_URL = 'https://default.example'; + process.env.DOMAIN_BRANDING = JSON.stringify([ + { + host: 'mail1.example.com', + loginCompanyName: 'Brand One', + loginWebsiteUrl: 'https://one.example', + }, + ]); + + const config = await getConfig({ host: 'mail1.example.com' }); + + expect(config.loginCompanyName).toBe('Brand One'); + expect(config.loginWebsiteUrl).toBe('https://one.example'); + }); + + it('falls through to the global value when the host has no entry', async () => { + process.env.LOGIN_COMPANY_NAME = 'Default Co'; + process.env.DOMAIN_BRANDING = JSON.stringify([ + { host: 'mail1.example.com', loginCompanyName: 'Brand One' }, + ]); + + const config = await getConfig({ host: 'unmapped.example.com' }); + + expect(config.loginCompanyName).toBe('Default Co'); + }); + + it('falls through field-by-field when the matching entry omits a field', async () => { + process.env.LOGIN_COMPANY_NAME = 'Default Co'; + process.env.LOGIN_WEBSITE_URL = 'https://default.example'; + process.env.DOMAIN_BRANDING = JSON.stringify([ + { host: 'mail1.example.com', loginCompanyName: 'Brand One' }, + ]); + + const config = await getConfig({ host: 'mail1.example.com' }); + + expect(config.loginCompanyName).toBe('Brand One'); + expect(config.loginWebsiteUrl).toBe('https://default.example'); + }); + + it('prefers X-Forwarded-Host over Host', async () => { + process.env.DOMAIN_BRANDING = JSON.stringify([ + { host: 'public.example.com', loginCompanyName: 'Public' }, + ]); + + const config = await getConfig({ + host: 'internal.example.com', + 'x-forwarded-host': 'public.example.com', + }); + + expect(config.loginCompanyName).toBe('Public'); + }); + + it('strips the port from the host header before matching', async () => { + process.env.DOMAIN_BRANDING = JSON.stringify([ + { host: 'mail1.example.com', loginCompanyName: 'Brand One' }, + ]); + + const config = await getConfig({ host: 'mail1.example.com:8443' }); + + expect(config.loginCompanyName).toBe('Brand One'); + }); + }); }); diff --git a/lib/__tests__/domain-branding.test.ts b/lib/__tests__/domain-branding.test.ts new file mode 100644 index 000000000..6672a2cbd --- /dev/null +++ b/lib/__tests__/domain-branding.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest'; +import { + matchDomainBranding, + parseDomainBranding, + pickRequestHost, + type DomainBrandingEntry, +} from '@/lib/admin/domain-branding'; + +function mockHeaders(map: Record): Headers { + const lc: Record = {}; + for (const [k, v] of Object.entries(map)) lc[k.toLowerCase()] = v; + return { + get(name: string) { + return lc[name.toLowerCase()] ?? null; + }, + } as unknown as Headers; +} + +describe('parseDomainBranding', () => { + it('returns [] for null/undefined/empty', () => { + expect(parseDomainBranding(null)).toEqual([]); + expect(parseDomainBranding(undefined)).toEqual([]); + expect(parseDomainBranding('')).toEqual([]); + expect(parseDomainBranding([])).toEqual([]); + }); + + it('parses a stringified JSON array', () => { + const raw = JSON.stringify([{ host: 'mail.example.com', loginCompanyName: 'Acme' }]); + expect(parseDomainBranding(raw)).toEqual([ + { host: 'mail.example.com', loginCompanyName: 'Acme' }, + ]); + }); + + it('lower-cases hosts and strips trailing dots', () => { + expect(parseDomainBranding([{ host: 'Mail.Example.COM.' }])).toEqual([ + { host: 'mail.example.com' }, + ]); + }); + + it('accepts wildcard hosts', () => { + expect(parseDomainBranding([{ host: '*.example.com', loginCompanyName: 'Wild' }])).toEqual([ + { host: '*.example.com', loginCompanyName: 'Wild' }, + ]); + }); + + it('drops entries with invalid hosts', () => { + const out = parseDomainBranding([ + { host: '' }, + { host: 'has space.com' }, + { host: 'foo..bar' }, + { host: '*.*.example.com' }, // embedded wildcard not allowed + { host: 'good.example.com' }, + ]); + expect(out.map(e => e.host)).toEqual(['good.example.com']); + }); + + it('drops duplicate hosts, keeping the first', () => { + const out = parseDomainBranding([ + { host: 'foo.com', loginCompanyName: 'First' }, + { host: 'FOO.com', loginCompanyName: 'Second' }, + ]); + expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'First' }]); + }); + + it('ignores non-string and empty-string override fields', () => { + const out = parseDomainBranding([ + { + host: 'foo.com', + loginCompanyName: '', + loginImprintUrl: 42, + loginWebsiteUrl: 'https://foo.com', + }, + ]); + expect(out).toEqual([{ host: 'foo.com', loginWebsiteUrl: 'https://foo.com' }]); + }); + + it('ignores unknown fields', () => { + const out = parseDomainBranding([ + { host: 'foo.com', notARealField: 'x', loginCompanyName: 'OK' }, + ]); + expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'OK' }]); + }); +}); + +describe('pickRequestHost', () => { + it('returns null when no host headers are set', () => { + expect(pickRequestHost(mockHeaders({}))).toBeNull(); + }); + + it('prefers X-Forwarded-Host over Host', () => { + expect(pickRequestHost(mockHeaders({ + 'x-forwarded-host': 'forwarded.example.com', + host: 'origin.example.com', + }))).toBe('forwarded.example.com'); + }); + + it('falls back to Host when X-Forwarded-Host is absent', () => { + expect(pickRequestHost(mockHeaders({ host: 'origin.example.com' }))).toBe('origin.example.com'); + }); + + it('strips the port', () => { + expect(pickRequestHost(mockHeaders({ host: 'example.com:8080' }))).toBe('example.com'); + }); + + it('takes the first entry of a comma-separated X-Forwarded-Host', () => { + expect(pickRequestHost(mockHeaders({ + 'x-forwarded-host': 'first.example.com, second.example.com', + }))).toBe('first.example.com'); + }); + + it('lower-cases the result', () => { + expect(pickRequestHost(mockHeaders({ host: 'EXAMPLE.com' }))).toBe('example.com'); + }); +}); + +describe('matchDomainBranding', () => { + const entries: DomainBrandingEntry[] = [ + { host: 'mail.example.com', loginCompanyName: 'Exact' }, + { host: '*.example.com', loginCompanyName: 'Wildcard' }, + { host: '*.dev.example.com', loginCompanyName: 'Specific Wildcard' }, + { host: 'other.com', loginCompanyName: 'Other' }, + ]; + + it('returns {} when host is null', () => { + expect(matchDomainBranding(null, entries)).toEqual({}); + }); + + it('returns {} when no entries match', () => { + expect(matchDomainBranding('unknown.org', entries)).toEqual({}); + }); + + it('prefers exact match over wildcard', () => { + expect(matchDomainBranding('mail.example.com', entries).loginCompanyName).toBe('Exact'); + }); + + it('matches wildcards on subdomains', () => { + expect(matchDomainBranding('foo.example.com', entries).loginCompanyName).toBe('Wildcard'); + }); + + it('prefers the longest wildcard suffix', () => { + expect(matchDomainBranding('app.dev.example.com', entries).loginCompanyName).toBe('Specific Wildcard'); + }); + + it('does not match the wildcard host against the apex domain', () => { + expect(matchDomainBranding('example.com', entries)).toEqual({}); + }); + + it('is case-insensitive on the request host', () => { + expect(matchDomainBranding('Mail.Example.COM', entries).loginCompanyName).toBe('Exact'); + }); +}); diff --git a/lib/admin/domain-branding.ts b/lib/admin/domain-branding.ts new file mode 100644 index 000000000..d5efe8ec7 --- /dev/null +++ b/lib/admin/domain-branding.ts @@ -0,0 +1,144 @@ +/** + * Per-domain branding overrides: schema, parsing, host extraction, and match. + * + * The webmail can be served on multiple hostnames (e.g. mail1.example.com, + * mail2.other.com). Each hostname can override a subset of branding fields; + * unset fields fall back to the global admin/env/default value. + */ + +import type { NextRequest } from 'next/server'; + +/** Config keys that can be overridden per domain. */ +export const BRANDING_OVERRIDE_KEYS = [ + 'appName', + 'appShortName', + 'appDescription', + 'faviconUrl', + 'pwaIconUrl', + 'pwaThemeColor', + 'pwaBackgroundColor', + 'appLogoLightUrl', + 'appLogoDarkUrl', + 'loginLogoLightUrl', + 'loginLogoDarkUrl', + 'loginCompanyName', + 'loginImprintUrl', + 'loginPrivacyPolicyUrl', + 'loginWebsiteUrl', +] as const; + +export type BrandingOverrideKey = (typeof BRANDING_OVERRIDE_KEYS)[number]; + +export interface DomainBrandingEntry { + /** + * Hostname this entry applies to. Either an exact host like + * "mail.example.com" or a wildcard like "*.example.com" (matches any + * direct or deeper subdomain). Case-insensitive; trailing dots are + * stripped on parse. + */ + host: string; + appName?: string; + appShortName?: string; + appDescription?: string; + faviconUrl?: string; + pwaIconUrl?: string; + pwaThemeColor?: string; + pwaBackgroundColor?: string; + appLogoLightUrl?: string; + appLogoDarkUrl?: string; + loginLogoLightUrl?: string; + loginLogoDarkUrl?: string; + loginCompanyName?: string; + loginImprintUrl?: string; + loginPrivacyPolicyUrl?: string; + loginWebsiteUrl?: string; +} + +// Accepts plain hostnames (foo, foo.bar, foo.bar.baz) and one-level wildcards +// at the leftmost label (*.example.com). Rejects IPs, scheme/path/userinfo, +// and embedded wildcards. +const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; + +function normalizeHost(host: string): string { + return host.trim().toLowerCase().replace(/\.+$/, ''); +} + +/** Parse the raw config value (array of entries, or string-JSON). Invalid entries are dropped. */ +export function parseDomainBranding(raw: unknown): DomainBrandingEntry[] { + if (!raw) return []; + let value = raw; + if (typeof value === 'string') { + if (!value.trim()) return []; + try { + value = JSON.parse(value); + } catch { + return []; + } + } + if (!Array.isArray(value)) return []; + + const seen = new Set(); + const out: DomainBrandingEntry[] = []; + for (const item of value) { + if (!item || typeof item !== 'object') continue; + const rec = item as Record; + const rawHost = typeof rec.host === 'string' ? rec.host : ''; + const host = normalizeHost(rawHost); + if (!host || !HOST_RE.test(host)) continue; + if (seen.has(host)) continue; + seen.add(host); + + const entry: DomainBrandingEntry = { host }; + const writable = entry as unknown as Record; + for (const key of BRANDING_OVERRIDE_KEYS) { + const v = rec[key]; + if (typeof v === 'string' && v.length > 0) { + writable[key] = v; + } + } + out.push(entry); + } + return out; +} + +type HeadersLike = Headers | { get(name: string): string | null }; + +/** + * Pick the request's host, preferring X-Forwarded-Host (first entry if + * comma-separated) over Host. Strips the port. Returns null when no usable + * host header is set. + */ +export function pickRequestHost(headersOrReq: NextRequest | HeadersLike): string | null { + const headers: HeadersLike = 'headers' in headersOrReq ? (headersOrReq as NextRequest).headers : headersOrReq; + const raw = headers.get('x-forwarded-host') || headers.get('host'); + if (!raw) return null; + const first = raw.split(',')[0]?.trim(); + if (!first) return null; + return normalizeHost(first.split(':')[0]); +} + +/** + * Find the entry whose host matches `host`. Exact match always wins; among + * wildcards the longest (most-specific) suffix wins. Returns {} when no + * entry matches. + */ +export function matchDomainBranding( + host: string | null, + entries: DomainBrandingEntry[], +): Partial { + if (!host || entries.length === 0) return {}; + const lower = normalizeHost(host); + let wildcardMatch: DomainBrandingEntry | undefined; + for (const entry of entries) { + if (entry.host === lower) return entry; + if (entry.host.startsWith('*.')) { + const suffix = entry.host.slice(1); // ".example.com" + if (lower.endsWith(suffix) && lower.length > suffix.length) { + if (!wildcardMatch || entry.host.length > wildcardMatch.host.length) { + wildcardMatch = entry; + } + } + } + } + return wildcardMatch ?? {}; +} diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 09d496c95..988c2b4ae 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -163,6 +163,7 @@ export const CONFIG_ENV_MAP: Record Date: Thu, 28 May 2026 20:13:11 +0200 Subject: [PATCH 009/437] docs: document DOMAIN_BRANDING env var in examples and README #332 --- .env.dev.example | 5 +++++ .env.example | 23 +++++++++++++++++++++++ README.md | 6 ++++++ 3 files changed, 34 insertions(+) diff --git a/.env.dev.example b/.env.dev.example index 6d5d44d61..efc75a095 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -47,3 +47,8 @@ LOG_LEVEL=debug # LOGIN_IMPRINT_URL=https://example.com/imprint # LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy # LOGIN_WEBSITE_URL=https://example.com + +# Per-domain branding overrides. Each entry must have "host" (exact or +# "*.subdomain" wildcard) plus any subset of branding fields to override. +# Unset fields fall through to the global values above. +# DOMAIN_BRANDING=[{"host":"localhost","loginCompanyName":"Local Dev"}] diff --git a/.env.example b/.env.example index 5f21140e4..be1fec14c 100644 --- a/.env.example +++ b/.env.example @@ -225,6 +225,29 @@ LOGIN_COMPANY_NAME=Bulwark Webmail # URL for the company website link on the login page. LOGIN_WEBSITE_URL=https://bulwarkmail.org +# --------------------------------------------------------------------------- +# Per-domain branding overrides (optional) +# --------------------------------------------------------------------------- +# +# When you serve the webmail on multiple hostnames, each hostname can override +# a subset of branding fields. Unset fields fall back to the global values +# above. Match is on the request's Host (or X-Forwarded-Host) header. +# +# Use the leftmost label "*." to match any subdomain (e.g. "*.example.com" +# matches mail.example.com and any deeper subdomain, but NOT example.com). +# Exact matches always win over wildcards; the longest wildcard suffix wins +# among multiple wildcard matches. +# +# Overridable keys: appName, appShortName, appDescription, faviconUrl, +# pwaIconUrl, pwaThemeColor, pwaBackgroundColor, appLogoLightUrl, +# appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, +# loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl. +# +# Prefer setting this from the admin dashboard (PATCH /api/admin/config). +# The env-var form is provided for stateless deployments. +# +# DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one-color.svg","loginLogoDarkUrl":"/branding/one-white.svg","loginWebsiteUrl":"https://one.example"},{"host":"maildomain2.com","loginCompanyName":"Company Two","faviconUrl":"/branding/two-favicon.svg"},{"host":"*.intranet.example.com","loginCompanyName":"Internal"}] + # ============================================================================= # Extension Directory / Marketplace # ============================================================================= diff --git a/README.md b/README.md index 4abe0f3d6..9dcca13aa 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,12 @@ LOGIN_COMPANY_NAME=My Company LOGIN_WEBSITE_URL=https://example.com LOGIN_IMPRINT_URL=https://example.com/imprint LOGIN_PRIVACY_POLICY_URL=https://example.com/privacy + +# Per-domain overrides (optional). When the webmail is served on multiple +# hostnames, each host can override any subset of the branding fields above. +# Match is on the request Host (or X-Forwarded-Host). Use "*.example.com" to +# match any subdomain. Unset fields fall back to the global values. +DOMAIN_BRANDING=[{"host":"maildomain1.com","loginCompanyName":"Company One","loginLogoLightUrl":"/branding/one.svg"},{"host":"maildomain2.com","loginCompanyName":"Company Two"}] ``` From eb7eeae1ac33bb7da856730887513f9260d75482 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 28 May 2026 20:21:25 +0200 Subject: [PATCH 010/437] feat: per-domain branding editor in admin panel #332 --- app/(main)/admin/_tabs/branding.tsx | 353 ++++++++++++++++++++++++---- app/api/admin/branding/route.ts | 187 ++++++++++++--- 2 files changed, 467 insertions(+), 73 deletions(-) diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx index b94266979..09bd2ce71 100644 --- a/app/(main)/admin/_tabs/branding.tsx +++ b/app/(main)/admin/_tabs/branding.tsx @@ -1,8 +1,14 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; +import { + BRANDING_OVERRIDE_KEYS, + parseDomainBranding, + type BrandingOverrideKey, + type DomainBrandingEntry, +} from '@/lib/admin/domain-branding'; interface ConfigEntry { value?: unknown; @@ -16,42 +22,65 @@ const IMAGE_FIELDS = [ { key: 'appLogoDarkUrl', label: 'App Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' }, { key: 'loginLogoLightUrl', label: 'Login Logo (Light Mode)', accept: '.svg,.png,.jpg,.webp' }, { key: 'loginLogoDarkUrl', label: 'Login Logo (Dark Mode)', accept: '.svg,.png,.jpg,.webp' }, -]; +] as const; const TEXT_FIELDS = [ { key: 'loginCompanyName', label: 'Company Name' }, { key: 'loginImprintUrl', label: 'Imprint URL' }, { key: 'loginPrivacyPolicyUrl', label: 'Privacy Policy URL' }, { key: 'loginWebsiteUrl', label: 'Company Website URL' }, -]; +] as const; const PWA_IMAGE_FIELDS = [ { key: 'pwaIconUrl', label: 'PWA Icon', accept: '.svg,.png,.jpg,.webp' }, -]; +] as const; const PWA_TEXT_FIELDS = [ { key: 'appShortName', label: 'Short Name', placeholder: 'Shown on home screen (max ~12 chars)' }, { key: 'appDescription', label: 'Description', placeholder: 'App description for install prompts' }, -]; +] as const; const PWA_COLOR_FIELDS = [ { key: 'pwaThemeColor', label: 'Theme Color', defaultValue: '#ffffff' }, { key: 'pwaBackgroundColor', label: 'Background Color', defaultValue: '#ffffff' }, -]; +] as const; + +// Accepts exact hosts and one-level wildcards (e.g. *.example.com). +const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; +// Tighter rule for uploads: wildcards can only point to externally-hosted +// URLs, since we'd have no concrete subdomain to serve a file from. +const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; export function BrandingTab() { const [config, setConfig] = useState>({}); - const [edits, setEdits] = useState>({}); + const [edits, setEdits] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [uploading, setUploading] = useState(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + const [selectedHost, setSelectedHost] = useState(null); + const [addingHost, setAddingHost] = useState(false); + const [newHostInput, setNewHostInput] = useState(''); + const [newHostError, setNewHostError] = useState(null); const fileInputRefs = useRef>({}); useEffect(() => { fetchConfig(); }, []); + const domainEntries = useMemo( + () => parseDomainBranding(config['domainBranding']?.value), + [config], + ); + + // Drop selection if the host disappeared from the config (e.g. concurrent edit). + useEffect(() => { + if (selectedHost && !domainEntries.some(e => e.host === selectedHost)) { + setSelectedHost(null); + setEdits({}); + } + }, [domainEntries, selectedHost]); + async function fetchConfig() { setLoading(true); const res = await apiFetch('/api/admin/config'); @@ -59,29 +88,81 @@ export function BrandingTab() { setLoading(false); } + function selectedEntry(): DomainBrandingEntry | null { + if (!selectedHost) return null; + return domainEntries.find(e => e.host === selectedHost) ?? null; + } + function handleChange(key: string, value: string) { setEdits(prev => ({ ...prev, [key]: value })); setMessage(null); } function currentValue(key: string): string { - if (key in edits) return edits[key] as string; + if (key in edits) return edits[key]; + if (selectedHost) { + const entry = selectedEntry(); + return (entry?.[key as BrandingOverrideKey] as string | undefined) ?? ''; + } return (config[key]?.value as string) ?? ''; } + function isOverriddenInScope(key: string): boolean { + if (selectedHost) { + const entry = selectedEntry(); + const v = entry?.[key as BrandingOverrideKey]; + return typeof v === 'string' && v.length > 0; + } + return config[key]?.source === 'admin'; + } + + const isUploadedFile = (key: string): boolean => { + const val = currentValue(key); + return val.startsWith('/api/admin/branding/'); + }; + + function buildUpdatedDomainBranding(merge: Record): DomainBrandingEntry[] { + if (!selectedHost) return domainEntries; + const next = domainEntries.slice(); + const idx = next.findIndex(e => e.host === selectedHost); + const base: DomainBrandingEntry = + idx === -1 ? { host: selectedHost } : { ...next[idx] }; + const writable = base as unknown as Record; + for (const [key, value] of Object.entries(merge)) { + if (!(BRANDING_OVERRIDE_KEYS as readonly string[]).includes(key)) continue; + if (typeof value === 'string' && value.length > 0) { + writable[key] = value; + } else { + delete writable[key]; + } + } + if (idx === -1) next.push(base); + else next[idx] = base; + return next; + } + async function handleSave() { if (Object.keys(edits).length === 0) return; setSaving(true); setMessage(null); + const payload = selectedHost + ? { domainBranding: buildUpdatedDomainBranding(edits) } + : edits; + const res = await apiFetch('/api/admin/config', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(edits), + body: JSON.stringify(payload), }); if (res.ok) { - setMessage({ type: 'success', text: 'Branding updated. Changes visible on next page load.' }); + setMessage({ + type: 'success', + text: selectedHost + ? `Branding for ${selectedHost} updated. Changes visible on next page load.` + : 'Branding updated. Changes visible on next page load.', + }); setEdits({}); await fetchConfig(); } else { @@ -92,12 +173,20 @@ export function BrandingTab() { } async function handleUpload(slot: string, file: File) { + if (selectedHost && !EXACT_HOST_RE.test(selectedHost)) { + setMessage({ + type: 'error', + text: 'Wildcard hosts cannot upload files. Enter a URL instead.', + }); + return; + } setUploading(slot); setMessage(null); const formData = new FormData(); formData.append('file', file); formData.append('slot', slot); + if (selectedHost) formData.append('host', selectedHost); const res = await apiFetch('/api/admin/branding', { method: 'POST', @@ -112,10 +201,9 @@ export function BrandingTab() { delete next[slot]; return next; }); - setConfig(prev => ({ - ...prev, - [slot]: { value: data.url, source: 'admin' }, - })); + // Refresh from server so domainBranding entries reflect the upload. + await fetchConfig(); + void data; } else { const data = await res.json(); setMessage({ type: 'error', text: data.error || 'Upload failed' }); @@ -126,10 +214,13 @@ export function BrandingTab() { async function handleDeleteUpload(slot: string) { setMessage(null); + const body: { slot: string; host?: string } = { slot }; + if (selectedHost) body.host = selectedHost; + const res = await apiFetch('/api/admin/branding', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slot }), + body: JSON.stringify(body), }); if (res.ok) { @@ -147,6 +238,25 @@ export function BrandingTab() { } async function handleRevert(key: string) { + if (selectedHost) { + // Domain scope: drop the field from the entry and PATCH the array. + const updated = buildUpdatedDomainBranding({ [key]: '' }); + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domainBranding: updated }), + }); + if (res.ok) { + setEdits(prev => { + const next = { ...prev }; + delete next[key]; + return next; + }); + await fetchConfig(); + } + return; + } + // Default scope: revert via DELETE /api/admin/config const res = await apiFetch('/api/admin/config', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, @@ -162,12 +272,71 @@ export function BrandingTab() { } } - const isUploadedFile = (key: string): boolean => { - const val = currentValue(key); - return val.startsWith('/api/admin/branding/'); - }; + async function handleAddDomain() { + const host = newHostInput.trim().toLowerCase().replace(/\.+$/, ''); + if (!host) { + setNewHostError('Enter a hostname'); + return; + } + if (!HOST_RE.test(host)) { + setNewHostError('Invalid hostname. Use foo.example.com or *.example.com'); + return; + } + if (domainEntries.some(e => e.host === host)) { + setNewHostError('A branding entry for this host already exists'); + return; + } + setNewHostError(null); + + const next: DomainBrandingEntry[] = [...domainEntries, { host }]; + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domainBranding: next }), + }); + if (res.ok) { + setNewHostInput(''); + setAddingHost(false); + setSelectedHost(host); + setEdits({}); + await fetchConfig(); + } else { + const data = await res.json(); + setNewHostError(data.error || 'Failed to add domain'); + } + } + + async function handleDeleteDomain() { + if (!selectedHost) return; + if (!confirm(`Remove branding entry for ${selectedHost}? Uploaded files for this domain will be left behind on disk.`)) { + return; + } + const next = domainEntries.filter(e => e.host !== selectedHost); + const res = await apiFetch('/api/admin/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ domainBranding: next }), + }); + if (res.ok) { + setSelectedHost(null); + setEdits({}); + await fetchConfig(); + setMessage({ type: 'success', text: `Removed branding entry for ${selectedHost}.` }); + } else { + const data = await res.json(); + setMessage({ type: 'error', text: data.error || 'Failed to remove domain' }); + } + } + + function handleScopeChange(host: string | null) { + if (Object.keys(edits).length > 0 && !confirm('Discard unsaved changes?')) return; + setSelectedHost(host); + setEdits({}); + setMessage(null); + } const hasEdits = Object.keys(edits).length > 0; + const wildcardScope = !!selectedHost && !EXACT_HOST_RE.test(selectedHost); if (loading) { return
Loading...
; @@ -192,6 +361,102 @@ export function BrandingTab() { )}
+ {/* Scope picker */} +
+
+ +

Scope

+
+
+
+ + {domainEntries.map(entry => ( + + ))} + {!addingHost && ( + + )} +
+ {addingHost && ( +
+ { setNewHostInput(e.target.value); setNewHostError(null); }} + onKeyDown={(e) => { if (e.key === 'Enter') void handleAddDomain(); }} + placeholder="mail.example.com or *.example.com" + className="h-8 w-64 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + + + {newHostError && {newHostError}} +
+ )} + {selectedHost ? ( +
+

+ Editing overrides for {selectedHost}. + Unset fields fall back to the Default values. + {wildcardScope && ' Uploads are disabled for wildcard hosts; enter a URL instead.'} +

+ +
+ ) : ( +

+ Editing the Default branding. Add a domain to override branding when the webmail is served on a specific hostname. +

+ )} +
+
+ {message && (
{message.text} @@ -209,9 +474,9 @@ export function BrandingTab() {
- {config[field.key]?.source === 'admin' && ( + {isOverriddenInScope(field.key) && ( - {isUploadedFile(field.key) ? 'uploaded' : 'admin'} + {isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'} )}
@@ -220,7 +485,7 @@ export function BrandingTab() { type="text" value={currentValue(field.key)} onChange={(e) => handleChange(field.key, e.target.value)} - placeholder="Enter URL or upload a file" + placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'} className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> @@ -251,7 +516,7 @@ export function BrandingTab() { )} - {config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && ( + {isOverriddenInScope(field.key) && !isUploadedFile(field.key) && ( @@ -287,9 +552,9 @@ export function BrandingTab() {
- {config[field.key]?.source === 'admin' && ( + {isOverriddenInScope(field.key) && ( - {isUploadedFile(field.key) ? 'uploaded' : 'admin'} + {isUploadedFile(field.key) ? 'uploaded' : selectedHost ? 'domain' : 'admin'} )}
@@ -298,7 +563,7 @@ export function BrandingTab() { type="text" value={currentValue(field.key)} onChange={(e) => handleChange(field.key, e.target.value)} - placeholder="Enter URL or upload a file" + placeholder={selectedHost ? 'Enter URL (uploads only for default scope)' : 'Enter URL or upload a file'} className="h-8 w-full sm:w-64 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> @@ -329,7 +594,7 @@ export function BrandingTab() { )} - {config[field.key]?.source === 'admin' && !isUploadedFile(field.key) && ( + {isOverriddenInScope(field.key) && !isUploadedFile(field.key) && ( @@ -355,8 +620,10 @@ export function BrandingTab() {
- {config[field.key]?.source === 'admin' && ( - admin + {isOverriddenInScope(field.key) && ( + + {selectedHost ? 'domain' : 'admin'} + )}
@@ -367,7 +634,7 @@ export function BrandingTab() { placeholder={field.placeholder} className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> - {config[field.key]?.source === 'admin' && ( + {isOverriddenInScope(field.key) && ( @@ -381,8 +648,10 @@ export function BrandingTab() {
- {config[field.key]?.source === 'admin' && ( - admin + {isOverriddenInScope(field.key) && ( + + {selectedHost ? 'domain' : 'admin'} + )}
@@ -400,7 +669,7 @@ export function BrandingTab() { placeholder={field.defaultValue} className="h-8 w-full sm:w-32 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> - {config[field.key]?.source === 'admin' && ( + {isOverriddenInScope(field.key) && ( @@ -421,8 +690,10 @@ export function BrandingTab() {
- {config[field.key]?.source === 'admin' && ( - admin + {isOverriddenInScope(field.key) && ( + + {selectedHost ? 'domain' : 'admin'} + )}
@@ -433,7 +704,7 @@ export function BrandingTab() { placeholder={field.key.includes('Url') ? 'https://...' : 'Enter value'} className="h-8 w-full sm:w-72 min-w-0 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" /> - {config[field.key]?.source === 'admin' && ( + {isOverriddenInScope(field.key) && ( diff --git a/app/api/admin/branding/route.ts b/app/api/admin/branding/route.ts index de9b44577..60dcead71 100644 --- a/app/api/admin/branding/route.ts +++ b/app/api/admin/branding/route.ts @@ -3,8 +3,13 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { configManager } from '@/lib/admin/config-manager'; import { getConfigDir } from '@/lib/admin/paths'; +import { + parseDomainBranding, + type DomainBrandingEntry, + type BrandingOverrideKey, +} from '@/lib/admin/domain-branding'; import { logger } from '@/lib/logger'; -import { writeFile, unlink, mkdir } from 'node:fs/promises'; +import { writeFile, unlink, mkdir, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import path from 'node:path'; @@ -22,7 +27,7 @@ const ALLOWED_MIME_TYPES = new Set([ ]); /** Slots that correspond to branding config keys */ -const VALID_SLOTS = new Set([ +const VALID_SLOTS = new Set([ 'faviconUrl', 'pwaIconUrl', 'appLogoLightUrl', @@ -31,17 +36,85 @@ const VALID_SLOTS = new Set([ 'loginLogoDarkUrl', ]); +const EXT_BY_MIME: Record = { + 'image/svg+xml': '.svg', + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/webp': '.webp', + 'image/x-icon': '.ico', + 'image/vnd.microsoft.icon': '.ico', +}; + +const POSSIBLE_EXTS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico']; + +// Exact hostnames only (no wildcards): wildcards can't be uploaded against +// because we'd need a real subdomain to serve the file from. +const EXACT_HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; + function sanitizeFilename(name: string): string { // Strip directory traversal, keep only safe chars return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_'); } +function normalizeHost(raw: string): string { + return raw.trim().toLowerCase().replace(/\.+$/, ''); +} + +/** Filename used to store a per-host uploaded asset. */ +function domainAssetName(host: string, slot: BrandingOverrideKey, ext: string): string { + return sanitizeFilename(`domain__${host}__${slot}${ext}`); +} + +/** True if the file belongs to the given host+slot (any extension). */ +function isDomainAssetFor(filename: string, host: string, slot: BrandingOverrideKey): boolean { + const prefix = sanitizeFilename(`domain__${host}__${slot}.`); + return filename.startsWith(prefix); +} + +/** Merge a per-host update into the existing domainBranding array. */ +function mergeDomainEntry( + current: DomainBrandingEntry[], + host: string, + patch: Partial, +): DomainBrandingEntry[] { + const next = current.slice(); + const idx = next.findIndex(e => e.host === host); + if (idx === -1) { + next.push({ host, ...patch }); + } else { + next[idx] = { ...next[idx], ...patch }; + } + return next; +} + +/** Remove keys from a host's entry. If the entry has nothing left besides + * `host`, drop it entirely. */ +function clearDomainKeys( + current: DomainBrandingEntry[], + host: string, + keys: BrandingOverrideKey[], +): DomainBrandingEntry[] { + const idx = current.findIndex(e => e.host === host); + if (idx === -1) return current; + const entry = { ...current[idx] }; + for (const key of keys) delete (entry as Record)[key]; + const next = current.slice(); + if (Object.keys(entry).filter(k => k !== 'host').length === 0) { + next.splice(idx, 1); + } else { + next[idx] = entry; + } + return next; +} + /** * POST /api/admin/branding - Upload a branding image file * * Expects multipart/form-data with: * - file: the image file * - slot: which branding field this is for (e.g. "faviconUrl") + * - host (optional): when set, the upload is stored against the + * per-domain entry for that hostname instead of the global default. */ export async function POST(request: NextRequest) { try { @@ -52,15 +125,24 @@ export async function POST(request: NextRequest) { const formData = await request.formData(); const file = formData.get('file') as File | null; const slot = formData.get('slot') as string | null; + const rawHost = (formData.get('host') as string | null) ?? ''; if (!file || !slot) { return NextResponse.json({ error: 'Missing file or slot' }, { status: 400 }); } - if (!VALID_SLOTS.has(slot)) { + if (!VALID_SLOTS.has(slot as BrandingOverrideKey)) { return NextResponse.json({ error: `Invalid slot: ${slot}` }, { status: 400 }); } + const host = rawHost ? normalizeHost(rawHost) : ''; + if (host && !EXACT_HOST_RE.test(host)) { + return NextResponse.json( + { error: `Invalid host: ${rawHost} (wildcards must be configured by URL, not upload)` }, + { status: 400 }, + ); + } + if (file.size > MAX_FILE_SIZE) { return NextResponse.json({ error: 'File too large (max 2 MB)' }, { status: 400 }); } @@ -72,34 +154,51 @@ export async function POST(request: NextRequest) { ); } - // Determine extension from mime type - const extMap: Record = { - 'image/svg+xml': '.svg', - 'image/png': '.png', - 'image/jpeg': '.jpg', - 'image/webp': '.webp', - 'image/x-icon': '.ico', - 'image/vnd.microsoft.icon': '.ico', - }; - const ext = extMap[file.type] || '.png'; - const safeName = sanitizeFilename(`${slot}${ext}`); + const ext = EXT_BY_MIME[file.type] ?? '.png'; + const safeName = host + ? domainAssetName(host, slot as BrandingOverrideKey, ext) + : sanitizeFilename(`${slot}${ext}`); const filePath = path.join(getBrandingDir(), safeName); - // Ensure branding directory exists if (!existsSync(getBrandingDir())) { await mkdir(getBrandingDir(), { recursive: true }); } - // Write file to disk + // Strip any prior asset for the same slot but a different extension so + // the directory doesn't accumulate orphan files on re-upload. + const dir = getBrandingDir(); + const allFiles = await readdir(dir).catch(() => [] as string[]); + for (const f of allFiles) { + if (f === safeName) continue; + const isSame = host + ? isDomainAssetFor(f, host, slot as BrandingOverrideKey) + : POSSIBLE_EXTS.some(e => f === `${slot}${e}`); + if (isSame) { + try { await unlink(path.join(dir, f)); } catch { /* ignore */ } + } + } + const buffer = Buffer.from(await file.arrayBuffer()); await writeFile(filePath, buffer); - // Update config to point to the served URL const servedUrl = `/api/admin/branding/${safeName}`; await configManager.ensureLoaded(); - await configManager.setAdminConfig({ [slot]: servedUrl }); - await auditLog('branding_upload', { slot, filename: safeName, size: file.size, mimeType: file.type }, ip); + if (host) { + const current = parseDomainBranding(configManager.get('domainBranding', [])); + const next = mergeDomainEntry(current, host, { [slot]: servedUrl }); + await configManager.setAdminConfig({ domainBranding: next }); + } else { + await configManager.setAdminConfig({ [slot]: servedUrl }); + } + + await auditLog('branding_upload', { + slot, + host: host || undefined, + filename: safeName, + size: file.size, + mimeType: file.type, + }, ip); return NextResponse.json({ url: servedUrl, filename: safeName }); } catch (error) { @@ -111,7 +210,11 @@ export async function POST(request: NextRequest) { /** * DELETE /api/admin/branding - Remove an uploaded branding file * - * Expects JSON body: { slot: string } + * Expects JSON body: { slot: string, host?: string } + * + * When `host` is provided, only the per-domain asset for that host+slot is + * removed (and the override in `domainBranding[host][slot]` is cleared). + * Otherwise the global asset and config override are removed. */ export async function DELETE(request: NextRequest) { try { @@ -119,28 +222,48 @@ export async function DELETE(request: NextRequest) { if ('error' in result) return result.error; const ip = getClientIP(request); - const { slot } = await request.json(); + const body = await request.json().catch(() => ({})) as { slot?: string; host?: string }; + const slot = body.slot; + const rawHost = body.host ?? ''; - if (!slot || !VALID_SLOTS.has(slot)) { + if (!slot || !VALID_SLOTS.has(slot as BrandingOverrideKey)) { return NextResponse.json({ error: 'Invalid or missing slot' }, { status: 400 }); } - // Find and remove matching files for this slot - const possibleExts = ['.svg', '.png', '.jpg', '.webp', '.ico']; + const host = rawHost ? normalizeHost(rawHost) : ''; + if (host && !EXACT_HOST_RE.test(host)) { + return NextResponse.json({ error: `Invalid host: ${rawHost}` }, { status: 400 }); + } + + const dir = getBrandingDir(); let removed = false; - for (const ext of possibleExts) { - const filePath = path.join(getBrandingDir(), `${slot}${ext}`); - if (existsSync(filePath)) { - await unlink(filePath); - removed = true; + if (host) { + const allFiles = await readdir(dir).catch(() => [] as string[]); + for (const f of allFiles) { + if (isDomainAssetFor(f, host, slot as BrandingOverrideKey)) { + try { await unlink(path.join(dir, f)); removed = true; } catch { /* ignore */ } + } + } + } else { + for (const ext of POSSIBLE_EXTS) { + const filePath = path.join(dir, `${slot}${ext}`); + if (existsSync(filePath)) { + await unlink(filePath); + removed = true; + } } } - // Clear the config override so it falls back to default/env await configManager.ensureLoaded(); - await configManager.removeAdminOverride(slot); + if (host) { + const current = parseDomainBranding(configManager.get('domainBranding', [])); + const next = clearDomainKeys(current, host, [slot as BrandingOverrideKey]); + await configManager.setAdminConfig({ domainBranding: next }); + } else { + await configManager.removeAdminOverride(slot); + } - await auditLog('branding_delete', { slot, fileRemoved: removed }, ip); + await auditLog('branding_delete', { slot, host: host || undefined, fileRemoved: removed }, ip); return NextResponse.json({ success: true }); } catch (error) { From 856496715bb98c77814bab267ee0fc5a5b922371 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 28 May 2026 20:27:40 +0200 Subject: [PATCH 011/437] i18n: add missing translation keys across 16 locales --- locales/cs/common.json | 1 + locales/da/common.json | 3 +++ locales/de/common.json | 3 +++ locales/es/common.json | 3 +++ locales/fr/common.json | 3 +++ locales/it/common.json | 3 +++ locales/ja/common.json | 3 +++ locales/ko/common.json | 3 +++ locales/lv/common.json | 3 +++ locales/nl/common.json | 1 + locales/pl/common.json | 3 +++ locales/pt/common.json | 3 +++ locales/ru/common.json | 3 +++ locales/tr/common.json | 3 +++ locales/uk/common.json | 3 +++ locales/zh/common.json | 3 +++ 16 files changed, 44 insertions(+) diff --git a/locales/cs/common.json b/locales/cs/common.json index 7a9eb55df..6620a55be 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1012,6 +1012,7 @@ "label": "Akce odstranění", "description": "Co se má stát po odstranění e-mailu", "trash": "Přesunout do Koše", + "trash_and_read": "Přesunout do Koše a označit jako přečtené", "permanent": "Trvale odstranit", "warning": "E-maily budou trvale odstraněny a nebude možné je obnovit. Tato akce je nevratná." }, diff --git a/locales/da/common.json b/locales/da/common.json index 5adbe4f2d..2f455b425 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -927,6 +927,8 @@ "description": "Modtag systemnotifikationer for ny mail, når dette site er lukket. Leveres via Bulwark push-relæet; relæet ser aldrig mail-indhold.", "relay_label": "Push-relæ", "relay_desc": "Som standard det hosted Bulwark-relæ. Ændr kun, hvis du selv-host'er.", + "relay_locked": "Indstillet af administrator", + "relay_locked_desc": "Push-relæ-URL'en er indstillet af din administrator og kan ikke ændres.", "relay_placeholder": "https://notifikationer.relay.eksempel.dk", "status_active": "Aktiv på denne enhed", "status_inactive": "Ikke aktiveret på denne enhed", @@ -1011,6 +1013,7 @@ "label": "Slet-handling", "description": "Hvad der sker, når du sletter en e-mail", "trash": "Flyt til papirkurv", + "trash_and_read": "Flyt til papirkurv og marker som læst", "permanent": "Slet permanent", "warning": "E-mails vil blive slettet permanent og kan ikke gendannes. Denne handling er irreversibel." }, diff --git a/locales/de/common.json b/locales/de/common.json index e80b5bd36..fa621a996 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -955,6 +955,8 @@ "reenable": "Neu registrieren", "relay_desc": "Standardmäßig wird das gehostete Bulwark-Relay verwendet. Nur ändern, wenn Sie selbst hosten.", "relay_label": "Push-Relay", + "relay_locked": "Vom Administrator festgelegt", + "relay_locked_desc": "Die Push-Relay-URL wurde von Ihrem Administrator festgelegt und kann nicht geändert werden.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Auf diesem Gerät aktiv", "status_busy": "Wird verarbeitet…", @@ -1010,6 +1012,7 @@ "label": "Löschaktion", "description": "Was passiert, wenn Sie eine E-Mail löschen", "trash": "In Papierkorb verschieben", + "trash_and_read": "In Papierkorb verschieben und als gelesen markieren", "permanent": "Dauerhaft löschen", "warning": "E-Mails werden dauerhaft gelöscht und können nicht wiederhergestellt werden. Diese Aktion ist unwiderruflich." }, diff --git a/locales/es/common.json b/locales/es/common.json index e81aaccf3..7b83d66a6 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -955,6 +955,8 @@ "reenable": "Volver a registrar", "relay_desc": "Usa el relay alojado de Bulwark de forma predeterminada. Cámbialo solo si te alojas tú mismo.", "relay_label": "Relay push", + "relay_locked": "Establecido por el administrador", + "relay_locked_desc": "La URL del relay push ha sido establecida por tu administrador y no se puede cambiar.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Activo en este dispositivo", "status_busy": "Trabajando…", @@ -1010,6 +1012,7 @@ "label": "Acción al Eliminar", "description": "Qué sucede cuando elimina un correo", "trash": "Mover a Papelera", + "trash_and_read": "Mover a Papelera y marcar como leído", "permanent": "Eliminar Permanentemente", "warning": "Los correos se eliminarán permanentemente y no se podrán recuperar. Esta acción es irreversible." }, diff --git a/locales/fr/common.json b/locales/fr/common.json index 8b26ae0c8..f9cb00ad8 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -955,6 +955,8 @@ "reenable": "Réenregistrer", "relay_desc": "Utilise par défaut le relais Bulwark hébergé. Ne le changez que si vous l'hébergez vous-même.", "relay_label": "Relais push", + "relay_locked": "Défini par l'administrateur", + "relay_locked_desc": "L'URL du relais push a été définie par votre administrateur et ne peut pas être modifiée.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Actif sur cet appareil", "status_busy": "Traitement en cours…", @@ -1010,6 +1012,7 @@ "label": "Action de suppression", "description": "Que se passe-t-il quand vous supprimez un email", "trash": "Déplacer vers la corbeille", + "trash_and_read": "Déplacer vers la corbeille et marquer comme lu", "permanent": "Supprimer définitivement", "warning": "Les emails seront supprimés définitivement et ne pourront pas être récupérés. Cette action est irréversible." }, diff --git a/locales/it/common.json b/locales/it/common.json index 4eaa318be..3c96458a0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -955,6 +955,8 @@ "reenable": "Registra di nuovo", "relay_desc": "Per impostazione predefinita usa il relay Bulwark ospitato. Cambialo solo se ospiti in autonomia.", "relay_label": "Relay push", + "relay_locked": "Impostato dall'amministratore", + "relay_locked_desc": "L'URL del relay push è stato impostato dall'amministratore e non può essere modificato.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Attivo su questo dispositivo", "status_busy": "In elaborazione…", @@ -1010,6 +1012,7 @@ "label": "Azione di eliminazione", "description": "Cosa accade quando elimini un messaggio", "trash": "Sposta nel cestino", + "trash_and_read": "Sposta nel cestino e segna come letto", "permanent": "Elimina definitivamente", "warning": "I messaggi verranno eliminati definitivamente e non potranno essere recuperati. Questa azione è irreversibile." }, diff --git a/locales/ja/common.json b/locales/ja/common.json index 6aa074039..aa366688d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -955,6 +955,8 @@ "reenable": "再登録", "relay_desc": "デフォルトはホストされた Bulwark リレーです。セルフホストする場合のみ変更してください。", "relay_label": "プッシュリレー", + "relay_locked": "管理者が設定", + "relay_locked_desc": "プッシュリレーの URL は管理者によって設定されており、変更できません。", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "このデバイスで有効", "status_busy": "処理中…", @@ -1010,6 +1012,7 @@ "label": "削除動作", "description": "メール削除時の動作", "trash": "ゴミ箱に移動", + "trash_and_read": "ゴミ箱に移動して既読にする", "permanent": "完全に削除", "warning": "メールは完全に削除され、復元できません。この操作は元に戻せません。" }, diff --git a/locales/ko/common.json b/locales/ko/common.json index 5a45aa988..79a238d76 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -955,6 +955,8 @@ "reenable": "다시 등록", "relay_desc": "기본값은 호스팅된 Bulwark 릴레이입니다. 셀프 호스팅 시에만 변경하세요.", "relay_label": "푸시 릴레이", + "relay_locked": "관리자에 의해 설정됨", + "relay_locked_desc": "푸시 릴레이 URL은 관리자가 설정했으며 변경할 수 없어요.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "이 기기에서 활성", "status_busy": "처리 중…", @@ -1010,6 +1012,7 @@ "label": "삭제 동작", "description": "메일을 삭제할 때의 동작을 선택해 주세요", "trash": "휴지통으로 이동", + "trash_and_read": "휴지통으로 이동하고 읽음으로 표시", "permanent": "영구 삭제", "warning": "메일이 영구적으로 삭제되며 복구할 수 없어요. 주의해서 사용해 주세요." }, diff --git a/locales/lv/common.json b/locales/lv/common.json index ddc7e420e..62a095095 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -955,6 +955,8 @@ "reenable": "Reģistrēt vēlreiz", "relay_desc": "Pēc noklusējuma izmanto izmitināto Bulwark releju. Mainiet tikai tad, ja izmitināt pats.", "relay_label": "Push relejs", + "relay_locked": "Iestatījis administrators", + "relay_locked_desc": "Push releja URL ir iestatījis administrators, un to nevar mainīt.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Aktīvs šajā ierīcē", "status_busy": "Notiek darbs…", @@ -1010,6 +1012,7 @@ "label": "Darbība dzēšot", "description": "Kas notiek, izdzēšot vēstuli", "trash": "Pārvietot uz atkritni", + "trash_and_read": "Pārvietot uz atkritni un atzīmēt kā lasītu", "permanent": "Dzēst neatgriezeniski", "warning": "Vēstules tiks dzēstas neatgriezeniski. Šī darbība nav atceļama." }, diff --git a/locales/nl/common.json b/locales/nl/common.json index 43679c438..d1208b362 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1012,6 +1012,7 @@ "label": "Verwijderactie", "description": "Wat er gebeurt wanneer je een e-mail verwijdert", "trash": "Verplaatsen naar prullenbak", + "trash_and_read": "Verplaatsen naar prullenbak en als gelezen markeren", "permanent": "Permanent verwijderen", "warning": "E-mails worden permanent verwijderd en kunnen niet worden hersteld. Deze actie is onomkeerbaar." }, diff --git a/locales/pl/common.json b/locales/pl/common.json index bc3367522..ee550e282 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -955,6 +955,8 @@ "reenable": "Zarejestruj ponownie", "relay_desc": "Domyślnie używa hostowanego przekaźnika Bulwark. Zmień tylko jeśli hostujesz samodzielnie.", "relay_label": "Przekaźnik push", + "relay_locked": "Ustawione przez administratora", + "relay_locked_desc": "Adres URL przekaźnika push został ustawiony przez administratora i nie można go zmienić.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Aktywne na tym urządzeniu", "status_busy": "Przetwarzanie…", @@ -1010,6 +1012,7 @@ "label": "Akcja usuwania", "description": "Co ma się stać po usunięciu wiadomości e-mail", "trash": "Przenieś do kosza", + "trash_and_read": "Przenieś do kosza i oznacz jako przeczytane", "permanent": "Usuń trwale", "warning": "Wiadomości e-mail zostaną trwale usunięte i nie będzie można ich odzyskać. Ta akcja jest nieodwracalna." }, diff --git a/locales/pt/common.json b/locales/pt/common.json index 9d2f65bf7..3f7ef129c 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -955,6 +955,8 @@ "reenable": "Registrar novamente", "relay_desc": "Usa o relay Bulwark hospedado por padrão. Altere apenas se você hospedar.", "relay_label": "Relay push", + "relay_locked": "Definido pelo administrador", + "relay_locked_desc": "A URL do relay push foi definida pelo seu administrador e não pode ser alterada.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Ativo neste dispositivo", "status_busy": "Processando…", @@ -1010,6 +1012,7 @@ "label": "Ação de Exclusão", "description": "O que acontece quando você exclui um e-mail", "trash": "Mover para Lixeira", + "trash_and_read": "Mover para Lixeira e marcar como lida", "permanent": "Excluir Permanentemente", "warning": "Os e-mails serão excluídos permanentemente e não poderão ser recuperados. Esta ação é irreversível." }, diff --git a/locales/ru/common.json b/locales/ru/common.json index a2205d4d6..747b269ce 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -955,6 +955,8 @@ "reenable": "Перерегистрировать", "relay_desc": "По умолчанию используется размещённый релей Bulwark. Меняйте только если хостите самостоятельно.", "relay_label": "Push-релей", + "relay_locked": "Установлено администратором", + "relay_locked_desc": "URL push-релея установлен вашим администратором и не может быть изменён.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Активно на этом устройстве", "status_busy": "Выполняется…", @@ -1010,6 +1012,7 @@ "label": "Действие при удалении", "description": "Что происходит при удалении письма", "trash": "Переместить в корзину", + "trash_and_read": "Переместить в корзину и пометить как прочитанное", "permanent": "Удалить навсегда", "warning": "Письма будут удалены навсегда и не могут быть восстановлены. Это действие необратимо." }, diff --git a/locales/tr/common.json b/locales/tr/common.json index 53b49aeb8..287522abb 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -955,6 +955,8 @@ "reenable": "Yeniden kaydet", "relay_desc": "Varsayılan olarak barındırılan Bulwark rölesini kullanır. Yalnızca kendiniz barındırıyorsanız değiştirin.", "relay_label": "Push röle", + "relay_locked": "Yönetici tarafından ayarlandı", + "relay_locked_desc": "Push röle URL'si yöneticiniz tarafından ayarlandı ve değiştirilemez.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Bu cihazda etkin", "status_busy": "Çalışıyor…", @@ -1010,6 +1012,7 @@ "label": "Silme İşlemi", "description": "Bir e-postayı sildiğinizde ne olacağı", "trash": "Çöp Kutusuna Taşı", + "trash_and_read": "Çöp Kutusuna Taşı ve okundu olarak işaretle", "permanent": "Kalıcı Olarak Sil", "warning": "E-postalar kalıcı olarak silinecek ve kurtarılamayacak. Bu işlem geri alınamaz." }, diff --git a/locales/uk/common.json b/locales/uk/common.json index 5ee0ba1ac..e08ee3729 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -955,6 +955,8 @@ "reenable": "Перереєструвати", "relay_desc": "Типово використовується розміщене реле Bulwark. Змінюйте лише, якщо хостите самостійно.", "relay_label": "Push-реле", + "relay_locked": "Встановлено адміністратором", + "relay_locked_desc": "URL push-реле встановлено вашим адміністратором і його не можна змінити.", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "Активно на цьому пристрої", "status_busy": "Виконується…", @@ -1010,6 +1012,7 @@ "label": "Видалити дію", "description": "Що відбувається, коли ви видаляєте електронний лист", "trash": "Перемістити в кошик", + "trash_and_read": "Перемістити в кошик і позначити як прочитане", "permanent": "Видалити назавжди", "warning": "Електронні листи буде видалено назавжди, і їх неможливо буде відновити. Ця дія незворотна." }, diff --git a/locales/zh/common.json b/locales/zh/common.json index 804bb0aab..00a7634f5 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -955,6 +955,8 @@ "reenable": "重新注册", "relay_desc": "默认使用托管的 Bulwark 中继。仅当您自托管时才更改。", "relay_label": "推送中继", + "relay_locked": "由管理员设置", + "relay_locked_desc": "推送中继 URL 已由您的管理员设置,无法更改。", "relay_placeholder": "https://notifications.relay.example.com", "status_active": "在此设备上活动", "status_busy": "工作中…", @@ -1010,6 +1012,7 @@ "label": "删除操作", "description": "删除邮件后的行为", "trash": "移至已删除", + "trash_and_read": "移至已删除并标记为已读", "permanent": "永久删除", "warning": "邮件将被永久删除且无法恢复。此操作是不可逆转的。" }, From 00b40fc48a7fcd43fa65f21b94b42bda70102546 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 28 May 2026 20:31:03 +0200 Subject: [PATCH 012/437] chore: update version to 1.7.2 --- CHANGELOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- VERSION | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 51 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f233e76dc..b89142179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 1.7.2 (2026-05-28) + +### Features + +- **Mail**: Scheduled send and send delay (#322) +- **Mail**: Drag emails out to the file explorer as `.eml` +- **Mail**: Import emails from `.zip` archives +- **Mail**: "Move to Trash and mark as read" delete action (#323) +- **Mail**: Include group inboxes in the unified mailbox view (#328) +- **Mail**: Locale-aware date format in the email list with a preset picker (#331) +- **Mail**: Allow drag-and-drop into shared mailboxes +- **Composer**: Ctrl/Cmd+Enter sends the open draft +- **Settings**: New Downloads tab with template editor for `.eml` and attachment filenames +- **Settings**: Filename transform settings and an ASCII-only "date (from-to) subject" template +- **Settings**: Post-export action (keep / archive / trash) +- **Settings**: Template for multi-email `.zip` filenames +- **Admin**: Per-domain branding editor with overrides on `/api/config`, manifest, and PWA icon (#332) +- **Admin**: Policy-controlled push relay URL with optional user lock +- **i18n**: `NEXT_PUBLIC_DEFAULT_LOCALE` for fallback UI locale (#243) + +### Fixes + +- **Mail**: Editable HTML signature in new mail; clean state on every compose entry (#329) +- **Mail**: Report real upload progress with XHR progress events (#333) +- **Mail**: Restore `blob:` in `object-src` and `frame-src` CSP for PDF/HTML previews +- **Mail**: Match user-avatar treatment on quick reply +- **Email viewer**: Stop shattering table cells with `word-break: break-word` +- **Composer**: Scope Ctrl/Cmd+Enter send to the focused composer +- **Composer**: Stop closing the form when editing any field +- **Pro**: Keep the empty viewer pane visible in the split layout +- **Pro**: Prevent an empty main pane when reordering tabs across panes +- **Mobile**: Collapse focus mail layout to multi-line +- **Mobile**: Keep a gutter on bare-HTML and plain-text emails +- **Calendar**: Align continued multi-week events with the week's left edge +- **Calendar**: Show the end date in the event popover for multi-day events (#318) +- **Calendar**: Convert `recurrenceRules` to singular in batch create +- **Calendar**: Handle malformed event dates (#316) +- **Files**: Stop URL-encoding drag-out filenames and preserve Unicode letters +- **Routing**: Prefix remaining ``, favicon, and WebDAV URLs with `basePath` (#319) +- **Routing**: Prefix hand-written URLs with `basePath` for subpath deployments +- **Auth**: `OAUTH_ALLOW_PRIVATE_ENDPOINTS` for split-DNS setups + +### i18n + +- Add missing translation keys across 16 locales + ## 1.7.1 (2026-05-22) ### Features diff --git a/README.md b/README.md index 9dcca13aa..42cf920f5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.7.1-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.7.2-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) [![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/) diff --git a/VERSION b/VERSION index 943f9cbc4..f8a696c8d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.1 +1.7.2 diff --git a/package-lock.json b/package-lock.json index 4a9d51409..ddcfc2513 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-webmail", - "version": "1.7.1", + "version": "1.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-webmail", - "version": "1.7.1", + "version": "1.7.2", "license": "AGPL-3.0-only", "dependencies": { "@tanstack/react-virtual": "^3.13.24", diff --git a/package.json b/package.json index dec662563..100281cf8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.7.1", + "version": "1.7.2", "description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only", From f238ca589852e00ea3e79ecf6376b0bb1ed09f0c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 28 May 2026 21:09:50 +0200 Subject: [PATCH 013/437] chore: update package-lock.json --- package-lock.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/package-lock.json b/package-lock.json index ddcfc2513..0e03d700a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5971,6 +5971,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7642,6 +7643,17 @@ } } }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", From 7341e47a1be58d9efe750957106ba20fcc18ec19 Mon Sep 17 00:00:00 2001 From: Roman Oswald <79031706+r-oswald@users.noreply.github.com> Date: Fri, 29 May 2026 20:50:17 +0200 Subject: [PATCH 014/437] feat: route Sent copy to shared-mailbox account on per-identity send --- lib/jmap/client.ts | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 54da61485..b0c14e002 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2153,7 +2153,13 @@ export class JMAPClient implements IJMAPClient { ): Promise { const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; const emailId = `send-${Date.now()}`; - const mailboxes = await this.getMailboxes(); + const targetAccountId = (fromEmail && Object.keys(this.accounts).find(id => + this.accounts[id]?.name?.toLowerCase() === fromEmail.toLowerCase() + )) || this.accountId; + const mboxResp = await this.request([ + ["Mailbox/get", { accountId: targetAccountId }, "0"] + ]); + const mailboxes = (mboxResp.methodResponses?.[0]?.[1]?.list || []) as Mailbox[]; const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) { throw new Error('No sent mailbox found'); @@ -2167,25 +2173,25 @@ export class JMAPClient implements IJMAPClient { let identityReplyTo: EmailAddress[] | undefined; { const identityResponse = await this.request([ - ["Identity/get", { accountId: this.accountId }, "0"] + ["Identity/get", { accountId: targetAccountId }, "0"] ]); if (!finalIdentityId) { - finalIdentityId = this.accountId; + finalIdentityId = targetAccountId; } if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") { const identities = (identityResponse.methodResponses[0][1].list || []) as Identity[]; if (identities.length > 0) { - if (!identityId) { + let matchingIdentity = identityId + ? identities.find((id) => id.id === identityId) + : undefined; + if (!matchingIdentity) { const target = fromEmail || this.username; - const matchingIdentity = identities.find((id) => id.email === target) + matchingIdentity = identities.find((id) => id.email === target) || (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined); - finalIdentityId = matchingIdentity?.id || identities[0].id; - identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo; - } else { - const matchedIdentity = identities.find((id) => id.id === identityId); - identityReplyTo = matchedIdentity?.replyTo; } + finalIdentityId = matchingIdentity?.id || identities[0].id; + identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo; } } } @@ -2278,21 +2284,21 @@ export class JMAPClient implements IJMAPClient { destroy: [draftId], }, "0"]); methodCalls.push(["Email/set", { - accountId: this.accountId, + accountId: targetAccountId, create: { [emailId]: emailCreate }, }, "1"]); methodCalls.push(["EmailSubmission/set", { - accountId: this.getSubmissionAccountId(), + accountId: this.getSubmissionAccountId(targetAccountId), create: buildSubmissionCreate("1"), onSuccessUpdateEmail, }, "2"]); } else { methodCalls.push(["Email/set", { - accountId: this.accountId, + accountId: targetAccountId, create: { [emailId]: emailCreate }, }, "0"]); methodCalls.push(["EmailSubmission/set", { - accountId: this.getSubmissionAccountId(), + accountId: this.getSubmissionAccountId(targetAccountId), create: buildSubmissionCreate("1"), onSuccessUpdateEmail, }, "1"]); From 241544cd08798cb7a6451d5f12682477555d97c8 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Sat, 30 May 2026 13:51:21 +0200 Subject: [PATCH 015/437] Fix: correct and localize the description per locale The root (main)/layout renders ABOVE the [locale] segment, so next-intl's getLocale() returns the default locale there - emitting on every page regardless of UI language (e.g. /de) and a hardcoded English description. Both are strong "translate this page" triggers in Chrome. proxy.ts already exposes the nonce to server components via the x-middleware-request-* mechanism; expose the request pathname the same way as x-pathname, and have the root layout derive the locale from it (falling back to getLocale() when the path has no locale segment) for both and the localized meta_description (new key in all 17 locales; the English value is unchanged). --- app/(main)/layout.tsx | 25 ++++++++++++++++++++++--- locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/fr/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ru/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh/common.json | 1 + proxy.ts | 12 ++++++++---- 19 files changed, 47 insertions(+), 7 deletions(-) diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index ac5c3b429..dac3d3d31 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -1,13 +1,26 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; -import { getLocale } from "next-intl/server"; +import { getLocale, getTranslations } from "next-intl/server"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { configManager } from "@/lib/admin/config-manager"; import { withBasePath } from "@/lib/browser-navigation"; +import { locales } from "@/i18n/routing"; import "../globals.css"; +// This layout renders and sits ABOVE the [locale] segment, so +// next-intl's getLocale() returns the default locale here - emitting +// on e.g. /de pages, which makes browsers offer to +// "translate this page". Recover the active locale from the request pathname +// (exposed by proxy.ts as x-pathname), falling back to getLocale() (cookie / +// Accept-Language) when the path carries no locale segment. +async function resolveRequestLocale(): Promise { + const pathname = (await headers()).get("x-pathname") || ""; + const seg = pathname.split("/").find((s) => (locales as readonly string[]).includes(s)); + return seg ?? (await getLocale()); +} + const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], @@ -27,10 +40,16 @@ export const viewport: Viewport = { export async function generateMetadata(): Promise { await configManager.ensureLoaded(); const faviconUrl = configManager.get("faviconUrl", "/branding/Bulwark_Favicon.svg"); + // Localize the description to match the UI language; a hardcoded + // English description is another signal that makes Chrome offer to + // "translate this page". Resolve the locale from the request path, since this + // layout is above the [locale] segment (see resolveRequestLocale). + const locale = await resolveRequestLocale(); + const t = await getTranslations({ locale }); return { title: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || "Webmail", - description: "Minimalist webmail client using JMAP protocol", + description: t("meta_description"), appleWebApp: { capable: true, statusBarStyle: "black-translucent", @@ -48,7 +67,7 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - const locale = await getLocale(); + const locale = await resolveRequestLocale(); const nonce = (await headers()).get("x-nonce") ?? ""; const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || ""; diff --git a/locales/cs/common.json b/locales/cs/common.json index 6620a55be..1d52d740a 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Minimalistický webmailový klient využívající protokol JMAP", "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/da/common.json b/locales/da/common.json index 2f455b425..0f316d4ab 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Minimalistisk webmail-klient, der bruger JMAP-protokollen", "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/de/common.json b/locales/de/common.json index fa621a996..74b90d063 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Minimalistischer Webmail-Client mit JMAP-Protokoll", "login": { "title": "Webmail", "username_label": "E-Mail", diff --git a/locales/en/common.json b/locales/en/common.json index 1dbba5934..ef8b8030a 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Minimalist webmail client using JMAP protocol", "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/es/common.json b/locales/es/common.json index 7b83d66a6..a436a5eac 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Cliente de correo web minimalista que usa el protocolo JMAP", "login": { "title": "Correo Web", "username_label": "Correo electrónico", diff --git a/locales/fr/common.json b/locales/fr/common.json index f9cb00ad8..a24d1d65b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Client de messagerie web minimaliste utilisant le protocole JMAP", "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/it/common.json b/locales/it/common.json index 3c96458a0..8794f7a54 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Client di posta web minimalista che utilizza il protocollo JMAP", "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/ja/common.json b/locales/ja/common.json index aa366688d..4d4e7518d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1,4 +1,5 @@ { + "meta_description": "JMAPプロトコルを使用するミニマルなウェブメールクライアント", "login": { "title": "ウェブメール", "username_label": "メールアドレス", diff --git a/locales/ko/common.json b/locales/ko/common.json index 79a238d76..031cc9878 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1,4 +1,5 @@ { + "meta_description": "JMAP 프로토콜을 사용하는 미니멀한 웹메일 클라이언트", "login": { "title": "웹메일", "username_label": "이메일", diff --git a/locales/lv/common.json b/locales/lv/common.json index 62a095095..c5718bee6 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Minimālistisks tīmekļa pasta klients, kas izmanto JMAP protokolu", "login": { "title": "Tīmekļa pasts", "username_label": "E-pasta adrese", diff --git a/locales/nl/common.json b/locales/nl/common.json index d1208b362..601f04207 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Minimalistische webmailclient die het JMAP-protocol gebruikt", "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/pl/common.json b/locales/pl/common.json index ee550e282..ac3e37ced 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Minimalistyczny klient poczty internetowej wykorzystujący protokół JMAP", "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/pt/common.json b/locales/pt/common.json index 3f7ef129c..d83bbfe87 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Cliente de webmail minimalista que usa o protocolo JMAP", "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/ru/common.json b/locales/ru/common.json index 747b269ce..c772e73a9 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Минималистичный веб-клиент электронной почты, использующий протокол JMAP", "login": { "title": "Веб-почта", "username_label": "Электронная почта", diff --git a/locales/tr/common.json b/locales/tr/common.json index 287522abb..ec0523685 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1,4 +1,5 @@ { + "meta_description": "JMAP protokolünü kullanan minimalist web posta istemcisi", "login": { "title": "Webmail", "username_label": "E-posta", diff --git a/locales/uk/common.json b/locales/uk/common.json index e08ee3729..c25be2c1a 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1,4 +1,5 @@ { + "meta_description": "Мінімалістичний вебклієнт електронної пошти, що використовує протокол JMAP", "login": { "title": "Веб-пошта", "username_label": "Електронна пошта", diff --git a/locales/zh/common.json b/locales/zh/common.json index 00a7634f5..835f5c5d1 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1,4 +1,5 @@ { + "meta_description": "使用 JMAP 协议的极简网页邮件客户端", "login": { "title": "网页邮箱", "username_label": "邮箱地址", diff --git a/proxy.ts b/proxy.ts index 26fc1dcb7..5e97fbb5e 100644 --- a/proxy.ts +++ b/proxy.ts @@ -143,11 +143,15 @@ export async function proxy(request: NextRequest) { const response = intlResponse ?? NextResponse.next(); const existing = response.headers.get("x-middleware-override-headers"); - response.headers.set( - "x-middleware-override-headers", - existing ? `${existing},x-nonce` : "x-nonce" - ); + // Expose the nonce AND the request pathname to server components as request + // headers. The root (main)/layout renders ABOVE the [locale] segment, + // so getLocale() can't resolve the active locale there and falls back to the + // default - emitting on e.g. /de pages, which makes browsers + // offer to "translate this page". The layout reads x-pathname to recover it. + const overrides = [existing, "x-nonce", "x-pathname"].filter(Boolean).join(","); + response.headers.set("x-middleware-override-headers", overrides); response.headers.set("x-middleware-request-x-nonce", nonce); + response.headers.set("x-middleware-request-x-pathname", pathname); response.headers.set("X-Content-Type-Options", "nosniff"); From 05e2837f6b3c20937e002e059ba4ab6ac4cd25f2 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 13:02:12 +0200 Subject: [PATCH 016/437] Fix: localize reply/forward quote header incl. sender address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reply/forward quote header was always emitted in English ("On {date}, {from} wrote:", "---------- Forwarded message ----------", From/Date/Subject) regardless of UI language, in both the main path (lib/quote-header.ts) and the composer's inline fallback. quote-header.ts now takes an optional localized QuoteHeaderLabels set (English defaults preserved for back-compat); page.tsx builds it from a new quote_header message namespace, and the composer fallback uses the same keys. Added the quote_header namespace to all 17 locales. Also folds in the forward-sender-address fix: the forward "From:" line now shows the full "Name " like every mail client (the reply line keeps the bare name, which reads naturally in "On … wrote:"). --- app/(main)/[locale]/page.tsx | 10 ++++++- components/email/email-composer.tsx | 25 ++++++++++++----- lib/quote-header.ts | 42 ++++++++++++++++++++++++++--- locales/cs/common.json | 7 +++++ locales/da/common.json | 7 +++++ locales/de/common.json | 7 +++++ locales/en/common.json | 7 +++++ locales/es/common.json | 7 +++++ locales/fr/common.json | 7 +++++ locales/it/common.json | 7 +++++ locales/ja/common.json | 7 +++++ locales/ko/common.json | 7 +++++ locales/lv/common.json | 7 +++++ locales/nl/common.json | 7 +++++ locales/pl/common.json | 7 +++++ locales/pt/common.json | 7 +++++ locales/ru/common.json | 7 +++++ locales/tr/common.json | 7 +++++ locales/uk/common.json | 7 +++++ locales/zh/common.json | 7 +++++ 20 files changed, 185 insertions(+), 11 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index ae57e8ece..e951b6c91 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -81,6 +81,7 @@ const SCHEDULED_MAILBOX_ID = '__scheduled__'; export default function Home() { const t = useTranslations(); const tCommon = useTranslations('common'); + const tQuote = useTranslations('quote_header'); const { appName } = useConfig(); const mailLayout = useSettingsStore((state) => state.mailLayout); const [showComposer, setShowComposer] = useState(false); @@ -1212,13 +1213,20 @@ export default function Home() { locale: useLocaleStore.getState().locale, timeFormat: useSettingsStore.getState().timeFormat, unknownLabel: tCommon('unknown'), + labels: { + formatReplyLine: (vars) => tQuote('reply_line', vars), + forwardedSeparator: tQuote('forwarded_separator'), + fromLabel: tQuote('from_label'), + dateLabel: tQuote('date_label'), + subjectLabel: tQuote('subject_label'), + }, }); setComposerQuoteHeader(header); } catch (err) { console.warn('[quote-header] plugin transform failed; using default', err); setComposerQuoteHeader(null); } - }, [tCommon]); + }, [tCommon, tQuote]); // Force a clean composer remount on every fresh entry point so prior // compose state can't bleed into the new session (#329 C). The composer is diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 64f9c9d8b..2b9566b1d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -196,6 +196,7 @@ export function EmailComposer({ }: EmailComposerProps) { const t = useTranslations('email_composer'); const tCommon = useTranslations('common'); + const tQuote = useTranslations('quote_header'); const timeFormat = useSettingsStore((state) => state.timeFormat); const plainTextMode = useSettingsStore((state) => state.plainTextMode); const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter); @@ -291,6 +292,13 @@ export function EmailComposer({ const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); + // Forward "From:" shows the full sender incl. address; reply line keeps + // the bare name (reads naturally in the localized "On … wrote:" line). + const fromStrFull = from + ? (from.name && from.email && from.name !== from.email + ? `${from.name} <${from.email}>` + : (from.email || from.name || tCommon('unknown'))) + : tCommon('unknown'); const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : ''); const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n'); @@ -311,9 +319,9 @@ export function EmailComposer({ } if (mode === 'forward') { - return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`; + return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`; + return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`; } return prefix; } @@ -336,6 +344,11 @@ export function EmailComposer({ const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); + const fromStrFull = from + ? (from.name && from.email && from.name !== from.email + ? `${from.name} <${from.email}>` + : (from.email || from.name || tCommon('unknown'))) + : tCommon('unknown'); const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, { embed: shouldEmbedSignatureAboveQuote, @@ -359,8 +372,8 @@ export function EmailComposer({ // Build quoted content as HTML if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { const quoteHeader = mode === 'forward' - ? `---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

` - : `On ${date}, ${fromStr} wrote:
`; + ? `${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${fromStrFull}
${tQuote('date_label')}: ${date}
${tQuote('subject_label')}: ${replyTo.subject || ''}

` + : `${tQuote('reply_line', { date, from: fromStr })}
`; // cid: image refs are rewritten so they render in the editor (browsers // can't fetch cid: URLs); see useEffect below for the data-URL backfill. const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody); @@ -370,9 +383,9 @@ export function EmailComposer({ if (replyTo.body) { const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); if (mode === 'forward') { - return `${prefix}${signatureBlock}

---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

${escapedOriginal}`; + return `${prefix}${signatureBlock}

${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${fromStrFull}
${tQuote('date_label')}: ${date}
${tQuote('subject_label')}: ${replyTo.subject || ''}

${escapedOriginal}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}${signatureBlock}

On ${date}, ${fromStr} wrote:
${escapedOriginal}
`; + return `${prefix}${signatureBlock}

${tQuote('reply_line', { date, from: fromStr })}
${escapedOriginal}
`; } } return prefix; diff --git a/lib/quote-header.ts b/lib/quote-header.ts index 786abfc82..c55eb046c 100644 --- a/lib/quote-header.ts +++ b/lib/quote-header.ts @@ -10,6 +10,17 @@ import { formatDateTime } from "@/lib/utils"; import { emailHooks } from "@/lib/plugin-hooks"; import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types"; +// Localized label set the caller passes in. Labels live on the client where +// useTranslations is available; this module stays framework-agnostic. +export interface QuoteHeaderLabels { + /** ICU-formatted reply line, e.g. "On {date}, {from} wrote:" with placeholders already substituted. */ + formatReplyLine: (vars: { date: string; from: string }) => string; + forwardedSeparator: string; + fromLabel: string; + dateLabel: string; + subjectLabel: string; +} + interface BuildArgs { mode: "reply" | "replyAll" | "forward"; email: { @@ -24,10 +35,24 @@ interface BuildArgs { locale: string; timeFormat: "12h" | "24h"; unknownLabel: string; + /** + * Localized labels. Optional for backward compatibility; falls back to + * English (matching the original hardcoded behaviour) when not supplied. + */ + labels?: QuoteHeaderLabels; } +const DEFAULT_LABELS: QuoteHeaderLabels = { + formatReplyLine: ({ date, from }) => `On ${date}, ${from} wrote:`, + forwardedSeparator: "---------- Forwarded message ----------", + fromLabel: "From", + dateLabel: "Date", + subjectLabel: "Subject", +}; + function defaultHeader(args: BuildArgs): QuoteHeader { const { mode, email, timeFormat, unknownLabel } = args; + const labels = args.labels ?? DEFAULT_LABELS; const date = email.receivedAt ? formatDateTime(email.receivedAt, timeFormat, { weekday: "short", @@ -38,16 +63,25 @@ function defaultHeader(args: BuildArgs): QuoteHeader { : ""; const from = email.from?.[0]; const fromStr = from ? `${from.name || from.email}` : unknownLabel; + // Forward header "From:" shows the full sender incl. address ("Name + // "), like every mail client. The reply line keeps the bare name + // (reads more naturally in "On … wrote:"). + const fromStrFull = from + ? (from.name && from.email && from.name !== from.email + ? `${from.name} <${from.email}>` + : (from.email || from.name || unknownLabel)) + : unknownLabel; const subject = email.subject || ""; if (mode === "forward") { - const text = `---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${subject}\n`; - const html = `
---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${subject}

`; + const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`; + const html = `
${labels.forwardedSeparator}
${labels.fromLabel}: ${fromStrFull}
${labels.dateLabel}: ${date}
${labels.subjectLabel}: ${subject}

`; return { html, text, wrapInBlockquote: false }; } - const text = `On ${date}, ${fromStr} wrote:\n`; - const html = `
On ${date}, ${fromStr} wrote:
`; + const replyLine = labels.formatReplyLine({ date, from: fromStr }); + const text = `${replyLine}\n`; + const html = `
${replyLine}
`; return { html, text, wrapInBlockquote: true }; } diff --git a/locales/cs/common.json b/locales/cs/common.json index 6620a55be..3ce401536 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Dne {date} napsal(a) {from}:", + "forwarded_separator": "---------- Přeposlaná zpráva ----------", + "from_label": "Od", + "date_label": "Datum", + "subject_label": "Předmět" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/da/common.json b/locales/da/common.json index 2f455b425..26d72337c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Den {date} skrev {from}:", + "forwarded_separator": "---------- Videresendt besked ----------", + "from_label": "Fra", + "date_label": "Dato", + "subject_label": "Emne" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/de/common.json b/locales/de/common.json index fa621a996..180258ae6 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Am {date} schrieb {from}:", + "forwarded_separator": "---------- Weitergeleitete Nachricht ----------", + "from_label": "Von", + "date_label": "Datum", + "subject_label": "Betreff" + }, "login": { "title": "Webmail", "username_label": "E-Mail", diff --git a/locales/en/common.json b/locales/en/common.json index 1dbba5934..b24adfd32 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "On {date}, {from} wrote:", + "forwarded_separator": "---------- Forwarded message ----------", + "from_label": "From", + "date_label": "Date", + "subject_label": "Subject" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/es/common.json b/locales/es/common.json index 7b83d66a6..615ebeb15 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "El {date}, {from} escribió:", + "forwarded_separator": "---------- Mensaje reenviado ----------", + "from_label": "De", + "date_label": "Fecha", + "subject_label": "Asunto" + }, "login": { "title": "Correo Web", "username_label": "Correo electrónico", diff --git a/locales/fr/common.json b/locales/fr/common.json index f9cb00ad8..0ac4ee61e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Le {date}, {from} a écrit :", + "forwarded_separator": "---------- Message transféré ----------", + "from_label": "De", + "date_label": "Date", + "subject_label": "Sujet" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/it/common.json b/locales/it/common.json index 3c96458a0..6da32b5e4 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Il {date}, {from} ha scritto:", + "forwarded_separator": "---------- Messaggio inoltrato ----------", + "from_label": "Da", + "date_label": "Data", + "subject_label": "Oggetto" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/ja/common.json b/locales/ja/common.json index aa366688d..ff4153fe4 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date}に{from}が書きました:", + "forwarded_separator": "---------- 転送メッセージ ----------", + "from_label": "差出人", + "date_label": "日付", + "subject_label": "件名" + }, "login": { "title": "ウェブメール", "username_label": "メールアドレス", diff --git a/locales/ko/common.json b/locales/ko/common.json index 79a238d76..1545df908 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date}에 {from}님이 작성:", + "forwarded_separator": "---------- 전달된 메시지 ----------", + "from_label": "보낸 사람", + "date_label": "날짜", + "subject_label": "제목" + }, "login": { "title": "웹메일", "username_label": "이메일", diff --git a/locales/lv/common.json b/locales/lv/common.json index 62a095095..2dd8d7b08 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date} {from} rakstīja:", + "forwarded_separator": "---------- Pārsūtītā ziņa ----------", + "from_label": "No", + "date_label": "Datums", + "subject_label": "Tēma" + }, "login": { "title": "Tīmekļa pasts", "username_label": "E-pasta adrese", diff --git a/locales/nl/common.json b/locales/nl/common.json index d1208b362..2901d9186 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Op {date} schreef {from}:", + "forwarded_separator": "---------- Doorgestuurd bericht ----------", + "from_label": "Van", + "date_label": "Datum", + "subject_label": "Onderwerp" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/pl/common.json b/locales/pl/common.json index ee550e282..5cf29463e 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date}, {from} napisał(a):", + "forwarded_separator": "---------- Wiadomość przekazana ----------", + "from_label": "Od", + "date_label": "Data", + "subject_label": "Temat" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/pt/common.json b/locales/pt/common.json index 3f7ef129c..47b5d68c7 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Em {date}, {from} escreveu:", + "forwarded_separator": "---------- Mensagem encaminhada ----------", + "from_label": "De", + "date_label": "Data", + "subject_label": "Assunto" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/ru/common.json b/locales/ru/common.json index 747b269ce..b46e2970f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date}, {from} написал:", + "forwarded_separator": "---------- Пересланное сообщение ----------", + "from_label": "От", + "date_label": "Дата", + "subject_label": "Тема" + }, "login": { "title": "Веб-почта", "username_label": "Электронная почта", diff --git a/locales/tr/common.json b/locales/tr/common.json index 287522abb..b590d642b 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date} tarihinde {from} şunu yazdı:", + "forwarded_separator": "---------- İletilen mesaj ----------", + "from_label": "Kimden", + "date_label": "Tarih", + "subject_label": "Konu" + }, "login": { "title": "Webmail", "username_label": "E-posta", diff --git a/locales/uk/common.json b/locales/uk/common.json index e08ee3729..c2aed60bd 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date}, {from} написав:", + "forwarded_separator": "---------- Переслане повідомлення ----------", + "from_label": "Від", + "date_label": "Дата", + "subject_label": "Тема" + }, "login": { "title": "Веб-пошта", "username_label": "Електронна пошта", diff --git a/locales/zh/common.json b/locales/zh/common.json index 00a7634f5..dc473be07 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "在 {date},{from} 写道:", + "forwarded_separator": "---------- 转发邮件 ----------", + "from_label": "发件人", + "date_label": "日期", + "subject_label": "主题" + }, "login": { "title": "网页邮箱", "username_label": "邮箱地址", From 0879030dc8f210604e908392852d47b02e319b08 Mon Sep 17 00:00:00 2001 From: Phongsaton Untan Date: Sat, 30 May 2026 07:19:11 +0700 Subject: [PATCH 017/437] feat(dev-jmap): persist identity create/update/destroy in mock server The dev mock's Identity/set discarded its payload and Identity/get always returned a static list, so saved identities never round-tripped in local development. Persist create (with mayDelete: true), update, and destroy in place, mirroring handleMailboxSet, so signature edits stick when testing without a real JMAP server. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/dev-jmap/[...path]/route.ts | 63 ++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 1d18ad4ad..74d5971bc 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -723,7 +723,18 @@ const emails: MockEmail[] = [ // Identities // --------------------------------------------------------------------------- -const IDENTITIES = [ +type MockIdentity = { + id: string; + name: string; + email: string; + replyTo: Array<{ name?: string; email: string }> | null; + bcc: Array<{ name?: string; email: string }> | null; + textSignature: string | null; + htmlSignature: string | null; + mayDelete: boolean; +}; + +const IDENTITIES: MockIdentity[] = [ { id: 'identity-001', name: 'Dev User', @@ -1562,13 +1573,55 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult { function handleIdentitySet(args: MethodArgs, callId: string): MethodResult { const created: Record = {}; - const create = args.create as Record | undefined; + const updated: Record = {}; + const destroyed: string[] = []; + + const create = args.create as Record> | undefined; if (create) { - for (const key of Object.keys(create)) { - created[key] = { id: `identity-new-${Date.now()}-${key}` }; + for (const [key, data] of Object.entries(create)) { + const newId = `identity-${Date.now()}-${key}`; + IDENTITIES.push({ + id: newId, + name: (data.name as string) || '', + email: (data.email as string) || '', + replyTo: (data.replyTo as MockIdentity['replyTo']) ?? null, + bcc: (data.bcc as MockIdentity['bcc']) ?? null, + textSignature: (data.textSignature as string | null) ?? null, + htmlSignature: (data.htmlSignature as string | null) ?? null, + mayDelete: true, + }); + created[key] = { id: newId }; } } - return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId]; + + const update = args.update as Record> | undefined; + if (update) { + for (const [id, changes] of Object.entries(update)) { + const identity = IDENTITIES.find((i) => i.id === id); + if (identity) { + // Email is immutable per the identity form, so it's never in `changes`. + if (changes.name !== undefined) identity.name = changes.name as string; + if (changes.replyTo !== undefined) identity.replyTo = changes.replyTo as MockIdentity['replyTo']; + if (changes.bcc !== undefined) identity.bcc = changes.bcc as MockIdentity['bcc']; + if (changes.textSignature !== undefined) identity.textSignature = changes.textSignature as string | null; + if (changes.htmlSignature !== undefined) identity.htmlSignature = changes.htmlSignature as string | null; + updated[id] = null; + } + } + } + + const destroy = args.destroy as string[] | undefined; + if (destroy) { + for (const id of destroy) { + const idx = IDENTITIES.findIndex((i) => i.id === id); + if (idx !== -1) { + IDENTITIES.splice(idx, 1); + destroyed.push(id); + } + } + } + + return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId]; } function handleThreadGet(args: MethodArgs, callId: string): MethodResult { From 196e51e91be2eee2520cfb02ee802926f673b96f Mon Sep 17 00:00:00 2001 From: Phongsaton Untan Date: Sat, 30 May 2026 07:19:38 +0700 Subject: [PATCH 018/437] fix: preserve HTML signature when sending a quick reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick-reply box built its body with appendPlainTextSignature, which runs the identity's HTML signature through htmlToPlainText, and sent a text-only message (htmlBody was undefined). A formatted signature (e.g. …) was therefore flattened to plain text in the sent mail, even though it previewed correctly in the identity editor. The full composer already builds an HTML signature block; quick reply did not. Add an appendHtmlSignature helper (mirrors the composer's send-time block) and, when the sending identity has an HTML signature, send a matching HTML body from handleQuickReply so the markup is preserved. Text-only identities keep the plain-text-only behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(main)/[locale]/page.tsx | 22 +++++++++++++++----- lib/__tests__/signature-utils.test.ts | 22 ++++++++++++++++++++ lib/signature-utils.ts | 29 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index ae57e8ece..dfaa9a202 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -55,7 +55,7 @@ import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailb import { Input } from "@/components/ui/input"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; -import { appendPlainTextSignature } from "@/lib/signature-utils"; +import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { resolveReplyFrom } from "@/lib/reply-identity"; @@ -2090,9 +2090,21 @@ export default function Home() { // Append signature from the sending identity (fall back to primary // when the reply-from lives on the same identity but a different alias). - const finalBody = appendPlainTextSignature(body, sendingIdentity, { - separator: useSettingsStore.getState().signatureSeparatorEnabled, - }); + const separator = useSettingsStore.getState().signatureSeparatorEnabled; + const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator }); + + // When the identity has an HTML signature, send a matching HTML body so the + // signature keeps its formatting; appendPlainTextSignature would otherwise + // flatten it to plain text. Text-only identities keep the plain-text-only + // behavior (htmlBody stays undefined). + const escapedBody = body + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + const finalHtmlBody = sendingIdentity?.htmlSignature?.trim() + ? appendHtmlSignature(`
${escapedBody}
`, sendingIdentity, { separator }) + : undefined; const originalEmailId = selectedEmail.id; const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds; @@ -2124,7 +2136,7 @@ export default function Home() { headerFromEmail, undefined, headerFromName, - undefined, + finalHtmlBody, undefined, threading?.inReplyTo, threading?.references, diff --git a/lib/__tests__/signature-utils.test.ts b/lib/__tests__/signature-utils.test.ts index d2a95d617..27cb2d524 100644 --- a/lib/__tests__/signature-utils.test.ts +++ b/lib/__tests__/signature-utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + appendHtmlSignature, appendPlainTextSignature, getPlainTextSignature, hasMeaningfulHtmlBody, @@ -27,6 +28,27 @@ describe('signature-utils', () => { }); }); + describe('appendHtmlSignature', () => { + it('appends a sanitized html signature, preserving formatting', () => { + expect(appendHtmlSignature('
Hello
', { htmlSignature: 'Alice' })) + .toBe('
Hello


--
Alice'); + }); + + it('escapes and appends a text signature when no html signature exists', () => { + expect(appendHtmlSignature('
Hello
', { textSignature: 'Alice\nEng' })) + .toBe('
Hello


--
Alice
Eng'); + }); + + it('omits the separator marker when disabled', () => { + expect(appendHtmlSignature('
Hi
', { htmlSignature: 'A' }, { separator: false })) + .toBe('
Hi


A'); + }); + + it('leaves the body untouched when no signature exists', () => { + expect(appendHtmlSignature('
Hi
', {})).toBe('
Hi
'); + }); + }); + describe('hasMeaningfulHtmlBody', () => { it('prefers html bodies that preserve signature formatting', () => { expect(hasMeaningfulHtmlBody('
Hello

Alice

')).toBe(true); diff --git a/lib/signature-utils.ts b/lib/signature-utils.ts index f348124e5..678fa4618 100644 --- a/lib/signature-utils.ts +++ b/lib/signature-utils.ts @@ -124,6 +124,35 @@ export function appendPlainTextSignature( return `${body}${sep}${plainTextSignature}`; } +/** + * Append a signature to an HTML body, preserving rich formatting. Used by the + * quick-reply path so an HTML signature keeps its markup instead of being + * flattened to plain text. Mirrors the composer's send-time signature block + * (`buildSignatureHtml` in email-composer.tsx). + */ +export function appendHtmlSignature( + htmlBody: string, + signature?: SignatureSource | null, + options: { separator?: boolean } = {}, +): string { + const sep = options.separator === false ? '

' : '

--
'; + + if (signature?.htmlSignature?.trim()) { + return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`; + } + + if (signature?.textSignature?.trim()) { + const escaped = signature.textSignature + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + return `${htmlBody}${sep}${escaped}`; + } + + return htmlBody; +} + export function hasMeaningfulHtmlBody(html: string): boolean { if (!html.trim()) return false; From f0d87d594a25c2a2d4ffc0555f1cf9328ec6fcc8 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 12:00:44 +0200 Subject: [PATCH 019/437] Fix: honour basePath in plugin sandbox, http.post proxy, and branding Upstream 1.7.2 prefixes most hand-written URLs with basePath via apiFetch / withBasePath, but four subpath-relevant spots were missed: - host-bridge: the sandbox iframe src was a bare "/plugin-sandbox" -> 404 under NEXT_PUBLIC_BASE_PATH, breaking all plugins. Wrap in withBasePath. - host-api doHttpPost: the same-origin /api/* plugin proxy used raw fetch on url.pathname -> 404 under a subpath. Route it through apiFetch. - admin branding preview : unprefixed src -> broken thumbnail. - (sandbox) layout: drop the Geist font + globals.css imports. The sandbox runs with an opaque origin, so those assets are CORS-blocked; the plugin bundle and all host API calls travel over the postMessage bridge, so no same-origin asset fetch happens there. --- app/(main)/admin/_tabs/branding.tsx | 6 +++--- app/(sandbox)/layout.tsx | 24 +++++++++--------------- lib/plugin-sandbox/host-api.ts | 2 +- lib/plugin-sandbox/host-bridge.ts | 6 +++++- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx index 09bd2ce71..99e2249aa 100644 --- a/app/(main)/admin/_tabs/branding.tsx +++ b/app/(main)/admin/_tabs/branding.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react'; -import { apiFetch } from '@/lib/browser-navigation'; +import { apiFetch, withBasePath } from '@/lib/browser-navigation'; import { BRANDING_OVERRIDE_KEYS, parseDomainBranding, @@ -528,7 +528,7 @@ export function BrandingTab() {
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} @@ -606,7 +606,7 @@ export function BrandingTab() {
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} diff --git a/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx index 9813c79df..45dfe9386 100644 --- a/app/(sandbox)/layout.tsx +++ b/app/(sandbox)/layout.tsx @@ -1,17 +1,14 @@ import type { Metadata } from 'next'; import type { ReactNode } from 'react'; -import { Geist, Geist_Mono } from 'next/font/google'; -import '../globals.css'; -const geistSans = Geist({ - variable: '--font-geist-sans', - subsets: ['latin'], -}); - -const geistMono = Geist_Mono({ - variable: '--font-geist-mono', - subsets: ['latin'], -}); +// The plugin sandbox iframe runs with an opaque origin (the `sandbox` +// attribute in production excludes `allow-same-origin` for isolation). Any +// asset request from this layout - bundled fonts, globals.css, etc. - is then +// cross-origin from the "null" origin to the host origin and gets blocked +// (fonts in particular require CORS). So this layout is intentionally minimal: +// no font imports, no CSS imports. Plugins ship their own styles, and both the +// plugin bundle and all host API calls travel over the postMessage RPC bridge, +// so the sandbox never fetches same-origin assets itself. export const metadata: Metadata = { title: 'Plugin sandbox', @@ -21,10 +18,7 @@ export const metadata: Metadata = { export default function PluginSandboxLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 856650d09..168c65ac4 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -140,7 +140,7 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown): headers['Authorization'] = client.getAuthHeader(); headers['X-JMAP-Username'] = client.getUsername(); } - const res = await fetch(url.pathname + url.search, { + const res = await apiFetch(url.pathname + url.search, { method: 'POST', headers, body: JSON.stringify(body), diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 90b0c6df6..a0235ec87 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -10,6 +10,7 @@ import type { InstalledPlugin, SlotName } from '../plugin-types'; import { dispatchApiCall } from './host-api'; import { SANDBOX_PATH } from './protocol'; +import { withBasePath } from '../browser-navigation'; import type { SandboxToHost, HostToSandbox, InitMsg, InitPayload, } from './protocol'; @@ -143,7 +144,10 @@ export class SandboxInstance { this.iframe.style.width = '100%'; this.iframe.style.height = '0px'; } - this.iframe.src = SANDBOX_PATH; + // Prefix with the mount path so the sandbox route resolves under a + // subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare + // "/plugin-sandbox" would hit the origin root and 404, breaking plugins. + this.iframe.src = withBasePath(SANDBOX_PATH); this.listener = (ev) => this.onMessage(ev); window.addEventListener('message', this.listener); From 229992853b43a30889e6890b5c2c9ff6a1228cf9 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 12:35:59 +0200 Subject: [PATCH 020/437] Fix: localize the PWA install prompt The PWA install prompt was hardcoded English regardless of the selected UI language (and the large English block tripped Chrome's translate popup on Android). Add a pwa_install namespace to all 17 locales, switch the component to useTranslations, and move from (main)/layout into (main)/[locale]/layout so it renders inside the IntlProvider. The title keeps the dynamic {appName}, so per-domain branding still applies. --- app/(main)/[locale]/layout.tsx | 2 ++ app/(main)/layout.tsx | 2 -- components/pwa-install-prompt.tsx | 14 ++++++++------ locales/cs/common.json | 8 ++++++++ locales/da/common.json | 8 ++++++++ locales/de/common.json | 8 ++++++++ locales/en/common.json | 8 ++++++++ locales/es/common.json | 8 ++++++++ locales/fr/common.json | 8 ++++++++ locales/it/common.json | 8 ++++++++ locales/ja/common.json | 8 ++++++++ locales/ko/common.json | 8 ++++++++ locales/lv/common.json | 8 ++++++++ locales/nl/common.json | 8 ++++++++ locales/pl/common.json | 8 ++++++++ locales/pt/common.json | 8 ++++++++ locales/ru/common.json | 8 ++++++++ locales/tr/common.json | 8 ++++++++ locales/uk/common.json | 8 ++++++++ locales/zh/common.json | 8 ++++++++ 20 files changed, 146 insertions(+), 8 deletions(-) diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 60944ea53..8c3126af8 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; +import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { locales } from "@/i18n/routing"; export default async function LocaleLayout({ @@ -41,6 +42,7 @@ export default async function LocaleLayout({ {children} + diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index ac5c3b429..99468f94f 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; import { getLocale } from "next-intl/server"; -import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { configManager } from "@/lib/admin/config-manager"; import { withBasePath } from "@/lib/browser-navigation"; @@ -92,7 +91,6 @@ export default async function RootLayout({ > {children} - ); diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx index 00c8bfb35..4cc95f953 100644 --- a/components/pwa-install-prompt.tsx +++ b/components/pwa-install-prompt.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; import { X, Download } from "lucide-react"; import { useConfig } from "@/hooks/use-config"; import { withBasePath } from "@/lib/browser-navigation"; @@ -17,6 +18,7 @@ export function PWAInstallPrompt() { useState(null); const [showPrompt, setShowPrompt] = useState(false); const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig(); + const t = useTranslations("pwa_install"); useEffect(() => { if (localStorage.getItem(DISMISSED_KEY)) return; @@ -84,17 +86,17 @@ export function PWAInstallPrompt() { )}

- Install {appName} + {t("title", { appName })}

- Install our app for quick access and offline support. + {t("description")}

@@ -105,20 +107,20 @@ export function PWAInstallPrompt() { onClick={handleDismiss} className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors" > - Not now + {t("not_now")}
diff --git a/locales/cs/common.json b/locales/cs/common.json index 6620a55be..40b0ed6af 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Nainstalovat {appName}", + "description": "Nainstalujte si naši aplikaci pro rychlý přístup a offline podporu.", + "not_now": "Teď ne", + "install": "Nainstalovat", + "dont_remind": "Už mi to nepřipomínat", + "dismiss_aria": "Zavřít výzvu k instalaci" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/da/common.json b/locales/da/common.json index 2f455b425..7b379c5d9 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Installer {appName}", + "description": "Installer vores app for hurtig adgang og offline-understøttelse.", + "not_now": "Ikke nu", + "install": "Installer", + "dont_remind": "Påmind mig ikke igen", + "dismiss_aria": "Afvis installationsprompt" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/de/common.json b/locales/de/common.json index fa621a996..7b3cd5ca5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "{appName} installieren", + "description": "Installiere unsere App für schnellen Zugriff und Offline-Unterstützung.", + "not_now": "Nicht jetzt", + "install": "Installieren", + "dont_remind": "Nicht mehr erinnern", + "dismiss_aria": "Installationshinweis schließen" + }, "login": { "title": "Webmail", "username_label": "E-Mail", diff --git a/locales/en/common.json b/locales/en/common.json index 1dbba5934..f9ababbe1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Install {appName}", + "description": "Install our app for quick access and offline support.", + "not_now": "Not now", + "install": "Install", + "dont_remind": "Don't remind me again", + "dismiss_aria": "Dismiss install prompt" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/es/common.json b/locales/es/common.json index 7b83d66a6..c75a5ed2b 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Instalar {appName}", + "description": "Instala nuestra app para un acceso rápido y soporte sin conexión.", + "not_now": "Ahora no", + "install": "Instalar", + "dont_remind": "No volver a recordármelo", + "dismiss_aria": "Cerrar aviso de instalación" + }, "login": { "title": "Correo Web", "username_label": "Correo electrónico", diff --git a/locales/fr/common.json b/locales/fr/common.json index f9cb00ad8..3d8b23ce3 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Installer {appName}", + "description": "Installez notre application pour un accès rapide et un support hors ligne.", + "not_now": "Pas maintenant", + "install": "Installer", + "dont_remind": "Ne plus me le rappeler", + "dismiss_aria": "Fermer l'invite d'installation" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/it/common.json b/locales/it/common.json index 3c96458a0..59e685d9f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Installa {appName}", + "description": "Installa la nostra app per un accesso rapido e il supporto offline.", + "not_now": "Non ora", + "install": "Installa", + "dont_remind": "Non ricordarmelo più", + "dismiss_aria": "Chiudi avviso di installazione" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/ja/common.json b/locales/ja/common.json index aa366688d..19790b80f 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "{appName} をインストール", + "description": "素早いアクセスとオフライン対応のため、アプリをインストールしましょう。", + "not_now": "後で", + "install": "インストール", + "dont_remind": "今後表示しない", + "dismiss_aria": "インストールプロンプトを閉じる" + }, "login": { "title": "ウェブメール", "username_label": "メールアドレス", diff --git a/locales/ko/common.json b/locales/ko/common.json index 79a238d76..fc5cc258f 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "{appName} 설치", + "description": "빠른 액세스와 오프라인 지원을 위해 앱을 설치하세요.", + "not_now": "나중에", + "install": "설치", + "dont_remind": "다시 알리지 않음", + "dismiss_aria": "설치 프롬프트 닫기" + }, "login": { "title": "웹메일", "username_label": "이메일", diff --git a/locales/lv/common.json b/locales/lv/common.json index 62a095095..980f1e3c7 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Instalēt {appName}", + "description": "Instalējiet mūsu lietotni ātrai piekļuvei un bezsaistes atbalstam.", + "not_now": "Ne tagad", + "install": "Instalēt", + "dont_remind": "Vairs man neatgādināt", + "dismiss_aria": "Aizvērt instalēšanas paziņojumu" + }, "login": { "title": "Tīmekļa pasts", "username_label": "E-pasta adrese", diff --git a/locales/nl/common.json b/locales/nl/common.json index d1208b362..49b627701 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "{appName} installeren", + "description": "Installeer onze app voor snelle toegang en offline-ondersteuning.", + "not_now": "Niet nu", + "install": "Installeren", + "dont_remind": "Niet meer herinneren", + "dismiss_aria": "Installatiemelding sluiten" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/pl/common.json b/locales/pl/common.json index ee550e282..b4d08a918 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Zainstaluj {appName}", + "description": "Zainstaluj naszą aplikację, aby uzyskać szybki dostęp i obsługę offline.", + "not_now": "Nie teraz", + "install": "Zainstaluj", + "dont_remind": "Nie przypominaj mi więcej", + "dismiss_aria": "Zamknij monit instalacji" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/pt/common.json b/locales/pt/common.json index 3f7ef129c..6b8d8408b 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Instalar {appName}", + "description": "Instale o nosso app para acesso rápido e suporte offline.", + "not_now": "Agora não", + "install": "Instalar", + "dont_remind": "Não lembrar novamente", + "dismiss_aria": "Dispensar aviso de instalação" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/ru/common.json b/locales/ru/common.json index 747b269ce..0d4302af5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Установить {appName}", + "description": "Установите наше приложение для быстрого доступа и работы офлайн.", + "not_now": "Не сейчас", + "install": "Установить", + "dont_remind": "Больше не напоминать", + "dismiss_aria": "Закрыть запрос на установку" + }, "login": { "title": "Веб-почта", "username_label": "Электронная почта", diff --git a/locales/tr/common.json b/locales/tr/common.json index 287522abb..a0728370c 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "{appName} uygulamasını yükle", + "description": "Hızlı erişim ve çevrimdışı destek için uygulamamızı yükleyin.", + "not_now": "Şimdi değil", + "install": "Yükle", + "dont_remind": "Bir daha hatırlatma", + "dismiss_aria": "Yükleme istemini kapat" + }, "login": { "title": "Webmail", "username_label": "E-posta", diff --git a/locales/uk/common.json b/locales/uk/common.json index e08ee3729..3e0601e3d 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Встановити {appName}", + "description": "Встановіть наш застосунок для швидкого доступу та офлайн-підтримки.", + "not_now": "Не зараз", + "install": "Встановити", + "dont_remind": "Більше не нагадувати", + "dismiss_aria": "Закрити запит на встановлення" + }, "login": { "title": "Веб-пошта", "username_label": "Електронна пошта", diff --git a/locales/zh/common.json b/locales/zh/common.json index 00a7634f5..cd296f350 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "安装 {appName}", + "description": "安装我们的应用,以获得快速访问和离线支持。", + "not_now": "暂不", + "install": "安装", + "dont_remind": "不再提醒", + "dismiss_aria": "关闭安装提示" + }, "login": { "title": "网页邮箱", "username_label": "邮箱地址", From 8353b28b339f0d795b7e958bebe4468c6f7ab818 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 13:05:36 +0200 Subject: [PATCH 021/437] =?UTF-8?q?Feature:=20extended=20filter=20rules=20?= =?UTF-8?q?=E2=80=94=20attachment=20field=20+=20multi-value=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Attachment" condition field (is present / of type ) backed by the RFC 5703 Sieve mime extension, matching the filename in both Content-Disposition and Content-Type headers so real-world senders that only put the name in Content-Type (Microsoft SMTPSVC, etc.) are caught. Users type extensions (pdf, doc) not MIME types. Also makes each text condition accept comma-separated multiple values emitted as a Sieve string list (OR within the condition), so "(domain1 OR domain2) AND attachment pdf/xml" is expressible in one rule. value is now string | string[] (single-value rules stay strings -> backward compatible). New filter locale keys in all 17 locales. --- components/filters/filter-rule-modal.tsx | 139 ++++++++++++++++---- components/settings/filter-settings.tsx | 29 ++++- lib/jmap/sieve-types.ts | 22 +++- lib/sieve/generator.ts | 65 ++++++++-- lib/sieve/parser.ts | 155 +++++++++++++++++++---- locales/cs/common.json | 5 + locales/da/common.json | 5 + locales/de/common.json | 5 + locales/en/common.json | 5 + locales/es/common.json | 5 + locales/fr/common.json | 5 + locales/it/common.json | 5 + locales/ja/common.json | 5 + locales/ko/common.json | 5 + locales/lv/common.json | 5 + locales/nl/common.json | 5 + locales/pl/common.json | 5 + locales/pt/common.json | 5 + locales/ru/common.json | 5 + locales/tr/common.json | 5 + locales/uk/common.json | 5 + locales/zh/common.json | 5 + 22 files changed, 431 insertions(+), 64 deletions(-) diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 25b0eb9b4..221a6e7a4 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -27,7 +27,7 @@ interface FilterRuleModalProps { } const ALL_FIELDS: FilterConditionField[] = [ - "from", "to", "cc", "subject", "header", "size", "body", + "from", "to", "cc", "subject", "header", "size", "body", "attachment", ]; const TEXT_COMPARATORS: FilterComparator[] = [ @@ -36,6 +36,14 @@ const TEXT_COMPARATORS: FilterComparator[] = [ const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"]; +const ATTACHMENT_COMPARATORS: FilterComparator[] = ["has_any", "has_type"]; + +function comparatorsFor(field: FilterConditionField): FilterComparator[] { + if (field === "size") return SIZE_COMPARATORS; + if (field === "attachment") return ATTACHMENT_COMPARATORS; + return TEXT_COMPARATORS; +} + const ALL_ACTION_TYPES: FilterActionType[] = [ "move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop", ]; @@ -47,6 +55,27 @@ function makeEmptyCondition(): FilterCondition { return { field: "from", comparator: "contains", value: "" }; } +// Multi-value handling: conditions are stored as string | string[]. The UI +// presents them as a single comma-separated text input — the user types +// "a, b, c" and the saved value becomes ["a","b","c"]. Single entries stay +// strings so existing single-value rules don't change shape. +function valueToInputString(v: string | string[]): string { + if (Array.isArray(v)) return v.join(", "); + return v; +} + +function inputStringToValue(s: string): string | string[] { + const parts = s.split(",").map((p) => p.trim()).filter((p) => p.length > 0); + if (parts.length === 0) return ""; + if (parts.length === 1) return parts[0]; + return parts; +} + +function isConditionValueEmpty(v: string | string[]): boolean { + if (Array.isArray(v)) return v.length === 0 || v.every((x) => !x.trim()); + return !v.trim(); +} + function makeEmptyAction(): FilterAction { return { type: "move", value: "" }; } @@ -97,9 +126,23 @@ export function FilterRuleModal({ return; } - const validConditions = conditions.filter( - (c) => c.value.trim() - ); + // While editing, condition.value is always the raw string typed into the + // input (commas not yet split). Convert to array form here on save so a + // user typing "a, b, c" actually persists as ["a","b","c"]. This is the + // moment we know editing is finished - splitting earlier would eat any + // comma the user just typed mid-edit. + const validConditions = conditions + .filter((c) => { + if (c.field === "attachment" && c.comparator === "has_any") return true; + return !isConditionValueEmpty(c.value); + }) + .map((c) => { + if (c.field === "attachment" && c.comparator === "has_any") return c; + if (c.field === "size") return c; // numeric, single-value only + if (typeof c.value !== "string") return c; // already structured + const parsed = inputStringToValue(c.value); + return { ...c, value: parsed }; + }); if (validConditions.length === 0) { toast.error(t("validation_empty_conditions")); return; @@ -129,15 +172,27 @@ export function FilterRuleModal({ prev.map((c, i) => { if (i !== index) return c; const updated = { ...c, ...updates }; - if (updates.field === "size" && !SIZE_COMPARATORS.includes(c.comparator)) { - updated.comparator = "greater_than"; - } - if (updates.field && updates.field !== "size" && SIZE_COMPARATORS.includes(c.comparator)) { - updated.comparator = "contains"; + // Reconcile the comparator when the field changes so we never end up + // with e.g. (field=attachment, comparator=contains) — invalid for the + // Sieve generator. Each field has its own valid comparator set. + if (updates.field && updates.field !== c.field) { + const allowed = comparatorsFor(updates.field); + if (!allowed.includes(c.comparator)) { + updated.comparator = allowed[0]; + } } if (updates.field && updates.field !== "header") { delete updated.headerName; } + // has_any takes no value; clear it so we don't leak old text into + // the generated Sieve. + if (updated.field === "attachment" && updated.comparator === "has_any") { + updated.value = ""; + } + // Size is numeric, single value only - collapse any list to scalar. + if (updated.field === "size" && Array.isArray(updated.value)) { + updated.value = updated.value[0] ?? ""; + } return updated; }) ); @@ -281,24 +336,58 @@ export function FilterRuleModal({ className={selectClass} aria-label={t("comparators.contains")} > - {(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map( - (c) => ( - - ) - )} + {comparatorsFor(condition.field).map((c) => ( + + ))} - updateCondition(index, { value: e.target.value })} - placeholder={ - condition.field === "size" ? t("size_placeholder") : t("header_placeholder") - } - className="flex-1 min-w-[120px]" - type={condition.field === "size" ? "number" : "text"} - /> + {/* has_any takes no value; render a stub so the row layout + stays consistent but no input is editable. */} + {condition.field === "attachment" && condition.comparator === "has_any" ? ( +
+ ) : ( + + // Store the raw input string while typing. Splitting + // commas into an array on every keystroke would eat + // the comma the moment it's typed. + updateCondition(index, { value: e.target.value }) + } + onBlur={(e) => { + // On blur: normalise comma-separated input into an + // array (or single string when only one item). Size + // stays numeric/single-value; attachment-has_any has + // no value at all. + if (condition.field === "size") return; + if ( + condition.field === "attachment" && + condition.comparator === "has_any" + ) + return; + const parsed = inputStringToValue(e.target.value); + // Only update if the normalised shape actually + // differs - avoids triggering a no-op re-render and + // resetting the user's cursor on every blur. + if ( + JSON.stringify(parsed) !== JSON.stringify(condition.value) + ) { + updateCondition(index, { value: parsed }); + } + }} + placeholder={ + condition.field === "size" + ? t("size_placeholder") + : condition.field === "attachment" + ? t("attachment_type_placeholder") + : t("value_placeholder_multi") + } + className="flex-1 min-w-[120px]" + type={condition.field === "size" ? "number" : "text"} + /> + )}
-
+
From plugin: {current.pluginId}
diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index e1754f873..0913adf57 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -53,6 +53,13 @@ export interface ServerPlugin { forceEnabled?: boolean; configSchema?: Record; settingsSchema?: Record; + /** + * Optional per-locale translation tables (locale -> key -> string) declared + * in the plugin manifest. Surfaced to the sandbox so plugin code can call + * `api.i18n.t(key)`; without it a plugin's strings stay in its hardcoded + * default language. + */ + locales?: Record>; installedAt: string; updatedAt: string; /** diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts index b388a41ff..a4b1aa091 100644 --- a/lib/plugin-loader.ts +++ b/lib/plugin-loader.ts @@ -10,32 +10,25 @@ import { activateAllSandboxed, deactivateAllSandboxed, setSandboxStoreAccessor, - setSandboxLocale, setupSandboxAutoDisable, } from './plugin-sandbox/loader'; import { all as allActive, get as getActive } from './plugin-sandbox/registry'; +// Re-export so the plugin store can keep the sandbox locale in step via this +// facade, instead of importing lib/plugin-sandbox/loader directly (which would +// also pull the hook buses into consumers' module graphs). +export { setSandboxLocale } from './plugin-sandbox/loader'; + /** - * Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__` - * so blob-imported plugin code could resolve `react`. With the sandbox model - * plugins receive React injected as a function argument inside their iframe - * runtime - there is nothing to expose on the host window. - * - * Kept as a no-op for callers that still invoke it during app bootstrap. + * Historically re-published React/ReactDOM on `globalThis` for the blob-import + * loader, and later also bootstrapped plugin locale sync. Both are obsolete: + * the sandbox injects React per-iframe, and locale sync now lives where plugin + * activation is orchestrated (stores/plugin-store -> initializePlugins, via + * setSandboxLocale). Kept as a no-op for the legacy activateAllPlugins() + * wrapper and its test. */ export function exposePluginExternals(): void { - if (typeof window === 'undefined') return; - // Initialise the locale sync once. Importing the store lazily avoids the - // circular module graph we used to fight before the sandbox refactor. - void import('@/stores/locale-store').then(({ useLocaleStore }) => { - setSandboxLocale(useLocaleStore.getState().locale); - useLocaleStore.subscribe((state) => setSandboxLocale(state.locale)); - // Mirror on a global so the slot-iframe component can read it at spawn. - (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = useLocaleStore.getState().locale; - useLocaleStore.subscribe((state) => { - (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = state.locale; - }); - }).catch(() => { /* locale sync is best-effort */ }); + /* no-op */ } // ─── Store accessor (status updates) ────────────────────────── diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 16894bb88..fbba5721c 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -41,11 +41,16 @@ export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor let currentLocale = 'en'; export function setSandboxLocale(locale: string): void { + // Ignore empty/falsy values so a not-yet-seeded locale store can't clobber a + // good locale back to '' - the initial 'en' default stands until the real + // locale arrives via the store subscription. + if (!locale) return; currentLocale = locale; - // Push to all active background instances. - // Slot iframes inherit locale at spawn time; they're short-lived. - // (We don't import the registry here to avoid a circular import; the - // PluginIframeSlot subscribes to locale changes on its own.) + // Background instances read `currentLocale` at load time; the slot-iframe + // component reads this global at spawn time (plugin-iframe-slot.tsx). Keep + // both in step from one place. Already-running instances are not re-pushed, + // so a locale switch only affects plugins/slots loaded afterwards. + (globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale; } // ─── Bundle fetch ───────────────────────────────────────────── diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index e171875e5..dbd4ba518 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -73,18 +73,26 @@ function uid(): string { // ─── Sandboxed API facade (calls flow to host via postMessage) ─ -function callApi(method: string, args: unknown[]): Promise { +const DEFAULT_API_TIMEOUT_MS = 30_000; + +function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise { const id = uid(); return new Promise((resolve, reject) => { pendingApi.set(id, { resolve, reject }); sendToHost({ type: 'api-request', id, method, args }); - // Reject after 30s to prevent unbounded promise leaks if the host hangs. - setTimeout(() => { - const entry = pendingApi.get(id); - if (!entry) return; - pendingApi.delete(id); - entry.reject(new Error(`API call ${method} timed out after 30s`)); - }, 30_000); + // Bounded so a hung host can't leak the promise forever. Interactive UI + // dialogs (ui.confirm/ui.alert) pass timeoutMs <= 0 to opt out: they wait + // for human input, the host always resolves them on confirm/cancel/close, + // and any still-pending call dies with the iframe on teardown - so there's + // nothing to leak, and a thinking user must not trip a 30s timeout. + if (timeoutMs > 0 && Number.isFinite(timeoutMs)) { + setTimeout(() => { + const entry = pendingApi.get(id); + if (!entry) return; + pendingApi.delete(id); + entry.reject(new Error(`API call ${method} timed out after ${Math.round(timeoutMs / 1000)}s`)); + }, timeoutMs); + } }); } @@ -151,12 +159,13 @@ function buildPluginApi(manifest: PluginManifest) { warning: (m: string) => { void callApi('toast.warning', [m]); }, }, ui: { - /** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. */ + /** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. + * No timeout - it waits for the user's choice. */ confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) => - callApi('ui.confirm', [opts]) as Promise, - /** Opens a host-rendered alert (one button). Resolves once dismissed. */ + callApi('ui.confirm', [opts], 0) as Promise, + /** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */ alert: (opts: { title?: string; message?: string; confirmLabel?: string }) => - callApi('ui.alert', [opts]) as Promise, + callApi('ui.alert', [opts], 0) as Promise, /** Opens an http/https URL in a new tab via host `window.open`. */ openExternalUrl: (url: string, target?: string) => callApi('ui.openExternalUrl', [url, target]) as Promise, @@ -173,6 +182,26 @@ function buildPluginApi(manifest: PluginManifest) { warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a), error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a), }, + // Localization for plugins. The host pushes the active locale (init + + // 'locale-change'); `t` resolves a key against the plugin's declared + // `locales` map (manifest.locales), falling back to English then the key + // itself, with optional {placeholder} interpolation. + i18n: { + get locale(): string { + return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; + }, + t(key: string, vars?: Record): string { + const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en'; + const tables = manifest.locales || {}; + let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key; + if (vars) { + for (const [k, v] of Object.entries(vars)) { + out = out.split('{' + k + '}').join(String(v)); + } + } + return out; + }, + }, }; } @@ -344,6 +373,9 @@ async function handleInit(payload: InitPayload): Promise { if (bootDone) return; bootDone = true; mode = payload.mode; + // Make the active locale available to plugin code (api.i18n) right away - + // not only after the first 'locale-change' push. + (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale; try { if (payload.mode === 'background') { await bootBackground(payload); diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 017b7007d..ad8d06048 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -6,7 +6,8 @@ import { persist } from 'zustand/middleware'; import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types'; import { pluginStorage } from '@/lib/plugin-storage'; import { extractPlugin } from '@/lib/plugin-validator'; -import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader'; +import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader'; +import { useLocaleStore } from '@/stores/locale-store'; import { removeAllPluginHooks } from '@/lib/plugin-hooks'; import { requestConsent } from '@/lib/plugin-sandbox/consent'; import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity'; @@ -17,6 +18,8 @@ import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; import type { Permission } from '@/lib/plugin-types'; let pluginInitializationPromise: Promise | null = null; +// One-time guard so we attach the locale->sandbox subscription only once. +let localeSubscribed = false; // ─── Store Interface ───────────────────────────────────────── @@ -258,6 +261,17 @@ export const usePluginStore = create()( setPluginStatus: get().setPluginStatus, }); setupAutoDisable(); + // Keep the sandbox locale in step with the app locale. Set it + // synchronously *before* activation so background instances get the + // right locale in their init payload (the bug: the only wiring lived + // in the dead activateAllPlugins() path, so the sandbox locale stayed + // 'en' forever and plugin i18n never localized). Subscribe once for + // later language switches; those affect plugins/slots loaded after. + setSandboxLocale(useLocaleStore.getState().locale); + if (!localeSubscribed) { + localeSubscribed = true; + useLocaleStore.subscribe((s) => setSandboxLocale(s.locale)); + } // Sync server-managed plugins before loading await syncServerPlugins(get, set); @@ -324,6 +338,33 @@ interface ServerPluginInfo { apiPostPaths?: string[]; /** Per-user settings schema, captured from the manifest server-side. */ settingsSchema?: InstalledPlugin['settingsSchema']; + /** Plugin-declared i18n tables (locale -> key -> string), from the manifest. */ + locales?: InstalledPlugin['locales']; +} + +/** + * Server-owned metadata, passed through verbatim on every sync. Centralised in + * ONE place so a newly added passthrough field can't be silently dropped at one + * of several copy sites - which is exactly what previously lost `settingsSchema` + * (hence the old "schema drift" special-case) and then `locales`. Excludes + * fields the client owns (id, type, enabled/status, settings, adminApproved). + */ +function serverMeta(sp: ServerPluginInfo) { + return { + name: sp.name, + version: sp.version, + author: sp.author, + description: sp.description, + permissions: sp.permissions, + entrypoint: sp.entrypoint, + managed: true as const, + forceEnabled: sp.forceEnabled, + bundleHash: sp.bundleHash, + httpOrigins: sp.httpOrigins, + apiPostPaths: sp.apiPostPaths, + settingsSchema: sp.settingsSchema, + locales: sp.locales, + }; } const SERVER_MANAGED_KEY = 'server-managed-plugin-ids'; @@ -402,113 +443,57 @@ async function syncServerPlugins( const local = get().plugins.find(p => p.id === sp.id); if (!local) { - // New server plugin - download and install + // New server plugin - download bundle and install. const code = await downloadPluginBundle(sp.id, sp.bundleHash); if (!code) continue; - await pluginStorage.saveCode(sp.id, code); const plugin: InstalledPlugin = { id: sp.id, - name: sp.name, - version: sp.version, - author: sp.author, - description: sp.description, type: sp.type as InstalledPlugin['type'], - permissions: sp.permissions, - entrypoint: sp.entrypoint, enabled: sp.forceEnabled, status: sp.forceEnabled ? 'enabled' : 'installed', - managed: true, - forceEnabled: sp.forceEnabled, adminApproved: true, // Server-managed plugins are always approved settings: {}, - settingsSchema: sp.settingsSchema, - bundleHash: sp.bundleHash, - ...(sp.httpOrigins && sp.httpOrigins.length > 0 - ? { httpOrigins: sp.httpOrigins } - : {}), - ...(sp.apiPostPaths && sp.apiPostPaths.length > 0 - ? { apiPostPaths: sp.apiPostPaths } - : {}), + ...serverMeta(sp), }; + set(state => + state.plugins.some(p => p.id === sp.id) + ? {} + : { plugins: [...state.plugins, plugin] }, + ); + continue; + } - set(state => { - if (state.plugins.some(p => p.id === sp.id)) { - return {}; - } - return { plugins: [...state.plugins, plugin] }; - }); - } else if ( + // Existing plugin. Re-download the bundle only when the code actually + // changed, but ALWAYS re-derive server-owned metadata from one place + // (serverMeta) so no passthrough field is silently dropped on a + // metadata-only change. Only write when something differs, to avoid a + // needless persist/re-render on every sync. + const needsBundle = local.version !== sp.version || // bundleHash mismatch covers re-uploads of the same version with new - // code. Falsy local hash (older installs that never carried one) also - // forces a refresh so we capture the hash on the next sync. - (sp.bundleHash && local.bundleHash !== sp.bundleHash) - ) { - // Version or content changed - re-download bundle + // code; a falsy local hash (older installs) also forces a refresh so + // we capture the hash on the next sync. + (!!sp.bundleHash && local.bundleHash !== sp.bundleHash); + + if (needsBundle) { const code = await downloadPluginBundle(sp.id, sp.bundleHash); if (!code) continue; - await pluginStorage.saveCode(sp.id, code); + } + // Force-enable in the same pass when the server flips it on, so the user + // doesn't need a second refresh for it to run. + const shouldAutoEnable = sp.forceEnabled && !local.enabled; + const next: InstalledPlugin = { + ...local, + ...serverMeta(sp), + ...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}), + }; + if (needsBundle || JSON.stringify(next) !== JSON.stringify(local)) { set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id - ? { - ...p, - name: sp.name, - version: sp.version, - author: sp.author, - description: sp.description, - permissions: sp.permissions, - entrypoint: sp.entrypoint, - managed: true, - forceEnabled: sp.forceEnabled, - bundleHash: sp.bundleHash, - httpOrigins: sp.httpOrigins, - apiPostPaths: sp.apiPostPaths, - settingsSchema: sp.settingsSchema, - } - : p - ), - })); - } else if (local.managed !== true || local.forceEnabled !== sp.forceEnabled) { - // When forceEnabled flips on, enable the plugin in the same pass so - // the user doesn't need a second refresh for it to run. - const shouldAutoEnable = sp.forceEnabled && !local.enabled; - set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id - ? { - ...p, - managed: true, - forceEnabled: sp.forceEnabled, - settingsSchema: sp.settingsSchema, - ...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}), - } - : p - ), - })); - } else if ( - JSON.stringify(local.settingsSchema ?? null) !== JSON.stringify(sp.settingsSchema ?? null) - ) { - // Schema drift: the bundle is current but the persisted plugin record - // pre-dates the server passing settingsSchema through, so the per-user - // settings UI was rendering empty. Patch the schema in place. - set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id ? { ...p, settingsSchema: sp.settingsSchema } : p - ), - })); - } else if (sp.forceEnabled && !local.enabled) { - // Force-enable if the server says so but client has it disabled - set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id - ? { ...p, enabled: true, status: 'enabled' as const, managed: true, forceEnabled: true } - : p - ), + plugins: state.plugins.map(p => (p.id === sp.id ? next : p)), })); } } From bebb394f542a759681f8d0ed15c086fdc20af176 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 13:22:38 +0200 Subject: [PATCH 026/437] Feature: read receipts (MDN, RFC 8098) Bulwark had no read-receipt support (JMAP/Stalwart have no native MDN). End-to-end, client-side, in three parts: - Request (compose): a toolbar toggle (MailCheck, green when on) sets Disposition-Notification-To on the outgoing message via the JMAP "header::asText" create property. Threaded composer -> page -> email-store -> client.sendEmail. Default from requestReadReceiptDefault. - Detect (viewer): reads Disposition-Notification-To case-insensitively from the parsed headers and shows a banner (green Send / red Ignore) in the unified notification bar. Hidden in Sent/Drafts/Trash/Junk and once handled. message/disposition-notification + message/delivery-status report parts are filtered out of the attachment list. - Respond (MDN): lib/mdn.ts builds an RFC 8098 multipart/report (text/plain + message/disposition-notification, UTF-8/base64, localized subject + body). client.sendReadReceipt uploads the blob, imports it into Sent via Email/import, then submits with an explicit envelope. Both Send and Ignore set the $MDNSent keyword (RFC 3503) so no client re-prompts. Behaviour configurable: ask / always / never. New: lib/mdn.ts, read-receipt-banner.tsx. Settings (requestReadReceiptDefault, readReceiptResponse) + UI. All 17 locales. --- app/(main)/[locale]/page.tsx | 3 +- components/email/email-composer.tsx | 23 ++- components/email/email-viewer.tsx | 119 +++++++++++++++- components/email/read-receipt-banner.tsx | 60 ++++++++ components/settings/composing-settings.tsx | 21 +++ lib/demo/demo-client.ts | 8 ++ lib/jmap/client-interface.ts | 22 +++ lib/jmap/client.ts | 112 ++++++++++++++- lib/mdn.ts | 154 +++++++++++++++++++++ locales/cs/common.json | 22 +++ locales/da/common.json | 22 +++ locales/de/common.json | 22 +++ locales/en/common.json | 22 +++ locales/es/common.json | 22 +++ locales/fr/common.json | 22 +++ locales/it/common.json | 22 +++ locales/ja/common.json | 22 +++ locales/ko/common.json | 22 +++ locales/lv/common.json | 22 +++ locales/nl/common.json | 22 +++ locales/pl/common.json | 22 +++ locales/pt/common.json | 22 +++ locales/ru/common.json | 22 +++ locales/tr/common.json | 22 +++ locales/uk/common.json | 22 +++ locales/zh/common.json | 22 +++ stores/email-store.ts | 6 +- stores/settings-store.ts | 8 ++ 28 files changed, 901 insertions(+), 9 deletions(-) create mode 100644 components/email/read-receipt-banner.tsx create mode 100644 lib/mdn.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index a7c57c612..d9ab5b779 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1130,6 +1130,7 @@ export default function Home() { inReplyTo?: string[]; references?: string[]; delayedUntil?: string; + requestReadReceipt?: boolean; }) => { if (!client) return; @@ -1137,7 +1138,7 @@ export default function Home() { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = selectedEmail?.id; - const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom); + const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt }); setShowComposer(false); if (result.scheduled) { await refreshScheduledMetadata(client); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index bf80385c3..acdf43fa7 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -90,6 +90,7 @@ interface EmailComposerProps { inReplyTo?: string[]; references?: string[]; delayedUntil?: string; + requestReadReceipt?: boolean; }) => void | Promise; onScheduledSendCreated?: () => void | Promise; onClose?: () => void; @@ -206,6 +207,7 @@ export function EmailComposer({ const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const signaturePosition = useSettingsStore((state) => state.signaturePosition); const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); + const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault); const activeIdentities = useIdentityStore((s) => s.identities); // Pro shell: surface identities from every connected account, grouped // for the From dropdown's s. Outside Pro this collapses to @@ -384,6 +386,7 @@ export function EmailComposer({ const [body, setBody] = useState(initialData?.body ?? getInitialBody()); const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc()); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false); + const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault); const [draftId, setDraftId] = useState(initialData?.draftId ?? null); // Mirror of draftId for synchronous reads inside chained saves; React's // setDraftId is async, so a queued saveDraft would otherwise see the old @@ -1656,6 +1659,7 @@ export function EmailComposer({ attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, inReplyTo: threadingHeaders?.inReplyTo, references: threadingHeaders?.references, + requestReadReceipt, delayedUntil: effectiveDelayedUntil, }); @@ -2217,7 +2221,7 @@ export function EmailComposer({ )} {/* Bottom toolbar */} -
+
{/* Left side actions */}
)} + + {/* Read-receipt request toggle */} +
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0f5b25e65..08b6d6033 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -83,6 +83,8 @@ import { useThemeStore } from "@/stores/theme-store"; import { EmailIdentityBadge } from "./email-identity-badge"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; +import { ReadReceiptBanner } from "./read-receipt-banner"; +import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useTour } from "@/components/tour/tour-provider"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; @@ -911,6 +913,7 @@ export function EmailViewer({ const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const mailLayout = useSettingsStore((state) => state.mailLayout); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); + const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); const dragOutActive = useMemo(() => isDragOutSupported(), []); @@ -2193,6 +2196,9 @@ export function EmailViewer({ // Hide inline cid-referenced images when the user has opted to keep them // out of the attachment list (default on): these are embedded in the body. .filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))) + // Hide machine-readable report parts (MDN read-receipts, DSN bounce + // reports). These are required MIME parts, not real user attachments. + .filter(att => att.type !== 'message/disposition-notification' && att.type !== 'message/delivery-status') .map((attachment, index) => ({ id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`, name: attachment.name || null, @@ -3252,6 +3258,103 @@ export function EmailViewer({ ? calendarInvitationParsingEnabled && !!findCalendarAttachment(email) : false; + // ── Read receipt (MDN, RFC 8098) ────────────────────────────── + // Detect a Disposition-Notification-To request on the open message. The + // header is parsed into email.headers by the client; look it up + // case-insensitively and extract the bare address. + const readReceiptRequestedBy = useMemo(() => { + const headers = email?.headers as Record | undefined; + if (!headers) return null; + let raw: string | undefined; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'disposition-notification-to') { + const v = headers[key]; + raw = Array.isArray(v) ? v[0] : v; + break; + } + } + if (!raw) return null; + const m = raw.match(/<([^>]+)>/); + const addr = (m ? m[1] : raw).trim(); + return addr || null; + }, [email?.headers]); + + // The identity whose address received the original message — the MDN is sent + // "from" that address. Falls back to the primary identity. + const receiptIdentity = useMemo(() => { + if (!identities?.length) return null; + const recipients = [...(email?.to || []), ...(email?.cc || [])] + .map(r => r.email?.toLowerCase()) + .filter(Boolean); + return identities.find(i => recipients.includes(i.email?.toLowerCase())) || identities[0]; + }, [identities, email?.to, email?.cc]); + + const mdnAlreadyHandled = email?.keywords?.['$mdnsent'] === true; + const [mdnHandledLocally, setMdnHandledLocally] = useState(false); + useEffect(() => { setMdnHandledLocally(false); }, [email?.id]); + + // Only offer the receipt for mail you're actually reading in a "received" + // location. Suppress your own copies (sent/drafts), discarded mail (trash), + // and spam (junk) - never confirm your address to spammers. Inbox, Archive + // and user folders all qualify. + const inReceiptEligibleFolder = !['sent', 'drafts', 'trash', 'junk'].includes(currentMailboxRole || ''); + + const shouldOfferReadReceipt = + !!readReceiptRequestedBy && + !mdnAlreadyHandled && + !mdnHandledLocally && + readReceiptResponse !== 'never' && + inReceiptEligibleFolder && + !isDraft && + !!receiptIdentity; + + const sendReadReceiptNow = useCallback(async (automatic: boolean) => { + if (!client || !email || !readReceiptRequestedBy || !receiptIdentity) return; + const { rawId } = stripCrossAccountIdentityPrefix(receiptIdentity.id); + try { + await client.sendReadReceipt({ + to: readReceiptRequestedBy, + fromEmail: receiptIdentity.email, + fromName: receiptIdentity.name, + identityId: rawId ?? receiptIdentity.id, + originalMessageId: email.messageId, + originalSubject: email.subject, + originalRecipient: receiptIdentity.email, + automatic, + subject: t('read_receipt.mdn_subject', { subject: email.subject || '' }), + humanText: t('read_receipt.mdn_body', { recipient: receiptIdentity.email }), + }); + await client.setKeyword(email.id, '$mdnsent'); + } catch (err) { + // Surface the failure instead of silently resetting the banner so we can + // see which step (upload / import / submission) failed. + console.error('Read-receipt (MDN) send failed:', err); + toast.error(t('read_receipt.send_failed'), { + message: err instanceof Error ? err.message : String(err), + }); + throw err; + } + }, [client, email, readReceiptRequestedBy, receiptIdentity, t]); + + const ignoreReadReceipt = useCallback(async () => { + setMdnHandledLocally(true); + if (client && email) { + // $MDNSent is the RFC 3503 flag every IMAP/JMAP client honours, so the + // request is suppressed everywhere - not just locally. + try { await client.setKeyword(email.id, '$mdnsent'); } catch { /* best effort */ } + } + }, [client, email]); + + // "always" mode: auto-send the MDN once when the message is opened. + const autoMdnRef = useRef(null); + useEffect(() => { + if (readReceiptResponse !== 'always') return; + if (!shouldOfferReadReceipt || !email) return; + if (autoMdnRef.current === email.id) return; + autoMdnRef.current = email.id; + sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; }); + }, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]); + // Show loading skeleton while email is being fetched if (isLoading && !email) { return ( @@ -4946,9 +5049,10 @@ export function EmailViewer({ error={smimeUnlockError} /> - {/* Unified Notification Banner - External Content + Calendar Invitation */} + {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */} {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || - hasCalendarInvitation) && ( + hasCalendarInvitation || + (readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
@@ -5003,6 +5107,17 @@ export function EmailViewer({ + {/* Read-receipt (MDN) request banner — only in "ask" mode */} + {readReceiptResponse === 'ask' && shouldOfferReadReceipt && readReceiptRequestedBy && ( +
+ sendReadReceiptNow(false)} + onIgnore={ignoreReadReceipt} + /> +
+ )} + {/* Calendar Invitation Banner */} {hasCalendarInvitation && (
diff --git a/components/email/read-receipt-banner.tsx b/components/email/read-receipt-banner.tsx new file mode 100644 index 000000000..933befc90 --- /dev/null +++ b/components/email/read-receipt-banner.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState } from 'react'; +import { MailCheck, Loader2, CheckCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +interface ReadReceiptBannerProps { + /** Address that requested the receipt (Disposition-Notification-To). */ + requestedBy: string; + /** Sends the MDN. Should resolve when the receipt has been submitted. */ + onSend: () => Promise; + /** Suppresses the request without sending (sets $MDNSent server-side). */ + onIgnore: () => void; +} + +export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceiptBannerProps) { + const t = useTranslations('email_viewer.read_receipt'); + const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle'); + + if (state === 'sent') { + return ( +
+ + {t('sent')} +
+ ); + } + + return ( +
+ + {t('prompt')} + {requestedBy} +
+ + +
+
+ ); +} diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index c7787b6db..7646e488e 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -28,6 +28,8 @@ export function ComposingSettings() { subAddressDelimiter, signaturePosition, signatureSeparatorEnabled, + requestReadReceiptDefault, + readReceiptResponse, updateSetting, } = useSettingsStore(); const { client } = useAuthStore(); @@ -78,6 +80,25 @@ export function ComposingSettings() { /> + + updateSetting('requestReadReceiptDefault', checked)} + /> + + + + 0} - aria-autocomplete="list" - aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined} - aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} - aria-invalid={validationError || undefined} - data-bwignore="true" - data-1p-ignore - data-op-ignore - data-lpignore="true" - data-form-type="other" - /> + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSaveEdit(editValue); + } else if (e.key === 'Escape') { + setEditingChip(null); + } else if (e.key === 'Tab') { + e.preventDefault(); + handleSaveEdit(editValue); + } + }} + onBlur={(e) => { + const relatedTarget = e.relatedTarget as Node | null; + if (relatedTarget && dropdownRef.current?.contains(relatedTarget)) { + return; + } + handleSaveEdit(editValue); + }} + className="flex-1 min-w-[80px] border-0 outline-none h-5 text-sm bg-transparent text-foreground placeholder:text-muted-foreground" + placeholder={editingChip?.editType === 'email' ? 'Email address' : 'Display name'} + data-bwignore="true" + /> + ) : ( + {chipDisplay} + )} + + + ); + })} + {!editingChip && ( + 0} + aria-autocomplete="list" + aria-controls={activeAutoField === field ? `autocomplete-${field}` : undefined} + aria-activedescendant={activeAutoField === field && autoSelectedIndex >= 0 ? `autocomplete-option-${autoSelectedIndex}` : undefined} + aria-invalid={validationError || undefined} + data-bwignore="true" + data-1p-ignore + data-op-ignore + data-lpignore="true" + data-form-type="other" + /> + )}
{validationError && validationMessage && (

{validationMessage}

@@ -2868,9 +3024,33 @@ function RecipientChipInput({ id={`autocomplete-${field}`} results={autocompleteResults} selectedIndex={autoSelectedIndex} - onSelect={(email) => onInsertAutocomplete(email, field)} + onSelect={(suggestion) => onInsertAutocomplete(suggestion, field)} /> )} + + {contextMenu.data && ( + <> +
+ {formatChipDisplay(contextMenu.data.chip)} +
+ + + + + { + if (contextMenu.data) { + handleChipRemove(contextMenu.data.index, { stopPropagation: () => {} } as React.MouseEvent); + } + closeContextMenu(); + }} destructive /> + + )} +
); } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 09e914be8..0308f25cc 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -5,6 +5,16 @@ import { toWildcardQuery } from "./search-utils"; import { debug } from "@/lib/debug"; import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization"; +/** Parse a recipient string that may be "Name " or bare "email" into { name?, email }. */ +function parseRecipientString(s: string): { name?: string; email: string } { + const trimmed = s.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + if (angleMatch) { + return { name: angleMatch[1].trim(), email: angleMatch[2].trim() }; + } + return { email: trimmed }; +} + export class RateLimitError extends Error { retryAfterMs: number; constructor(retryAfterMs: number) { @@ -2210,12 +2220,12 @@ export class JMAPClient implements IJMAPClient { const emailCreate: Record = { from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }], replyTo: identityReplyTo?.length ? identityReplyTo : undefined, - to: to.map(email => ({ email })), + to: to.map(parseRecipientString), // RFC 5322 §3.6.3: To/Cc carry an address-list (non-empty). Sending // cc:[] makes the server emit a literal `Cc:` header with no addresses, // which is malformed and a spam signal. Omit the field when empty. - cc: cc?.length ? cc.map(email => ({ email })) : undefined, - bcc: bcc?.length ? bcc.map(email => ({ email })) : undefined, + cc: cc?.length ? cc.map(parseRecipientString) : undefined, + bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined, subject, inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined, references: normalizedReferences?.length ? normalizedReferences : undefined, diff --git a/stores/contact-store.ts b/stores/contact-store.ts index 8f908f229..a61592352 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -909,9 +909,13 @@ export const useContactStore = create()( }, addToTrustedSendersBook: async (client, email) => { - const normalizedEmail = email.toLowerCase().trim(); + // Parse "Name " format to extract display name and email + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const displayName = angleMatch ? angleMatch[1].trim() : undefined; + const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); const { trustedSenderEmails } = get(); - if (trustedSenderEmails.includes(normalizedEmail)) return; + if (trustedSenderEmails.includes(emailAddress)) return; let bookId = get().trustedSendersBookId; if (!bookId) { @@ -920,17 +924,21 @@ export const useContactStore = create()( } if (!bookId) throw new Error('Could not find or create trusted senders address book'); - debug.log('contacts', 'Adding trusted sender:', normalizedEmail, 'to book:', bookId); + debug.log('contacts', 'Adding trusted sender:', emailAddress, 'to book:', bookId); await client.createContact({ addressBookIds: { [bookId]: true }, - emails: { email: { address: normalizedEmail } }, + ...(displayName ? { name: { full: displayName } } : {}), + emails: { email: { address: emailAddress } }, }); - set((state) => ({ trustedSenderEmails: [...state.trustedSenderEmails, normalizedEmail] })); + set((state) => ({ trustedSenderEmails: [...state.trustedSenderEmails, emailAddress] })); debug.log('contacts', 'Trusted sender added successfully'); }, removeFromTrustedSendersBook: async (client, email) => { - const normalizedEmail = email.toLowerCase().trim(); + // Parse "Name " format to extract just the email address + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const normalizedEmail = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); const { trustedSendersBookId } = get(); if (!trustedSendersBookId) return; @@ -947,7 +955,10 @@ export const useContactStore = create()( }, isTrustedAddressBookSender: (email) => { - const normalizedEmail = email.toLowerCase().trim(); + // Parse "Name " format to extract just the email address + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const normalizedEmail = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); return get().trustedSenderEmails.includes(normalizedEmail); }, diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 4abfc3efc..3a0a44b25 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -666,23 +666,32 @@ export const useSettingsStore = create()( // Trusted senders methods addTrustedSender: (email: string) => { - const normalizedEmail = email.toLowerCase().trim(); + // Parse "Name " format to extract just the email address + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); const current = get().trustedSenders; - if (!current.includes(normalizedEmail)) { - set({ trustedSenders: [...current, normalizedEmail] }); + if (!current.includes(emailAddress)) { + set({ trustedSenders: [...current, emailAddress] }); } }, removeTrustedSender: (email: string) => { - const normalizedEmail = email.toLowerCase().trim(); + // Parse "Name " format to extract just the email address + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); set({ - trustedSenders: get().trustedSenders.filter(e => e !== normalizedEmail) + trustedSenders: get().trustedSenders.filter(e => e !== emailAddress) }); }, isSenderTrusted: (email: string) => { - const normalizedEmail = email.toLowerCase().trim(); - return get().trustedSenders.includes(normalizedEmail); + // Parse "Name " format to extract just the email address + const trimmed = email.trim(); + const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); + const emailAddress = (angleMatch ? angleMatch[2] : trimmed).toLowerCase().trim(); + return get().trustedSenders.includes(emailAddress); }, // Keyword methods From 7c11e2b3c9c18d97dd7c03523a006513572c6a44 Mon Sep 17 00:00:00 2001 From: Max Hao Date: Tue, 9 Jun 2026 11:16:29 -0400 Subject: [PATCH 083/437] add localized translation placeholders --- components/email/email-composer.tsx | 10 ++++++---- locales/en/common.json | 6 +++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 477ae35df..7f6a92cac 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2747,6 +2747,8 @@ function RecipientChipInput({ validationMessage?: string; onTab?: () => void; }) { + const t = useTranslations('email_composer'); + const tCommon = useTranslations('common'); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<{ index: number; chip: string }>(); const [editingChip, setEditingChip] = useState<{ index: number; chip: string; editType: 'email' | 'name' } | null>(null); const [editValue, setEditValue] = useState(''); @@ -2963,7 +2965,7 @@ function RecipientChipInput({ handleSaveEdit(editValue); }} className="flex-1 min-w-[80px] border-0 outline-none h-5 text-sm bg-transparent text-foreground placeholder:text-muted-foreground" - placeholder={editingChip?.editType === 'email' ? 'Email address' : 'Display name'} + placeholder={editingChip?.editType === 'email' ? t('recipient_email_placeholder') : t('recipient_name_placeholder')} data-bwignore="true" /> ) : ( @@ -3039,10 +3041,10 @@ function RecipientChipInput({ {formatChipDisplay(contextMenu.data.chip)}
- - + + - { + { if (contextMenu.data) { handleChipRemove(contextMenu.data.index, { stopPropagation: () => {} } as React.MouseEvent); } diff --git a/locales/en/common.json b/locales/en/common.json index 947b44538..9abd04a01 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -649,7 +649,11 @@ "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?" + "send_delay_unsupported_confirm": "This account does not support send delay. Send immediately instead?", + "recipient_edit_email": "Edit email address", + "recipient_edit_name": "Edit display name", + "recipient_email_placeholder": "Email address", + "recipient_name_placeholder": "Display name" }, "confirm_dialog": { "confirm": "Confirm", From be58cee9895a5f7cfeac5aea1a97863409770fef Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 11 Jun 2026 18:24:43 +0200 Subject: [PATCH 084/437] fix: dedupe scheduling emails, Stalwart-compatible calendar filters --- .../email/calendar-invitation-banner.tsx | 41 +-- lib/__tests__/recurrence-expansion.test.ts | 83 +++++ lib/jmap/client.ts | 320 ++++++++---------- lib/recurrence-expansion.ts | 219 ++++++++++-- stores/calendar-store.ts | 42 +-- 5 files changed, 438 insertions(+), 267 deletions(-) diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 17cc4fde8..bd21fc54a 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -553,40 +553,10 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const replyToForRsvp = parsedEvent.replyTo || (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null); - const imipStatus = status.toUpperCase() as 'ACCEPTED' | 'TENTATIVE' | 'DECLINED'; - const organizerEmail = parsedEvent?.replyTo?.imip?.replace('mailto:', '') - || parsedEvent?.organizerCalendarAddress?.replace('mailto:', '') - || summary?.organizerEmail - || null; - - // Send the iMIP REPLY email to the organizer (client-side scheduling). - // Called after updating the local calendar event. Best-effort - if it - // fails we still report the RSVP as sent since the calendar was updated. - const sendImipReply = async () => { - if (!organizerEmail || !parsedEvent?.uid || !currentUserEmail) { - return; - } - - try { - await client.sendImipReply({ - organizerEmail, - organizerName: summary?.organizer || undefined, - attendeeEmail: currentUserEmail, - attendeeName: myParticipant?.participant.name || undefined, - uid: parsedEvent.uid, - summary: parsedEvent.title, - dtStart: parsedEvent.start || undefined, - dtEnd: summary?.end || undefined, - timeZone: parsedEvent.timeZone || undefined, - isAllDay: parsedEvent.showWithoutTime || false, - sequence: parsedEvent.sequence, - status: imipStatus, - }); - } catch { - // Best-effort: don't block the RSVP success notification - } - }; - + // The iMIP REPLY to the organizer is sent by the server: rsvpEvent / + // updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set + // and Stalwart queues the iTIP REPLY itself. A manual client-side reply + // here produced duplicate emails. try { const eventForRsvp = existingEvent ? await resolveExistingEventForRsvp() @@ -601,7 +571,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp ); if (eventForRsvp && existingEventParticipant) { await rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp); - await sendImipReply(); setRsvpStatus(status); setActionNotice(t('rsvp_sent')); setState('parsed'); @@ -615,7 +584,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp participants: repairedParticipants, replyTo: replyToForRsvp ?? undefined, }, true); - await sendImipReply(); setRsvpStatus(status); setActionNotice(t('rsvp_sent')); setState('parsed'); @@ -632,7 +600,6 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp || (newEvent && currentUserEmail ? findParticipantByEmail(newEvent, currentUserEmail) : null); if (newEvent && participant) { await rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp); - await sendImipReply(); setRsvpStatus(status); setActionNotice(t('rsvp_sent')); } else { diff --git a/lib/__tests__/recurrence-expansion.test.ts b/lib/__tests__/recurrence-expansion.test.ts index 30edbacb6..48d8f8403 100644 --- a/lib/__tests__/recurrence-expansion.test.ts +++ b/lib/__tests__/recurrence-expansion.test.ts @@ -323,6 +323,89 @@ describe('expandRecurringEvents', () => { }); }); + // ----------------------------------------------------------------------- + // excludedRecurrenceRules (EXRULE) + // ----------------------------------------------------------------------- + describe('excludedRecurrenceRules', () => { + it('removes occurrences generated by excluded rules', () => { + const event = makeEvent({ + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], + excludedRecurrenceRules: [{ + '@type': 'RecurrenceRule', + frequency: 'weekly', + byDay: [{ day: 'tu' }], + } as any], + }); + // Jan 6-12 2025: Mon-Sun, Tuesday Jan 7 excluded + const result = expand(event, '2025-01-06T00:00:00', '2025-01-13T00:00:00'); + const days = result.map(e => e.start.substring(0, 10)); + expect(days).not.toContain('2025-01-07'); + expect(days).toContain('2025-01-06'); + expect(days).toContain('2025-01-08'); + expect(result).toHaveLength(6); + }); + }); + + // ----------------------------------------------------------------------- + // Long-running series + // ----------------------------------------------------------------------- + describe('long-running series', () => { + it('expands a daily series started years before the visible range', () => { + const event = makeEvent({ + start: '2022-01-03T09:00:00', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily' } as any], + }); + const result = expand(event, '2025-06-02T00:00:00', '2025-06-05T00:00:00'); + expect(starts(result)).toEqual([ + '2025-06-02T09:00:00', + '2025-06-03T09:00:00', + '2025-06-04T09:00:00', + ]); + }); + + it('expands a weekly series started years before the visible range', () => { + const event = makeEvent({ + start: '2020-01-06T09:00:00', // Monday + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], + }); + const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00'); + const days = result.map(e => e.start.substring(0, 10)); + expect(days).toEqual(['2025-06-02', '2025-06-09', '2025-06-16', '2025-06-23']); + }); + + it('still respects count for old series (no fast-forward shortcut)', () => { + const event = makeEvent({ + start: '2025-01-06T09:00:00', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'daily', count: 5 } as any], + }); + // Range far after the 5 occurrences ran out + const result = expand(event, '2025-06-01T00:00:00', '2025-06-30T00:00:00'); + expect(result).toHaveLength(0); + }); + }); + + // ----------------------------------------------------------------------- + // Event-timezone DST handling + // ----------------------------------------------------------------------- + describe('event timezone DST handling', () => { + it('keeps wall time across a DST transition in the event timezone', () => { + // America/New_York: EST (UTC-5) until 2025-03-09, EDT (UTC-4) after + const event = makeEvent({ + start: '2025-03-03T10:00:00', + timeZone: 'America/New_York', + utcStart: '2025-03-03T15:00:00Z', + utcEnd: '2025-03-03T16:00:00Z', + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' } as any], + } as Partial); + const result = expand(event, '2025-03-03T00:00:00', '2025-03-17T00:00:00'); + const utcStarts = result.map(e => (e as any).utcStart); + expect(utcStarts[0]).toBe('2025-03-03T15:00:00.000Z'); // EST: 10:00 -5 + expect(utcStarts[1]).toBe('2025-03-10T14:00:00.000Z'); // EDT: 10:00 -4 + const utcEnds = result.map(e => (e as any).utcEnd); + expect(utcEnds[1]).toBe('2025-03-10T15:00:00.000Z'); + }); + }); + // ----------------------------------------------------------------------- // All-day events // ----------------------------------------------------------------------- diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 09e914be8..538c6af5c 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -358,12 +358,53 @@ function foldIcsLine(line: string): string { return chunks.join('\r\n'); } +/** + * Escape a TEXT property value per RFC 5545 §3.3.11. Backslash, semicolon, + * comma and newlines must be escaped - otherwise a title like "1,2;3" or a + * multi-line description corrupts the component. + * @see https://www.rfc-editor.org/rfc/rfc5545#section-3.3.11 + */ +function escapeIcsText(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r\n|\r|\n/g, '\\n'); +} + +/** + * Encode a parameter value (e.g. CN=) per RFC 5545 §3.2: values containing + * COLON, SEMICOLON or COMMA must be quoted; DQUOTE itself is not allowed in + * parameter values, so replace it. + */ +function icsParamValue(value: string): string { + const cleaned = value.replace(/[\r\n"]/g, "'"); + return /[;:,]/.test(cleaned) ? `"${cleaned}"` : cleaned; +} + // JMAP RFC 8621 stores Message-IDs without angle brackets. Strip any that // snuck in (e.g. when echoing values that originated from RFC 5322 headers). function stripMessageIdBrackets(id: string): string { return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim(); } +/** + * Build a CalendarEvent/query filter restricting results to the given + * calendars. Stalwart implements the singular `inCalendar` condition (one + * calendar id per condition), not the draft's plural `inCalendars` array - + * sending the plural form fails the whole query with `unsupportedFilter`. + * Multiple calendars are expressed as an OR of singular conditions. + */ +function buildInCalendarFilter(calendarIds: string[]): Record { + if (calendarIds.length === 1) { + return { inCalendar: calendarIds[0] }; + } + return { + operator: 'OR', + conditions: calendarIds.map((id) => ({ inCalendar: id })), + }; +} + // Some servers (notably Stalwart) return Identity.name in RFC 5322 mailbox // form: `Display Name `. Re-emitting that as the JMAP // from.name field produces a doubled From header (`"Name " `) @@ -2374,7 +2415,10 @@ export class JMAPClient implements IJMAPClient { /** * Send an iMIP (RFC 6047) REPLY email to the organizer after an RSVP. - * This is needed when the server does not handle sendSchedulingMessages. + * + * Fallback only: when `sendSchedulingMessages` is passed to the + * CalendarEvent/set RSVP patch, the server sends the iTIP REPLY itself - + * calling this in addition produces duplicate reply emails. */ async sendImipReply(opts: { organizerEmail: string; @@ -2474,14 +2518,14 @@ export class JMAPClient implements IJMAPClient { } } if (opts.summary) { - lines.push(`SUMMARY:${opts.summary}`); + lines.push(`SUMMARY:${escapeIcsText(opts.summary)}`); } if (opts.sequence != null) { lines.push(`SEQUENCE:${opts.sequence}`); } - const orgCn = opts.organizerName ? `;CN=${opts.organizerName}` : ''; + const orgCn = opts.organizerName ? `;CN=${icsParamValue(opts.organizerName)}` : ''; lines.push(`ORGANIZER${orgCn}:mailto:${opts.organizerEmail}`); - const attCn = opts.attendeeName ? `;CN=${opts.attendeeName}` : ''; + const attCn = opts.attendeeName ? `;CN=${icsParamValue(opts.attendeeName)}` : ''; lines.push(`ATTENDEE;PARTSTAT=${opts.status}${attCn}:mailto:${opts.attendeeEmail}`); lines.push('END:VEVENT'); lines.push('END:VCALENDAR'); @@ -2568,7 +2612,12 @@ export class JMAPClient implements IJMAPClient { /** * Send an iMIP (RFC 6047) REQUEST email to all participants of a calendar event. - * Used when creating or updating an event with participants. + * + * Fallback only: when `sendSchedulingMessages` is passed to CalendarEvent/set, + * the server (Stalwart) queues the iTIP messages itself - calling this in + * addition produces duplicate invitation emails. Note the generated ICS is + * a minimal snapshot (no RRULE/VTIMEZONE), so server-side scheduling should + * always be preferred. */ async sendImipInvitation(event: CalendarEvent): Promise { if (!event.participants) return; @@ -2639,7 +2688,13 @@ export class JMAPClient implements IJMAPClient { } } - if (event.utcEnd) { + // Prefer DURATION over DTEND (RFC 5545 §3.6.1): DTSTART above is a local + // date-time (optionally with TZID) while event.utcEnd is a UTC instant - + // emitting both mixed reference frames, and paired a floating DTSTART + // with a UTC DTEND for events without a timezone. + if (event.duration) { + lines.push(`DURATION:${event.duration}`); + } else if (event.utcEnd) { if (event.showWithoutTime) { const dateOnly = event.utcEnd.replace(/[-]/g, '').substring(0, 8); lines.push(`DTEND;VALUE=DATE:${dateOnly}`); @@ -2647,23 +2702,20 @@ export class JMAPClient implements IJMAPClient { const formatted = formatIcalDate(event.utcEnd, event.timeZone); lines.push(formatted.startsWith('TZID=') ? `DTEND;${formatted}` : `DTEND:${formatted}`); } - } else if (event.duration) { - // Fallback: emit DURATION when utcEnd is absent (RFC 5545 §3.6.1) - lines.push(`DURATION:${event.duration}`); } - if (event.title) lines.push(`SUMMARY:${event.title}`); - if (event.description) lines.push(`DESCRIPTION:${event.description}`); + if (event.title) lines.push(`SUMMARY:${escapeIcsText(event.title)}`); + if (event.description) lines.push(`DESCRIPTION:${escapeIcsText(event.description)}`); if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`); if (event.status) lines.push(`STATUS:${event.status.toUpperCase()}`); - const orgCn = organizerName ? `;CN=${organizerName}` : ''; + const orgCn = organizerName ? `;CN=${icsParamValue(organizerName)}` : ''; lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`); for (const attendee of attendees) { const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', ''); if (!email) continue; - const cn = attendee.name ? `;CN=${attendee.name}` : ''; + const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : ''; const partstat = attendee.participationStatus ? `;PARTSTAT=${attendee.participationStatus.toUpperCase()}` : ';PARTSTAT=NEEDS-ACTION'; @@ -2738,7 +2790,10 @@ export class JMAPClient implements IJMAPClient { /** * Send an iMIP (RFC 6047) CANCEL email to all participants of a calendar event. - * Used when deleting an event that has participants. + * + * Fallback only: when `sendSchedulingMessages` is passed to the + * CalendarEvent/set destroy, the server sends the iTIP CANCEL itself - + * calling this in addition produces duplicate cancellation emails. */ async sendImipCancellation(event: CalendarEvent): Promise { if (!event.participants) return; @@ -2810,16 +2865,16 @@ export class JMAPClient implements IJMAPClient { } } - if (event.title) lines.push(`SUMMARY:${event.title}`); + if (event.title) lines.push(`SUMMARY:${escapeIcsText(event.title)}`); if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`); - const orgCn = organizerName ? `;CN=${organizerName}` : ''; + const orgCn = organizerName ? `;CN=${icsParamValue(organizerName)}` : ''; lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`); for (const attendee of attendees) { const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', ''); if (!email) continue; - const cn = attendee.name ? `;CN=${attendee.name}` : ''; + const cn = attendee.name ? `;CN=${icsParamValue(attendee.name)}` : ''; lines.push(`ATTENDEE${cn}:mailto:${email}`); } @@ -4193,7 +4248,7 @@ export class JMAPClient implements IJMAPClient { const queryArgs: Record = { accountId, limit: 1000 }; if (calendarIds && calendarIds.length > 0) { - queryArgs.filter = { inCalendars: calendarIds }; + queryArgs.filter = buildInCalendarFilter(calendarIds); } // First, query to get all IDs @@ -4693,86 +4748,96 @@ export class JMAPClient implements IJMAPClient { // ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ─── + /** + * Fetch all Task objects via the CalendarEvent endpoints. + * + * Stalwart has no `types` filter on CalendarEvent/query (it fails the whole + * query with `unsupportedFilter`), and the previous CalendarEvent/get + * ids:null fallback was capped at the server's maxObjectsInGet (500 by + * default), silently hiding tasks in larger accounts. Instead, page through + * CalendarEvent/query (which returns events *and* tasks), fetch in + * /get-sized batches, and detect tasks client-side. + */ async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); debug.group('CalendarTask/fetch', 'tasks'); debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' }); try { - // Strategy 1: query with types filter (JMAP spec compliant) - const filter: Record = { types: ['Task'] }; - if (calendarIds && calendarIds.length > 0) { - filter.inCalendars = calendarIds; - } + // Page through the query to collect all object ids. + const QUERY_PAGE = 1000; + const MAX_IDS = 50000; // safety bound + const ids: string[] = []; + for (let position = 0; position < MAX_IDS;) { + const queryArgs: Record = { accountId, limit: QUERY_PAGE, position }; + if (calendarIds && calendarIds.length > 0) { + queryArgs.filter = buildInCalendarFilter(calendarIds); + } + const response = await this.request([ + ["CalendarEvent/query", queryArgs, "0"], + ], this.calendarUsing()); - debug.log('tasks', 'CalendarTask/fetch query filter', filter); - - const response = await this.request([ - ["CalendarEvent/query", { accountId, filter, limit: 1000 }, "0"], - ["CalendarEvent/get", { - accountId, - properties: [...CALENDAR_TASK_PROPERTIES], - "#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" }, - }, "1"] - ], this.calendarUsing()); - - const queryResponse = response.methodResponses?.[0]; - const getResponse = response.methodResponses?.[1]; - - debug.log('tasks', 'CalendarTask/fetch query method', queryResponse?.[0]); - debug.log('tasks', 'CalendarTask/fetch query result', queryResponse?.[1]); - - if (queryResponse?.[0] === "error") { - debug.warn('tasks', 'CalendarTask/fetch types filter not supported, falling back to full scan', queryResponse[1]); - const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId); - debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks'); - debug.groupEnd(); - return tasks; - } - - if (getResponse?.[0] === "CalendarEvent/get") { - const list = (getResponse[1].list || []) as CalendarTask[]; - const queryIds = queryResponse?.[1]?.ids || []; - debug.log('calendar', 'CalendarTask/fetch query returned', queryIds.length, 'ids:', queryIds); - debug.log('calendar', 'CalendarTask/fetch get returned', list.length, 'objects'); - - // If the types filter returned 0 results, the server may have silently - // ignored it (e.g. Stalwart with CalDAV-created VTODOs). Fall back to - // a full scan so we can detect tasks by their properties. - if (queryIds.length === 0) { - debug.warn('tasks', 'CalendarTask/fetch types filter returned 0 results, falling back to full scan'); - const tasks = await this.getCalendarTasksFallback(calendarIds, targetAccountId); - debug.log('tasks', 'CalendarTask/fetch fallback returned', tasks.length, 'tasks'); + if (response.methodResponses?.[0]?.[0] === "error") { + const error = response.methodResponses[0][1]; + debug.warn('tasks', 'CalendarTask/fetch query failed', error); debug.groupEnd(); - return tasks; + return []; } - list.forEach((task, i) => { - debug.log('tasks', `CalendarTask/fetch [${i}]`, { - id: task.id, - uid: task.uid, - '@type': task['@type'], - title: task.title, - due: task.due, - start: task.start, - progress: task.progress, - showWithoutTime: task.showWithoutTime, - calendarIds: task.calendarIds, - }); - }); - - const results = list.map((task) => ({ - ...task, - '@type': 'Task' as const, - })); - debug.log('tasks', 'CalendarTask/fetch complete,', results.length, 'tasks'); - debug.groupEnd(); - return results; + const pageIds: string[] = response.methodResponses?.[0]?.[1]?.ids || []; + ids.push(...pageIds); + if (pageIds.length < QUERY_PAGE) break; + position += pageIds.length; } - debug.warn('tasks', 'CalendarTask/fetch unexpected response shape', response.methodResponses); + debug.log('tasks', 'CalendarTask/fetch query returned', ids.length, 'object ids'); + if (ids.length === 0) { + debug.groupEnd(); + return []; + } + + // Fetch the objects in batches that respect the server's /get limit. + const GET_BATCH_SIZE = this.getMaxObjectsInGet(); + const allObjects: Record[] = []; + for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) { + const batchIds = ids.slice(i, i + GET_BATCH_SIZE); + const getResponse = await this.request([ + ["CalendarEvent/get", { + accountId, + properties: [...CALENDAR_TASK_PROPERTIES], + ids: batchIds, + }, "0"] + ], this.calendarUsing()); + + if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { + allObjects.push(...(getResponse.methodResponses[0][1].list || [])); + } + } + + const tasks: CalendarTask[] = []; + for (const obj of allObjects) { + const type = obj['@type']; + const isExplicitTask = typeof type === 'string' && type.toLowerCase() === 'task'; + // CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type + // set to something other than 'Event'. Detect them by the presence of + // task-specific keys (due, progress, percentComplete), which RFC 8984 §5.2 + // defines as Task-only - a VEVENT will never include them in the response. + // We check for key presence (even if null) because Stalwart may return null + // instead of the RFC defaults (e.g. progress default is "needs-action"). + // @see https://www.rfc-editor.org/rfc/rfc8984#section-5.2 + const hasTaskFields = ('due' in obj) + || ('progress' in obj) + || ('percentComplete' in obj); + const isCalDavTask = type !== 'Event' && hasTaskFields; + + if (!isExplicitTask && !isCalDavTask) continue; + + tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask); + } + + debug.log('tasks', 'CalendarTask/fetch complete,', tasks.length, 'tasks of', allObjects.length, 'objects'); debug.groupEnd(); - return []; + return tasks; } catch (error) { debug.error('CalendarTask/fetch failed', error); debug.groupEnd(); @@ -4780,91 +4845,6 @@ export class JMAPClient implements IJMAPClient { } } - /** - * Fallback for servers that don't support the `types` filter in CalendarEvent/query. - * Uses CalendarEvent/get with ids:null to fetch ALL calendar objects (JMAP spec), - * since CalendarEvent/query may only return Event-type objects on some servers. - */ - private async getCalendarTasksFallback(calendarIds?: string[], targetAccountId?: string): Promise { - const accountId = targetAccountId || this.getCalendarsAccountId(); - debug.log('calendar', 'CalendarTask/fallback using CalendarEvent/get ids:null to fetch all objects'); - - // CalendarEvent/get with ids:null returns ALL calendar objects regardless of @type - const response = await this.request([ - ["CalendarEvent/get", { - accountId, - ids: null, - properties: [...CALENDAR_TASK_PROPERTIES], - }, "0"] - ], this.calendarUsing()); - - if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") { - debug.warn('calendar', 'CalendarTask/fallback unexpected response', response.methodResponses?.[0]); - return []; - } - - const allObjects = (response.methodResponses[0][1].list || []) as Record[]; - debug.log('tasks', 'CalendarTask/fallback total calendar objects returned:', allObjects.length); - - const tasks: CalendarTask[] = []; - const calendarIdSet = calendarIds ? new Set(calendarIds) : null; - - allObjects.forEach((obj) => { - const type = obj['@type']; - const isExplicitTask = typeof type === 'string' && type.toLowerCase() === 'task'; - // CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type - // set to something other than 'Event'. Detect them by the presence of - // task-specific keys (due, progress, percentComplete), which RFC 8984 §5.2 - // defines as Task-only - a VEVENT will never include them in the response. - // We check for key presence (even if null) because Stalwart may return null - // instead of the RFC defaults (e.g. progress default is "needs-action"). - // @see https://www.rfc-editor.org/rfc/rfc8984#section-5.2 - const hasTaskFields = ('due' in obj) - || ('progress' in obj) - || ('percentComplete' in obj); - const isCalDavTask = type !== 'Event' && hasTaskFields; - - debug.log('tasks', 'CalendarTask/fallback scan', { - id: obj.id, - '@type': type, - title: obj.title, - hasProgress: 'progress' in obj, - progress: obj.progress, - due: obj.due, - isExplicitTask, - isCalDavTask, - }); - - if (!isExplicitTask && !isCalDavTask) return; - - // Filter by calendar if requested - if (calendarIdSet) { - const objCalendarIds = obj.calendarIds as Record | undefined; - if (objCalendarIds && !Object.keys(objCalendarIds).some(id => calendarIdSet.has(id))) { - debug.log('tasks', 'CalendarTask/fallback skipping task (not in requested calendars)', obj.id); - return; - } - } - - tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask); - }); - - debug.log('tasks', 'CalendarTask/fallback detected', tasks.length, 'tasks'); - tasks.forEach((t, i) => { - debug.log('tasks', `CalendarTask/fallback [${i}]`, { - id: t.id, - uid: t.uid, - title: t.title, - due: t.due, - progress: t.progress, - showWithoutTime: t.showWithoutTime, - calendarIds: t.calendarIds, - }); - }); - - return tasks; - } - async createCalendarTask(task: Partial, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); const { '@type': _type, ...taskData } = task; diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts index 76f9e8fbd..a805bb0c9 100644 --- a/lib/recurrence-expansion.ts +++ b/lib/recurrence-expansion.ts @@ -11,7 +11,7 @@ * in the browser. */ -import { parseISO, format, addDays, addWeeks, addMonths, addYears } from 'date-fns'; +import { parseISO, format, addDays, addWeeks, addMonths, addYears, differenceInCalendarDays } from 'date-fns'; import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types'; const DAY_INDEX: Record = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 }; @@ -64,6 +64,10 @@ export function expandRecurringEvents( return result; } +function occurrenceDateKey(master: CalendarEvent, date: Date): string { + return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString(); +} + function expandEvent( master: CalendarEvent, rangeStart: Date, @@ -77,13 +81,23 @@ function expandEvent( const occurrences: CalendarEvent[] = []; const seenDates = new Set(); + // RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules + // (iCalendar EXRULE) are removed from the recurrence set. + const excludedDates = new Set(); + for (const exRule of master.excludedRecurrenceRules || []) { + // includeStartDate=false: "the series start is always an occurrence" + // applies to recurrence rules, not to exclusion rules. + for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) { + excludedDates.add(occurrenceDateKey(master, date)); + } + } + for (const rule of rules) { const dates = generateDates(eventStart, rule, rangeStart, rangeEnd); for (const date of dates) { - const dateKey = master.showWithoutTime - ? format(date, 'yyyy-MM-dd') - : date.toISOString(); + const dateKey = occurrenceDateKey(master, date); + if (excludedDates.has(dateKey)) continue; if (seenDates.has(dateKey)) continue; seenDates.add(dateKey); @@ -106,9 +120,9 @@ function expandEvent( if (isNaN(overrideDate.getTime())) continue; if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue; - const dateKey = master.showWithoutTime - ? format(overrideDate, 'yyyy-MM-dd') - : overrideDate.toISOString(); + // Overrides take precedence over excludedRecurrenceRules (they re-add + // a concrete instance), so only dedupe against already-generated dates. + const dateKey = occurrenceDateKey(master, overrideDate); if (seenDates.has(dateKey)) continue; seenDates.add(dateKey); @@ -118,6 +132,65 @@ function expandEvent( return occurrences; } +// Cache Intl formatters per IANA timezone id - constructing them is expensive +// and expansion runs over hundreds of occurrences. `null` marks ids the +// runtime rejected so we don't retry them. +const tzFormatterCache = new Map(); + +function getTzFormatter(timeZone: string): Intl.DateTimeFormat | null { + let formatter = tzFormatterCache.get(timeZone); + if (formatter === undefined) { + try { + formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + } catch { + formatter = null; + } + tzFormatterCache.set(timeZone, formatter); + } + return formatter; +} + +/** The wall-clock reading of `instant` in the formatter's zone, re-encoded as a UTC timestamp. */ +function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number): number { + const map: Record = {}; + for (const part of formatter.formatToParts(new Date(instant))) { + if (part.type !== 'literal') map[part.type] = part.value; + } + const hour = map.hour === '24' ? 0 : Number(map.hour); + return Date.UTC( + Number(map.year), Number(map.month) - 1, Number(map.day), + hour, Number(map.minute), Number(map.second), + ); +} + +/** + * Interpret the wall-clock fields of `wall` as a local time in `timeZone` + * and return the corresponding UTC instant. Two fixup iterations converge + * for all real offsets, including across DST transitions. + */ +function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null { + const formatter = getTzFormatter(timeZone); + if (!formatter) return null; + const wallAsUtc = Date.UTC( + wall.getFullYear(), wall.getMonth(), wall.getDate(), + wall.getHours(), wall.getMinutes(), wall.getSeconds(), + ); + let guess = wallAsUtc; + for (let i = 0; i < 2; i++) { + guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess); + } + return new Date(guess); +} + function createOccurrence( master: CalendarEvent, date: Date, @@ -133,17 +206,33 @@ function createOccurrence( // return the correct dates instead of the master's original UTC times. let utcStart: string | undefined; let utcEnd: string | undefined; - if (!master.showWithoutTime && master.utcStart && master.start) { - const masterLocal = parseISO(master.start); - const masterUtc = parseISO(master.utcStart); - const offsetMs = masterUtc.getTime() - masterLocal.getTime(); - utcStart = new Date(date.getTime() + offsetMs).toISOString(); + if (!master.showWithoutTime) { + let durationMs: number | null = null; + if (master.utcStart && master.utcEnd) { + const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime(); + if (!isNaN(ms)) durationMs = ms; + } - // Shift utcEnd by the same amount as utcStart - if (master.utcEnd) { - const masterUtcEnd = parseISO(master.utcEnd); - const durationMs = masterUtcEnd.getTime() - masterUtc.getTime(); - utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString(); + // Convert the occurrence's wall time using the event's own timezone, so + // occurrences on the other side of a DST transition in that zone keep + // their wall time. Reusing the master's fixed UTC offset (the fallback + // below) would shift them by the DST delta. + const zoned = master.timeZone ? zonedWallTimeToUtc(date, master.timeZone) : null; + if (zoned) { + utcStart = zoned.toISOString(); + if (durationMs !== null) { + utcEnd = new Date(zoned.getTime() + durationMs).toISOString(); + } + } else if (master.utcStart && master.start) { + // Floating events (or an unrecognized timezone id): keep the master's + // offset, which by definition doesn't vary. + const masterLocal = parseISO(master.start); + const masterUtc = parseISO(master.utcStart); + const offsetMs = masterUtc.getTime() - masterLocal.getTime(); + utcStart = new Date(date.getTime() + offsetMs).toISOString(); + if (durationMs !== null) { + utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString(); + } } } @@ -218,6 +307,7 @@ function generateDates( rawRule: CalendarRecurrenceRule, rangeStart: Date, rangeEnd: Date, + includeStartDate = true, ): Date[] { const rule = addImplicitByX(rawRule, eventStart); const dates: Date[] = []; @@ -225,20 +315,34 @@ function generateDates( const countLimit = rule.count || Infinity; const until = rule.until ? parseISO(rule.until) : null; let totalCount = 0; - let current = new Date(eventStart); + // Without a `count` limit we can jump straight to the period containing + // the visible range. With one, every occurrence since the series start + // must be generated so it counts against `count`. + let current = rule.count + ? new Date(eventStart) + : fastForwardToRange(eventStart, rule.frequency, interval, rangeStart); const maxIterations = 2000; + // Cap on *emitted* (in-range) dates. This must not count occurrences + // before rangeStart, otherwise a series started long ago (e.g. a daily + // event from two years back) exhausts the budget before reaching the + // visible range and silently renders nothing. const maxOccurrences = 500; let iterations = 0; while (iterations++ < maxIterations) { - if (totalCount >= countLimit || totalCount >= maxOccurrences) break; + if (totalCount >= countLimit || dates.length >= maxOccurrences) break; if (until && current > until) break; - // For frequencies that produce one candidate per iteration at a time, - // we can stop when we pass rangeEnd. But for frequencies that expand - // into multiple candidates per period, we need the candidate generation. - if (current >= rangeEnd && rule.frequency !== 'yearly' && rule.frequency !== 'monthly' - && rule.frequency !== 'weekly') break; + // Stop once no candidate in this or a later period can fall before + // rangeEnd. Monthly/yearly candidates may precede the period anchor + // within the same month/year, so floor the anchor before comparing. + if (rule.frequency === 'monthly') { + if (new Date(current.getFullYear(), current.getMonth(), 1) >= rangeEnd) break; + } else if (rule.frequency === 'yearly') { + if (new Date(current.getFullYear(), 0, 1) >= rangeEnd) break; + } else if (current >= rangeEnd) { + break; + } // Generate candidates for the current period, then filter const candidates = generateCandidatesForPeriod(current, rule, eventStart); @@ -250,7 +354,7 @@ function generateDates( for (const d of filtered) { if (until && d > until) break; - if (totalCount >= countLimit || totalCount >= maxOccurrences) break; + if (totalCount >= countLimit || dates.length >= maxOccurrences) break; // Spec rule 4: eliminate dates before event start if (d < eventStart) continue; @@ -262,7 +366,7 @@ function generateDates( if (d >= rangeEnd) break; } - if (totalCount >= countLimit || totalCount >= maxOccurrences) break; + if (totalCount >= countLimit || dates.length >= maxOccurrences) break; current = advancePeriod(current, rule.frequency, interval, rule.firstDayOfWeek || 'mo'); if (current <= eventStart && iterations === 1) { @@ -272,7 +376,7 @@ function generateDates( } // Spec rule 1: the initial start date-time is ALWAYS the first occurrence - if (dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) { + if (includeStartDate && dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) { if (eventStart >= rangeStart && eventStart < rangeEnd) { // Check it's not already in the list if (!dates.some(d => d.getTime() === eventStart.getTime())) { @@ -535,6 +639,67 @@ function advancePeriod( } } +// --------------------------------------------------------------------------- +// Jump to the period just before the visible range in O(1), so long-running +// series don't burn the iteration budget on years of out-of-range periods. +// Only valid for rules without `count` (counted rules must enumerate every +// occurrence from the series start). +// --------------------------------------------------------------------------- +function fastForwardToRange( + eventStart: Date, + frequency: CalendarRecurrenceRule['frequency'], + interval: number, + rangeStart: Date, +): Date { + if (eventStart >= rangeStart) return new Date(eventStart); + + let periods: number; + switch (frequency) { + case 'secondly': + periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (1000 * interval)); + break; + case 'minutely': + periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (60000 * interval)); + break; + case 'hourly': + periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (3600000 * interval)); + break; + case 'daily': + periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / interval); + break; + case 'weekly': + periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / (7 * interval)); + break; + case 'monthly': { + const months = (rangeStart.getFullYear() - eventStart.getFullYear()) * 12 + + (rangeStart.getMonth() - eventStart.getMonth()); + periods = Math.floor(months / interval); + break; + } + case 'yearly': + periods = Math.floor((rangeStart.getFullYear() - eventStart.getFullYear()) / interval); + break; + default: + return new Date(eventStart); + } + + // Land one full period early so candidates inside the boundary period + // (which can precede the period anchor) are still generated. + periods = Math.max(0, periods - 1); + if (periods === 0) return new Date(eventStart); + + switch (frequency) { + case 'secondly': return new Date(eventStart.getTime() + periods * interval * 1000); + case 'minutely': return new Date(eventStart.getTime() + periods * interval * 60000); + case 'hourly': return new Date(eventStart.getTime() + periods * interval * 3600000); + case 'daily': return addDays(eventStart, periods * interval); + case 'weekly': return addWeeks(eventStart, periods * interval); + case 'monthly': return addMonths(eventStart, periods * interval); + case 'yearly': return addYears(eventStart, periods * interval); + default: return new Date(eventStart); + } +} + // --------------------------------------------------------------------------- // Helper: expand byDay within a specific month (monthly or yearly context) // --------------------------------------------------------------------------- diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 3d21a5540..7040d55e4 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -471,13 +471,9 @@ export const useCalendarStore = create()( } set((state) => ({ events: [...state.events, mappedCreated] })); - if (sendSchedulingMessages && created.participants) { - try { - await client.sendImipInvitation(created); - } catch (e) { - debug.error('Failed to send invitation emails:', e); - } - } + // Invitation emails are sent by the server: `sendSchedulingMessages` + // on CalendarEvent/set makes Stalwart queue the iTIP REQUEST itself. + // Sending a client-side iMIP copy here produced duplicate emails. return mappedCreated; } catch (error) { debug.error('Failed to create event:', error); @@ -541,22 +537,9 @@ export const useCalendarStore = create()( return merged; }), })); - if (sendSchedulingMessages) { - const mergedParticipants = cleanUpdates.participants ?? storeEvent?.participants; - if (mergedParticipants) { - const eventForInvitation = { - ...(storeEvent ?? {}), - ...cleanUpdates, - id: realId, - participants: mergedParticipants, - } as import('@/lib/jmap/types').CalendarEvent; - try { - await client.sendImipInvitation(eventForInvitation); - } catch (e) { - debug.error('Failed to send invitation emails:', e); - } - } - } + // Update emails (iTIP REQUEST/REPLY) are sent by the server via the + // `sendSchedulingMessages` argument already passed above - a manual + // iMIP send here produced duplicate emails. } catch (error) { debug.error('Failed to update event:', error); set({ error: 'Failed to update event' }); @@ -787,16 +770,9 @@ export const useCalendarStore = create()( const realId = storeEvent?.originalId || stripLocalAccountPrefix(id, storeEvent?.localAccountId); const targetAccountId = storeEvent?.accountId; client = resolveAccountClient(client, storeEvent?.localAccountId); - if (sendSchedulingMessages) { - try { - const event = await client.getCalendarEvent(realId, targetAccountId); - if (event?.participants) { - await client.sendImipCancellation(event); - } - } catch (e) { - debug.error('Failed to send cancellation emails:', e); - } - } + // Cancellation emails (iTIP CANCEL) are sent by the server via the + // `sendSchedulingMessages` argument on the destroy below - a manual + // iMIP send here produced duplicate emails. debug.log('calendar', 'Calendar deleteEvent', { storeId: id, realId, From 08f344403b1a39f1398c43c1818d208830a3db97 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:05:11 +0200 Subject: [PATCH 085/437] feat: recurrence editor, set-default calendar, and timezone-aware calendar queries --- .../calendar/calendar-sidebar-panel.tsx | 23 +- components/calendar/event-detail-popover.tsx | 17 +- components/calendar/event-modal.tsx | 112 ++++- components/calendar/recurrence-editor.tsx | 471 ++++++++++++++++++ lib/demo/demo-client.ts | 6 + lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 96 +++- locales/cs/common.json | 46 +- locales/da/common.json | 46 +- locales/de/common.json | 46 +- locales/en/common.json | 35 +- locales/es/common.json | 46 +- locales/fr/common.json | 46 +- locales/hu/common.json | 46 +- locales/it/common.json | 46 +- locales/ja/common.json | 46 +- locales/ko/common.json | 46 +- locales/lv/common.json | 46 +- locales/nl/common.json | 46 +- locales/pl/common.json | 46 +- locales/pt/common.json | 46 +- locales/ru/common.json | 46 +- locales/tr/common.json | 46 +- locales/uk/common.json | 46 +- locales/zh/common.json | 46 +- stores/calendar-store.ts | 38 +- 26 files changed, 1519 insertions(+), 62 deletions(-) create mode 100644 components/calendar/recurrence-editor.tsx diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 913e9bcbf..2fc8dc110 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; -import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react"; +import { ChevronDown, ChevronRight, Globe, ListTodo, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "lucide-react"; import { cn, formatDateTime } from "@/lib/utils"; import type { Calendar } from "@/lib/jmap/types"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; @@ -96,6 +96,7 @@ export function CalendarSidebarPanel({ ); const refreshICalSubscription = useCalendarStore((s) => s.refreshICalSubscription); const removeICalSubscription = useCalendarStore((s) => s.removeICalSubscription); + const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar); const timeFormat = useSettingsStore((s) => s.timeFormat); const sharedCalendarColors = useSettingsStore((s) => s.sharedCalendarColors); const enableCalendarTasks = useSettingsStore((s) => s.enableCalendarTasks); @@ -219,6 +220,16 @@ export function CalendarSidebarPanel({ } }; + const handleSetDefault = async (calendarId: string) => { + if (!client) return; + try { + await setDefaultCalendar(client, calendarId); + toast.success(tMgmt('default_updated')); + } catch { + toast.error(tMgmt('error_default')); + } + }; + if (calendars.length === 0 && !onSubscribe) return null; const renderCalendarItem = (cal: Calendar) => { @@ -306,12 +317,13 @@ export function CalendarSidebarPanel({ const isBirthday = cal.id === BIRTHDAY_CALENDAR_ID; const canCreate = onCreateEvent && !isBirthday && cal.myRights?.mayWriteOwn !== false; const canShare = onShareCalendar && cal.myRights?.mayShare && !cal.isShared; + const canSetDefault = !!client && !isBirthday && !cal.isShared && !cal.isDefault; const canChangeColor = !!onColorChange; const hasColorOverride = !!cal.isShared && !!sharedCalendarColors[sharedCalendarColorKey(cal)]; const canResetColor = !!onResetColor && hasColorOverride; const canClear = onClearCalendar && !isBirthday && cal.myRights?.mayDelete !== false; const canDelete = onDeleteCalendar && !isBirthday && !cal.isDefault && !cal.isShared; - const showSeparator = (canCreate || canShare || canChangeColor || canResetColor) && (canClear || canDelete); + const showSeparator = (canCreate || canShare || canSetDefault || canChangeColor || canResetColor) && (canClear || canDelete); const color = cal.color || "#3b82f6"; return ( @@ -330,6 +342,13 @@ export function CalendarSidebarPanel({ onClick={() => { closeContextMenu(); onShareCalendar(cal); }} /> )} + {canSetDefault && ( + { closeContextMenu(); handleSetDefault(cal.id); }} + /> + )} {canChangeColor && (
diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 1f0e4f672..23c21d43d 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; import { createPortal } from "react-dom"; import { Button } from "@/components/ui/button"; import { @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import { parseDuration, getEventColor } from "./event-card"; import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils"; +import { buildRecurrenceSummary } from "./recurrence-editor"; import { isOrganizer, getUserParticipantId, @@ -101,16 +102,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType): string | null { +function getRecurrenceLabel(event: CalendarEvent, t: ReturnType, locale: string): string | null { if (!event.recurrenceRules?.length) return null; - const freq = event.recurrenceRules[0].frequency; - const labels: Record = { - daily: t("recurrence.daily"), - weekly: t("recurrence.weekly"), - monthly: t("recurrence.monthly"), - yearly: t("recurrence.yearly"), - }; - return labels[freq] || null; + return buildRecurrenceSummary(event.recurrenceRules[0], t, locale); } export function EventDetailPopover({ @@ -130,6 +124,7 @@ export function EventDetailPopover({ isMobile, }: EventDetailPopoverProps) { const t = useTranslations("calendar"); + const locale = useLocale(); const popoverRef = useRef(null); const noteInputRef = useRef(null); const [position, setPosition] = useState<{ top: number; left: number } | null>(null); @@ -158,7 +153,7 @@ export function EventDetailPopover({ }, [event.virtualLocations]); const participants = useMemo(() => getParticipantList(event), [event]); - const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]); + const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t, locale), [event, t, locale]); const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]); const userIsOrganizer = useMemo(() => { diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index c0d7c87ab..5fc192e33 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -1,12 +1,13 @@ "use client"; import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react"; import { format, parseISO, addHours, addDays, isSameDay } from "date-fns"; -import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types"; +import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types"; +import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor"; import { parseDuration, getEventColor } from "./event-card"; import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils"; import { ParticipantInput, type ParticipantInputHandle } from "./participant-input"; @@ -75,7 +76,7 @@ function buildDuration(startDate: Date, endDate: Date): string { return dur; } -type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly"; +type RecurrenceOption = "none" | "daily" | "weekly" | "monthly" | "yearly" | "custom"; type AlertUnit = "at_time" | "minutes" | "hours" | "days" | "weeks"; @@ -157,16 +158,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType): string | null { +function getRecurrenceLabel(event: CalendarEvent, t: ReturnType, locale: string): string | null { if (!event.recurrenceRules?.length) return null; - const freq = event.recurrenceRules[0].frequency; - const labels: Record = { - daily: t("recurrence.daily"), - weekly: t("recurrence.weekly"), - monthly: t("recurrence.monthly"), - yearly: t("recurrence.yearly"), - }; - return labels[freq] || null; + return buildRecurrenceSummary(event.recurrenceRules[0], t, locale); } export function EventModal({ @@ -186,6 +180,7 @@ export function EventModal({ isMobile = false, }: EventModalProps) { const t = useTranslations("calendar"); + const locale = useLocale(); const timeFormat = useSettingsStore((s) => s.timeFormat); const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; const isEdit = !!event; @@ -270,8 +265,35 @@ export function EventModal({ }); const [recurrence, setRecurrence] = useState(() => { if (!event?.recurrenceRules?.length) return "none"; - return event.recurrenceRules[0].frequency as RecurrenceOption; + const rule = event.recurrenceRules[0]; + return isSimpleRecurrenceRule(rule) ? (rule.frequency as RecurrenceOption) : "custom"; }); + const [customRule, setCustomRule] = useState(() => { + if (!event?.recurrenceRules?.length) return null; + const rule = event.recurrenceRules[0]; + return isSimpleRecurrenceRule(rule) ? null : rule; + }); + const [showRecurrenceEditor, setShowRecurrenceEditor] = useState(false); + // Dropdown value to restore when the custom editor is cancelled without a saved rule. + const recurrenceBeforeCustomRef = useRef("none"); + + const handleRecurrenceEditorSave = useCallback((rule: CalendarRecurrenceRule) => { + setCustomRule(rule); + setRecurrence("custom"); + setShowRecurrenceEditor(false); + }, []); + + const handleRecurrenceEditorCancel = useCallback(() => { + setShowRecurrenceEditor(false); + if (!customRule) { + setRecurrence(recurrenceBeforeCustomRef.current); + } + }, [customRule]); + + const customRuleSummary = useMemo( + () => (customRule ? buildRecurrenceSummary(customRule, t, locale) : null), + [customRule, t, locale] + ); const preservedAlertsRef = useRef>({}); const [alertRows, setAlertRows] = useState(() => { if (!event?.alerts) return []; @@ -444,7 +466,9 @@ export function EventModal({ data.virtualLocations = null; } - if (recurrence !== "none") { + if (recurrence === "custom" && customRule) { + data.recurrenceRules = [customRule]; + } else if (recurrence !== "none" && recurrence !== "custom") { data.recurrenceRules = [{ "@type": "RecurrenceRule", frequency: recurrence, @@ -511,7 +535,7 @@ export function EventModal({ } finally { setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]); const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => { if (!event || !userParticipantId || !onRsvp) return; @@ -737,7 +761,7 @@ export function EventModal({ const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null; const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null; const viewParticipants = getParticipantList(event); - const recurrenceLabel = getRecurrenceLabel(event, t); + const recurrenceLabel = getRecurrenceLabel(event, t, locale); const alertLabel = getAlertLabel(event, t); const eventCalendar = calendars.find(c => event.calendarIds[c.id]); const color = getEventColor(event, eventCalendar); @@ -1131,17 +1155,51 @@ export function EventModal({
- +
+ + {recurrence === "custom" && !showRecurrenceEditor && ( + + )} +
+ {showRecurrenceEditor && ( + { + const d = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`); + return isNaN(d.getTime()) ? new Date() : d; + })()} + onSave={handleRecurrenceEditorSave} + onCancel={handleRecurrenceEditorCancel} + /> + )}
diff --git a/components/calendar/recurrence-editor.tsx b/components/calendar/recurrence-editor.tsx new file mode 100644 index 000000000..94a4fa6ae --- /dev/null +++ b/components/calendar/recurrence-editor.tsx @@ -0,0 +1,471 @@ +"use client"; + +import { useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { X } from "lucide-react"; +import { addYears, format } from "date-fns"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { CalendarRecurrenceRule } from "@/lib/jmap/types"; + +type EditorFrequency = "daily" | "weekly" | "monthly" | "yearly"; +type MonthlyMode = "day" | "nth"; +type EndsMode = "never" | "on" | "after"; + +const EDITOR_FREQUENCIES: EditorFrequency[] = ["daily", "weekly", "monthly", "yearly"]; + +// 2024-01-01 is a Monday - used to render localized weekday names via Intl. +const WEEKDAYS: string[] = ["mo", "tu", "we", "th", "fr", "sa", "su"]; +const DAY_TO_REF_DATE: Record = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 }; +const INDEX_TO_DAY = ["su", "mo", "tu", "we", "th", "fr", "sa"]; + +const FREQ_LABEL_KEYS: Record = { + daily: "recurrence.editor_freq_day", + weekly: "recurrence.editor_freq_week", + monthly: "recurrence.editor_freq_month", + yearly: "recurrence.editor_freq_year", +}; + +const UNIT_LABEL_KEYS: Record = { + daily: "recurrence.editor_unit_days", + weekly: "recurrence.editor_unit_weeks", + monthly: "recurrence.editor_unit_months", + yearly: "recurrence.editor_unit_years", +}; + +type CalendarT = ReturnType; + +function weekdayName(day: string, locale: string, style: "long" | "short" = "long"): string { + const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1); + return new Intl.DateTimeFormat(locale, { weekday: style }).format(ref); +} + +function monthName(month: number, locale: string): string { + return new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2024, month - 1, 1)); +} + +function nthLabel(nth: number, t: CalendarT): string { + if (nth === -1) return t("recurrence.nth_last"); + if (nth >= 1 && nth <= 4) return t(`recurrence.nth_${nth}`); + return String(nth); +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +/** + * Extract an "nth weekday" pattern from a rule, accepting both the + * byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding. + */ +function getNthDay(rule: CalendarRecurrenceRule): { day: string; nth: number } | null { + if (rule.byDay?.length === 1) { + const nd = rule.byDay[0]; + if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod }; + if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] }; + } + return null; +} + +/** + * True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly + * dropdown presets produce, i.e. it needs no custom editor to represent. + */ +export function isSimpleRecurrenceRule(rule: CalendarRecurrenceRule): boolean { + return ( + (EDITOR_FREQUENCIES as string[]).includes(rule.frequency) && + (!rule.interval || rule.interval === 1) && + !rule.byDay?.length && + !rule.byMonthDay?.length && + !rule.byMonth?.length && + !rule.byYearDay?.length && + !rule.byWeekNo?.length && + !rule.bySetPosition?.length && + !rule.count && + !rule.until + ); +} + +/** + * Human-readable summary of a recurrence rule, e.g. + * "Every 2 months on the third Thursday · 12 occurrences". + * Returns null for frequencies the UI cannot describe (hourly etc.). + */ +export function buildRecurrenceSummary( + rule: CalendarRecurrenceRule, + t: CalendarT, + locale: string, +): string | null { + const interval = rule.interval || 1; + let base: string; + switch (rule.frequency) { + case "daily": + base = interval > 1 ? t("recurrence.every_n_days", { count: interval }) : t("recurrence.daily"); + break; + case "weekly": + base = interval > 1 ? t("recurrence.every_n_weeks", { count: interval }) : t("recurrence.weekly"); + break; + case "monthly": + base = interval > 1 ? t("recurrence.every_n_months", { count: interval }) : t("recurrence.monthly"); + break; + case "yearly": + base = interval > 1 ? t("recurrence.every_n_years", { count: interval }) : t("recurrence.yearly"); + break; + default: + return null; + } + + const parts = [base]; + + if (rule.frequency === "weekly" && rule.byDay?.length) { + const days = rule.byDay + .filter((d) => WEEKDAYS.includes(d.day)) + .sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day)) + .map((d) => weekdayName(d.day, locale, "short")) + .join(", "); + if (days) parts.push(t("recurrence.on_days", { days })); + } + + if (rule.frequency === "monthly" || rule.frequency === "yearly") { + if (rule.frequency === "yearly" && rule.byMonth?.length) { + const m = parseInt(rule.byMonth[0], 10); + if (m >= 1 && m <= 12) parts.push(t("recurrence.in_month", { month: monthName(m, locale) })); + } + const nthDay = getNthDay(rule); + if (nthDay) { + parts.push(t("recurrence.on_the_nth", { + nth: nthLabel(nthDay.nth, t), + day: weekdayName(nthDay.day, locale), + })); + } else if (rule.byMonthDay?.length) { + parts.push(t("recurrence.on_day_n", { day: rule.byMonthDay[0] })); + } + } + + let summary = parts.join(" "); + if (rule.count) { + summary += ` · ${t("recurrence.occurrences", { count: rule.count })}`; + } else if (rule.until) { + const d = new Date(rule.until); + if (!isNaN(d.getTime())) { + summary += ` · ${t("recurrence.until")} ${new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(d)}`; + } + } + return summary; +} + +interface RecurrenceEditorProps { + rule: CalendarRecurrenceRule | null; + eventStart: Date; + onSave: (rule: CalendarRecurrenceRule) => void; + onCancel: () => void; +} + +export function RecurrenceEditor({ rule, eventStart, onSave, onCancel }: RecurrenceEditorProps) { + const t = useTranslations("calendar"); + const locale = useLocale(); + + const startDay = INDEX_TO_DAY[eventStart.getDay()]; + const initialNthDay = rule ? getNthDay(rule) : null; + + const [frequency, setFrequency] = useState(() => + rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency) + ? (rule.frequency as EditorFrequency) + : "weekly" + ); + const [interval, setIntervalValue] = useState(rule?.interval || 1); + const [weekDays, setWeekDays] = useState(() => { + if (rule?.frequency === "weekly" && rule.byDay?.length) { + const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d)); + if (days.length) return days; + } + return [startDay]; + }); + const [monthlyMode, setMonthlyMode] = useState(initialNthDay ? "nth" : "day"); + const [monthDay, setMonthDay] = useState(() => { + const md = rule?.byMonthDay?.[0]; + return md && md >= 1 && md <= 31 ? md : eventStart.getDate(); + }); + const [nth, setNth] = useState(() => { + if (initialNthDay && (initialNthDay.nth === -1 || (initialNthDay.nth >= 1 && initialNthDay.nth <= 4))) { + return initialNthDay.nth; + } + return Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1); + }); + const [nthDay, setNthDay] = useState(() => + initialNthDay && WEEKDAYS.includes(initialNthDay.day) ? initialNthDay.day : startDay + ); + const [month, setMonth] = useState(() => { + const m = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN; + return m >= 1 && m <= 12 ? m : eventStart.getMonth() + 1; + }); + const [endsMode, setEndsMode] = useState(rule?.count ? "after" : rule?.until ? "on" : "never"); + const [untilDate, setUntilDate] = useState(() => { + if (rule?.until) { + const d = new Date(rule.until); + if (!isNaN(d.getTime())) return format(d, "yyyy-MM-dd"); + } + return format(addYears(eventStart, 1), "yyyy-MM-dd"); + }); + const [count, setCount] = useState(rule?.count ?? 12); + + const toggleWeekDay = (day: string) => { + setWeekDays((prev) => + prev.includes(day) + ? prev.length > 1 ? prev.filter((d) => d !== day) : prev + : [...prev, day] + ); + }; + + const handleSave = () => { + const built: CalendarRecurrenceRule = { + "@type": "RecurrenceRule", + frequency, + interval: Math.max(1, interval), + rscale: "gregorian", + skip: "omit", + firstDayOfWeek: "mo", + byDay: null, + byMonthDay: null, + byMonth: null, + byYearDay: null, + byWeekNo: null, + byHour: null, + byMinute: null, + bySecond: null, + bySetPosition: null, + count: endsMode === "after" ? Math.max(1, count) : null, + until: endsMode === "on" && untilDate ? `${untilDate}T23:59:59` : null, + }; + + if (frequency === "weekly") { + const days = weekDays.length ? weekDays : [startDay]; + built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day })); + } else if (frequency === "monthly" || frequency === "yearly") { + if (monthlyMode === "nth") { + built.byDay = [{ day: nthDay, nthOfPeriod: nth }]; + } else { + built.byMonthDay = [Math.min(31, Math.max(1, monthDay))]; + } + if (frequency === "yearly") { + built.byMonth = [String(month)]; + } + } + + onSave(built); + }; + + const selectCls = "rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"; + + return ( +
+
+
+ {EDITOR_FREQUENCIES.map((f) => ( + + ))} +
+ +
+ +
+

{t("recurrence.editor_repeats_on")}

+
+ {t("recurrence.editor_every")} + { + const n = parseInt(e.target.value, 10); + setIntervalValue(Number.isFinite(n) ? Math.max(1, n) : 1); + }} + className="w-20" + aria-label={t("recurrence.editor_every")} + /> + {t(UNIT_LABEL_KEYS[frequency])} +
+ + {frequency === "weekly" && ( +
+ {WEEKDAYS.map((day) => ( + + ))} +
+ )} + + {frequency === "yearly" && ( +
+ {t("recurrence.editor_in")} + +
+ )} + + {(frequency === "monthly" || frequency === "yearly") && ( +
+ + +
+ )} +
+ +
+

{t("recurrence.editor_ends")}

+ + + +
+ +
+ + +
+
+ ); +} diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 38d2a2a6f..0d4a94563 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -701,6 +701,12 @@ export class DemoJMAPClient implements IJMAPClient { if (cal) Object.assign(cal, updates); } + async setDefaultCalendar(calendarId: string): Promise { + for (const cal of this.data.calendars) { + cal.isDefault = cal.id === calendarId; + } + } + async deleteCalendar(calendarId: string): Promise { this.data.calendars = this.data.calendars.filter(c => c.id !== calendarId); this.data.calendarEvents = this.data.calendarEvents.filter(e => !e.calendarIds[calendarId]); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index e635765d6..069265488 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -263,6 +263,7 @@ export interface IJMAPClient { getAllCalendars(): Promise; createCalendar(calendar: Partial, targetAccountId?: string): Promise; updateCalendar(calendarId: string, updates: Partial, targetAccountId?: string): Promise; + setDefaultCalendar(calendarId: string, targetAccountId?: string): Promise; deleteCalendar(calendarId: string, targetAccountId?: string): Promise; getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise; getCalendarEvent(id: string, targetAccountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 538c6af5c..219fa4ce4 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -125,6 +125,23 @@ const CALENDAR_PROPERTIES = [ "myRights", ] as const; +// Properties Stalwart's Calendar/set accepts in create/update. Anything else +// (id, isDefault, myRights, client-side bookkeeping fields) makes the whole +// update fail with invalidProperties ("Field could not be set"). +const CALENDAR_SETTABLE_PROPERTIES = new Set([ + "name", + "description", + "color", + "timeZone", + "sortOrder", + "isSubscribed", + "isVisible", + "includeInAvailability", + "defaultAlertsWithTime", + "defaultAlertsWithoutTime", + "shareWith", +]); + // Stalwart's default property list for AddressBook/get omits shareWith, so // existing shares would be invisible after a fresh login. const ADDRESS_BOOK_PROPERTIES = [ @@ -235,6 +252,22 @@ const CALENDAR_TASK_PROPERTIES = [ 'percentComplete', // Task-only per RFC 8984 §5.2.4 - used in detection heuristic ] as const; +/** + * IANA time zone of the browser, sent as the `timeZone` argument on + * CalendarEvent/query and CalendarEvent/get. Stalwart interprets the + * LocalDateTime `after`/`before` filter values and computes utcStart/utcEnd + * for floating events in this zone, defaulting to UTC when absent - which + * shifts range boundaries and floating-event times for any user not in UTC. + * Stalwart ignores unparseable values, so sending it is always safe. + */ +function getUserTimeZone(): string | undefined { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; + } catch { + return undefined; + } +} + /** * Stalwart's calcard crate uses singular property names ("recurrenceRule") * instead of the RFC 8984 plural forms ("recurrenceRules"). @@ -4196,11 +4229,24 @@ export class JMAPClient implements IJMAPClient { async updateCalendar(calendarId: string, updates: Partial, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); + // Stalwart rejects the whole update with invalidProperties if any key is + // not settable (e.g. id, isDefault, myRights, or client-only fields), so + // only forward the properties its Calendar/set actually accepts. Keys + // containing '/' are JSON-pointer patches; keep those whose root segment + // is settable (shareWith/..., defaultAlertsWithTime/...). + const cleanUpdates: Record = {}; + for (const [key, value] of Object.entries(updates as Record)) { + const root = key.split('/', 1)[0]; + if (CALENDAR_SETTABLE_PROPERTIES.has(root)) { + cleanUpdates[key] = value; + } + } + const response = await this.request([ ["Calendar/set", { accountId, update: { - [calendarId]: updates + [calendarId]: cleanUpdates } }, "0"] ], this.calendarUsing()); @@ -4218,6 +4264,31 @@ export class JMAPClient implements IJMAPClient { throw new Error("Failed to update calendar"); } + /** + * Mark a calendar as the account default. `isDefault` is read-only in + * Stalwart's Calendar/set - the default is changed via the + * `onSuccessSetIsDefault` request argument instead. + */ + async setDefaultCalendar(calendarId: string, targetAccountId?: string): Promise { + const accountId = targetAccountId || this.getCalendarsAccountId(); + + const response = await this.request([ + ["Calendar/set", { + accountId, + onSuccessSetIsDefault: calendarId + }, "0"] + ], this.calendarUsing()); + + const methodName = response.methodResponses?.[0]?.[0]; + if (methodName === "error") { + const error = response.methodResponses?.[0]?.[1]; + throw new Error(error?.description || error?.type || "Failed to set default calendar"); + } + if (methodName !== "Calendar/set") { + throw new Error("Failed to set default calendar"); + } + } + async deleteCalendar(calendarId: string, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); @@ -4245,8 +4316,12 @@ export class JMAPClient implements IJMAPClient { async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); const GET_BATCH_SIZE = this.getMaxObjectsInGet(); + const timeZone = getUserTimeZone(); const queryArgs: Record = { accountId, limit: 1000 }; + if (timeZone) { + queryArgs.timeZone = timeZone; + } if (calendarIds && calendarIds.length > 0) { queryArgs.filter = buildInCalendarFilter(calendarIds); } @@ -4274,6 +4349,7 @@ export class JMAPClient implements IJMAPClient { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: batchIds, + ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); @@ -4337,12 +4413,18 @@ export class JMAPClient implements IJMAPClient { ): Promise { try { const accountId = targetAccountId || this.getCalendarsAccountId(); + const timeZone = getUserTimeZone(); const queryArgs: Record = { accountId, filter, limit: limit || 1000, }; + // Interpret the LocalDateTime after/before filter values in the user's + // time zone (Stalwart defaults to UTC, shifting range boundaries). + if (timeZone) { + queryArgs.timeZone = timeZone; + } // NOTE: We do NOT use expandRecurrences because Stalwart returns synthetic // IDs that cannot be used for CalendarEvent/set (update/destroy). // Recurrence expansion is done client-side instead. @@ -4374,6 +4456,7 @@ export class JMAPClient implements IJMAPClient { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: batchIds, + ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); @@ -4410,11 +4493,13 @@ export class JMAPClient implements IJMAPClient { async getCalendarEvent(id: string, targetAccountId?: string): Promise { try { const accountId = targetAccountId || this.getCalendarsAccountId(); + const timeZone = getUserTimeZone(); const response = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: [id], + ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); @@ -4568,11 +4653,13 @@ export class JMAPClient implements IJMAPClient { } // Fetch all created events in a single CalendarEvent/get + const refetchTimeZone = getUserTimeZone(); const getResponse = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: createdIds, + ...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}), }, "0"] ], this.calendarUsing()); @@ -4767,9 +4854,13 @@ export class JMAPClient implements IJMAPClient { // Page through the query to collect all object ids. const QUERY_PAGE = 1000; const MAX_IDS = 50000; // safety bound + const timeZone = getUserTimeZone(); const ids: string[] = []; for (let position = 0; position < MAX_IDS;) { const queryArgs: Record = { accountId, limit: QUERY_PAGE, position }; + if (timeZone) { + queryArgs.timeZone = timeZone; + } if (calendarIds && calendarIds.length > 0) { queryArgs.filter = buildInCalendarFilter(calendarIds); } @@ -4806,6 +4897,7 @@ export class JMAPClient implements IJMAPClient { accountId, properties: [...CALENDAR_TASK_PROPERTIES], ids: batchIds, + ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); @@ -4884,11 +4976,13 @@ export class JMAPClient implements IJMAPClient { // Fetch back with task-specific properties debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] }); + const refetchTimeZone = getUserTimeZone(); const getResponse = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_TASK_PROPERTIES], ids: [createdId], + ...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}), }, "0"] ], this.calendarUsing()); diff --git a/locales/cs/common.json b/locales/cs/common.json index 9d9c04e6a..4c8ffba1d 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopírovat", "copied": "Zkopírováno", "password_instructions": "Použijte výše uvedené uživatelské jméno JMAP společně s heslem aplikace pro přihlášení v poštovním klientovi. Pokud jste tak ještě neučinili, vytvořte si heslo aplikace v sekci výše." + }, + "link_device": { + "title": "Propojit mobilní aplikaci", + "description": "Přihlaste se do mobilní aplikace Bulwark Mail bez psaní. Vygenerujte zde QR kód a naskenujte jej na přihlašovací obrazovce aplikace.", + "generate": "Zobrazit QR kód", + "regenerate": "Zobrazit nový kód", + "instructions": "Otevřete aplikaci Bulwark Mail, na přihlašovací obrazovce klepněte na \"Naskenovat QR kód\" a namiřte fotoaparát sem.", + "expires_in": "Tento kód vyprší za {seconds} sekund. Lze jej použít pouze jednou.", + "expired": "Platnost tohoto kódu vypršela.", + "generating": "Generování…", + "error": "Párovací kód se nepodařilo vytvořit. Zkuste to prosím znovu." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Každých {count} týdnů", "every_n_months": "Každých {count} měsíců", "until": "Do", - "occurrences": "{count} opakování" + "occurrences": "{count} opakování", + "custom": "Vlastní…", + "edit_custom": "Upravit vlastní opakování", + "every_n_years": "Každých {count} let", + "on_days": "v {days}", + "on_day_n": "v den {day}", + "on_the_nth": "{nth} {day}", + "in_month": "v měsíci {month}", + "nth_1": "první", + "nth_2": "druhý", + "nth_3": "třetí", + "nth_4": "čtvrtý", + "nth_last": "poslední", + "editor_freq_day": "Den", + "editor_freq_week": "Týden", + "editor_freq_month": "Měsíc", + "editor_freq_year": "Rok", + "editor_repeats_on": "Opakuje se", + "editor_every": "Každých", + "editor_unit_days": "dní", + "editor_unit_weeks": "týdnů", + "editor_unit_months": "měsíců", + "editor_unit_years": "let", + "editor_on_day": "v den", + "editor_on_the": "v", + "editor_in": "v měsíci", + "editor_ends": "Končí", + "editor_never": "Nikdy", + "editor_ends_on": "Dne", + "editor_ends_after": "Po", + "editor_occurrences": "opakováních" }, "recurrence_scope": { "edit_title": "Upravit opakující se událost", @@ -2652,6 +2693,9 @@ "default": "Výchozí", "confirm_delete": "Odstranit \"{name}\"? Všechny události v tomto kalendáři budou odstraněny.", "confirm_clear": "Vymazat všechny události z \"{name}\"? Tuto akci nelze vrátit zpět.", + "set_default": "Nastavit jako výchozí", + "default_updated": "Výchozí kalendář aktualizován", + "error_default": "Nepodařilo se nastavit výchozí kalendář", "clear_events": "Vymazat události", "events_cleared": "Vymazáno {count} událostí", "error_clear": "Vymazání událostí kalendáře selhalo", diff --git a/locales/da/common.json b/locales/da/common.json index 9d1aed8fd..d0a1e6f88 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1383,6 +1383,17 @@ "copy": "Kopier", "copied": "Kopieret", "password_instructions": "Brug dit JMAP-brugernavn ovenfor sammen med en app-adgangskode for at logge ind i din e-mailklient. Opret en app-adgangskode i sektionen ovenfor, hvis du ikke allerede har gjort det." + }, + "link_device": { + "title": "Tilknyt mobilapp", + "description": "Log ind i Bulwark Mail-mobilappen uden at skrive noget. Generér en QR-kode her, og scan den fra appens loginskærm.", + "generate": "Vis QR-kode", + "regenerate": "Vis en ny kode", + "instructions": "Åbn Bulwark Mail-appen, tryk på \"Scan QR-kode\" på loginskærmen, og ret dit kamera herhen.", + "expires_in": "Denne kode udløber om {seconds} sekunder. Den kan kun bruges én gang.", + "expired": "Denne kode er udløbet.", + "generating": "Genererer…", + "error": "Kunne ikke oprette en parringskode. Prøv igen." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Hver {count} uge", "every_n_months": "Hver {count} måned", "until": "Indtil", - "occurrences": "{count} forekomster" + "occurrences": "{count} forekomster", + "custom": "Tilpasset…", + "edit_custom": "Rediger tilpasset gentagelse", + "every_n_years": "Hvert {count}. år", + "on_days": "på {days}", + "on_day_n": "på dag {day}", + "on_the_nth": "den {nth} {day}", + "in_month": "i {month}", + "nth_1": "første", + "nth_2": "anden", + "nth_3": "tredje", + "nth_4": "fjerde", + "nth_last": "sidste", + "editor_freq_day": "Dag", + "editor_freq_week": "Uge", + "editor_freq_month": "Måned", + "editor_freq_year": "År", + "editor_repeats_on": "Gentages", + "editor_every": "Hver", + "editor_unit_days": "dag(e)", + "editor_unit_weeks": "uge(r)", + "editor_unit_months": "måned(er)", + "editor_unit_years": "år", + "editor_on_day": "på dag", + "editor_on_the": "den", + "editor_in": "i", + "editor_ends": "Slutter", + "editor_never": "Aldrig", + "editor_ends_on": "Den", + "editor_ends_after": "Efter", + "editor_occurrences": "gentagelser" }, "recurrence_scope": { "edit_title": "Redigér tilbagevendende begivenhed", @@ -2666,6 +2707,9 @@ "default": "Standard", "confirm_delete": "Slet \"{name}\"? Alle begivenheder i denne kalender fjernes.", "confirm_clear": "Ryd alle begivenheder fra \"{name}\"? Dette kan ikke fortrydes.", + "set_default": "Angiv som standard", + "default_updated": "Standardkalender opdateret", + "error_default": "Kunne ikke angive standardkalender", "clear_events": "Ryd begivenheder", "events_cleared": "{count} begivenheder ryddet", "error_clear": "Kunne ikke rydde kalenderbegivenheder", diff --git a/locales/de/common.json b/locales/de/common.json index 360ba6ee5..38bbf347f 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopieren", "copied": "Kopiert", "password_instructions": "Verwenden Sie Ihren oben angezeigten JMAP-Benutzernamen zusammen mit einem App-Passwort, um sich bei Ihrem E-Mail-Client anzumelden. Erstellen Sie im obigen Abschnitt ein App-Passwort, falls Sie noch keines haben." + }, + "link_device": { + "title": "Mobile App verknüpfen", + "description": "Melden Sie sich in der Bulwark Mail App an, ohne etwas einzutippen. Erzeugen Sie hier einen QR-Code und scannen Sie ihn auf dem Anmeldebildschirm der App.", + "generate": "QR-Code anzeigen", + "regenerate": "Neuen Code anzeigen", + "instructions": "Öffnen Sie die Bulwark Mail App, tippen Sie auf dem Anmeldebildschirm auf \"QR-Code scannen\" und richten Sie die Kamera hierauf.", + "expires_in": "Dieser Code läuft in {seconds} Sekunden ab. Er kann nur einmal verwendet werden.", + "expired": "Dieser Code ist abgelaufen.", + "generating": "Wird erstellt…", + "error": "Kopplungscode konnte nicht erstellt werden. Bitte versuchen Sie es erneut." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Alle {count} Wochen", "every_n_months": "Alle {count} Monate", "until": "Bis", - "occurrences": "{count} Wiederholungen" + "occurrences": "{count} Wiederholungen", + "custom": "Benutzerdefiniert…", + "edit_custom": "Benutzerdefinierte Wiederholung bearbeiten", + "every_n_years": "Alle {count} Jahre", + "on_days": "am {days}", + "on_day_n": "am Tag {day}", + "on_the_nth": "am {nth} {day}", + "in_month": "im {month}", + "nth_1": "ersten", + "nth_2": "zweiten", + "nth_3": "dritten", + "nth_4": "vierten", + "nth_last": "letzten", + "editor_freq_day": "Tag", + "editor_freq_week": "Woche", + "editor_freq_month": "Monat", + "editor_freq_year": "Jahr", + "editor_repeats_on": "Wiederholt sich", + "editor_every": "Alle", + "editor_unit_days": "Tag(e)", + "editor_unit_weeks": "Woche(n)", + "editor_unit_months": "Monat(e)", + "editor_unit_years": "Jahr(e)", + "editor_on_day": "am Tag", + "editor_on_the": "am", + "editor_in": "im", + "editor_ends": "Endet", + "editor_never": "Nie", + "editor_ends_on": "Am", + "editor_ends_after": "Nach", + "editor_occurrences": "Terminen" }, "recurrence_scope": { "edit_title": "Wiederkehrendes Ereignis bearbeiten", @@ -2662,6 +2703,9 @@ "copy_url": "CalDAV-URL kopieren", "url_copied": "CalDAV-URL in die Zwischenablage kopiert", "confirm_clear": "Alle Ereignisse aus \"{name}\" löschen? Dies kann nicht rückgängig gemacht werden.", + "set_default": "Als Standard festlegen", + "default_updated": "Standardkalender aktualisiert", + "error_default": "Standardkalender konnte nicht festgelegt werden", "clear_events": "Ereignisse löschen", "events_cleared": "{count} Ereignisse gelöscht", "error_clear": "Kalenderereignisse konnten nicht gelöscht werden", diff --git a/locales/en/common.json b/locales/en/common.json index 947b44538..fba1e9ffa 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2501,7 +2501,37 @@ "every_n_weeks": "Every {count} weeks", "every_n_months": "Every {count} months", "until": "Until", - "occurrences": "{count} occurrences" + "occurrences": "{count} occurrences", + "custom": "Custom…", + "edit_custom": "Edit custom recurrence", + "every_n_years": "Every {count} years", + "on_days": "on {days}", + "on_day_n": "on day {day}", + "on_the_nth": "on the {nth} {day}", + "in_month": "in {month}", + "nth_1": "first", + "nth_2": "second", + "nth_3": "third", + "nth_4": "fourth", + "nth_last": "last", + "editor_freq_day": "Day", + "editor_freq_week": "Week", + "editor_freq_month": "Month", + "editor_freq_year": "Year", + "editor_repeats_on": "Repeats on", + "editor_every": "Every", + "editor_unit_days": "day(s)", + "editor_unit_weeks": "week(s)", + "editor_unit_months": "month(s)", + "editor_unit_years": "year(s)", + "editor_on_day": "on day", + "editor_on_the": "on the", + "editor_in": "in", + "editor_ends": "Ends", + "editor_never": "Never", + "editor_ends_on": "On", + "editor_ends_after": "After", + "editor_occurrences": "occurrences" }, "recurrence_scope": { "edit_title": "Edit recurring event", @@ -2678,6 +2708,9 @@ "default": "Default", "confirm_delete": "Delete \"{name}\"? All events in this calendar will be removed.", "confirm_clear": "Clear all events from \"{name}\"? This cannot be undone.", + "set_default": "Set as default", + "default_updated": "Default calendar updated", + "error_default": "Failed to set default calendar", "clear_events": "Clear events", "events_cleared": "{count} events cleared", "error_clear": "Failed to clear calendar events", diff --git a/locales/es/common.json b/locales/es/common.json index e14d9da9a..b117dc995 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1380,6 +1380,17 @@ "copy": "Copiar", "copied": "Copiado", "password_instructions": "Use su nombre de usuario JMAP indicado arriba junto con una contraseña de aplicación para iniciar sesión en su cliente de correo. Cree una contraseña de aplicación en la sección anterior si aún no lo ha hecho." + }, + "link_device": { + "title": "Vincular aplicación móvil", + "description": "Inicie sesión en la aplicación móvil de Bulwark Mail sin escribir nada. Genere un código QR aquí y escanéelo desde la pantalla de inicio de sesión de la aplicación.", + "generate": "Mostrar código QR", + "regenerate": "Mostrar un código nuevo", + "instructions": "Abra la aplicación Bulwark Mail, toque \"Escanear código QR\" en la pantalla de inicio de sesión y apunte su cámara aquí.", + "expires_in": "Este código caduca en {seconds} segundos. Solo se puede usar una vez.", + "expired": "Este código ha caducado.", + "generating": "Generando…", + "error": "No se pudo crear un código de vinculación. Inténtelo de nuevo." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Cada {count} semanas", "every_n_months": "Cada {count} meses", "until": "Hasta", - "occurrences": "{count} repeticiones" + "occurrences": "{count} repeticiones", + "custom": "Personalizado…", + "edit_custom": "Editar repetición personalizada", + "every_n_years": "Cada {count} años", + "on_days": "los {days}", + "on_day_n": "el día {day}", + "on_the_nth": "el {nth} {day}", + "in_month": "en {month}", + "nth_1": "primer", + "nth_2": "segundo", + "nth_3": "tercer", + "nth_4": "cuarto", + "nth_last": "último", + "editor_freq_day": "Día", + "editor_freq_week": "Semana", + "editor_freq_month": "Mes", + "editor_freq_year": "Año", + "editor_repeats_on": "Se repite", + "editor_every": "Cada", + "editor_unit_days": "día(s)", + "editor_unit_weeks": "semana(s)", + "editor_unit_months": "mes(es)", + "editor_unit_years": "año(s)", + "editor_on_day": "el día", + "editor_on_the": "el", + "editor_in": "en", + "editor_ends": "Termina", + "editor_never": "Nunca", + "editor_ends_on": "El", + "editor_ends_after": "Después de", + "editor_occurrences": "repeticiones" }, "recurrence_scope": { "edit_title": "Editar evento recurrente", @@ -2662,6 +2703,9 @@ "copy_url": "Copiar URL de CalDAV", "url_copied": "URL de CalDAV copiada al portapapeles", "confirm_clear": "¿Borrar todos los eventos de \"{name}\"? Esta acción no se puede deshacer.", + "set_default": "Establecer como predeterminado", + "default_updated": "Calendario predeterminado actualizado", + "error_default": "Error al establecer el calendario predeterminado", "clear_events": "Borrar eventos", "events_cleared": "{count} eventos borrados", "error_clear": "No se pudieron borrar los eventos del calendario", diff --git a/locales/fr/common.json b/locales/fr/common.json index e4dd9846a..55086fa8f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1380,6 +1380,17 @@ "copy": "Copier", "copied": "Copié", "password_instructions": "Utilisez votre nom d'utilisateur JMAP ci-dessus avec un mot de passe d'application pour vous connecter à votre client de messagerie. Créez un mot de passe d'application dans la section ci-dessus si vous ne l'avez pas encore fait." + }, + "link_device": { + "title": "Associer l'application mobile", + "description": "Connectez-vous à l'application mobile Bulwark Mail sans rien saisir. Générez un QR code ici et scannez-le depuis l'écran de connexion de l'application.", + "generate": "Afficher le QR code", + "regenerate": "Afficher un nouveau code", + "instructions": "Ouvrez l'application Bulwark Mail, appuyez sur \"Scanner le QR code\" sur l'écran de connexion et pointez votre caméra ici.", + "expires_in": "Ce code expire dans {seconds} secondes. Il ne peut être utilisé qu'une seule fois.", + "expired": "Ce code a expiré.", + "generating": "Génération…", + "error": "Impossible de créer un code d'association. Veuillez réessayer." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Toutes les {count} semaines", "every_n_months": "Tous les {count} mois", "until": "Jusqu'au", - "occurrences": "{count} occurrences" + "occurrences": "{count} occurrences", + "custom": "Personnalisé…", + "edit_custom": "Modifier la récurrence personnalisée", + "every_n_years": "Tous les {count} ans", + "on_days": "le {days}", + "on_day_n": "le jour {day}", + "on_the_nth": "le {nth} {day}", + "in_month": "en {month}", + "nth_1": "premier", + "nth_2": "deuxième", + "nth_3": "troisième", + "nth_4": "quatrième", + "nth_last": "dernier", + "editor_freq_day": "Jour", + "editor_freq_week": "Semaine", + "editor_freq_month": "Mois", + "editor_freq_year": "Année", + "editor_repeats_on": "Se répète", + "editor_every": "Tous les", + "editor_unit_days": "jour(s)", + "editor_unit_weeks": "semaine(s)", + "editor_unit_months": "mois", + "editor_unit_years": "an(s)", + "editor_on_day": "le jour", + "editor_on_the": "le", + "editor_in": "en", + "editor_ends": "Se termine", + "editor_never": "Jamais", + "editor_ends_on": "Le", + "editor_ends_after": "Après", + "editor_occurrences": "occurrences" }, "recurrence_scope": { "edit_title": "Modifier l'événement récurrent", @@ -2676,6 +2717,9 @@ "copy_url": "Copier l'URL CalDAV", "url_copied": "URL CalDAV copiée dans le presse-papiers", "confirm_clear": "Supprimer tous les événements de \"{name}\" ? Cette action est irréversible.", + "set_default": "Définir par défaut", + "default_updated": "Calendrier par défaut mis à jour", + "error_default": "Échec de la définition du calendrier par défaut", "clear_events": "Supprimer les événements", "events_cleared": "{count} événements supprimés", "error_clear": "Impossible de supprimer les événements du calendrier", diff --git a/locales/hu/common.json b/locales/hu/common.json index 8f7a1e3d7..3abc697a3 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1383,6 +1383,17 @@ "copy": "Másolás", "copied": "Másolva", "password_instructions": "Használd a fenti JMAP felhasználóneved egy alkalmazás jelszóval együtt az e-mail kliensbe való bejelentkezéshez. Hozz létre egy alkalmazás jelszót a fenti részben, ha még nem tetted." + }, + "link_device": { + "title": "Mobilalkalmazás összekapcsolása", + "description": "Jelentkezz be a Bulwark Mail mobilalkalmazásba gépelés nélkül. Generálj itt egy QR-kódot, és olvasd be az alkalmazás bejelentkezési képernyőjén.", + "generate": "QR-kód megjelenítése", + "regenerate": "Új kód megjelenítése", + "instructions": "Nyisd meg a Bulwark Mail alkalmazást, koppints a \"QR-kód beolvasása\" lehetőségre a bejelentkezési képernyőn, és irányítsd ide a kamerát.", + "expires_in": "Ez a kód {seconds} másodperc múlva lejár. Csak egyszer használható.", + "expired": "Ez a kód lejárt.", + "generating": "Generálás…", + "error": "Nem sikerült párosítási kódot létrehozni. Próbáld újra." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Minden {count} hét", "every_n_months": "Minden {count} hónap", "until": "Amíg", - "occurrences": "{count} alkalom" + "occurrences": "{count} alkalom", + "custom": "Egyéni…", + "edit_custom": "Egyéni ismétlődés szerkesztése", + "every_n_years": "Minden {count}. évben", + "on_days": "ezeken a napokon: {days}", + "on_day_n": "a hónap {day}. napján", + "on_the_nth": "a(z) {nth} {day}", + "in_month": "{month} hónapban", + "nth_1": "első", + "nth_2": "második", + "nth_3": "harmadik", + "nth_4": "negyedik", + "nth_last": "utolsó", + "editor_freq_day": "Nap", + "editor_freq_week": "Hét", + "editor_freq_month": "Hónap", + "editor_freq_year": "Év", + "editor_repeats_on": "Ismétlődés", + "editor_every": "Minden", + "editor_unit_days": "nap", + "editor_unit_weeks": "hét", + "editor_unit_months": "hónap", + "editor_unit_years": "év", + "editor_on_day": "a hónap napján:", + "editor_on_the": "a(z)", + "editor_in": "hónap:", + "editor_ends": "Vége", + "editor_never": "Soha", + "editor_ends_on": "Ekkor", + "editor_ends_after": "Ennyi után:", + "editor_occurrences": "alkalom" }, "recurrence_scope": { "edit_title": "Ismétlődő esemény szerkesztése", @@ -2667,6 +2708,9 @@ "default": "Alapértelmezett", "confirm_delete": "Törlöd a \"{name}\" naptárat? Az összes esemény eltávolításra kerül.", "confirm_clear": "Törlöd az összes eseményt a \"{name}\" naptárból? Ez nem vonható vissza.", + "set_default": "Beállítás alapértelmezettként", + "default_updated": "Alapértelmezett naptár frissítve", + "error_default": "Nem sikerült beállítani az alapértelmezett naptárat", "clear_events": "Események törlése", "events_cleared": "{count} esemény törölve", "error_clear": "Nem sikerült törölni a naptár eseményeket", diff --git a/locales/it/common.json b/locales/it/common.json index daf2a99b9..84398dc7c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1380,6 +1380,17 @@ "copy": "Copia", "copied": "Copiato", "password_instructions": "Usa il tuo nome utente JMAP sopra indicato insieme a una password dell'app per accedere al tuo client e-mail. Crea una password dell'app nella sezione sopra se non l'hai ancora fatto." + }, + "link_device": { + "title": "Collega l'app mobile", + "description": "Accedi all'app mobile Bulwark Mail senza digitare nulla. Genera qui un codice QR e scansionalo dalla schermata di accesso dell'app.", + "generate": "Mostra codice QR", + "regenerate": "Mostra un nuovo codice", + "instructions": "Apri l'app Bulwark Mail, tocca \"Scansiona codice QR\" nella schermata di accesso e inquadra qui con la fotocamera.", + "expires_in": "Questo codice scade tra {seconds} secondi. Può essere usato una sola volta.", + "expired": "Questo codice è scaduto.", + "generating": "Generazione…", + "error": "Impossibile creare un codice di associazione. Riprova." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Ogni {count} settimane", "every_n_months": "Ogni {count} mesi", "until": "Fino al", - "occurrences": "{count} ripetizioni" + "occurrences": "{count} ripetizioni", + "custom": "Personalizzato…", + "edit_custom": "Modifica ricorrenza personalizzata", + "every_n_years": "Ogni {count} anni", + "on_days": "di {days}", + "on_day_n": "il giorno {day}", + "on_the_nth": "il {nth} {day}", + "in_month": "a {month}", + "nth_1": "primo", + "nth_2": "secondo", + "nth_3": "terzo", + "nth_4": "quarto", + "nth_last": "ultimo", + "editor_freq_day": "Giorno", + "editor_freq_week": "Settimana", + "editor_freq_month": "Mese", + "editor_freq_year": "Anno", + "editor_repeats_on": "Si ripete", + "editor_every": "Ogni", + "editor_unit_days": "giorno/i", + "editor_unit_weeks": "settimana/e", + "editor_unit_months": "mese/i", + "editor_unit_years": "anno/i", + "editor_on_day": "il giorno", + "editor_on_the": "il", + "editor_in": "a", + "editor_ends": "Termina", + "editor_never": "Mai", + "editor_ends_on": "Il", + "editor_ends_after": "Dopo", + "editor_occurrences": "occorrenze" }, "recurrence_scope": { "edit_title": "Modifica evento ricorrente", @@ -2662,6 +2703,9 @@ "copy_url": "Copia URL CalDAV", "url_copied": "URL CalDAV copiato negli appunti", "confirm_clear": "Cancellare tutti gli eventi da \"{name}\"? Questa azione non può essere annullata.", + "set_default": "Imposta come predefinito", + "default_updated": "Calendario predefinito aggiornato", + "error_default": "Impossibile impostare il calendario predefinito", "clear_events": "Cancella eventi", "events_cleared": "{count} eventi cancellati", "error_clear": "Impossibile cancellare gli eventi del calendario", diff --git a/locales/ja/common.json b/locales/ja/common.json index 35f6fb14c..3145ed6ca 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1380,6 +1380,17 @@ "copy": "コピー", "copied": "コピー済み", "password_instructions": "上記のJMAPユーザー名とアプリパスワードを使用してメールクライアントにサインインします。まだ作成していない場合は、上のセクションでアプリパスワードを作成してください。" + }, + "link_device": { + "title": "モバイルアプリを連携", + "description": "入力なしで Bulwark Mail モバイルアプリにサインインできます。ここで QR コードを生成し、アプリのログイン画面からスキャンしてください。", + "generate": "QRコードを表示", + "regenerate": "新しいコードを表示", + "instructions": "Bulwark Mail アプリを開き、ログイン画面で「QRコードをスキャン」をタップして、カメラをここに向けてください。", + "expires_in": "このコードは{seconds}秒で期限切れになります。一度しか使用できません。", + "expired": "このコードは期限切れです。", + "generating": "生成中…", + "error": "ペアリングコードを作成できませんでした。もう一度お試しください。" } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "{count}週間ごと", "every_n_months": "{count}か月ごと", "until": "終了日", - "occurrences": "{count}回" + "occurrences": "{count}回", + "custom": "カスタム…", + "edit_custom": "カスタム繰り返しを編集", + "every_n_years": "{count}年ごと", + "on_days": "{days}", + "on_day_n": "{day}日", + "on_the_nth": "{nth}{day}", + "in_month": "{month}", + "nth_1": "第1", + "nth_2": "第2", + "nth_3": "第3", + "nth_4": "第4", + "nth_last": "最終", + "editor_freq_day": "日", + "editor_freq_week": "週", + "editor_freq_month": "月", + "editor_freq_year": "年", + "editor_repeats_on": "繰り返し", + "editor_every": "間隔:", + "editor_unit_days": "日ごと", + "editor_unit_weeks": "週間ごと", + "editor_unit_months": "か月ごと", + "editor_unit_years": "年ごと", + "editor_on_day": "日付:", + "editor_on_the": "曜日:", + "editor_in": "月:", + "editor_ends": "終了", + "editor_never": "なし", + "editor_ends_on": "終了日", + "editor_ends_after": "回数", + "editor_occurrences": "回" }, "recurrence_scope": { "edit_title": "繰り返しイベントを編集", @@ -2662,6 +2703,9 @@ "copy_url": "CalDAV URLをコピー", "url_copied": "CalDAV URLをクリップボードにコピーしました", "confirm_clear": "\"{name}\"のすべてのイベントを削除しますか?この操作は元に戻せません。", + "set_default": "デフォルトに設定", + "default_updated": "デフォルトのカレンダーを更新しました", + "error_default": "デフォルトのカレンダーを設定できませんでした", "clear_events": "イベントを削除", "events_cleared": "{count}件のイベントを削除しました", "error_clear": "カレンダーイベントの削除に失敗しました", diff --git a/locales/ko/common.json b/locales/ko/common.json index 554ca3a43..56cfb7292 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1380,6 +1380,17 @@ "copy": "복사", "copied": "복사되었어요", "password_instructions": "위의 JMAP 사용자 이름과 앱 비밀번호를 사용해서 이메일 클라이언트에 로그인하세요. 아직 만들지 않았다면 위에서 앱 비밀번호를 먼저 만들어 주세요." + }, + "link_device": { + "title": "모바일 앱 연결", + "description": "아무것도 입력하지 않고 Bulwark Mail 모바일 앱에 로그인하세요. 여기에서 QR 코드를 생성하고 앱의 로그인 화면에서 스캔하세요.", + "generate": "QR 코드 표시", + "regenerate": "새 코드 표시", + "instructions": "Bulwark Mail 앱을 열고 로그인 화면에서 \"QR 코드 스캔\"을 누른 다음 카메라를 여기에 비추세요.", + "expires_in": "이 코드는 {seconds}초 후에 만료됩니다. 한 번만 사용할 수 있습니다.", + "expired": "이 코드는 만료되었습니다.", + "generating": "생성 중…", + "error": "페어링 코드를 만들지 못했습니다. 다시 시도해 주세요." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "{count}주마다", "every_n_months": "{count}개월마다", "until": "종료일:", - "occurrences": "{count}회 반복" + "occurrences": "{count}회 반복", + "custom": "맞춤…", + "edit_custom": "맞춤 반복 수정", + "every_n_years": "{count}년마다", + "on_days": "{days}", + "on_day_n": "{day}일", + "on_the_nth": "{nth} {day}", + "in_month": "{month}", + "nth_1": "첫째", + "nth_2": "둘째", + "nth_3": "셋째", + "nth_4": "넷째", + "nth_last": "마지막", + "editor_freq_day": "일", + "editor_freq_week": "주", + "editor_freq_month": "월", + "editor_freq_year": "년", + "editor_repeats_on": "반복", + "editor_every": "매", + "editor_unit_days": "일", + "editor_unit_weeks": "주", + "editor_unit_months": "개월", + "editor_unit_years": "년", + "editor_on_day": "날짜:", + "editor_on_the": "요일:", + "editor_in": "월:", + "editor_ends": "종료", + "editor_never": "안 함", + "editor_ends_on": "종료일", + "editor_ends_after": "횟수", + "editor_occurrences": "회" }, "recurrence_scope": { "edit_title": "반복 일정 수정", @@ -2652,6 +2693,9 @@ "default": "기본", "confirm_delete": "\"{name}\" 캘린더를 삭제할까요? 캘린더 안의 모든 일정이 함께 지워져요.", "confirm_clear": "\"{name}\" 캘린더의 모든 일정을 지울까요? 이 작업은 되돌릴 수 없어요.", + "set_default": "기본으로 설정", + "default_updated": "기본 캘린더가 업데이트되었습니다", + "error_default": "기본 캘린더를 설정하지 못했습니다", "clear_events": "일정 모두 지우기", "events_cleared": "{count}개의 일정을 지웠어요", "error_clear": "일정을 지우지 못했어요", diff --git a/locales/lv/common.json b/locales/lv/common.json index 0c8b8b4b1..f69d99840 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopēt", "copied": "Nokopēts", "password_instructions": "Izmantojiet norādīto JMAP lietotājvārdu kopā ar lietotnes paroli. Izveidojiet lietotnes paroli sadaļā augstāk." + }, + "link_device": { + "title": "Saistīt mobilo lietotni", + "description": "Pierakstieties Bulwark Mail mobilajā lietotnē, neko nerakstot. Šeit izveidojiet QR kodu un noskenējiet to lietotnes pierakstīšanās ekrānā.", + "generate": "Rādīt QR kodu", + "regenerate": "Rādīt jaunu kodu", + "instructions": "Atveriet Bulwark Mail lietotni, pierakstīšanās ekrānā pieskarieties \"Skenēt QR kodu\" un pavērsiet kameru šeit.", + "expires_in": "Šī koda derīgums beigsies pēc {seconds} sekundēm. To var izmantot tikai vienu reizi.", + "expired": "Šī koda derīgums ir beidzies.", + "generating": "Ģenerē…", + "error": "Neizdevās izveidot pārošanas kodu. Lūdzu, mēģiniet vēlreiz." } }, "identities": { @@ -2489,7 +2500,37 @@ "every_n_weeks": "Ik pēc {count} nedēļām", "every_n_months": "Ik pēc {count} mēnešiem", "until": "Līdz", - "occurrences": "{count} reizes" + "occurrences": "{count} reizes", + "custom": "Pielāgots…", + "edit_custom": "Rediģēt pielāgotu atkārtošanos", + "every_n_years": "Ik pēc {count} gadiem", + "on_days": "šajās dienās: {days}", + "on_day_n": "mēneša {day}. dienā", + "on_the_nth": "{nth} {day}", + "in_month": "{month} mēnesī", + "nth_1": "pirmajā", + "nth_2": "otrajā", + "nth_3": "trešajā", + "nth_4": "ceturtajā", + "nth_last": "pēdējā", + "editor_freq_day": "Diena", + "editor_freq_week": "Nedēļa", + "editor_freq_month": "Mēnesis", + "editor_freq_year": "Gads", + "editor_repeats_on": "Atkārtojas", + "editor_every": "Ik pēc", + "editor_unit_days": "dienām", + "editor_unit_weeks": "nedēļām", + "editor_unit_months": "mēnešiem", + "editor_unit_years": "gadiem", + "editor_on_day": "mēneša dienā", + "editor_on_the": "šajā:", + "editor_in": "mēnesī", + "editor_ends": "Beidzas", + "editor_never": "Nekad", + "editor_ends_on": "Datumā", + "editor_ends_after": "Pēc", + "editor_occurrences": "reizēm" }, "recurrence_scope": { "edit_title": "Rediģēt atkārtotu pasākumu", @@ -2651,6 +2692,9 @@ "default": "Noklusējuma", "confirm_delete": "Dzēst «{name}»? Visi pasākumi šajā kalendārā tiks dzēsti.", "confirm_clear": "Iztīrīt visus pasākumus no «{name}»? Šo darbību nevar atcelt.", + "set_default": "Iestatīt kā noklusējumu", + "default_updated": "Noklusējuma kalendārs atjaunināts", + "error_default": "Neizdevās iestatīt noklusējuma kalendāru", "clear_events": "Iztīrīt pasākumus", "events_cleared": "Iztīrīti {count} pasākumi", "error_clear": "Neizdevās iztīrīt kalendāra pasākumus", diff --git a/locales/nl/common.json b/locales/nl/common.json index 6a7f02f74..4412ccbda 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopiëren", "copied": "Gekopieerd", "password_instructions": "Gebruik uw hierboven vermelde JMAP-gebruikersnaam samen met een app-wachtwoord om in te loggen bij uw e-mailclient. Maak een app-wachtwoord aan in het bovenstaande gedeelte als u dat nog niet heeft gedaan." + }, + "link_device": { + "title": "Mobiele app koppelen", + "description": "Log in bij de Bulwark Mail mobiele app zonder iets te typen. Genereer hier een QR-code en scan deze vanaf het inlogscherm van de app.", + "generate": "QR-code tonen", + "regenerate": "Nieuwe code tonen", + "instructions": "Open de Bulwark Mail app, tik op \"QR-code scannen\" op het inlogscherm en richt uw camera hierop.", + "expires_in": "Deze code verloopt over {seconds} seconden. Hij kan maar één keer worden gebruikt.", + "expired": "Deze code is verlopen.", + "generating": "Genereren…", + "error": "Kon geen koppelingscode aanmaken. Probeer het opnieuw." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Elke {count} weken", "every_n_months": "Elke {count} maanden", "until": "Tot", - "occurrences": "{count} herhalingen" + "occurrences": "{count} herhalingen", + "custom": "Aangepast…", + "edit_custom": "Aangepaste herhaling bewerken", + "every_n_years": "Elke {count} jaar", + "on_days": "op {days}", + "on_day_n": "op dag {day}", + "on_the_nth": "op de {nth} {day}", + "in_month": "in {month}", + "nth_1": "eerste", + "nth_2": "tweede", + "nth_3": "derde", + "nth_4": "vierde", + "nth_last": "laatste", + "editor_freq_day": "Dag", + "editor_freq_week": "Week", + "editor_freq_month": "Maand", + "editor_freq_year": "Jaar", + "editor_repeats_on": "Herhaalt zich", + "editor_every": "Elke", + "editor_unit_days": "dag(en)", + "editor_unit_weeks": "week/weken", + "editor_unit_months": "maand(en)", + "editor_unit_years": "jaar/jaren", + "editor_on_day": "op dag", + "editor_on_the": "op de", + "editor_in": "in", + "editor_ends": "Eindigt", + "editor_never": "Nooit", + "editor_ends_on": "Op", + "editor_ends_after": "Na", + "editor_occurrences": "herhalingen" }, "recurrence_scope": { "edit_title": "Terugkerend evenement bewerken", @@ -2662,6 +2703,9 @@ "copy_url": "CalDAV-URL kopiëren", "url_copied": "CalDAV-URL gekopieerd naar klembord", "confirm_clear": "Alle afspraken uit \"{name}\" verwijderen? Dit kan niet ongedaan worden gemaakt.", + "set_default": "Als standaard instellen", + "default_updated": "Standaardagenda bijgewerkt", + "error_default": "Standaardagenda instellen mislukt", "clear_events": "Afspraken verwijderen", "events_cleared": "{count} afspraken verwijderd", "error_clear": "Kan agendagebeurtenissen niet verwijderen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 877caccfa..17804a80b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopiuj", "copied": "Skopiowano", "password_instructions": "Użyj powyższej nazwy użytkownika JMAP wraz z hasłem aplikacji, aby zalogować się w kliencie poczty. Utwórz hasło aplikacji w sekcji powyżej, jeśli jeszcze tego nie zrobiłeś." + }, + "link_device": { + "title": "Połącz aplikację mobilną", + "description": "Zaloguj się do aplikacji mobilnej Bulwark Mail bez wpisywania czegokolwiek. Wygeneruj tutaj kod QR i zeskanuj go na ekranie logowania aplikacji.", + "generate": "Pokaż kod QR", + "regenerate": "Pokaż nowy kod", + "instructions": "Otwórz aplikację Bulwark Mail, na ekranie logowania stuknij \"Skanuj kod QR\" i skieruj aparat tutaj.", + "expires_in": "Ten kod wygaśnie za {seconds} sekund. Można go użyć tylko raz.", + "expired": "Ten kod wygasł.", + "generating": "Generowanie…", + "error": "Nie udało się utworzyć kodu parowania. Spróbuj ponownie." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Co {count} tygodni", "every_n_months": "Co {count} miesięcy", "until": "Do", - "occurrences": "{count} wystąpień" + "occurrences": "{count} wystąpień", + "custom": "Niestandardowe…", + "edit_custom": "Edytuj niestandardowe powtarzanie", + "every_n_years": "Co {count} lat", + "on_days": "w {days}", + "on_day_n": "{day}. dnia miesiąca", + "on_the_nth": "w {nth} {day}", + "in_month": "w {month}", + "nth_1": "pierwszy", + "nth_2": "drugi", + "nth_3": "trzeci", + "nth_4": "czwarty", + "nth_last": "ostatni", + "editor_freq_day": "Dzień", + "editor_freq_week": "Tydzień", + "editor_freq_month": "Miesiąc", + "editor_freq_year": "Rok", + "editor_repeats_on": "Powtarza się", + "editor_every": "Co", + "editor_unit_days": "dni", + "editor_unit_weeks": "tyg.", + "editor_unit_months": "mies.", + "editor_unit_years": "lat(a)", + "editor_on_day": "dnia", + "editor_on_the": "w", + "editor_in": "w miesiącu", + "editor_ends": "Kończy się", + "editor_never": "Nigdy", + "editor_ends_on": "Dnia", + "editor_ends_after": "Po", + "editor_occurrences": "wystąpieniach" }, "recurrence_scope": { "edit_title": "Edytuj wydarzenie cykliczne", @@ -2652,6 +2693,9 @@ "default": "Domyślny", "confirm_delete": "Usunąć \"{name}\"? Wszystkie wydarzenia w tym kalendarzu zostaną usunięte.", "confirm_clear": "Wyczyścić wszystkie wydarzenia z \"{name}\"? Tej operacji nie można cofnąć.", + "set_default": "Ustaw jako domyślny", + "default_updated": "Zaktualizowano domyślny kalendarz", + "error_default": "Nie udało się ustawić domyślnego kalendarza", "clear_events": "Wyczyść wydarzenia", "events_cleared": "Wyczyszczono {count} wydarzeń", "error_clear": "Nie udało się wyczyścić wydarzeń kalendarza", diff --git a/locales/pt/common.json b/locales/pt/common.json index 1c2b6a1e0..c78fd3d04 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1380,6 +1380,17 @@ "copy": "Copiar", "copied": "Copiado", "password_instructions": "Use seu nome de usuário JMAP acima junto com uma senha de aplicativo para entrar no seu cliente de e-mail. Crie uma senha de aplicativo na seção acima se ainda não tiver uma." + }, + "link_device": { + "title": "Vincular aplicativo móvel", + "description": "Entre no aplicativo móvel Bulwark Mail sem digitar nada. Gere um código QR aqui e escaneie-o na tela de login do aplicativo.", + "generate": "Mostrar código QR", + "regenerate": "Mostrar um novo código", + "instructions": "Abra o aplicativo Bulwark Mail, toque em \"Escanear código QR\" na tela de login e aponte sua câmera para cá.", + "expires_in": "Este código expira em {seconds} segundos. Ele só pode ser usado uma vez.", + "expired": "Este código expirou.", + "generating": "Gerando…", + "error": "Não foi possível criar um código de pareamento. Tente novamente." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "A cada {count} semanas", "every_n_months": "A cada {count} meses", "until": "Até", - "occurrences": "{count} repetições" + "occurrences": "{count} repetições", + "custom": "Personalizado…", + "edit_custom": "Editar recorrência personalizada", + "every_n_years": "A cada {count} anos", + "on_days": "em {days}", + "on_day_n": "no dia {day}", + "on_the_nth": "no {nth} {day}", + "in_month": "em {month}", + "nth_1": "1.º", + "nth_2": "2.º", + "nth_3": "3.º", + "nth_4": "4.º", + "nth_last": "último", + "editor_freq_day": "Dia", + "editor_freq_week": "Semana", + "editor_freq_month": "Mês", + "editor_freq_year": "Ano", + "editor_repeats_on": "Repete-se", + "editor_every": "A cada", + "editor_unit_days": "dia(s)", + "editor_unit_weeks": "semana(s)", + "editor_unit_months": "mês(es)", + "editor_unit_years": "ano(s)", + "editor_on_day": "no dia", + "editor_on_the": "no", + "editor_in": "em", + "editor_ends": "Termina", + "editor_never": "Nunca", + "editor_ends_on": "Em", + "editor_ends_after": "Após", + "editor_occurrences": "repetições" }, "recurrence_scope": { "edit_title": "Editar evento recorrente", @@ -2676,6 +2717,9 @@ "copy_url": "Copiar URL CalDAV", "url_copied": "URL CalDAV copiada para a área de transferência", "confirm_clear": "Limpar todos os eventos de \"{name}\"? Esta ação não pode ser desfeita.", + "set_default": "Definir como padrão", + "default_updated": "Calendário padrão atualizado", + "error_default": "Falha ao definir calendário padrão", "clear_events": "Limpar eventos", "events_cleared": "{count} eventos removidos", "error_clear": "Falha ao limpar os eventos do calendário", diff --git a/locales/ru/common.json b/locales/ru/common.json index fc2225cfe..e62089fd0 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1380,6 +1380,17 @@ "copy": "Копировать", "copied": "Скопировано", "password_instructions": "Используйте указанное выше имя пользователя JMAP вместе с паролем приложения для входа в почтовый клиент. Создайте пароль приложения в разделе выше, если вы ещё этого не сделали." + }, + "link_device": { + "title": "Привязать мобильное приложение", + "description": "Войдите в мобильное приложение Bulwark Mail без ввода данных. Создайте здесь QR-код и отсканируйте его на экране входа в приложении.", + "generate": "Показать QR-код", + "regenerate": "Показать новый код", + "instructions": "Откройте приложение Bulwark Mail, нажмите \"Сканировать QR-код\" на экране входа и наведите камеру сюда.", + "expires_in": "Срок действия кода истечёт через {seconds} секунд. Его можно использовать только один раз.", + "expired": "Срок действия кода истёк.", + "generating": "Создание…", + "error": "Не удалось создать код привязки. Пожалуйста, попробуйте ещё раз." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Каждые {count} недель", "every_n_months": "Каждые {count} месяцев", "until": "До", - "occurrences": "{count} повторений" + "occurrences": "{count} повторений", + "custom": "Свой вариант…", + "edit_custom": "Изменить настраиваемое повторение", + "every_n_years": "Каждые {count} лет", + "on_days": "в {days}", + "on_day_n": "{day}-го числа", + "on_the_nth": "в {nth} {day}", + "in_month": "в {month}", + "nth_1": "первый", + "nth_2": "второй", + "nth_3": "третий", + "nth_4": "четвёртый", + "nth_last": "последний", + "editor_freq_day": "День", + "editor_freq_week": "Неделя", + "editor_freq_month": "Месяц", + "editor_freq_year": "Год", + "editor_repeats_on": "Повторяется", + "editor_every": "Каждые", + "editor_unit_days": "дн.", + "editor_unit_weeks": "нед.", + "editor_unit_months": "мес.", + "editor_unit_years": "г.", + "editor_on_day": "в день", + "editor_on_the": "в", + "editor_in": "в месяце", + "editor_ends": "Заканчивается", + "editor_never": "Никогда", + "editor_ends_on": "Дата", + "editor_ends_after": "После", + "editor_occurrences": "повторений" }, "recurrence_scope": { "edit_title": "Редактировать повторяющееся событие", @@ -2652,6 +2693,9 @@ "default": "По умолчанию", "confirm_delete": "Удалить «{name}»? Все события в этом календаре будут удалены.", "confirm_clear": "Очистить все события из «{name}»? Это действие нельзя отменить.", + "set_default": "Сделать по умолчанию", + "default_updated": "Календарь по умолчанию обновлён", + "error_default": "Не удалось установить календарь по умолчанию", "clear_events": "Очистить события", "events_cleared": "{count} событий очищено", "error_clear": "Не удалось очистить события календаря", diff --git a/locales/tr/common.json b/locales/tr/common.json index dbf12d53c..7d3fd0757 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1380,6 +1380,17 @@ "copy": "Kopyala", "copied": "Kopyalandı", "password_instructions": "E-posta istemcinizde oturum açmak için yukarıdaki JMAP kullanıcı adı ile bir uygulama parolasını birlikte kullanın. Henüz oluşturmadıysanız yukarıdaki bölümden bir uygulama parolası oluşturun." + }, + "link_device": { + "title": "Mobil uygulamayı bağla", + "description": "Hiçbir şey yazmadan Bulwark Mail mobil uygulamasında oturum açın. Burada bir QR kodu oluşturun ve uygulamanın giriş ekranından tarayın.", + "generate": "QR kodunu göster", + "regenerate": "Yeni kod göster", + "instructions": "Bulwark Mail uygulamasını açın, giriş ekranında \"QR kodu tara\" seçeneğine dokunun ve kameranızı buraya doğrultun.", + "expires_in": "Bu kodun süresi {seconds} saniye içinde dolacak. Yalnızca bir kez kullanılabilir.", + "expired": "Bu kodun süresi doldu.", + "generating": "Oluşturuluyor…", + "error": "Eşleştirme kodu oluşturulamadı. Lütfen tekrar deneyin." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Her {count} haftada bir", "every_n_months": "Her {count} ayda bir", "until": "Şu tarihe kadar", - "occurrences": "{count} tekrar" + "occurrences": "{count} tekrar", + "custom": "Özel…", + "edit_custom": "Özel yinelemeyi düzenle", + "every_n_years": "Her {count} yılda bir", + "on_days": "{days} günleri", + "on_day_n": "ayın {day}. günü", + "on_the_nth": "{nth} {day} günü", + "in_month": "{month} ayında", + "nth_1": "ilk", + "nth_2": "ikinci", + "nth_3": "üçüncü", + "nth_4": "dördüncü", + "nth_last": "son", + "editor_freq_day": "Gün", + "editor_freq_week": "Hafta", + "editor_freq_month": "Ay", + "editor_freq_year": "Yıl", + "editor_repeats_on": "Yinelenme", + "editor_every": "Her", + "editor_unit_days": "günde bir", + "editor_unit_weeks": "haftada bir", + "editor_unit_months": "ayda bir", + "editor_unit_years": "yılda bir", + "editor_on_day": "ayın şu günü:", + "editor_on_the": "şu gün:", + "editor_in": "ay:", + "editor_ends": "Bitiş", + "editor_never": "Asla", + "editor_ends_on": "Tarihinde", + "editor_ends_after": "Sonra:", + "editor_occurrences": "tekrar" }, "recurrence_scope": { "edit_title": "Yinelenen etkinliği düzenle", @@ -2666,6 +2707,9 @@ "default": "Varsayılan", "confirm_delete": "\"{name}\" silinsin mi? Bu takvimdeki tüm etkinlikler kaldırılacak.", "confirm_clear": "\"{name}\" takvimdeki tüm etkinlikler temizlensin mi? Bu geri alınamaz.", + "set_default": "Varsayılan olarak ayarla", + "default_updated": "Varsayılan takvim güncellendi", + "error_default": "Varsayılan takvim ayarlanamadı", "clear_events": "Etkinlikleri temizle", "events_cleared": "{count} etkinlik temizlendi", "error_clear": "Takvim etkinlikleri temizlenemedi", diff --git a/locales/uk/common.json b/locales/uk/common.json index 86cf9001a..37d1d9a6c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1380,6 +1380,17 @@ "copy": "Копія", "copied": "Скопійовано", "password_instructions": "Використовуйте своє ім’я користувача JMAP вище разом із паролем програми, щоб увійти у свій поштовий клієнт. Створіть пароль програми в розділі вище, якщо ви ще цього не зробили." + }, + "link_device": { + "title": "Прив'язати мобільний застосунок", + "description": "Увійдіть у мобільний застосунок Bulwark Mail без введення даних. Створіть тут QR-код і відскануйте його на екрані входу в застосунку.", + "generate": "Показати QR-код", + "regenerate": "Показати новий код", + "instructions": "Відкрийте застосунок Bulwark Mail, натисніть \"Сканувати QR-код\" на екрані входу та наведіть камеру сюди.", + "expires_in": "Термін дії коду закінчиться через {seconds} секунд. Його можна використати лише один раз.", + "expired": "Термін дії цього коду закінчився.", + "generating": "Створення…", + "error": "Не вдалося створити код прив'язки. Будь ласка, спробуйте ще раз." } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "Кожні {count} тижнів", "every_n_months": "Кожні {count} місяців", "until": "Поки", - "occurrences": "{count} випадків" + "occurrences": "{count} випадків", + "custom": "Власний…", + "edit_custom": "Змінити власне повторення", + "every_n_years": "Кожні {count} років", + "on_days": "у {days}", + "on_day_n": "{day}-го числа", + "on_the_nth": "у {nth} {day}", + "in_month": "у {month}", + "nth_1": "перший", + "nth_2": "другий", + "nth_3": "третій", + "nth_4": "четвертий", + "nth_last": "останній", + "editor_freq_day": "День", + "editor_freq_week": "Тиждень", + "editor_freq_month": "Місяць", + "editor_freq_year": "Рік", + "editor_repeats_on": "Повторюється", + "editor_every": "Кожні", + "editor_unit_days": "дн.", + "editor_unit_weeks": "тиж.", + "editor_unit_months": "міс.", + "editor_unit_years": "р.", + "editor_on_day": "у день", + "editor_on_the": "у", + "editor_in": "у місяці", + "editor_ends": "Закінчується", + "editor_never": "Ніколи", + "editor_ends_on": "Дата", + "editor_ends_after": "Після", + "editor_occurrences": "повторень" }, "recurrence_scope": { "edit_title": "Редагувати повторювану подію", @@ -2652,6 +2693,9 @@ "default": "За замовчуванням", "confirm_delete": "Видалити \"{name}\"? Усі події в цьому календарі буде видалено.", "confirm_clear": "Очистити всі події з \"{name}\"? Це неможливо скасувати.", + "set_default": "Зробити типовим", + "default_updated": "Типовий календар оновлено", + "error_default": "Не вдалося встановити типовий календар", "clear_events": "Ясні події", "events_cleared": "{count} подій видалено", "error_clear": "Не вдалося очистити події календаря", diff --git a/locales/zh/common.json b/locales/zh/common.json index c5f052375..feb684df4 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1380,6 +1380,17 @@ "copy": "复制", "copied": "已复制", "password_instructions": "使用上面的 JMAP 用户名和应用密码登录您的邮件客户端。如果您还没有创建应用密码,请在上面的部分中创建。" + }, + "link_device": { + "title": "关联移动应用", + "description": "无需输入任何内容即可登录 Bulwark Mail 移动应用。在此生成二维码,然后在应用的登录界面扫描。", + "generate": "显示二维码", + "regenerate": "显示新的二维码", + "instructions": "打开 Bulwark Mail 应用,在登录界面点按\"扫描二维码\",然后将相机对准此处。", + "expires_in": "此二维码将在 {seconds} 秒后过期,且只能使用一次。", + "expired": "此二维码已过期。", + "generating": "生成中…", + "error": "无法创建配对码。请重试。" } }, "identities": { @@ -2490,7 +2501,37 @@ "every_n_weeks": "每 {count} 周", "every_n_months": "每 {count} 个月", "until": "直到", - "occurrences": "{count} 次出现" + "occurrences": "{count} 次出现", + "custom": "自定义…", + "edit_custom": "编辑自定义重复规则", + "every_n_years": "每 {count} 年", + "on_days": "{days}", + "on_day_n": "{day}日", + "on_the_nth": "{nth}{day}", + "in_month": "{month}", + "nth_1": "第一个", + "nth_2": "第二个", + "nth_3": "第三个", + "nth_4": "第四个", + "nth_last": "最后一个", + "editor_freq_day": "天", + "editor_freq_week": "周", + "editor_freq_month": "月", + "editor_freq_year": "年", + "editor_repeats_on": "重复于", + "editor_every": "每", + "editor_unit_days": "天", + "editor_unit_weeks": "周", + "editor_unit_months": "个月", + "editor_unit_years": "年", + "editor_on_day": "日期:", + "editor_on_the": "在", + "editor_in": "月份:", + "editor_ends": "结束", + "editor_never": "永不", + "editor_ends_on": "于", + "editor_ends_after": "次数:", + "editor_occurrences": "次" }, "recurrence_scope": { "edit_title": "编辑重复事件", @@ -2652,6 +2693,9 @@ "default": "默认", "confirm_delete": "删除\"{name}\"?此日历中的所有活动都将被删除。", "confirm_clear": "清除\"{name}\"中的所有活动吗?此操作无法撤销。", + "set_default": "设为默认", + "default_updated": "默认日历已更新", + "error_default": "无法设置默认日历", "clear_events": "清除活动", "events_cleared": "{count} 个活动已清除", "error_clear": "无法清除日历活动", diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 7040d55e4..5ccaf0432 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -232,6 +232,7 @@ interface CalendarStore { rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record | null) => Promise; importEvents: (client: IJMAPClient, events: Partial[], calendarId: string) => Promise; updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial) => Promise; + setDefaultCalendar: (client: IJMAPClient, calendarId: string) => Promise; shareCalendar: (client: IJMAPClient, calendarId: string, principalId: string, rights: CalendarRights | null) => Promise; createCalendar: (client: IJMAPClient, calendar: Partial) => Promise; removeCalendar: (client: IJMAPClient, calendarId: string) => Promise; @@ -549,9 +550,10 @@ export const useCalendarStore = create()( rsvpEvent: async (client, eventId, participantId, status, replyTo) => { set({ error: null }); - // JMAP participant IDs are opaque strings - they can contain @, ., :, / etc. - // Only reject empty or obviously malicious values (path traversal). - if (!participantId || participantId.includes('..')) { + // JMAP participant IDs are opaque strings - they can contain @, ., :, + // / etc. The id is RFC 6901-escaped below before being embedded in the + // patch pointer, so any character is safe; only reject empty values. + if (!participantId) { set({ error: 'Invalid participant ID' }); throw new Error('Invalid participant ID'); } @@ -815,6 +817,36 @@ export const useCalendarStore = create()( } }, + setDefaultCalendar: async (client, calendarId) => { + set({ error: null }); + try { + const cal = get().calendars.find(c => c.id === calendarId); + const realId = cal?.originalId || stripLocalAccountPrefix(calendarId, cal?.localAccountId); + const targetAccountId = cal?.accountId; + client = resolveAccountClient(client, cal?.localAccountId); + await client.setDefaultCalendar(realId, targetAccountId); + set((state) => ({ + calendars: state.calendars.map(c => { + if (c.id === calendarId) return { ...c, isDefault: true }; + // Only one default per account - clear the flag on siblings of + // the same local account / shared-account scope. + if ( + c.isDefault + && (c.localAccountId ?? null) === (cal?.localAccountId ?? null) + && (c.accountId ?? null) === (cal?.accountId ?? null) + ) { + return { ...c, isDefault: false }; + } + return c; + }), + })); + } catch (error) { + debug.error('Failed to set default calendar:', error); + set({ error: 'Failed to set default calendar' }); + throw error; + } + }, + shareCalendar: async (client, calendarId, principalId, rights) => { set({ error: null }); try { From 569dde9985fad75e542811fdee1eb0a3251b718d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:10:06 +0200 Subject: [PATCH 086/437] fix: preserve folder list when mailbox refetch hits concurrent-request limit --- lib/jmap/client.ts | 12 ++++++++++++ stores/email-store.ts | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 219fa4ce4..8aa1772c3 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -967,6 +967,8 @@ export class JMAPClient implements IJMAPClient { return this.getMailboxes(); } + let fetchFailed = false; + for (const accountId of accountIds) { const account = this.accounts[accountId]; const isPrimary = accountId === this.accountId; @@ -1013,10 +1015,20 @@ export class JMAPClient implements IJMAPClient { allMailboxes.push(...mailboxes); } } catch (error) { + fetchFailed = true; console.error(`Failed to fetch mailboxes for account ${accountId}:`, error); } } + // If every account fetch failed (e.g. a transient maxConcurrentRequests + // limit during a burst of deletes) we have an empty list that is NOT a + // real "this account has no mailboxes" result. Throwing lets the caller's + // catch preserve the existing folder list instead of clobbering it with + // [] — which would leave the sidebar stuck on "Loading mailboxes...". + if (allMailboxes.length === 0 && fetchFailed) { + throw new Error('Failed to fetch mailboxes for all accounts'); + } + return allMailboxes; } catch (error) { console.error("Failed to fetch all mailboxes:", error); diff --git a/stores/email-store.ts b/stores/email-store.ts index ab746e709..2b381a83d 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -646,6 +646,14 @@ export const useEmailStore = create((set, get) => ({ try { const mailboxes = await client.getAllMailboxes(); + // Guard against a transient fetch returning an empty list (e.g. a server + // concurrent-request limit hit during a burst of deletes). Replacing a + // populated folder list with [] leaves the sidebar stuck on "Loading + // mailboxes..." until the next successful refetch. Keep what we have. + if (mailboxes.length === 0 && !isInitialLoad) { + return; + } + // Auto-select inbox if no mailbox is selected or the current selection // doesn't exist in the fetched list (e.g. after an account switch) const currentSelectedMailbox = get().selectedMailbox; From 20fd9ff4ded00ac01d5c206178b291bf9e6c9314 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:24:05 +0200 Subject: [PATCH 087/437] fix: prevent wide email tables from rendering with rotated headers #409 --- components/email/email-viewer.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 837defd9e..f53383181 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2936,13 +2936,20 @@ export function EmailViewer({ (see below): the email's own ${effectiveEmailContent.html}`; - }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]); + }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, isDark, emailHasNativeDarkMode]); // Imperatively restore blocked external content inside the iframe document. // Avoids re-rendering the iframe srcDoc (which would reload and flash) when @@ -3414,6 +3423,9 @@ export function EmailViewer({ if (autoMdnRef.current === email.id) return; autoMdnRef.current = email.id; sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; }); + // email is already captured via email?.id and sendReadReceiptNow (which + // depends on `email`); the autoMdnRef guard prevents a double send. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]); // Show loading skeleton while email is being fetched diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index 49dd46d19..a175e157f 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -25,11 +25,9 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { const t = useTranslations(); const client = useAuthStore((s) => s.client); const sendEmail = useEmailStore((s) => s.sendEmail); - const fetchEmails = useEmailStore((s) => s.fetchEmails); const refreshCurrentMailbox = useEmailStore((s) => s.refreshCurrentMailbox); const fetchScheduledEmails = useEmailStore((s) => s.fetchScheduledEmails); const refreshScheduledMetadata = useEmailStore((s) => s.refreshScheduledMetadata); - const selectedMailbox = useEmailStore((s) => s.selectedMailbox); const isScheduledView = useEmailStore((s) => s.isScheduledView); const closeTab = useProTabStore((s) => s.closeTab); const updateTabTitle = useProTabStore((s) => s.updateTabTitle); @@ -116,7 +114,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { console.error('Failed to send email:', error); toast.error(t('notifications.error_sending')); } - }, [client, sendEmail, fetchEmails, selectedMailbox, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated]); + }, [client, sendEmail, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated, refreshCurrentMailbox]); const handleClose = useCallback(() => { closeTab(tabIdRef.current); diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 6ccf363e8..4954423ca 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -222,7 +222,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: } finally { endDrag(); } - }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]); + }, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, sourceMailboxId, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]); const valid = isValidTarget(); From dda9fd143371e62e37fcfab2c2481d7cf5277b27 Mon Sep 17 00:00:00 2001 From: Paul H Date: Fri, 19 Jun 2026 13:55:14 +0100 Subject: [PATCH 122/437] feat(i18n): add Romanian (ro) locale Adds locales/ro/common.json and wires ro through routing, request, intl-provider, the language switcher and the flag list. Plurals use Romanian one/few/other forms. --- FEATURES.md | 2 +- components/providers/intl-provider.tsx | 2 + components/ui/flag-icons.tsx | 12 + components/ui/language-switcher.tsx | 1 + i18n/request.ts | 3 + i18n/routing.ts | 2 +- locales/ro/common.json | 3188 ++++++++++++++++++++++++ 7 files changed, 3208 insertions(+), 2 deletions(-) create mode 100644 locales/ro/common.json diff --git a/FEATURES.md b/FEATURES.md index 375ff2368..9e9d001fd 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -103,7 +103,7 @@ ## Internationalization -18 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文 +19 languages: Česky · Dansk · Deutsch · English · Español · Français · Italiano · Latviešu · Magyar · Nederlands · Polski · Português · Română · Türkçe · Русский · Українська · 한국어 · 日本語 · 简体中文 Automatic browser detection with persistent preference. Configurable locale URL prefix via `NEXT_PUBLIC_LOCALE_PREFIX`. diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx index bf4f24b20..4e0203b7a 100644 --- a/components/providers/intl-provider.tsx +++ b/components/providers/intl-provider.tsx @@ -17,6 +17,7 @@ import lvMessages from '@/locales/lv/common.json'; import nlMessages from '@/locales/nl/common.json'; import plMessages from '@/locales/pl/common.json'; import ptMessages from '@/locales/pt/common.json'; +import roMessages from '@/locales/ro/common.json'; import ruMessages from '@/locales/ru/common.json'; import trMessages from '@/locales/tr/common.json'; import ukMessages from '@/locales/uk/common.json'; @@ -38,6 +39,7 @@ const ALL_MESSAGES = { nl: nlMessages, pl: plMessages, pt: ptMessages, + ro: roMessages, ru: ruMessages, tr: trMessages, uk: ukMessages, diff --git a/components/ui/flag-icons.tsx b/components/ui/flag-icons.tsx index 07101f002..cb5404f0d 100644 --- a/components/ui/flag-icons.tsx +++ b/components/ui/flag-icons.tsx @@ -222,6 +222,17 @@ export function FlagDK(props: FlagProps) { ); } +/** Romania – Vertical blue, yellow, red tricolour */ +export function FlagRO(props: FlagProps) { + return ( + + + + + + ); +} + /** Map locale codes to flag components */ export const flagComponents: Record ReactElement> = { cs: FlagCS, @@ -238,6 +249,7 @@ export const flagComponents: Record ReactElement> nl: FlagNL, pl: FlagPL, pt: FlagBR, + ro: FlagRO, ru: FlagRU, tr: FlagTR, uk: FlagUA, diff --git a/components/ui/language-switcher.tsx b/components/ui/language-switcher.tsx index 4fd5e0ec1..99827c984 100644 --- a/components/ui/language-switcher.tsx +++ b/components/ui/language-switcher.tsx @@ -20,6 +20,7 @@ const languages = [ { value: 'nl', label: 'Nederlands' }, { value: 'pl', label: 'Polski' }, { value: 'pt', label: 'Português' }, + { value: 'ro', label: 'Română' }, { value: 'tr', label: 'Türkçe' }, { value: 'ru', label: 'Русский' }, { value: 'uk', label: 'Українська' }, diff --git a/i18n/request.ts b/i18n/request.ts index ff8d8fd09..a20ab9a0e 100644 --- a/i18n/request.ts +++ b/i18n/request.ts @@ -50,6 +50,9 @@ export default getRequestConfig(async ({ requestLocale }) => { case 'pt': messages = (await import('../locales/pt/common.json')).default; break; + case 'ro': + messages = (await import('../locales/ro/common.json')).default; + break; case 'ru': messages = (await import('../locales/ru/common.json')).default; break; diff --git a/i18n/routing.ts b/i18n/routing.ts index 70719825d..bd9003a92 100644 --- a/i18n/routing.ts +++ b/i18n/routing.ts @@ -12,7 +12,7 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as | 'always' | 'as-needed'; -const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const; +const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'hu', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ro', 'ru', 'tr', 'uk', 'zh'] as const; // Fallback locale used when the visitor's Accept-Language header does not // match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins diff --git a/locales/ro/common.json b/locales/ro/common.json new file mode 100644 index 000000000..aa3c04502 --- /dev/null +++ b/locales/ro/common.json @@ -0,0 +1,3188 @@ +{ + "meta_description": "Client de webmail minimalist care utilizează protocolul JMAP", + "login": { + "title": "Webmail", + "username_label": "E-mail", + "username_placeholder": "user@example.com", + "password_label": "Parolă", + "password_placeholder": "Introduceți parola", + "jmap_endpoint_label": "JMAP Server", + "jmap_endpoint_placeholder": "https://mail.example.com", + "jmap_endpoint_cors_hint": "Serverul trebuie să permită cererile CORS provenite de la acest domeniu.", + "jmap_server_label": "Server", + "jmap_server_auto_picked": "Server selectat din domeniul dvs. de e-mail.", + "sign_in": "Autentificare", + "signing_in": "Autentificare în curs...", + "loading": "Se încarcă...", + "reconnecting": "Conexiune pierdută. Se încearcă reconectarea…", + "error": { + "invalid_credentials": "Adresă de e-mail sau parolă invalidă. Vă rugăm să verificați datele de autentificare și să încercați din nou.", + "connection_failed": "Nu se poate stabili conexiunea cu serverul. Verificați conexiunea la internet și încercați din nou.", + "cors_blocked": "Serverul este accesibil, dar blochează cererile de tip „cross-origin”. Verificați setările CORS ale serverului JMAP și permiteți acest domeniu.", + "server_error": "Serverul este temporar indisponibil. Vă rugăm să încercați din nou mai târziu.", + "generic": "A apărut o eroare neașteptată. Dacă problema persistă, contactați administratorul.", + "totp_required": "Este necesar un cod de autentificare în doi pași. Vă rugăm să introduceți codul mai jos.", + "totp_invalid": "Cod de autentificare invalid. Vă rugăm să verificați aplicația de autentificare și să încercați din nou.", + "oauth_discovery_failed": "SSO este activată, dar furnizorul de identitate nu a putut fi contactat. Verificați configurația OAuth." + }, + "show_password": "Afișează parola", + "hide_password": "Ascunde parola", + "totp_toggle": "Am un cod 2FA", + "remember_me": "Reține-mă", + "config_error": { + "title": "Eroare de configurare", + "fetch_failed": "Nu se poate încărca configurația aplicației. Vă rugăm să încercați din nou mai târziu.", + "server_not_configured": "Serverul de e-mail nu a fost configurat. Vă rugăm să contactați administratorul." + }, + "remove_from_history": "Elimină din istoric", + "totp_label": "Cod de autentificare", + "totp_placeholder": "000000", + "session_expired": "Sesiunea dvs. a expirat. Vă rugăm să vă autentificați din nou.", + "dismiss": "Ignoră", + "or": "sau", + "sign_in_sso": "Conectați-vă cu SSO", + "add_account_title": "Adăugare cont", + "add_account_subtitle": "Conectați-vă cu un alt cont", + "cancel": "Anulează", + "website": "Site web", + "imprint": "Mențiuni legale", + "privacy_policy": "Politica de confidențialitate", + "try_demo": "Încercați demo-ul", + "demo_description": "Explorați cu date de exemplu – nu este necesar un cont", + "demo_launching": "Se lansează demonstrația...", + "demo_login_button": "Lansați demonstrația", + "demo_tagline": "Descoperiți un client de e-mail cu funcționalități complete. Nu este necesar un cont.", + "demo_no_signup": "Nu este necesară înregistrarea – explorați liber cu date de exemplu", + "oauth_completing": "Se finalizează autentificarea...", + "oauth_error": { + "title": "Autentificare eșuată", + "invalid_state": "Validarea de securitate a eșuat. Vă rugăm să încercați să vă autentificați din nou.", + "missing_params": "Lipsesc datele de autentificare. Vă rugăm să încercați să vă autentificați din nou.", + "token_exchange_failed": "Autentificarea nu a reușit. Vă rugăm să încercați din nou.", + "access_denied": "Accesul a fost refuzat. Vă rugăm să contactați administratorul.", + "back_to_login": "Înapoi la autentificare" + } + }, + "sidebar": { + "close": "Închide", + "compose": "Compune", + "compose_hint": "Compune (c)", + "search_placeholder": "Căutare în e-mail...", + "search_placeholder_hint": "Căutare în e-mail... (apăsați /)", + "storage": "Stocare", + "storage_used": "Utilizat", + "storage_free": "Gratuit", + "storage_total": "Total", + "sign_out": "Deconectare", + "sign_out_of": "Deconectați-vă de pe {account}", + "sign_out_all": "Deconectați-vă de la toate conturile", + "add_account": "Adăugare cont", + "set_as_default": "Setați ca implicită", + "switch_account": "Schimbă contul", + "contacts": "Contacte", + "calendar": "Calendar", + "settings": "Setări", + "admin": "Admin", + "files": "Fișiere", + "loading_mailboxes": "Se încarcă căsuțele poștale...", + "push_connected": "Actualizări în timp real active", + "push_disconnected": "Actualizări în timp real inactive", + "keyboard_shortcuts": "Comenzi rapide de la tastatură", + "theme": { + "light": "Modul luminos", + "dark": "Modul întunecat", + "system": "Tema sistemului" + }, + "language": { + "title": "Limba" + }, + "mailboxes": { + "inbox": "Primite", + "sent": "Trimise", + "drafts": "Ciorne", + "trash": "Coș de gunoi", + "archive": "Arhivă", + "starred": "Cu stea", + "all_mail": "Toate mesajele", + "spam": "Spam", + "important": "Important" + }, + "unified_inbox": "Căsuță de intrare unificată", + "unified_sent": "Toate mesajele trimise", + "unified_drafts": "Toate ciorne", + "unified_trash": "Toate coșul de gunoi", + "unified_archive": "Arhivă completă", + "unified_junk": "Toate mesajele nedorite", + "all_accounts": "Toate conturile", + "expand": "Extindeți", + "collapse": "Reduceți", + "expand_tooltip": "Extindeți", + "collapse_tooltip": "Reduceți", + "mobile": { + "search": "Căutare", + "compose": "Compune", + "go_back": "Înapoi" + }, + "clear_search": "Șterge căutarea", + "vacation_active": "Răspunsul automat de vacanță este activ", + "demo_banner": "Modul demo", + "demo_reset": "Resetare", + "demo_tour": "Tur de prezentare", + "tags": "Etichete", + "folders": "Dosare", + "shared": "Partajat", + "mail": "E-mail", + "nav_label": "Navigare", + "add_app": "Aplicații", + "scheduled": "Programat" + }, + "protocol_handlers": { + "title": "Aplicații implicite", + "description": "Alegeți dacă linkurile către e-mail și calendar se deschid în Bulwark. Din punct de vedere tehnic, Bulwark se înregistrează ca gestionar de protocol pentru linkurile de tip mailto: și webcal:.", + "unsupported": "Acest browser sau această conexiune nu acceptă înregistrarea manuală a gestionarului de protocoale. Este posibil să puteți utiliza totuși aplicația PWA instalată prin setările browserului sau ale sistemului de operare.", + "mailto_label": "Linkuri din e-mail", + "mailto_description": "Deschideți linkurile „mailto:” în Bulwark cu o fereastră de redactare precompletată.", + "protocol_open_mode_label": "La deschiderea linkurilor de protocol", + "protocol_open_mode_description": "Alegeți dacă Bulwark deschide linkurile mailto: și webcal: într-o filă nouă sau reutilizează o sesiune deschisă. Opțiunea „sesiune activă” necesită permisiunea de notificare, astfel încât să puteți face clic pe o notificare de rezervă pentru a aduce Bulwark în prim-plan dacă browserul blochează focalizarea.", + "protocol_open_mode_active_session": "Deschideți într-o sesiune activă, dacă este posibil", + "protocol_open_mode_new_tab": "Deschideți întotdeauna o filă nouă", + "focus_notification_title": "Deschideți Bulwark", + "focus_notification_body": "Linkul a fost deschis în Bulwark. Faceți clic pentru a aduce fereastra în prim-plan.", + "webcal_label": "Linkuri către calendar", + "webcal_description": "Deschideți linkurile webcal: din Bulwark cu o fereastră de dialog de abonare la calendar completată în prealabil.", + "register_mailto": "Înregistrați aplicația de e-mail", + "register_webcal": "Înregistrați aplicația de calendar", + "mailto_registered": "S-a solicitat înregistrarea unui gestionar de e-mail", + "webcal_registered": "S-a solicitat înregistrarea unui handler pentru calendar", + "registration_failed": "Înregistrarea gestionarului de protocol a eșuat", + "opening_mailto": "Se deschide fereastra de redactare...", + "opening_webcal": "Se deschide calendarul...", + "browser_note": "Este posibil ca browserul sau sistemul de operare să vă solicite confirmarea acestei acțiuni și să necesite instalareBulwarkului înainte de a putea fi selectat ca aplicație implicită.", + "select_account_title": "Alegeți contul", + "select_mailto_account": "Alegeți contul în care să se deschidă acest link de e-mail.", + "select_webcal_account": "Alegeți contul care ar trebui să deschidă acest link către calendar.", + "select_account_note": "Aceasta se aplică numai acestui link de protocol.", + "detail_to": "Către", + "detail_subject": "Subiect", + "detail_no_subject": "Fără subiect", + "detail_calendar": "Calendar", + "detail_source": "Sursă", + "active_account": "Activ", + "switching_account": "Se schimbă contul..." + }, + "sidebar_apps": { + "modal_title": "Aplicații din bara laterală", + "add_new": "Adăugați aplicația", + "edit_app": "Editați aplicația", + "name_label": "Nume", + "name_placeholder": "Aplicația mea", + "name_required": "Numele este obligatoriu", + "url_label": "URL", + "url_required": "URL este obligatoriu", + "url_invalid": "Introduceți o adresă URL validă de tip http sau https", + "icon_label": "Pictogramă", + "icon_required": "Pictograma este obligatorie", + "open_mode_label": "Modul Deschis", + "open_new_tab": "Filă nouă", + "open_inline": "În text", + "cancel": "Anulează", + "add": "Adăugați", + "update": "Actualizare", + "delete": "Șterge", + "delete_confirm_title": "Șterge aplicația", + "delete_confirm": "Sunteți sigur că doriți să ștergeți „{name}”?", + "no_apps": "Nu s-au adăugat încă aplicații", + "no_apps_hint": "Adăugați aplicații personalizate și linkuri în bara laterală", + "search_icons": "Pictograme de căutare...", + "show_popular": "Popular", + "show_all": "Toate", + "no_icons_found": "Nu s-au găsit pictograme", + "inline_badge": "În text", + "tab_badge": "Tab", + "show_on_mobile": "Afișare pe mobil" + }, + "email_list": { + "no_emails": "Nu s-au găsit mesaje", + "no_emails_description": "Această căsuță poștală este goală", + "no_search_results": "Nu s-au găsit rezultate", + "no_search_results_description": "Încercați să modificați criteriile de căutare sau filtrele", + "loading": "Se încarcă e-mailurile...", + "unread": "necitit", + "to_me": "Pentru mine", + "to_recipients": "Către destinatarii {count}", + "and_others": "și {count} altele", + "draft": "Ciorne", + "starred": "Cu stea", + "conversations_count": "{count} din conversațiile de pe {total}", + "conversations_count_plus": "{count}+ conversations", + "conversations_count_simple": "{count} conversații", + "no_conversations": "Fără conversații", + "loading_more": "Se încarcă mai multe e-mailuri...", + "no_more_emails": "Nu mai sunt e-mailuri de încărcat", + "batch_actions": { + "select": "Selectați e-mailurile", + "select_all": "Selectează tot", + "selected_messages": "{count, plural, one {1 e-mail} few {# e-mailuri} other {# de e-mailuri}} selectat", + "mark_read": "Marcați ca citit", + "mark_unread": "Marcați ca necitit", + "delete": "Șterge", + "delete_confirm_title": "Ștergeți e-mailurile", + "delete_confirm_message": "Sunteți sigur că doriți să ștergeți {count, plural, one {1 e-mail} few {# e-mailuri} other {# de e-mailuri}}?", + "clear_selection": "Șterge selecția" + }, + "permanent_delete": "Șterge definitiv", + "permanent_delete_confirm_title": "Șterge definitiv", + "permanent_delete_confirm_message": "Acest e-mail va fi șters definitiv. Această acțiune nu poate fi anulată.", + "permanent_delete_confirm_batch_message": "Acestea {count, plural, one {1 e-mail} few {# e-mailuri} other {# de e-mailuri}} va fi șters definitiv. Această acțiune nu poate fi anulată.", + "empty_folder": { + "button": "Folder gol", + "confirm_title": "Folder gol", + "confirm_message": "Toate e-mailurile din acest dosar vor fi șterse definitiv. Această acțiune nu poate fi anulată.", + "confirm_button": "Folder gol", + "junk_hint": "Puteți goli folderul „Junk” pentru a șterge definitiv toate mesajele.", + "trash_hint": "Puteți goli folderul Coș de gunoi pentru a șterge definitiv toate mesajele." + }, + "no_scheduled_emails": "Fără e-mailuri programate", + "no_scheduled_emails_description": "Mesajele programate pentru mai târziu vor apărea aici.", + "scheduled_count": "{count, plural, one {1 e-mail programat} few {# e-mailuri programate} other {# de e-mailuri programate}}", + "scheduled_actions_hint": "Mesajele programate pot fi anulate, reprogramate sau editate din lista acțiunilor programate.", + "cancel_scheduled_send": "Anulează trimiterea", + "reschedule_send": "Reprogramare", + "cancel_and_edit": "Anulează și editează", + "cancel_and_compose_again": "Anulează și redactează din nou", + "reschedule_prompt": "Introduceți o nouă dată/oră, de exemplu 2026-05-04T15:30" + }, + "email_viewer": { + "read_receipt": { + "prompt": "Expeditorul a solicitat o confirmare de citire:", + "send": "Trimite confirmare de primire", + "ignore": "Ignoră", + "sent": "Confirmare de citire trimisă.", + "send_failed": "Nu s-a putut trimite confirmarea de citire", + "mdn_subject": "Citiți: {subject}", + "mdn_body": "Aceasta este o confirmare de primire pentru mesajul pe care l-ați trimis la {recipient}.\n\nNotă: Această confirmare de primire atestă doar faptul că mesajul a fost afișat pe computerul destinatarului. Nu există nicio garanție că destinatarul a citit sau a înțeles conținutul mesajului." + }, + "no_email_selected": "Nu a fost selectat niciun e-mail", + "no_email_description": "Selectați un e-mail din listă pentru a-l vizualiza aici", + "no_conversation_selected": "Nu este selectată nicio conversație", + "no_conversation_description": "Alegeți o conversație din listă pentru a o citi aici", + "compose": "Compune", + "compose_hint": "Redactați un mesaj nou", + "no_subject": "(Fără subiect)", + "no_body_content": "(Nu există conținut disponibil)", + "no_preview_available": "Nu este disponibilă previzualizarea", + "loading_email": "Se încarcă e-mailul...", + "loading": "Se încarcă...", + "reply": "Răspunde", + "reply_all": "Răspunde tuturor", + "forward": "Redirecționează", + "delete": "Șterge", + "archive": "Arhivează", + "star": "Stea", + "unstar": "Anulează marcarea cu stea", + "mark_unread": "Marcați ca necitit", + "mark_read": "Marcați ca citit", + "unread": "Necitit", + "read": "Citiți", + "spam_short": "Spam", + "not_spam_short": "Nu este spam", + "move": "Mutare", + "print": "Imprimare", + "view_source": "Vizualizați sursa", + "export_email": "Exportați ca fișier .eml", + "import_email": "Importați fișiere .eml sau .zip", + "keyboard_shortcuts": "Comenzi rapide de la tastatură (?)", + "email_source": "Sursa e-mailului", + "draft_banner": "Acest mesaj este o schiță", + "edit_draft": "Editare", + "copy_source": "Copiați în clipboard", + "source_copied": "Sursă copiată în clipboard", + "attachments": "Anexe", + "important": "Important", + "download": "Descărcați", + "download_all": "Descărcați tot", + "from": "De la", + "to": "Către", + "cc": "CC", + "bcc": "BCC", + "date": "Data", + "subject": "Subiect", + "show_details": "Afișați detalii", + "hide_details": "Ascunde detaliile", + "external_content_warning": "Imaginile și conținutul extern au fost blocate", + "load_external_content": "Încărcați imagini", + "trust_sender": "Aveți întotdeauna încredere în acest expeditor", + "back_to_list": "Înapoi la listă", + "view_contact": "Vizualizare contact", + "message_details": "Detalii mesaj", + "more_reply_options": "Mai multe opțiuni de răspuns", + "set_color": "Setați eticheta", + "tag": "Etichetă", + "more_actions": "Alte acțiuni", + "previous": "Anterior", + "next": "Următorul", + "move_to": "Mergi la...", + "remove_color": "Eliminați eticheta", + "more_count": "+{count} mai multe", + "characters_count": "{count} caractere", + "quick_reply_placeholder": "Scrie un răspuns rapid...", + "more_options": "Mai multe opțiuni", + "sending": "Se trimite...", + "security_authentication": "Securitate și autentificare", + "technical_details": "Detalii tehnice", + "message_id_label": "IDul mesajului:", + "reply_to_label": "Răspunde la:", + "delivery_time_label": "Termen de livrare:", + "conversation_part_label": "Parte a conversației:", + "previous_messages": "{count} mesajul anterior", + "previous_messages_plural": "{count} mesaje anterioare", + "time": { + "day": "zi", + "days": "zile", + "hour": "oră", + "hours": "ore", + "minute": "minut", + "minutes": "minute" + }, + "unknown_sender": "Necunoscut", + "recipient_me": "eu", + "recipient_and_others": "{name} și {count} altele", + "recipient_to_prefix": "Către:", + "authentication": { + "title": "Autentificare", + "status": { + "verified": "Verificat", + "warning": "Avertisment", + "none": "Nu sunteți autentificat" + }, + "spf": { + "pass": "SPF Admis", + "fail": "SPF Eșec", + "none": "Fără „SPF”" + }, + "dkim": { + "pass": "DKIM Valid", + "fail": "DKIM Invalid", + "none": "Fără „DKIM”" + }, + "dmarc": { + "pass": "DMARC Admis", + "fail": "DMARC Eșec", + "none": "Fără „DMARC”" + }, + "spam_score": "Scorul de spam", + "tooltip_spf": "Sender Policy Framework: verifică dacă serverul expeditor este autorizat să trimită e-mailuri în numele domeniului", + "tooltip_dkim": "DomainKeys Identified Mail: confirmă faptul că e-mailul nu a fost modificat în timpul transmiterii, folosind o semnătură criptografică", + "tooltip_dmarc": "Autentificarea, raportarea și conformitatea mesajelor bazate pe domeniu (DMARC): Asigură că adresele SPF și DKIM corespund domeniului expeditorului și stabilește o politică pentru eșecuri", + "policy": "Politică", + "result": { + "pass": "Admis", + "fail": "Eșec", + "softfail": "Eșec ușor", + "neutral": "Neutru", + "permerror": "Eroare permanentă", + "temperror": "Eroare temporară", + "none": "Niciunul" + } + }, + "details": { + "recipients_routing": "Destinatari și rutare", + "authentication_security": "Autentificare și securitate", + "identifiers_threading": "Identificatori și organizarea în firuri de discuție", + "mailing_list": "Listă de distribuție", + "message_properties": "Proprietățile mesajului", + "sent": "Trimis", + "received": "Primit", + "delivery_time": "Termen de livrare", + "in_reply_to": "In-Reply-To", + "references": "Referințe", + "thread_id": "ID de discuție", + "size": "Dimensiune", + "mime_type": "MIME tip", + "attachments_summary": "{count} fișiere · {size}", + "list_id": "Listă ID", + "list_help": "Ajutor pentru listă", + "list_post": "Postare în listă", + "list_unsubscribe": "Dezabonare", + "iprev": "DNS inversat", + "spam_status": "Starea spamului", + "ai_verdict": "Verdictul AI", + "account": "Cont", + "no_subject": "(fără subiect)" + }, + "headers": { + "routing": "Rutare", + "received": "Primit", + "message_id": "IDul mesajelor", + "list_info": "Informații despre listă" + }, + "color_tag": { + "title": "Etichetă de culoare", + "red": "Roșu", + "orange": "Portocaliu", + "yellow": "Galben", + "green": "Verde", + "blue": "Albastru", + "purple": "Violet", + "pink": "Roz", + "none": "Niciunul" + }, + "tooltips": { + "reply": "Răspunde (r)", + "reply_all": "Răspunde tuturor (a)", + "forward": "Redirecționează (f)", + "archive": "Arhivare (e)", + "delete": "Ștergere (# sau Del)", + "star": "Stea (stele)", + "unstar": "Dezactivează marcajul (marcajele)", + "compose": "Compune (c)", + "previous": "E-mailul anterior", + "next": "Următorul e-mail", + "edit_draft": "Editează schița" + }, + "spam": { + "button_title": "Raportează spamul", + "not_spam_title": "Marcați ca legitim", + "toast_success": "Mutate în folderul „Junk”", + "toast_batch": "{count} e-mailuri mutate în folderul „Junk”", + "toast_undo": "Anulează", + "toast_not_spam_success": "Mutată în Inbox", + "toast_not_spam_batch": "{count} e-mailuri mutate în Inbox", + "error": "Nu s-a reușit raportarea spamului", + "error_not_spam": "Nu s-a putut restaura e-mailul" + }, + "unsubscribe_banner": { + "label": "Buletin informativ", + "button": "Dezabonare", + "confirm_title": "Doriți să vă dezabonați de la acest expeditor?", + "confirm_button": "Confirmare", + "cancel": "Anulează", + "success_http": "Pagina de dezabonare se deschide într-o filă nouă", + "success_mailto": "Cerere de dezabonare trimisă către clientul dvs. de e-mail", + "error": "Nu se poate dezabona", + "dismiss": "Ignoră" + }, + "calendar_invitation": { + "loading": "Se încarcă detaliile evenimentului…", + "title": "Invitație în calendar", + "published_title": "Eveniment publicat", + "response_title": "Răspuns la evenimente", + "update_title": "Actualizare eveniment", + "counter_title": "Contra-propunere", + "refresh_title": "Cerere de reîmprospătare", + "declined_counter_title": "Contrapropunere respinsă", + "cancelled_title": "Eveniment anulat", + "organizer": "Organizat de {name}", + "organizer_label": "Organizat după", + "attendees": "{count, plural, one {# participant} few {# participanți} other {# de participanți}}", + "accept": "Accept", + "maybe": "Poate", + "decline": "Declinare", + "add_to_calendar": "Adaugă în calendar", + "added": "Adăugat în calendar", + "rsvp_sent": "Răspuns trimis", + "parse_error": "Nu s-a putut citi invitația", + "action_failed": "Nu s-a putut finaliza acea acțiune din calendar.", + "no_calendar": "Calendarul nu este disponibil", + "published_info": "Acest eveniment a fost distribuit cu titlu informativ.", + "response_info": "Acest mesaj conține răspunsul unui participant.", + "response_info_organizer": "Răspunsul acestui participant actualizează evenimentul dumneavoastră.", + "update_info": "Acest mesaj actualizează un eveniment existent.", + "counter_info": "Acest mesaj propune modificări la un eveniment.", + "counter_info_organizer": "Acest participant a propus modificări la evenimentul dvs.", + "refresh_info": "Acest mesaj solicită detaliile celui mai recent eveniment.", + "refresh_info_organizer": "Un participant a solicitat detaliile cele mai recente despre eveniment.", + "declined_counter_info": "Organizatorul a respins o contrapropunere.", + "authentication_failed_info": "Verificările de autentificare a adresei de e-mail pentru această invitație au eșuat. Tratați cu precauție acțiunile din calendar.", + "authentication_missing_info": "Această invitație nu include autentificarea verificată a adresei de e-mail. Confirmați detaliile cu organizatorul dacă ceva pare neobișnuit.", + "sender_mismatch_info": "Această invitație a fost trimisă de la {sender}, în timp ce organizatorul menționat în datele din calendar este {organizer}.", + "sender_mismatch_unverified_info": "Această invitație a fost trimisă de la adresa {sender}, în timp ce organizatorul menționat în datele din calendar este {organizer}, iar mesajul nu a putut fi verificat.", + "organizer_role": "Tu organizezi acest eveniment", + "your_response": "Răspunsul dvs.: {status}", + "response_needed": "Necesită răspuns", + "response_accepted": "Acceptat", + "response_tentative": "Provizoriu", + "response_declined": "Respins", + "response_delegated": "Delegat", + "actor_sent_info": "Trimis de {name}.", + "actor_response_info": "{name} a răspuns {status}.", + "actor_counter_info": "{name} modificări propuse pentru acest eveniment.", + "actor_refresh_info": "{name} a solicitat detalii despre cel mai recent eveniment.", + "actor_declined_counter_info": "{name} a respins contrapropunerea.", + "actor_note": "Notă: {comment}", + "actor_unknown": "Cineva", + "proposed_changes": "Modificări propuse", + "change_title": "Titlu", + "change_time": "Ora", + "change_location": "Locație", + "change_description": "Descriere", + "change_empty": "Niciunul", + "change_from_to": "{before} -> {after}", + "apply_proposal": "Aplicați modificările propuse", + "proposal_applied": "Modificările propuse au fost aplicate.", + "review_proposal": "Revizuirea propunerii", + "review_request": "Solicitare de revizuire", + "view_in_calendar": "Vizualizare în calendar", + "select_calendar": "Selectați calendarul", + "already_in_calendar": "Deja în calendarul dvs.", + "request_info": "Ați fost invitat la acest eveniment. Răspundeți pentru a-i comunica organizatorului disponibilitatea dumneavoastră.", + "cancel_info": "Organizatorul a anulat acest eveniment.", + "event_updated": "Actualizați # {sequence}", + "event_status_tentative": "Provizoriu", + "event_status_cancelled": "Anulat", + "expand": "Afișați detalii", + "collapse": "Ascunde detaliile" + }, + "send": "Trimite", + "more": "mai mult", + "scheduled_banner": "Programat pentru trimitere la {date}", + "scheduled_send_created": "E-mail programat pentru trimitere", + "cancel_scheduled_send": "Anulează trimiterea", + "reschedule_send": "Reprogramare", + "cancel_and_edit": "Anulează și editează", + "cancel_and_compose_again": "Anulează și redactează din nou", + "reschedule_prompt": "Introduceți o nouă dată/oră, de exemplu 2026-05-04T15:30", + "scheduled_actions_only": "Vizualizarea programată acceptă numai acțiuni programate", + "undo_send_scheduled": "Mesaj programat pentru trimitere", + "undo_send": "Anulează trimiterea", + "contact_sidebar": { + "title": "Contact", + "close": "Închide bara laterală", + "action_email": "E-mail", + "action_email_title": "Trimite e-mail", + "action_copy": "Copiere", + "action_copy_title": "Copiați e-mailul", + "action_edit_title": "Editează contactul", + "section_emails": "E-mailuri", + "section_phones": "Telefoane", + "section_organizations": "Organizații", + "section_addresses": "Adrese", + "section_notes": "Note", + "not_in_contacts": "Nu se află în lista dvs. de contacte", + "add_to_contacts": "Adaugă la contacte", + "copied": "Copiat!", + "copy_failed": "Copierea a eșuat" + } + }, + "email_composer": { + "read_receipt_on": "Solicitare confirmare de citire (faceți clic pentru a dezactiva)", + "read_receipt_off": "Solicitați o confirmare de citire", + "new_message": "Mesaj nou", + "reply": "Răspunde", + "reply_all": "Răspunde tuturor", + "forward": "Redirecționează", + "reply_to": "Răspunde", + "reply_all_to": "Răspunde tuturor", + "forward_message": "Redirecționează", + "from": "De la", + "to": "Către", + "cc": "CC", + "bcc": "BCC", + "subject": "Subiect", + "body_placeholder": "Scrieți mesajul...", + "send": "Trimite", + "cancel": "Anulează", + "attach": "Atașați", + "attach_photos": "Fotografii și videoclipuri", + "attach_files": "Fișiere", + "discard": "Ignorați", + "discard_draft_title": "Doriți să ștergeți schița?", + "discard_draft_confirm": "Aveți modificări nesalvate. Doriți să renunțați la această schiță?", + "saving": "Se salvează...", + "sending": "Se trimite...", + "add_link": "Adăugați un link", + "link_url_prompt": "Accesați URL", + "draft_saved": "Ciorna a fost salvată", + "save_failed": "Nu s-a putut salva", + "to_placeholder": "Adresele de e-mail ale destinatarilor", + "cc_placeholder": "Destinatari în Cc", + "bcc_placeholder": "Destinatari Bcc", + "subject_placeholder": "Subiect", + "cc_label": "Cc:", + "bcc_label": "Bcc:", + "subject_label": "Subiect:", + "file_size_kb": "KB", + "prefix": { + "forward": "Redirecționare:", + "reply": "Re:" + }, + "no_subject": "(Fără subiect)", + "unknown_sender": "Necunoscut", + "quote": { + "reply_header": "Pe {date}, {sender} a scris:", + "forward_header": "---------- Mesaj redirecționat ----------", + "from": "De la: {sender}", + "date": "Data: {date}", + "subject": "Subiect: {subject}", + "to": "Către: {recipients}" + }, + "remove_sub_address": "Eliminați subadresa", + "from_override": { + "toggle_off": "Suprascrieți", + "toggle_on": "Anulare suprascriere", + "toggle_tooltip": "Puteți modifica liber numele și adresa din câmpul „De la”. E-mailul este trimis în continuare sub identitatea dvs. – se modifică doar antetul vizibil „De la”.", + "name_label": "Numele expeditorului", + "name_placeholder": "Nume", + "email_label": "De la adresa de e-mail", + "email_placeholder": "alias@example.com" + }, + "use_template": "Șablon", + "save_as_template": "Salvează ca șablon", + "validation": { + "recipient_required": "Adăugați un destinatar căruia să îi trimiteți mesajul", + "subject_required": "Adăugați un subiect", + "body_required": "Scrieți un mesaj sau atașați un fișier" + }, + "upload_progress": "Încărcare {uploaded} / {total}", + "upload_cancel": "Anulează încărcarea", + "upload_failed": "Nu s-a putut încărca {filename}", + "drop_files": "Trageți fișierele pentru a le atașa", + "show_less": "Afișați mai puțin", + "send_failed": "Nu s-a putut trimite e-mailul", + "continue_draft": "Continuă schița", + "close_draft_title": "Să salvați sau să renunțați la schiță?", + "close_draft_message": "Aveți modificări nesalvate. Doriți să salvați acest mesaj ca schiță sau să îl ignorați?", + "save_draft": "Salvează schița", + "smime_sign_on": "S/MIME semnarea activată", + "smime_sign_off": "Activați semnarea cu „S/MIME”", + "smime_encrypt_on": "S/MIME criptare activată", + "smime_encrypt_off": "Activați criptareS/MIME", + "smime_encrypt_unavailable": "S/MIME Criptarea nu este disponibilă – lipsesc certificatele destinatarilor", + "smime_unlock_title": "Deblocați o cheiS/MIME", + "smime_unlock_message": "Introduceți fraza de acces pentru a debloca cheia de semnare S/MIME.", + "smime_unlock_button": "Deblochează", + "smime_passphrase_placeholder": "Frază de acces", + "forgot_attachment": { + "title": "Ați uitat să atașați un fișier?", + "message": "Mesajul dvs. menționează „{keyword}”, dar nu este atașat niciun fișier. Doriți să trimiteți oricum?", + "send_anyway": "Trimite oricum", + "back": "Înapoi la editare" + }, + "schedule_send": "Programează trimiterea", + "schedule_send_description": "Alegeți momentul în care serverul ar trebui să transmită acest mesaj.", + "schedule_send_required": "Alegeți o dată și o oră.", + "schedule_send_invalid": "Introduceți o dată și o oră valide.", + "schedule_send_future": "Alegeți o dată și o oră viitoare.", + "schedule_send_too_late": "Ora aceasta este mai târzie decât cea permisă de server.", + "schedule_send_unsupported": "Trimiterea programată nu este acceptată pentru acest cont.", + "schedule_send_cleanup_warning": "Trimiterea programată a fost creată, dar curățarea schițelor a eșuat.", + "send_delay_unsupported": "Întârzierea trimiterii nu este acceptată pentru acest cont.", + "send_delay_unsupported_confirm": "Acest cont nu acceptă amânarea trimiterii. Doriți să trimiteți imediat?", + "recipient_edit_email": "Editați adresa de e-mail", + "recipient_edit_name": "Editați numele afișat", + "recipient_email_placeholder": "Adresă de e-mail", + "recipient_name_placeholder": "Numele afișat" + }, + "confirm_dialog": { + "confirm": "Confirmare", + "cancel": "Anulează" + }, + "common": { + "loading": "Se încarcă...", + "error": "Eroare", + "success": "Succes", + "cancel": "Anulează", + "save": "Salvare", + "delete": "Șterge", + "edit": "Editare", + "close": "Închide", + "search": "Căutare", + "refresh": "Reîmprospătează", + "settings": "Setări", + "help": "Ajutor", + "logout": "Deconectare", + "yes": "Da", + "no": "Nu", + "unknown": "Necunoscut", + "app_title": "Webmail", + "reconnecting": "Conexiune pierdută. Se încearcă reconectarea…", + "rate_limited_title": "Autentificarea pe server este temporar limitată ca frecvență.", + "rate_limited_detail": "Bulwark A suspendat solicitările de fundal pentru a evita blocarea. Se va încerca din nou în câteva {seconds} s.", + "rate_limited_action_title": "Solicitarea a fost pusă în așteptare pentru a evita blocarea contului.", + "rate_limited_action_detail": "Bulwark Așteaptă încheierea perioadei de așteptare a serverului înainte de a trimite alte cereri autentificate. Încercați din nou în câteva {seconds}." + }, + "notifications": { + "email_sent": "E-mailul a fost trimis cu succes", + "email_deleted": "E-mail șters", + "email_archived": "E-mail arhivat", + "email_starred": "E-mail marcat cu stea", + "email_unstarred": "E-mail fără stea", + "email_marked_read": "E-mail marcat ca citit", + "email_marked_unread": "E-mail marcat ca necitit", + "copied_to_clipboard": "Copiat în clipboard", + "source_copied": "Sursă copiată în clipboard", + "error_sending": "Nu s-a putut trimite e-mailul", + "error_deleting": "Nu s-a putut șterge e-mailul", + "error_loading": "Nu s-au putut încărca e-mailurile", + "new_email": "E-mail nou", + "new_email_from": "De pe {sender}", + "click_to_view": "Faceți clic pentru a vizualiza", + "email_moved": "E-mail mutat", + "emails_moved": "{count} e-mailuri mutate", + "moved_to_mailbox": "Mutată la {mailbox}", + "move_failed": "Mutarea a eșuat", + "move_error": "Nu s-au putut muta e-mailurile în folderul selectat", + "email_tagged": "E-mail etichetat", + "emails_tagged": "{count} e-mailuri etichetate", + "tag_failed": "Etichetarea a eșuat", + "identity_created": "Identitatea a fost creată cu succes", + "identity_updated": "Identitatea a fost actualizată cu succes", + "identity_deleted": "Identitate ștearsă", + "identity_set_primary": "Identitate principală actualizată", + "identity_create_failed": "Nu s-a putut crea identitatea: {error}", + "identity_update_failed": "Nu s-a putut actualiza identitatea: {error}", + "identity_delete_failed": "Nu s-a putut șterge identitatea: {error}", + "identity_unauthorized": "Nu aveți autorizație să trimiteți mesaje de la această adresă de e-mail", + "identity_not_found": "Identitate neidentificată", + "vacation_saved": "Setările răspunsului automat de vacanță au fost salvate", + "vacation_save_failed": "Nu s-au putut salva setările răspunsului automat de concediu", + "filters_saved": "Filtrele au fost salvate cu succes", + "filters_save_failed": "Nu s-au putut salva filtrele", + "filters_deleted": "Regula de filtrare a fost ștersă", + "templates_exported": "Șabloanele au fost exportate cu succes", + "templates_imported": "{count, plural, one {# șablon} few {# șabloane} other {# de șabloane}} importat", + "templates_import_errors": "Unele șabloane nu au putut fi importate", + "templates_import_empty": "Nu s-au găsit șabloane în fișier", + "export_email_error": "Nu s-a putut exporta e-mailul", + "import_email_success": "E-mailul a fost importat cu succes", + "import_email_error": "Importul e-mailului a eșuat" + }, + "date": { + "today": "Astăzi", + "yesterday": "Ieri", + "this_week": "Săptămâna aceasta", + "last_week": "Săptămâna trecută", + "this_month": "Luna aceasta", + "older": "Mai vechi", + "just_now": "Chiar acum", + "minutes_ago": "{count} acum un minut", + "minutes_ago_plural": "{count} acum câteva minute", + "hours_ago": "{count} acum o oră", + "hours_ago_plural": "{count} acum câteva ore", + "days_ago": "{count} acum o zi", + "days_ago_plural": "{count} zile în urmă" + }, + "language": { + "title": "Limba", + "english": "Engleză", + "french": "Français", + "japanese": "日本語", + "spanish": "Español", + "italian": "Italiano", + "german": "Deutsch", + "dutch": "Nederlands", + "portuguese": "Portugheză", + "russian": "Русский", + "select_language": "Selectați limba", + "switch_to_english": "Comutați la limba engleză", + "switch_to_french": "Comutați la limba franceză", + "switch_to_japanese": "Comutați la limba japoneză", + "switch_to_spanish": "Comutați la limba spaniolă", + "switch_to_italian": "Comutați la limba italiană", + "switch_to_german": "Comutați la limba germană", + "switch_to_dutch": "Comutați la limba olandeză", + "switch_to_portuguese": "Comutați la portugheză", + "switch_to_russian": "Comutați la limba rusă", + "switching": "Schimbarea limbii...", + "switch": "Schimbă limba", + "current": "Limba curentă", + "polish": "Poloneză", + "switch_to_polish": "Comutați la limba poloneză", + "en": "Engleză", + "fr": "Français", + "de": "Deutsch", + "es": "Español", + "it": "Italiano", + "ja": "日本語", + "ko": "한국어", + "lv": "Latviešu", + "nl": "Nederlands", + "pl": "Poloneză", + "pt": "Portugheză", + "ru": "Русский", + "uk": "Ukrainiană", + "zh": "Chineză simplificată" + }, + "settings": { + "title": "Setări", + "back_to_mail": "Înapoi la Mail", + "save_success": "Setările au fost salvate cu succes", + "import_success": "Setările au fost importate cu succes", + "import_error": "Nu s-au putut importa setările", + "reset_confirm": "Sunteți sigur că doriți să resetați toate setările la valorile implicite?", + "unsaved_changes": "Aveți modificări nesalvate", + "discard_changes": "Doriți să renunțați la modificările nesalvate?", + "discard": "Ignorați", + "keep_editing": "Continuați editarea", + "search_placeholder": "Setări de căutare", + "search_clear": "Șterge căutarea", + "search_no_results": "Nu există setări corespunzătoare", + "tabs": { + "appearance": "Aspect", + "language": "Limba, regiunea și ora", + "email": "Comportamentul e-mailurilor", + "composer": "Compozitor", + "privacy": "Confidențialitate și securitate", + "account": "Cont", + "identities": "Identități", + "vacation": "Răspuns automat de vacanță", + "advanced": "Nivel avansat", + "calendar": "Calendar", + "filters": "Filtre", + "templates": "Șabloane", + "folders": "Dosare", + "keywords": "Etichete", + "security": "Securitate", + "files": "Fișiere", + "contacts": "Contacte", + "encryption": "Criptare", + "protocol_handlers": "Aplicații implicite", + "sidebar_apps": "Aplicații din bara laterală", + "notifications": "Notificări", + "layout": "Aspect", + "reading": "Citire", + "composing": "Redactarea", + "downloads": "Descărcări", + "content_senders": "Conținut și expeditori", + "about_data": "Despre & Date", + "debug": "Depanare" + }, + "tab_groups": { + "general": "Generalități", + "account": "Cont și identitate", + "organization": "Organizarea e-mailurilor", + "apps": "Aplicații", + "system": "Sistem", + "appearance": "Aspect", + "mail": "E-mail", + "privacy": "Confidențialitate și securitate", + "advanced": "Nivel avansat" + }, + "appearance": { + "title": "Aspect", + "description": "Personalizați aspectul și funcționalitatea serviciului dvs. de webmail", + "theme": { + "label": "Tema", + "description": "Alegeți schema de culori preferată", + "light": "Ușor", + "dark": "Întunecat", + "system": "Sistem" + }, + "language": { + "label": "Limba", + "description": "Alegeți limba preferată" + }, + "font_size": { + "label": "Dimensiunea fontului", + "description": "Ajustați dimensiunea textului pentru o mai bună lizibilitate", + "small": "Mic", + "medium": "Nivel mediu", + "large": "Mare" + }, + "list_density": { + "label": "Densitate", + "description": "Controlați spațierea și marginile interioare în întreaga interfață de utilizator", + "extra_compact": "Extra compact", + "compact": "Compact", + "regular": "Regulat", + "comfortable": "Confortabil" + }, + "animations": { + "label": "Activați animațiile", + "description": "Prezentați tranziții și efecte fluide" + }, + "toolbar_position": { + "label": "Poziția barei de instrumente", + "description": "Unde să afișați butoanele de acțiune pentru e-mailuri (Răspunde, Arhivează, Șterge etc.)", + "top": "Sus", + "below_subject": "Sub subiect" + }, + "toolbar_labels": { + "label": "Afișați etichetele barei de instrumente", + "description": "Afișați etichete text lângă pictogramele din bara de instrumente. Dezactivați-le pentru a economisi spațiu odată ce v-ați familiarizat cu pictogramele." + }, + "hide_account_switcher": { + "label": "Ascunde comutatorul de conturi din bara laterală", + "description": "Ascundeți selectorul de conturi din partea de sus a barei laterale a folderelor. Puteți comuta între conturi în continuare din bara de navigare din partea de jos." + }, + "show_rail_account_list": { + "label": "Afișați avatarurile conturilor în bara de navigare", + "description": "Afișați cercurile conturilor individuale în partea de jos a barei de navigare pentru comutarea rapidă, cu un buton de deconectare sub acestea." + }, + "unified_mailbox": { + "label": "Căsuță poștală unificată", + "description": "Afișați folderele combinate (Mesaje primite, Mesaje trimise etc.) pentru toate conturile conectate", + "include_group": { + "label": "Includeți căsuțele de e-mail de grup", + "description": "De asemenea, integrați căsuțele de e-mail partajate/de grup în vizualizarea unificată." + } + }, + "all_mail": { + "label": "Toate mesajele", + "description": "Afișați o intrare „Toate mesajele” deasupra folderelor, care reunește mesajele din toate folderele acestui cont într-o singură listă.", + "folders_label": "Dosare în „Toate mesajele”", + "folders_description": "Alegeți ce dosare să fie incluse în vizualizarea „Toate mesajele”.", + "no_folders": "Nu sunt disponibile foldere." + }, + "colorful_sidebar_icons": { + "label": "Pictograme colorate în bara laterală", + "description": "Colorați pictogramele folderelor și etichetelor în funcție de tip (albastru pentru „Inbox”, roșu pentru „Junk”, verde pentru „Sent” etc.). Dezactivați această opțiune pentru o bară laterală monocromă." + }, + "pro_interface": { + "label": "Interfață Pro (experimentală)", + "description": "Aspect destinat utilizatorilor avansați, disponibil doar pe desktop, cu navigare prin mesaje în mai multe file și fluxuri de lucru între conturi. Interfața standard nu este afectată; puteți reveni la aceasta în orice moment.", + "open_label": "Interfața Open Pro", + "back_to_standard": "Înapoi la standard" + } + }, + "keywords": { + "title": "Etichete de e-mail", + "description": "Definiți etichete pentru a vă organiza e-mailurile pe culori. Acestea sunt stocate pe server sub formă de cuvinte-cheie dJMAP.", + "add_keyword": "Adăugați etichetă", + "reset_defaults": "Resezare la setările implicite", + "label_field": "Nume afișat", + "label_placeholder": "de ex. Serviciu, Personal, Urgent", + "id_field": "EtichetăID", + "id_placeholder": "de exemplu: profesional, personal", + "color_field": "Culoare", + "id_exists": "Această etichetă ID există deja", + "edit": "Editați eticheta", + "delete": "Șterge eticheta", + "save": "Salvare", + "add": "Adăugați", + "cancel": "Anulează", + "migrating": "Actualizarea etichetei pentru e-mailurile existente…", + "migration_error": "Nu s-a putut actualiza eticheta pentru e-mailurile existente" + }, + "notifications": { + "test_sound": "Testați sunetul de notificare", + "sounds": { + "default": "Implicit (Bip)", + "cheerful": "Vesel", + "involved": "Implicat", + "swift": "Gest rapid", + "relax": "Relaxează-te" + }, + "push": { + "title": "Notificări în fundal", + "description": "Primiți notificări de sistem pentru e-mailuri noi chiar și atunci când acest site este închis. Notificările sunt transmise prin intermediul serverului de push Bulwark; serverul nu vede niciodată conținutul e-mailurilor.", + "relay_label": "Relay push", + "relay_desc": "Implicit este setat serverul de retransmitere găzduit Bulwark. Modificați această setare numai dacă utilizați un server propriu.", + "relay_locked": "Configurat de administrator", + "relay_locked_desc": "Relay-ul push URL a fost configurat de administratorul dvs. și nu poate fi modificat.", + "relay_placeholder": "https://notifications.relay.example.com", + "status_active": "Activ pe acest dispozitiv", + "status_inactive": "Nu este activat pe acest dispozitiv", + "status_unsupported": "Acest browser nu acceptă Web Push", + "status_busy": "Se lucrează…", + "enable": "Activați", + "reenable": "Reînregistrează-te", + "disable": "Dezactivare", + "confirm_disable_title": "Dezactivați notificările din fundal?", + "confirm_disable_message": "Acest dispozitiv nu va mai primi alerte atunci când site-ul este închis.", + "ios_hint": "Pe iOS, instalați mai întâi site-ul pe ecranul de start – Safari trimite notificări Web Push numai către aplicațiile web progresive (PWA) instalate." + }, + "sound_selection": { + "title": "Sunet de notificare", + "description": "Alegeți ce sunet să se redea pentru notificări", + "choose": "Sunet", + "choose_desc": "Selectați un ton de notificare și faceți clic pe pictograma difuzorului pentru a-l asculta" + }, + "email": { + "title": "Notificări prin e-mail", + "description": "Configurați notificările pentru e-mailurile primite", + "enabled": "Notificări prin e-mail", + "enabled_desc": "Afișați notificări la primirea de e-mailuri noi", + "sound": "Sunet de notificare", + "sound_desc": "Redă o alertă audio la sosirea unor e-mailuri noi" + }, + "calendar": { + "title": "Notificări din calendar", + "description": "Configurați notificările pentru evenimentele din calendar", + "enabled": "Notificări privind evenimentele", + "enabled_desc": "Afișați alerte pentru evenimentele viitoare din calendar", + "sound": "Sunet de notificare", + "sound_desc": "Redă o alertă audio pentru mementourile din calendar", + "invitation_parsing": "Analizați invitațiile prin e-mail", + "invitation_parsing_desc": "Detectați invitațiile din calendar din atașamentele de e-mail și afișați acțiunile din calendar" + } + }, + "language_region": { + "title": "Limba, regiunea și ora", + "description": "Limba, formatul datei, formatul orei și alte preferințe regionale", + "language": { + "label": "Limba", + "description": "Alegeți limba preferată", + "english": "Engleză", + "french": "Français" + }, + "date_format": { + "label": "Formatul datei", + "description": "Modul în care sunt afișate datele în lista de e-mailuri", + "smart": "Inteligent (adaptat la setările locale)", + "relative": "Relativ (acum 1 oră, acum 2 zile)", + "full": "Întotdeauna data completă", + "preview_today": "Astăzi:", + "preview_this_week": "În această săptămână:", + "preview_older": "Versiune anterioară:" + }, + "time_format": { + "label": "Formatul orei", + "description": "Alegeți între formatul de 12 ore sau cel de 24 de ore", + "12h": "12 ore", + "24h": "24 de ore" + }, + "first_day": { + "label": "Prima zi a săptămânii", + "description": "Începeți săptămâna duminică sau luni", + "sunday": "Duminică", + "monday": "Luni" + } + }, + "email_behavior": { + "request_read_receipt": { + "label": "Solicitați confirmări de citire în mod implicit", + "description": "Activați în prealabil solicitarea de confirmare de citire atunci când redactați un mesaj nou." + }, + "read_receipt_response": { + "label": "Răspundeți la solicitările de confirmare de citire", + "description": "Ce trebuie făcut atunci când un mesaj primit solicită o confirmare de citire.", + "ask": "Întrebați de fiecare dată", + "always": "Trimiteți întotdeauna", + "never": "Nu trimiteți niciodată" + }, + "title": "Comportamentul e-mailurilor", + "description": "Configurați modul în care sunt gestionate e-mailurile", + "mark_read": { + "label": "Marcați ca citit", + "description": "Când să marcați e-mailurile ca citite atunci când sunt deschise", + "instant": "Imediat", + "delay_3s": "După 3 secunde", + "delay_5s": "După 5 secunde", + "never": "Niciodată" + }, + "delete_action": { + "label": "Acțiunea Ștergere", + "description": "Ce se întâmplă când ștergeți un e-mail", + "trash": "Mută în Coșul de gunoi", + "trash_and_read": "Mută în Coșul de gunoi și marchează ca citit", + "permanent": "Șterge definitiv", + "warning": "E-mailurile vor fi șterse definitiv și nu pot fi recuperate. Această acțiune este ireversibilă." + }, + "archive_mode": { + "label": "Arhivați în", + "description": "Cum să organizați e-mailurile atunci când le arhivați", + "single": "Un singur dosar", + "year": "Un dosar pentru fiecare an", + "month": "Un dosar pentru fiecare lună", + "reorganize": "Reorganizați arhiva existentă", + "reorganize_success": "{count, plural, =0 {Nu există e-mailuri de reorganizat} one {1 e-mail reorganizat} few {# e-mailuri reorganizate} other {# de e-mailuri reorganizate}}", + "reorganize_error": "Nu s-a putut reorganiza arhiva" + }, + "permanently_delete_junk": { + "label": "Șterge definitiv mesajele nedorite", + "description": "Ștergeți definitiv e-mailurile din folderul „Junk/Spam” în loc să le mutați în „Coșul de gunoi”" + }, + "mail_layout": { + "label": "Structura e-mailului", + "description": "Alegeți între panoul de citire clasic divizat, un flux de citire concentrat în stilul Gmail sau un panou de citire în partea de jos, în stilul Zimbra.", + "split": "Panou divizat", + "split_description": "Mențineți lista de mesaje și panoul de citire vizibile unul lângă celălalt.", + "focus": "Listă cu elemente evidențiate", + "focus_description": "Afișați câte o linie pe mesaj și deschideți e-mailul pe toată lățimea ecranului, menținând în același timp vizibilă bara laterală a folderelor.", + "horizontal": "Panoul de citire în partea de jos", + "horizontal_description": "Afișați lista de mesaje în partea de sus și deschideți mesajul selectat într-un panou de citire situat sub aceasta." + }, + "show_preview": { + "label": "Afișați textul de previzualizare", + "description": "Afișați previzualizarea e-mailurilor în listă", + "focus_description": "Afișați textul de previzualizare în linie în lista de mesaje cu o singură linie selectată" + }, + "disable_threading": { + "label": "Dezactivați gruparea conversațiilor", + "description": "Afișați e-mailurile ca mesaje individuale, în loc să le grupați pe conversații" + }, + "plain_text_mode": { + "label": "Numai text simplu", + "description": "Dezactivați editorul de text formatat și trimiteți toate e-mailurile doar ca text simplu, inclusiv răspunsurile și redirecționările" + }, + "auto_select_reply_identity": { + "label": "Răspunde de la adresa de la care a fost primit mesajul", + "description": "Când răspundeți, trimiteți mesajul de la adresa la care a fost trimis inițial. Se potrivesc mai întâi identitățile; pentru livrările de tip „catch-all” ale domeniului, antetul „De la” este rescris cu aliasul, în timp ce trimiterea se face prin identitatea dvs. principală." + }, + "signature_position": { + "label": "Poziția semnăturii", + "description": "Unde să inserați semnătura în răspunsuri și redirecționări. Deasupra textului citat, semnătura se citește în mod natural ca o încheiere a răspunsului; sub text, mesajul original rămâne contiguu.", + "above_quote": "Înaintea textului citat", + "below_quote": "După textul între ghilimele" + }, + "signature_separator": { + "label": "Delimitator de semnătură", + "description": "Prefațați semnătura cu linia standard de delimitare „-- ” (RFC 3676). Dezactivați această opțiune dacă preferați ca mesajul să treacă direct în semnătură." + }, + "sub_address_delimiter": { + "label": "Delimitator de subadresă", + "description": "Caracterul care separă numele dvs. de utilizator de eticheta subadresei. Folosiți delimitatorul utilizat de serverul dvs. de e-mail (de exemplu, user {delimiter} tag@domain.com).", + "option": "{delimiter} ({delimiter} tag@domain.com)", + "custom": "Personalizare…", + "custom_input_label": "Caracter delimitator personalizat" + }, + "attachment_click_action": { + "label": "Acțiunea de clic pe atașament", + "description": "Alegeți dacă, la clic pe un fișier atașat, acesta să fie previzualizat sau descărcat imediat", + "preview": "Previzualizați, dacă este posibil", + "download": "Descărcați imediat" + }, + "attachment_position": { + "label": "Poziția atașamentului", + "description": "Unde să afișați atașamentele în antetul e-mailului", + "beside-sender": "Lângă expeditor", + "below-header": "Sub antet" + }, + "emails_per_page": { + "10": "10 e-mailuri", + "25": "25 de e-mailuri", + "50": "50 de e-mailuri", + "100": "100 de e-mailuri", + "label": "E-mailuri pe pagină", + "description": "Numărul de e-mailuri care se încarcă simultan" + }, + "always_light_mode": { + "label": "Afișați întotdeauna e-mailurile în modul luminos", + "description": "Afișați conținutul e-mailurilor în modul luminos chiar și atunci când aplicația se află în modul întunecat, evitând astfel problemele de conversie asociate modului întunecat" + }, + "external_content": { + "label": "Conținut extern", + "description": "Cum se gestionează imaginile și conținutul extern", + "ask": "Întrebați întotdeauna", + "block": "Blocați întotdeauna", + "allow": "Permiteți întotdeauna" + }, + "trusted_senders": { + "label": "Expeditori de încredere", + "description": "Gestionați expeditorii ale căror imagini se încarcă automat", + "count_zero": "Niciunul", + "count_one": "1 expeditor", + "count_other": "{count} expeditori", + "modal_title": "Expeditori de încredere", + "empty_title": "Nu există încă expeditori de încredere", + "empty_description": "Când vizualizați un e-mail cu imagini blocate, faceți clic pe „Aveți întotdeauna încredere în acest expeditor” pentru a le adăuga aici.", + "add_manually": "Adăugați expeditorul manual", + "add_button": "Adăugați", + "add_placeholder": "Introduceți adresa de e-mail", + "search_placeholder": "Căutați expeditori...", + "no_results": "Nu există expeditori care să corespundă căutării dvs.", + "remove": "Eliminați", + "close": "Închide", + "invalid_email": "Vă rugăm să introduceți o adresă de e-mail validă", + "already_added": "Acest expeditor este deja de încredere", + "save_error": "Nu s-a putut salva – verificați jurnalul de depanare al Contacte pentru detalii", + "use_address_book_label": "Sincronizare cu agenda de adrese", + "use_address_book_description": "Salvați expeditorii de încredere într-o agendă dedicată, denumită „Expeditori de încredere”, astfel încât aceștia să se sincronizeze pe toate dispozitivele dvs." + }, + "hover_actions": { + "label": "Acțiuni rapide la trecerea cursorului", + "description": "Alegeți ce acțiuni rapide să apară atunci când treceți cu mouse-ul peste un e-mail din listă", + "delete": "Șterge", + "star": "Marcați cu stea / Dezmarcați", + "mark_read": "Marcați ca citit / necitit", + "archive": "Arhivează", + "tag": "Etichetă", + "spam": "Marcați ca spam", + "none_selected": "Nu sunt selectate acțiuni", + "mode_label": "Modul de afișare", + "mode_inline": "În text", + "mode_floating": "Plutitoare", + "corner_label": "Poziție flotantă", + "corner_top-left": "Stânga sus", + "corner_top-right": "Colțul din dreapta sus", + "corner_bottom-left": "Stânga jos", + "corner_bottom-right": "În partea dreaptă jos" + }, + "default_mail_program": { + "label": "Programul de e-mail implicit", + "description": "Setați {appName} ca program de e-mail implicit pentru linkurile de tip mailto:", + "button": "Setează ca implicit", + "success": "Browserul este solicitat să îl seteze ca implicit", + "error": "Browserul dvs. nu acceptă această funcție" + }, + "attachment_reminder": { + "label": "Memento privind atașamentele", + "description": "Afișați un avertisment înainte de trimitere atunci când mesajul menționează atașamente, dar nu sunt atașate niciunul", + "keywords_label": "Cuvinte-cheie declanșatoare", + "keywords_description": "Cuvinte sau expresii care declanșează memento-ul atunci când sunt găsite în mesajul dvs.", + "add_placeholder": "Adăugați cuvântul cheie...", + "add": "Adăugați", + "remove": "Eliminați" + }, + "hide_inline_image_attachments": { + "label": "Ascundeți imaginile încorporate din atașamente", + "description": "Imaginile încorporate în corpul mesajului nu sunt listate ca atașamente separate" + }, + "attachment_image_previews": { + "label": "Afișați previzualizări ale imaginilor din atașamente", + "description": "Afișați atașamentele de tip imagine sub formă de miniaturi, în loc de pictograme generice de fișiere" + }, + "send_delay": { + "label": "Anulare trimitere / amânare trimitere", + "description": "Amânați trimiterile normale pentru o scurtă perioadă de timp pe partea de server.", + "off": "Oprit", + "seconds": "{seconds} secunde", + "unsupported": "Contul curent nu indică faptul că acceptă trimiterea amânată. Setarea este totuși salvată pentru alte conturi." + } + }, + "composer": { + "title": "Compozitor", + "description": "Configurați setările de redactare a e-mailurilor", + "autosave": { + "label": "Interval de salvare automată", + "description": "Cât de des să se salveze automat schițele", + "30s": "La fiecare 30 de secunde", + "1m": "În fiecare minut", + "2m": "La fiecare 2 minute", + "5m": "La fiecare 5 minute" + }, + "send_confirmation": { + "label": "Confirmare trimitere", + "description": "Solicitați confirmarea înainte de a trimite e-mailurile" + }, + "default_reply": { + "label": "Modul implicit de răspuns", + "description": "Acțiunea implicită la clic pe „Răspunde”", + "reply": "Răspunde", + "reply_all": "Răspunde tuturor" + } + }, + "privacy": { + "title": "Confidențialitate și securitate", + "description": "Gestionați-vă setările de confidențialitate și securitate", + "external_images": { + "label": "Blocați imaginile externe", + "description": "Preveniți urmărirea prin intermediul imaginilor externe" + }, + "session_timeout": { + "label": "Expirarea sesiunii", + "description": "Deconectare automată după o perioadă de inactivitate", + "never": "Niciodată", + "30m": "30 de minute", + "1h": "1 oră", + "4h": "4 ore" + }, + "clear_cache": { + "label": "Golește memoria cache", + "description": "Ștergeți datele stocate în cache și fișierele temporare", + "button": "Golește memoria cache", + "confirm": "Sunteți sigur că doriți să goliți memoria cache?", + "success": "Memoria cache a fost golită cu succes" + } + }, + "account": { + "title": "Cont", + "description": "Vizualizați informațiile contului dvs.", + "name_label": "Nume afișat", + "username_label": "Nume de utilizator", + "account_type_label": "Tipul contului", + "auth_method_label": "Autentificare", + "auth_method_oauth": "Autentificare unică (OAuth /OIDC)", + "auth_method_basic": "Parolă", + "demo_account": "Cont demo", + "email": { + "label": "Adresă de e-mail", + "value": "{email}" + }, + "server": { + "label": "JMAP Server", + "value": "{server}" + }, + "storage": { + "label": "Utilizarea spațiului de stocare", + "used": "{used} {total}-ul utilizat", + "percentage": "{percent} % utilizat" + }, + "last_sync": { + "label": "Ultima sincronizare", + "value": "{time}" + }, + "accounts": { + "title": "Conturi conectate", + "description": "Trageți pentru a reordona modul în care apar conturile în meniul derulant al conturilor", + "active": "Cont activ în prezent", + "default_badge": "Cont implicit", + "set_default": "Setați ca implicită", + "switch_to": "Treceți la acest cont", + "move_up": "Sus", + "move_down": "Derulați în jos", + "drag_handle": "Trageți pentru a reordona", + "add": "Adăugare cont" + }, + "shared_accounts": { + "title": "Partajat cu mine", + "description": "Conturi de grup și conturi partajate pe care le puteți gestiona. Selectați unul pentru a-i edita filtrele, răspunsul automat de vacanță, calendarele și contactele.", + "shared_label": "Cont partajat" + } + }, + "scoped": { + "back": "Înapoi la contul meu", + "managing": "Administrare: {name}" + }, + "security": { + "title": "Securitatea contului", + "description": "Gestionați-vă parola, autentificarea în doi pași și setările de securitate", + "detecting": "Se detectează capacitățile serverului...", + "not_available": "Gestionarea securității contului nu este disponibilă pentru acest server de e-mail. Este posibil ca permisiunile necesare să fie dezactivate. Consultați documentația pentru detalii.", + "password": { + "title": "Schimbă parola", + "current": "Parola curentă", + "new": "Parolă nouă", + "confirm": "Confirmă noua parolă", + "submit": "Schimbă parola", + "success": "Parola a fost modificată cu succes", + "error_title": "Schimbarea parolei a eșuat", + "error_mismatch": "Noile parole nu se potrivesc", + "error_min_length": "Parola trebuie să conțină cel puțin 8 caractere", + "error_generic": "Nu s-a putut schimba parola" + }, + "display_name": { + "label": "Nume afișat", + "description": "Numele dvs. așa cum apare pe server", + "placeholder": "Introduceți numele afișat", + "save": "Salvare", + "success": "Numele afișat a fost actualizat", + "error": "Nu s-a reușit actualizarea numelui afișat" + }, + "totp": { + "section_title": "Autentificare în doi pași", + "label": "Autentificare TOTP", + "description": "Adăugați un nivel suplimentar de securitate cu o parolă unică, valabilă o singură dată, bazată pe timp", + "active": "Activat", + "inactive": "Dezactivat", + "enabled": "Autentificarea în doi pași este activată", + "disabled": "Autentificarea în doi pași este dezactivată", + "enable_error": "Nu s-a reușit activarea autentificării în doi pași (2FA)", + "disable_error": "Nu s-a reușit dezactivarea autentificării în doi pași (2FA)", + "setup_instructions": "Copiați acest cod URL în aplicația dvs. de autentificare (Google Authenticator, Authy etc.):", + "verification_code": "Cod de verificare", + "confirm": "Confirmare", + "disable": "Dezactivare", + "disable_confirm_prompt": "Introduceți parola pentru a dezactiva autentificarea în doi pași.", + "password_required": "Este necesară introducerea parolei", + "code_required": "Este necesar un cod de verificare", + "code_invalid": "Cod de verificare invalid. Verificați aplicația de autentificare și încercați din nou." + }, + "app_passwords": { + "title": "Parole pentru aplicații", + "description": "Creați parole pentru aplicațiile care nu acceptă autentificarea în doi pași", + "add": "Adăugați", + "create": "Creați", + "cancel": "Anulează", + "done": "Gata", + "generate": "Generați", + "name_label": "Numele aplicației", + "name_placeholder": "de exemplu, Thunderbird, Mail pe iPhone", + "expires_label": "Expiră (opțional)", + "allowed_ips_label": "Adresele IP permise (opțional)", + "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24", + "allowed_ips_hint": "Separate prin virgulă sau spațiu. Lăsați câmpul gol pentru a permite orice adresă IP.", + "password_label": "Parolă (lăsați câmpul gol pentru generarea automată)", + "password_placeholder": "Generat automat dacă este gol", + "copy_now_warning": "Copiați această parolă acum – nu va mai fi afișată din nou.", + "added": "Parola aplicației a fost creată", + "removed": "Parola aplicației a fost eliminată", + "add_error": "Nu s-a putut crea parola aplicației", + "remove_error": "Nu s-a reușit eliminarea parolei aplicației", + "none": "Nu sunt configurate parole pentru aplicații" + }, + "api_keys": { + "title": "API Chei", + "description": "Creați chei de tip „API” pentru scripturi și integrări care comunică direct cu serverul", + "name_label": "Numele cheii", + "name_placeholder": "de ex. script de backup, CI runner", + "copy_now_warning": "Copiați acum această cheie „API” – nu va mai fi afișată din nou.", + "added": "API cheie creată", + "removed": "API cheie eliminată", + "add_error": "Nu s-a putut crea cheia dAPI", + "remove_error": "Nu s-a putut elimina cheia „API”", + "none": "Nu sunt configurate tastele „API”" + }, + "encryption": { + "section_title": "Criptarea datelor stocate", + "label": "Criptarea e-mailurilor", + "description": "Criptați e-mailurile stocate pe server pentru un plus de confidențialitate", + "active": "{type} criptare activată", + "inactive": "Dezactivat", + "enabled": "Criptarea datelor stocate este activată", + "disabled_success": "Criptarea datelor stocate este dezactivată", + "error": "Nu s-au putut actualiza setările de criptare" + }, + "email_client": { + "title": "Configurarea clientului de e-mail", + "description": "Utilizați aceste date de autentificare pentru a configura clientul de e-mail de pe desktop sau de pe dispozitivul mobil (Thunderbird, Apple Mail, Outlook etc.)", + "jmap_username_label": "JMAP Nume de utilizator", + "copy": "Copiere", + "copied": "Copiat", + "password_instructions": "Folosiți numele de utilizator de la JMAP menționat mai sus, împreună cu o parolă de aplicație, pentru a vă autentifica în clientul de e-mail. Creați o parolă de aplicație în secțiunea de mai sus, dacă nu ați făcut-o deja." + }, + "link_device": { + "title": "Aplicație mobilă", + "description": "Conectați-vă la aplicația mobilă „Bulwark” fără a introduce nimic. Generați un cod QR aici și scanați-l din ecranul de conectare al aplicației.", + "generate": "Afișați codul QR", + "regenerate": "Afișați un cod nou", + "instructions": "Deschideți aplicația „BulwarkMail”, atingeți „Scanează codul QR” pe ecranul de autentificare și îndreptați camera în această direcție.", + "expires_in": "Acest cod expiră în {seconds} secunde. Poate fi utilizat o singură dată.", + "expired": "Acest cod a expirat.", + "generating": "Se generează…", + "error": "Nu s-a putut crea un cod de asociere. Vă rugăm să încercați din nou." + } + }, + "identities": { + "title": "Identități de expediere", + "description": "Gestionați adresele de e-mail de la care puteți trimite mesaje", + "identities_count": { + "label": "Identitățile tale", + "description": "Adrese de e-mail configurate pentru trimitere", + "count_zero": "Fără identități", + "count_one": "1 identitate", + "count_other": "{count} identități" + }, + "manage": "Gestionarea identităților", + "sub_addressing": { + "label": "Subadresare", + "description": "Folosiți etichete precum user+tag@domain.com pentru a organiza e-mailurile primite", + "learn_more": "Aflați mai multe" + } + }, + "vacation": { + "title": "Răspuns automat de vacanță", + "description": "Răspundeți automat la e-mailurile primite în timp ce sunteți plecat", + "loading": "Se încarcă setările de concediu...", + "not_supported": "Serverul dvs. de e-mail nu acceptă răspunsurile de vacanță.", + "fetch_error": "Nu s-au putut încărca setările de concediu. Vă rugăm să încercați din nou.", + "status": { + "label": "Răspuns automat de vacanță", + "description": "Trimiteți un răspuns automat persoanelor care vă trimit e-mailuri", + "active": "Activ", + "inactive": "Inactiv" + }, + "date_range": { + "title": "Interval de date", + "description": "Opțional, limitați răspunsul automat la o anumită perioadă", + "start": "Data de începere", + "start_description": "Lăsați necompletat dacă nu există o limită inițială", + "end": "Data de încheiere", + "end_description": "Lăsați necompletat dacă nu există o limită de sfârșit" + }, + "message": { + "title": "Mesaj de răspuns automat", + "description": "Mesajul care va fi trimis ca răspuns", + "subject_label": "Subiect", + "subject_description": "Subiectul răspunsului automat", + "subject_placeholder": "Absent de la birou", + "body_label": "Corpul mesajului", + "body_description": "Conținutul mesajelor în text simplu", + "body_placeholder": "Vă mulțumesc pentru e-mail. În prezent nu mă aflu la birou și vă voi răspunde la întoarcere." + }, + "preview": { + "title": "Previzualizare", + "show": "Afișează previzualizarea", + "hide": "Ascunde previzualizarea" + }, + "save": "Salvați modificările", + "saving": "Se salvează...", + "warnings": { + "end_before_start": "Data de încheiere trebuie să fie ulterioară datei de începere", + "start_in_past": "Data de începere este în trecut", + "empty_body": "Corpul mesajului este gol – destinatarii vor primi un răspuns gol" + } + }, + "folders": { + "role_memos": "Note", + "title": "Dosare", + "description": "Gestionați-vă folderele de e-mail și atribuiți roluri standard", + "folder_list": "Dosarele dvs.", + "folder_list_description": "Faceți clic pe pictograma unui dosar pentru a-l personaliza", + "standard_roles": "Rolurile standard ale folderelor", + "standard_roles_description": "Stabiliți care sunt folderele utilizate pentru rolurile standard ale căsuței poștale, cum ar fi „Inbox”, „Sent”, „Trash” etc.", + "role_inbox": "Primite", + "role_drafts": "Ciorne", + "role_sent": "Trimise", + "role_trash": "Coș de gunoi", + "role_junk": "Spam / Mesaje nedorite", + "role_archive": "Arhivă", + "role_none": "Niciunul", + "create_folder": "Creați un dosar", + "create_subfolder": "Creați un subfolder", + "subfolder_of": "În interiorul {name}", + "subfolder_name": "Numele subfolderului", + "new_folder_name": "Numele folderului", + "rename": "Redenumire", + "change_icon": "Schimbă pictograma", + "delete": "Șterge", + "confirm_delete": "Sunteți sigur că doriți să ștergeți „{name}”? E-mailurile din acest folder vor fi mutate în Coșul de gunoi.", + "create": "Creați", + "cancel": "Anulează", + "no_folders": "Fără foldere personalizate", + "cannot_delete_role": "Nu se poate șterge un dosar cu un rol standard. Eliminați mai întâi rolul.", + "folder_created": "Folder creat", + "folder_renamed": "Folder redenumit", + "folder_deleted": "Dosarul a fost șters", + "role_updated": "Rolul folderului a fost actualizat", + "error_create": "Nu s-a putut crea folderul", + "error_rename": "Nu s-a putut redenumi folderul", + "error_delete": "Nu s-a putut șterge folderul", + "error_delete_has_children": "Nu se poate șterge folderul: acesta conține încă subfoldere. Ștergeți-le sau mutați-le mai întâi.", + "error_delete_has_email": "Nu se poate șterge folderul: acesta conține încă e-mailuri. Mutați-le sau ștergeți-le mai întâi.", + "error_role": "Nu s-a putut actualiza rolul folderului" + }, + "advanced": { + "title": "Nivel avansat", + "description": "Opțiuni avansate și setări pentru dezvoltatori", + "debug_mode": { + "label": "Modul de depanare", + "description": "Activați înregistrarea detaliată a evenimentelor pentru depanare" + }, + "debug_categories": { + "description": "Selectați categoriile pe care doriți să le înregistrați. Dezactivați categoriile de care nu aveți nevoie pentru a reduce zgomotul din consolă.", + "jmap": "JMAP Client", + "jmap_description": "Operațiuni cu căsuța poștală, preluarea e-mailurilor și cererile prin protocolul JMAP", + "calendar": "Calendar", + "calendar_description": "Evenimente din calendar, importuri și programarea mesajelor", + "tasks": "Sarcini", + "tasks_description": "Crearea, preluarea și actualizarea sarcinilor din calendar", + "auth": "Autentificare", + "auth_description": "Autentificare, TOTP, schimb de tokenuri și gestionarea sesiunilor", + "filters": "Filtre", + "filters_description": "Sieve reguli de filtrare și scripturi de vacanță", + "email": "Vizualizarea e-mailurilor", + "email_description": "Renderizarea e-mailurilor, procesarea TNEF și marcarea ca citit", + "push": "Notificări push", + "push_description": "Configurarea și livrarea notificărilor push", + "contacts": "Contacte și agende de adrese", + "contacts_description": "Sincronizarea contactelor, operațiunile din agenda de adrese și expeditorii de încredere" + }, + "settings_sync": { + "label": "Sincronizarea setărilor", + "description": "Sincronizați-vă setările între browsere și dispozitive" + }, + "sender_favicons": { + "label": "Favicon-uri ale expeditorului", + "description": "Afișați pictogramele site-urilor web ca imagini de profil pentru expeditorii de afaceri" + }, + "show_avatars_in_junk": { + "label": "Afișați avatarurile din folderul „Junk”", + "description": "Afișați imaginile de profil și faviconurile expeditorilor din folderul de mesaje nedorite. Această opțiune este dezactivată în mod implicit pentru a evita conferirea unei aparențe de legitimitate încercărilor de phishing." + }, + "keyboard_shortcuts": { + "label": "Comenzi rapide de la tastatură", + "description": "Vedeți comenzile rapide de la tastatură disponibile", + "button": "Comenzi rapide de vizualizare" + }, + "reset_settings": { + "label": "Resetare setări", + "description": "Restabiliți toate setările la valorile implicite", + "button": "Resezare la setările implicite" + }, + "export_settings": { + "label": "Setări de export", + "description": "Descărcați setările în format JSON", + "button": "Export" + }, + "import_settings": { + "label": "Importați setările", + "description": "Încărcați setările dintr-un fișier JSON", + "button": "Import" + }, + "about": { + "title": "Bulwark Webmail" + } + }, + "sidebar_apps": { + "title": "Aplicații din bara laterală", + "description": "Gestionați aplicațiile personalizate și linkurile din bara laterală", + "keep_loaded": "Mențineți aplicațiile încărcate", + "keep_loaded_description": "Mențineți aplicațiile încorporate în execuție în fundal atunci când comutați între ele, pentru a evita reîncărcarea", + "manage_title": "Aplicații personalizate", + "manage_description": "Adăugați, editați sau eliminați aplicații personalizate din bara laterală" + }, + "contacts": { + "title": "Contacte", + "description": "Importați și exportați contactele", + "group_by_letter_label": "Grupați după prima literă", + "group_by_letter_description": "Afișați titlurile secțiunilor în ordine alfabetică în lista de contacte", + "import_label": "Importați contactele", + "import_description": "Importați contactele dintr-un fișier „vCard” (.vcf)", + "export_label": "Exportați contactele", + "export_description": "Exportați toate contactele ca fișier VCF (vCard).", + "manage_title": "Agende de adrese", + "manage_description": "Redenumiți agendele de adrese", + "no_address_books": "Nu s-au găsit agende de adrese", + "categories_title": "Categorii", + "categories_description": "Redenumiți categoriile de contacte", + "no_categories": "Nu s-au găsit categorii" + }, + "downloads": { + "title": "Descărcări", + "description": "Personalizați modul în care sunt denumite e-mailurile și atașamentele descărcate.", + "reset": "Restabilește setările implicite", + "preview": "Previzualizare:", + "email_template": { + "label": "Numele fișierului de e-mail (.eml)", + "description": "Șablon utilizat atunci când exportați un e-mail sau îl trageți în sistemul de fișiere. Extensia .eml se adaugă automat." + }, + "attachment_template": { + "label": "Numele fișierului atașat", + "description": "Șablon utilizat la descărcarea sau glisarea unui atașament. Dacă omiteți „{filename}” și „{ext}”, extensia originală este păstrată." + }, + "bundle_template": { + "label": "Nume de fișier .zip cu mai multe e-mailuri", + "description": "Șablon utilizat atunci când trageți sau descărcați mai multe e-mailuri selectate ca o singură arhivă .zip. Extensia .zip se adaugă automat." + }, + "spaces": { + "label": "Spații", + "description": "Înlocuiți spațiile din numele fișierului rezultat cu un alt caracter.", + "keep": "Păstrați spațiile", + "underscore": "Înlocuiți cu _", + "dash": "Înlocuiți cu -" + }, + "lowercase": { + "label": "Cu litere mici", + "description": "Convertiți întregul nume de fișier în litere mici." + }, + "strip_diacritics": { + "label": "Eliminați semnele diacritice", + "description": "Convertiți literele accentuate în echivalentele lor ASCII (ä → a, é → e). Util pentru instrumentele care denaturează numele de fișiere Unicode." + }, + "collapse_separators": { + "label": "Ascunde separatoarele repetate", + "description": "Reduceți șirurile de spații, linii de subliniere sau liniuțe la un singur caracter." + }, + "after_export": { + "label": "După export", + "description": "Opțional, mutați e-mailul după ce l-ați exportat în format .eml.", + "keep": "Păstrează în căsuța poștală", + "archive": "Mută în arhivă", + "trash": "Mută în coșul de gunoi" + } + }, + "filters": { + "title": "Filtre de e-mail", + "description": "Creați reguli pentru a sorta, eticheta și gestiona automat e-mailurile primite", + "add_rule": "Adăugați regulă", + "no_rules": "Fără reguli de filtrare", + "no_rules_description": "Creați reguli pentru a vă organiza automat e-mailurile primite", + "vacation_active": "Răspunsul automat de vacanță este activ", + "vacation_active_description": "Răspunsul automat este activat pentru mesajele primite", + "vacation_configure": "Configurare", + "edit_rule": "Editați regula", + "new_rule": "Regulă nouă", + "delete_rule": "Șterge regulă", + "delete_confirm": "Sunteți sigur că doriți să ștergeți această regulă?", + "enable": "Activați", + "disable": "Dezactivare", + "raw_editor": "Editorul „Sieve” (fără formatare)", + "raw_editor_warning": "Editarea scriptului brut Sieve poate afecta funcționalitatea editorului vizual de reguli. Modificările efectuate aici au prioritate față de cele din editorul vizual.", + "validate": "Validați", + "validation_success": "Scriptul este valid", + "validation_error": "Scriptul conține erori", + "save": "Salvați regulile", + "saving": "Se salvează...", + "saved": "Filtrele au fost salvate cu succes", + "save_failed": "Nu s-au putut salva filtrele", + "loading": "Se încarcă filtrele...", + "not_supported": "Serverul dvs. de e-mail nu acceptă filtre de e-mail.", + "rule_name": "Numele regulii", + "rule_name_placeholder": "de ex., Sortează buletinele informative", + "match_all": "Îndepliniți TOATE condițiile", + "match_any": "Îndepliniți ORICE condiție", + "conditions": "Condiții", + "add_condition": "Adăugați condiție", + "actions": "Acțiuni", + "add_action": "Adăugare acțiune", + "stop_processing": "Opriți procesarea regulilor următoare", + "attachment_type_placeholder": "de ex. pdf, doc, jpg", + "value_placeholder_multi": "Valoare (mai multe valori separate prin virgule)", + "condition_fields": { + "attachment": "Atașament", + "from": "De la", + "to": "Către", + "cc": "Cc", + "subject": "Subiect", + "header": "Antet personalizat", + "size": "Dimensiune", + "body": "Corpul mesajului" + }, + "comparators": { + "has_any": "este prezent", + "has_type": "de tip", + "contains": "conține", + "not_contains": "nu conține", + "is": "este exact", + "not_is": "nu este", + "starts_with": "începe cu", + "ends_with": "se termină cu", + "matches": "se potrivește cu modelul", + "greater_than": "este mai mare decât", + "less_than": "este mai mic decât" + }, + "action_types": { + "move": "Mută în folder", + "copy": "Copiere în folder", + "forward": "Redirecționează către", + "mark_read": "Marcați ca citit", + "star": "Marcați mesajul cu stea", + "add_label": "Adăugați o etichetă", + "discard": "Ignorare (ștergere fără confirmare)", + "reject": "Respinge cu mesaj", + "keep": "Păstrează în căsuța de primire", + "stop": "Opriți procesarea" + }, + "move_to_folder": "Selectează folderul", + "copy_to_folder": "Selectează folderul", + "forward_to": "Redirecționează către adresa de e-mail", + "forward_placeholder": "email@example.com", + "reject_message": "Mesaj de respingere", + "reject_placeholder": "E-mailul dvs. a fost respins", + "label_name": "Numele etichetei", + "label_placeholder": "Selectați eticheta", + "header_name": "Numele antetului", + "header_placeholder": "de ex., X-Mailing-List", + "size_bytes": "Dimensiune în octeți", + "size_placeholder": "de exemplu, 1000000", + "system_managed": "Regulă gestionată de sistem", + "opaque_warning": "Acest script a fost editat în afara editorului vizual. Este disponibilă doar editarea directă a fișierului „Sieve”.", + "open_sieve_editor": "Deschideți editorul de fișiere Sieve în format brut", + "fetch_error": "Nu s-au putut încărca filtrele", + "expanded_view": "Vizualizare extinsă", + "expanded_view_description": "Afișați regulile de filtrare cu blocuri detaliate de condiții și acțiuni", + "if": "Dacă", + "then": "Apoi", + "match_all_conditions": "toate se potrivesc", + "match_any_condition": "orice potriviri", + "and": "și", + "or": "sau", + "cancel": "Anulează", + "confirm_delete": "Șterge", + "rule_list": "Reguli de filtrare", + "drag_to_reorder": "Trageți pentru a reordona", + "match_type": "Potriviți tipul", + "reset_to_visual": "Reveniți la editorul vizual", + "reset_warning": "Aceasta va anula scriptul curent și va începe de la zero.", + "confirm_reset": "Resetare", + "validation_empty_name": "Numele regulii este obligatoriu", + "validation_empty_conditions": "Este necesară cel puțin o condiție cu o valoare", + "validation_empty_actions": "Este necesară cel puțin o acțiune", + "templates_section": "Începeți de la șablon", + "template_newsletters": "Mutați buletinele informative în dosar", + "template_receipts": "Arhivarea automată a confirmărilor de primire", + "template_important": "Marcați e-mailurile importante", + "template_notifications": "Filtrează notificările", + "sieve_editor": { + "title": "Sieve Editor de scripturi", + "warning": "Editarea scriptului brut Sieve poate afecta funcționalitatea editorului vizual de reguli. Modificările efectuate aici au prioritate față de cele din editorul vizual.", + "script_content": "Sieve script", + "valid": "Scriptul este valid", + "invalid": "Scriptul conține erori", + "save_warning": "Salvarea va suprascrie orice reguli vizuale. Această acțiune nu poate fi anulată. Faceți clic din nou pe Salvare pentru a confirma.", + "validating": "Se validează...", + "validate": "Validați", + "cancel": "Anulează", + "save": "Salvare", + "confirm_save": "Confirmare Salvare", + "validation_failed": "Solicitarea de validare a eșuat" + }, + "rule_summary": { + "conditions_count": "{count, plural, one {# condiție} few {# condiții} other {# de condiții}}", + "actions_count": "{count, plural, one {# acțiune} few {# acțiuni} other {# de acțiuni}}" + }, + "origin_external": "Extern", + "managed_by_tooltip": "Gestionat de {source}. Editați-l în acea aplicație sau utilizați editorul brut Sieve." + }, + "templates": { + "title": "Șabloane de e-mail", + "description": "Creați șabloane de e-mail reutilizabile cu variabile de substituție", + "add": "Șablon nou", + "edit": "Editați șablonul", + "name": "Numele șablonului", + "name_placeholder": "de exemplu, e-mail de urmărire", + "category": "Categorie", + "category_placeholder": "de exemplu, Serviciu, Personal", + "subject": "Subiect", + "subject_placeholder": "Subiectul e-mailului", + "body": "Corpul mesajului", + "body_placeholder": "Conținutul corpului e-mailului...", + "recipients_placeholder": "email@example.com", + "identity": "Trimite ca", + "default_identity": "Identitate implicită", + "favorite": "Favorite", + "cancel": "Anulează", + "create": "Creați", + "update": "Actualizare", + "confirm_delete": "Șterge", + "no_templates": "Nu există încă șabloane", + "manage": "Gestionarea șabloanelor", + "count": "{count, plural, one {# șablon} few {# șabloane} other {# de șabloane}}", + "export_import": "Export și import", + "export_import_description": "Faceți o copie de rezervă a șabloanelor sau transferați-le pe un alt dispozitiv", + "export": "Export", + "import": "Import", + "validation": { + "empty": "Numele șablonului este obligatoriu", + "too_long": "Numele șablonului trebuie să aibă cel mult 200 de caractere" + } + }, + "files": { + "display": { + "title": "Afișare", + "description": "Configurați modul de afișare a fișierelor și folderelor" + }, + "folder_layout": { + "label": "Navigarea în dosare", + "description": "Alegeți modul de afișare a folderelor: alături de fișiere sau într-o structură arborescentă din bara laterală", + "inline": "În text", + "sidebar": "Bara laterală" + }, + "default_view": { + "label": "Vizualizare implicită", + "description": "Alegeți între afișarea sub formă de grilă și cea sub formă de listă", + "list": "Listă", + "grid": "Tabel" + }, + "default_sort": { + "label": "Sortare implicită", + "description": "Alegeți sortarea implicită pentru fișiere", + "name": "Nume", + "size": "Dimensiune", + "modified": "Modificat" + }, + "sort_direction": { + "label": "Direcția de sortare", + "description": "Alegeți ordinea crescătoare sau descrescătoare", + "ascending": "În ordine crescătoare", + "descending": "Descendent" + }, + "icons": { + "title": "Pictograme", + "description": "Configurați aspectul pictogramelor fișierelor" + }, + "show_icons": { + "label": "Afișați pictogramele fișierelor", + "description": "Afișați pictograme lângă fișiere și foldere" + }, + "colored_icons": { + "label": "Pictograme colorate", + "description": "Folosiți pictograme colorate în locul celor monocrome" + }, + "show_thumbnails": { + "label": "Afișează miniaturi", + "description": "Afișați previzualizări ale imaginilor în locul pictogramelor pentru fișierele imagine" + }, + "behavior": { + "title": "Comportament", + "description": "Configurați comportamentul browserului de fișiere" + }, + "show_hidden": { + "label": "Afișează fișierele ascunse", + "description": "Afișați fișierele și folderele care încep cu un punct" + }, + "preview": { + "label": "Previzualizare" + } + } + }, + "errors": { + "page_error_title": "A apărut o eroare", + "page_error_description": "A apărut o eroare neașteptată. Vă rugăm să încercați din nou sau să reveniți la pagina principală.", + "sidebar_error": "Nu se pot încărca căsuțele poștale", + "email_list_error": "Nu se pot încărca e-mailurile", + "viewer_error_title": "Nu se poate afișa e-mailul", + "viewer_error_description": "A apărut o problemă la afișarea acestui e-mail. Este posibil ca acesta să conțină conținut neacceptat.", + "composer_error": "Nu se poate încărca editorul", + "settings_error_title": "Setări indisponibile", + "settings_error_description": "Nu se pot încărca setările. Este posibil ca preferințele dvs. să nu fie salvate.", + "try_again": "Încercați din nou", + "reload": "Reîncarcă", + "reload_emails": "Reîncarcă e-mailurile", + "reload_settings": "Reîncarcă setările", + "retry": "Reîncearcă", + "go_home": "Accesați căsuța de intrare" + }, + "context_menu": { + "reply": "Răspunde", + "reply_all": "Răspunde tuturor", + "forward": "Redirecționează", + "mark_read": "Marcați ca citit", + "mark_unread": "Marcați ca necitit", + "star": "Stea", + "unstar": "Anulează marcarea cu stea", + "move_to": "Mergi la...", + "archive": "Arhivează", + "delete": "Șterge", + "mark_as_spam": "Raportează spamul", + "not_spam": "Nu este spam", + "color_tag": "Etichetă", + "remove_color": "Eliminați eticheta", + "items_selected": "{count} e-mailuri selectate", + "edit_draft": "Editează schița", + "cancel_scheduled_send": "Anulează trimiterea", + "reschedule_send": "Reprogramare", + "cancel_and_edit": "Anulează și editează", + "cancel_and_compose_again": "Anulează și redactează din nou" + }, + "mailbox_context_menu": { + "mark_folder_read": "Marcați folderul ca citit", + "mark_folder_tree_read": "Marcați folderul și subfolderele ca citite", + "mark_all_folders_read": "Marcați toate folderele ca citite", + "new_subfolder": "Subfolder nou...", + "new_folder": "Dosar nou...", + "rename": "Redenumire...", + "import_email": "Importați fișiere .eml sau .zip...", + "empty_folder": "Folder gol", + "empty_folder_generic": "Folder gol", + "delete_folder": "Șterge dosarul", + "refresh": "Reîmprospătează", + "mark_all_confirm_title": "Marcați toate folderele ca citite", + "mark_all_confirm_message": "Doriți să marcați toate mesajele necitite din contul dvs. personal ca fiind citite?", + "delete_confirm_title": "Șterge dosarul", + "delete_confirm_message": "Doriți să ștergeți definitiv folderul „{name}”? Această acțiune nu poate fi anulată.", + "prompt_new_subfolder": "Introduceți un nume pentru noul subfolder.", + "prompt_new_folder": "Introduceți un nume pentru noul folder.", + "prompt_rename": "Introduceți un nume nou pentru acest folder.", + "placeholder_folder_name": "Numele folderului", + "create": "Creați", + "rename_confirm": "Redenumire", + "toast_marked_read": "Dosar marcat ca citit", + "toast_marked_read_count": "Marcat {count, plural, one {1 mesaj} few {# mesaje} other {# de mesaje}} ca citit", + "toast_already_read": "Nu există mesaje necitite", + "toast_marked_all_read": "Toate folderele marcate ca citite", + "toast_emptied": "Dosarul a fost golit", + "toast_folder_created": "Folder creat", + "toast_folder_renamed": "Folder redenumit", + "toast_folder_deleted": "Dosarul a fost șters", + "toast_error_mark_read": "Nu s-a reușit marcarea ca citit", + "toast_error_empty": "Nu s-a reușit golirea folderului", + "toast_error_create": "Nu s-a putut crea folderul", + "toast_error_rename": "Nu s-a putut redenumi folderul", + "toast_error_delete": "Nu s-a putut șterge folderul", + "toast_error_delete_has_children": "Dosarul conține subdosare. Ștergeți-le mai întâi.", + "toast_error_delete_has_email": "Dosarul nu este gol. Goliți-l mai întâi." + }, + "shortcuts": { + "title": "Comenzi rapide de la tastatură", + "tip": "Apăsați ? oricând pentru a afișa acest ajutor", + "sections": { + "navigation": "Navigare", + "actions": "Acțiuni de e-mail", + "global": "Global", + "threads": "Subiecte", + "composer": "Compozitor" + }, + "navigation": { + "next_email": "Următorul e-mail", + "previous_email": "E-mailul anterior", + "open_email": "Deschide e-mailul", + "close_email": "Închide / Deselectează" + }, + "actions": { + "reply": "Răspunde", + "reply_all": "Răspunde tuturor", + "forward": "Redirecționează", + "star": "Comută steluța", + "archive": "Arhivează", + "delete": "Șterge", + "mark_unread": "Marcați ca necitit", + "mark_read": "Marcați ca citit", + "toggle_spam": "Raportează spam / Nu este spam" + }, + "global": { + "compose": "Redactați un e-mail nou", + "search": "Căutare focalizată", + "help": "Afișați comenzile rapide", + "refresh": "Reîmprospătați e-mailurile", + "select_all": "Selectează tot" + }, + "threads": { + "expand_collapse": "Extindeți/reduceți firul de discuție" + }, + "composer": { + "send": "Trimite e-mail", + "schedule_send": "Programează trimiterea", + "template_picker": "Deschide selectorul de șabloane" + } + }, + "threads": { + "messages_one": "{count} mesaj", + "messages_other": "{count} mesaje", + "messages_tooltip": "{count, plural, one {# mesajul din această conversație} few {# mesaje din această conversație} other {# de mesaje din această conversație}}", + "expand": "Extinde conversația", + "collapse": "Ascunde conversația", + "loading": "Se încarcă conversația...", + "mark_read": "Marcați conversația ca citită", + "mark_unread": "Marcați conversația ca necitită", + "archive": "Arhivează conversația", + "delete": "Șterge conversația", + "star": "Marcați conversația cu stea", + "unstar": "Dezactivează marcajul conversației", + "toggle_thread": "Comutare fir de discuție" + }, + "identities": { + "modal_title": "Gestionarea identităților de expediere", + "create_new": "Creați o identitate nouă", + "edit_identity": "Editați identitatea", + "delete_confirm": "Doriți să ștergeți această identitate? Această acțiune nu poate fi anulată.", + "cannot_delete": "Această identitate nu poate fi ștearsă", + "primary_identity": "Primar", + "set_as_primary": "Setați ca principal", + "no_identities": "Nu s-au găsit identități", + "display": { + "reply_to": "Răspunde la:", + "bcc": "BCC:", + "signature": "Semnătură:", + "preview": "Previzualizare:" + }, + "validation_errors": { + "invalid_emails": "Adrese de e-mail nevalide: {emails}", + "unknown_error": "Eroare necunoscută" + }, + "form": { + "name_label": "Nume afișat", + "name_placeholder": "de exemplu, E-mail de serviciu, Personal", + "name_required": "Numele este obligatoriu", + "email_label": "Adresă de e-mail", + "email_placeholder": "your.email@example.com", + "email_required": "Adresa de e-mail este obligatorie", + "email_invalid": "Vă rugăm să introduceți o adresă de e-mail validă", + "email_immutable": "Adresa de e-mail nu poate fi modificată după creare", + "reply_to_label": "Răspunde la (opțional)", + "reply_to_placeholder": "different@email.com", + "bcc_label": "BCC automat (opțional)", + "bcc_placeholder": "archive@email.com", + "text_signature_label": "Semnătură text", + "html_signature_label": "HTML Semnătură", + "signature_byte_counter": "{bytes} / {max} bytes", + "signature_byte_limit_reached": "Limita serverului a fost atinsă", + "save": "Salvați identitatea", + "cancel": "Anulează", + "creating": "Se creează...", + "updating": "Se actualizează..." + }, + "sub_address": { + "button_tooltip": "Utilizați subadrese", + "popover_title": "Adăugați eticheta „Sub-adresă”", + "tag_input_placeholder": "Introduceți o etichetă (de exemplu, cumpărături)", + "preview_label": "Previzualizare:", + "recent_tags": "Etichete recente", + "suggested_tags": "Sugestii", + "use_address": "Utilizați această adresă", + "invalid_tag": "Eticheta trebuie să conțină doar caractere alfanumerice și liniuțe", + "tag_too_long": "Eticheta trebuie să aibă cel mult 30 de caractere", + "help_text": "E-mailurile trimise către adresele {delimiter} și tag@domain.com vor ajunge în căsuța dvs. de e-mail primite", + "validation": { + "empty": "Câmpul „Tag” nu poate fi gol", + "too_long": "Eticheta trebuie să conțină cel mult{max}e", + "invalid_chars": "Eticheta trebuie să conțină numai litere, cifre și liniuțe" + } + }, + "badge": { + "sent_via": "via", + "sub_address_tag": "Trimis folosind subadresa: {tag}", + "identity_name": "Trimis folosind identitatea: {name}", + "identity_short": "via {name}", + "subaddress_tag": "+{tag}" + }, + "delete_button": "Șterge", + "delete_confirm_title": "Ștergeți identitatea" + }, + "templates": { + "picker_title": "Alegeți un șablon", + "search_placeholder": "Căutați șabloane...", + "section_favorites": "Favorite", + "section_recent": "Recent", + "section_uncategorized": "Altele", + "no_templates": "Nu există încă șabloane", + "no_results": "Nu s-au găsit șabloane", + "fill_placeholders": "Completați valorile din câmpurile de substituție", + "enter_value": "Introduceți o valoare...", + "preview": "Previzualizare", + "insert_with_values": "Inserați cu valori", + "insert_raw": "Inserați Raw", + "copy_suffix": "(copiere)", + "placeholder": "Variabilă", + "placeholders": { + "recipient_name": "Numele destinatarului", + "company": "Numele companiei", + "date": "Data curentă", + "day_of_week": "Ziua săptămânii", + "sender_name": "Numele dvs." + } + }, + "contacts": { + "title": "Contacte", + "search_placeholder": "Căutare contacte...", + "create_new": "Contact nou", + "no_category": "Fără categorie", + "rename_category": "Redenumire categorie", + "category_name_label": "Numele categoriei", + "category_renamed": "Categorie redenumită", + "category_rename_failed": "Nu s-a putut redenumi categoria", + "category_added": "Contact adăugat la {name}", + "category_added_plural": "{count} contacte adăugate la {name}", + "empty_state": "Nu există încă contacte", + "empty_state_title": "Nu există încă contacte", + "empty_state_subtitle": "Creați primul dvs. contact sau importați-l dintr-un fișier „vCard”", + "empty_search": "Nu există contacte care să corespundă căutării dvs.", + "empty_search_hint": "Încercați un alt termen de căutare", + "empty_filtered": "Nu există contacte care să corespundă filtrelor dvs.", + "empty_filtered_hint": "Încercați să modificați sau să ștergeți filtrele", + "clear_search": "Șterge căutarea", + "import_vcard": "ImportvCard", + "delete_confirm_title": "Șterge contactul", + "delete_confirm": "Sunteți sigur că doriți să ștergeți acest contact?", + "local_mode": "Contactele sunt stocate local (serverul nu acceptă contactele dJMAP)", + "back_to_contacts": "Înapoi la contacte", + "back_to_email": "Înapoi la e-mail", + "open_categories": "Deschideți categoriile", + "tabs": { + "all": "Toate", + "groups": "Grupuri" + }, + "shared": { + "title": "Partajat" + }, + "address_books": { + "title": "Agendele mele de adrese", + "shared_prefix": "Partajat: {name}", + "moved": "Contactul a fost mutat la {name}", + "moved_plural": "{count} contactele au fost mutate la {name}", + "move_failed": "Nu s-a putut muta contactul", + "address_book": "Agendă", + "rename": "Redenumire agendă", + "name_label": "Numele agendei de adrese", + "renamed": "Agenda de adrese a fost redenumită", + "rename_failed": "Nu s-a putut redenumi agenda de adrese", + "default": "Implicit", + "manage": "Gestionați agendele de adrese", + "share": "Partajați agenda de adrese", + "new_contact_in_book": "Contact nou în această agendă", + "delete": "Șterge agenda de adrese", + "confirm_delete": "Doriți să ștergeți „{name}”? Toate contactele din această agendă vor fi eliminate.", + "deleted": "Agenda de adrese a fost ștersă", + "delete_failed": "Nu s-a putut șterge agenda de adrese" + }, + "detail": { + "emails": "Adrese de e-mail", + "phones": "Numere de telefon", + "organizations": "Organizații", + "addresses": "Adrese", + "notes": "Note", + "titles": "Titluri și roluri", + "online_services": "Servicii online", + "anniversaries": "Aniversări", + "personal_info": "Informații personale", + "languages": "Limbi", + "categories": "Categorii", + "related_contacts": "Contacte asociate", + "crypto_keys": "Chei criptografice", + "cert_issuer": "Emitent", + "cert_expires": "Expiră", + "cert_expired": "Expirat", + "cert_fingerprint": "Amprentă digitală", + "cert_algorithm": "Algoritm", + "import_to_smime": "Importați în S/MIME", + "cert_already_imported": "Deja importat pe S/MIME", + "cert_imported": "Certificat importat în magazinul S/MIME", + "cert_import_failed": "Importul certificatului a eșuat", + "no_contact_selected": "Selectați un contact pentru a vizualiza detaliile", + "compose_email": "Redactați un e-mail", + "copy_email": "Copiați e-mailul", + "copy_phone": "Copiați numărul de telefon", + "copy_url": "Copiați URL", + "copied": "Copiat în clipboard", + "copy_failed": "Nu s-a putut copia în clipboard", + "created": "Creat", + "updated": "Ultima actualizare", + "timezone": "Fus orar", + "anniversary_birth": "Ziua de naștere", + "anniversary_death": "A decedat", + "anniversary_wedding": "Aniversare", + "anniversary_other": "Altele", + "personal_expertise": "Competențe", + "personal_hobby": "Hobby", + "personal_interest": "Interes", + "personal_other": "Altele", + "gender": "Gen", + "gender_masculine": "Bărbat", + "gender_feminine": "Femeie", + "gender_other": "Altele", + "gender_none": "Nu se aplică", + "gender_unknown": "Necunoscut", + "calendar": "Calendar", + "calendar_uri": "URL de calendar", + "scheduling_uri": "Programare URL", + "freebusy_uri": "URL „Liber/Ocupat”", + "section_contact": "Date de contact", + "section_work": "Muncă", + "section_personal": "Personal", + "email_default_label": "E-mail", + "phone_default_label": "Telefon", + "address_default_label": "Adresă", + "online_service_default_label": "Online", + "organization_label": "Organizare", + "title_label": "Titlu", + "role_label": "Rol", + "language_label": "Limba", + "related_default_label": "Legat de", + "more_actions": "Alte acțiuni", + "age_years": "{count, plural, one {1 an} few {# ani} other {# de ani}}", + "years_since": "{count, plural, one {1 an} few {# ani} other {# de ani}}" + }, + "activity": { + "recent_emails": "E-mailuri recente", + "upcoming_events": "Evenimente viitoare", + "no_emails": "Nu există e-mailuri recente", + "no_events": "Nu există evenimente viitoare", + "no_subject": "(Fără subiect)", + "no_title": "(Fără titlu)", + "load_failed": "Nu s-a putut încărca", + "unknown_sender": "Expeditor necunoscut", + "all_day": "Toată ziua" + }, + "form": { + "create_title": "Contact nou", + "edit_title": "Editează contactul", + "section_address_book": "Director", + "select_address_book": "Selectați un director...", + "section_identity": "Nume și identitate", + "section_work": "Muncă și organizare", + "prefix": "Prefix", + "prefix_placeholder": "Dr., Dl., Dna.", + "given_name": "Prenume", + "middle_name": "Al doilea prenume", + "surname": "Nume de familie", + "suffix": "Sufix", + "suffix_placeholder": "Jr., Sr., III", + "nickname": "Pseudonim", + "nickname_placeholder": "Pseudonim", + "email": "E-mail", + "email_placeholder": "email@example.com", + "phone": "Telefon", + "phone_placeholder": "+1 234 567 890", + "phone_type": "Tastați", + "phone_voice": "Voce", + "phone_cell": "Mobil", + "phone_fax": "Fax", + "phone_pager": "Pager", + "phone_video": "Video", + "phone_text": "Text", + "organization": "Organizare", + "organization_placeholder": "Numele companiei", + "department": "Departament", + "department_placeholder": "Departament", + "job_title": "Funcția", + "job_title_placeholder": "de ex., inginer software", + "role": "Rol", + "role_placeholder": "de ex., șef de echipă", + "addresses": "Adrese", + "add_address": "Adăugați adresa", + "street": "Stradă", + "city": "Oraș", + "region": "Stat / Regiune", + "postcode": "Cod poștal", + "country": "Țară", + "online_services": "Servicii online", + "add_online_service": "Adăugați un serviciu online", + "url_placeholder": "https://...", + "service_placeholder": "Serviciu", + "anniversaries": "Aniversări", + "add_anniversary": "Adăugați data", + "anniversary_birth": "Ziua de naștere", + "anniversary_wedding": "Aniversare", + "anniversary_death": "Memorial", + "anniversary_other": "Altele", + "personal_info": "Informații personale", + "add_personal_info": "Adăugați o intrare", + "personal_info_placeholder": "de exemplu, Fotografie", + "personal_expertise": "Competențe", + "personal_hobby": "Hobby", + "personal_interest": "Interes", + "personal_other": "Altele", + "level": "Nivel", + "level_high": "Nivel ridicat", + "level_medium": "Nivel mediu", + "level_low": "Nivel scăzut", + "categories": "Categorii", + "categories_placeholder": "de exemplu, Familie, Prieteni, Colegi", + "categories_hint": "Tastați pentru a căuta sau a adăuga categorii", + "category_add": "Adăugați", + "note": "Note", + "note_placeholder": "Adăugați o notă...", + "gender": "Gen", + "gender_sex": "Sex", + "gender_male": "Bărbat", + "gender_female": "Femeie", + "gender_other": "Altele", + "gender_none": "Nu se aplică", + "gender_unknown": "Necunoscut", + "gender_identity": "Identitatea de gen", + "gender_identity_placeholder": "Identitatea de gen...", + "calendar": "Calendar", + "calendar_uri": "URL de calendar", + "scheduling_uri": "Programare URL", + "freebusy_uri": "URL „Liber/Ocupat”", + "context_work": "Muncă", + "context_private": "Privat", + "add_email": "Adăugați un e-mail", + "add_phone": "Adăugați numărul de telefon", + "save": "Salvare", + "cancel": "Anulează", + "creating": "Se creează...", + "updating": "Se actualizează...", + "name_required": "Este necesar cel puțin un prenume sau un nume de familie", + "email_invalid": "Vă rugăm să introduceți o adresă de e-mail validă", + "email_error_inline": "Format de e-mail nevalid", + "save_failed": "Nu s-a putut salva contactul", + "delete": "Șterge", + "upload_photo": "Încărcați o fotografie", + "remove_photo": "Elimină fotografia", + "photo_hint": "JPG sau PNG, până la 10 MB. Dimensiunea imaginii va fi redimensionată.", + "photo_too_large": "Imaginea este prea mare (max. 10 MB)", + "photo_invalid": "Fișier imagine nevalid", + "change_photo": "Modificare" + }, + "groups": { + "create": "Grup nou", + "edit": "Editare grup", + "empty": "Nu există încă grupuri", + "delete_confirm_title": "Șterge grupul", + "delete_confirm": "Sunteți sigur că doriți să ștergeți acest grup?", + "name_label": "Numele grupului", + "name_placeholder": "de exemplu, Echipa, Familia", + "name_required": "Numele grupului este obligatoriu", + "save_failed": "Nu s-a putut salva grupul", + "members_label": "Membri", + "search_members": "Căutați contacte pentru a le adăuga...", + "no_members": "Nu există membri în acest grup", + "member_count": "{count, plural, =0 {Nu există membri} one {1 membru} few {# membri} other {# de membri}}", + "send_email": "Trimite e-mail către grup", + "send_email_to": "Către", + "send_email_cc": "Cc", + "send_email_bcc": "Bcc", + "no_member_emails": "Acest grup nu are membri cu adresă de e-mail." + }, + "import": { + "title": "Importați contactele", + "drop_hint": "Faceți clic pentru a selecta un fișier de tip „vCard”", + "file_types": "Fișiere .vcf sau .vcard", + "no_contacts": "Nu s-au găsit contacte în fișier", + "parse_error": "Nu s-a putut analiza fișierul „vCard”", + "found": "{count, plural, one {S-a găsit 1 contact} few {# contacte găsite} other {# de contacte găsite}}", + "duplicate": "Duplicare", + "select_all": "Selectează tot", + "deselect_all": "Deselectați tot", + "selected": "{count, plural, one {1 selectat} few {# selectat} other {# de selectat}}", + "import_button": "Import", + "importing": "Se importă...", + "success": "{count, plural, one {1 contact importat} few {# contacte importate} other {# de contacte importate}}", + "failed": "Importul a eșuat", + "close": "Închide", + "file_too_large": "Fișierul este prea mare (max. 5 MB)" + }, + "export": { + "title": "Exportați contactele", + "success": "{count, plural, one {1 contact exportat} few {# contacte exportate} other {# de contacte exportate}}" + }, + "bulk": { + "selected": "{count, plural, one {1 selectat} few {# selectat} other {# de selectat}}", + "select_all": "Selectează tot", + "delete": "Șterge", + "delete_confirm_title": "Ștergeți contactele", + "delete_confirm": "Șterge {count, plural, one {1 contact} few {# contacte} other {# de contacte}}?", + "deleted": "{count, plural, one {1 contact șters} few {# contacte șterse} other {# de contacte șterse}}", + "add_to_group": "Adaugă la grup", + "choose_group": "Alegeți un grup", + "adding_contacts": "Adăugare {count, plural, one {1 contact} few {# contacte} other {# de contacte}}", + "added_to_group": "Contacte adăugate la grup", + "export": "Export", + "clear": "Șterge selecția" + }, + "toast": { + "created": "Contact creat", + "updated": "Date de contact actualizate", + "deleted": "Contact șters", + "error_create": "Nu s-a putut crea contactul", + "error_update": "Nu s-a putut actualiza contactul", + "error_delete": "Nu s-a putut șterge contactul" + }, + "context_menu": { + "open": "Deschide", + "edit": "Editare", + "send_email": "Trimite e-mail", + "add_to_group": "Adaugă la grup", + "export_vcard": "Exportați cvCard", + "delete": "Șterge", + "call": "Apel", + "duplicate": "Duplicare", + "print": "Imprimare" + }, + "filters": { + "toggle": "Filtre", + "select": "Selectați", + "clear": "Ștergeți", + "close": "Închide", + "title": "Filtre avansate", + "organization": "Companie", + "organization_placeholder": "de ex. Acme Corp", + "job_title": "Funcția", + "job_title_placeholder": "de ex. Designer", + "location": "Locație", + "location_placeholder": "Oraș sau țară", + "email_domain": "Domeniul de e-mail", + "email_domain_placeholder": "example.com", + "birthday_month": "Ziua de naștere în", + "any_month": "Orice lună", + "has_email": "Are e-mail", + "has_phone": "Are telefon", + "has_photo": "Are fotografie" + } + }, + "calendar": { + "title": "Calendar", + "back_to_email": "Înapoi la e-mail", + "back_to_month": "Înapoi la lună", + "my_calendars": "Calendare", + "birthday_calendar": "Zile de naștere", + "mini_calendar_change": "Faceți clic pentru a schimba luna", + "views": { + "month": "Luna", + "week": "Săptămână", + "day": "Zi", + "agenda": "Agendă", + "today": "Astăzi", + "month_hint": "Luna (m)", + "week_hint": "Săptămână (s)", + "day_hint": "Ziua (d)", + "agenda_hint": "Agendă (a)", + "tasks": "Sarcini", + "tasks_hint": "Sarcini (k)" + }, + "events": { + "create": "Creați un eveniment", + "edit": "Editați evenimentul", + "delete": "Ștergere eveniment", + "details": "Detalii despre eveniment", + "no_events": "Fără evenimente", + "all_day": "Toată ziua", + "more": "+{count} mai multe", + "no_title": "(Fără titlu)", + "resize": "Eveniment de redimensionare", + "duplicate": "Duplicare", + "today_header": "Astăzi", + "tomorrow_header": "Mâine", + "export_ics": "Exportați ca fișier .ics", + "copy_title": "Copiați titlul", + "copy_link": "Copiați linkul întâlnirii", + "new_event": "Eveniment nou", + "new_all_day_event": "Eveniment nou pe toată durata zilei", + "new_task": "Sarcină nouă", + "go_to_today": "Mergi la astăzi" + }, + "detail": { + "add_note": "Adăugați o notă...", + "save_note": "Salvare", + "note_saved": "Notă adăugată", + "open_link": "Deschideți linkul", + "meeting_link": "Link către întâlnire", + "tentative": "Provizoriu", + "cancelled": "Anulat", + "delete_confirm": "Doriți să ștergeți acest eveniment?" + }, + "form": { + "title": "Titlu", + "description": "Descriere", + "location": "Locație", + "meeting_link": "Link către întâlnire", + "start_date": "Data de începere", + "end_date": "Data de încheiere", + "start_time": "Ora de începere", + "end_time": "Ora de încheiere", + "all_day_event": "Eveniment pe toată durata zilei", + "calendar_select": "Calendar", + "save": "Salvare", + "cancel": "Anulează", + "delete_confirm": "Sunteți sigur că doriți să ștergeți acest eveniment?", + "color": "Culoare" + }, + "participants": { + "title": "Participanți", + "add": "Adăugați un participant", + "organizer": "Organizator", + "attendee": "Participant", + "accepted": "Acceptat", + "declined": "Respins", + "tentative": "Provizoriu", + "needs_action": "Necesită acțiune", + "remove": "Eliminați", + "edit": "Editare", + "email_placeholder": "Adăugați o adresă de e-mail sau căutați contacte", + "send_invitations": "Trimiteți invitații participanților", + "status_summary": "{accepted} acceptat, {pending} în așteptare", + "invited_by": "Invitație de la {name}", + "respond_below": "Răspundeți folosind butoanele de mai jos", + "rsvp_label": "Răspunsul dvs.", + "cancel_notification": "Participanții vor fi informați cu privire la anulare", + "you_organizer": "Tu ești organizatorul", + "you_attendee": "Sunteți participant", + "no_participants": "Nu există participanți", + "count": "{count, plural, one {# participant} few {# participanți} other {# de participanți}}" + }, + "recurrence": { + "title": "Recurență", + "none": "Nu se repetă", + "daily": "Zilnic", + "weekly": "Săptămânal", + "monthly": "Lunar", + "yearly": "Anual", + "every_n_days": "La fiecare {count} zile", + "every_n_weeks": "La fiecare {count} săptămâni", + "every_n_months": "La fiecare {count} luni", + "until": "Până la", + "occurrences": "{count} ocurențe", + "custom": "Personalizare…", + "edit_custom": "Editați recurența personalizată", + "every_n_years": "La fiecare {count} ani", + "on_days": "pe {days}", + "on_day_n": "în ziua de{day}", + "on_the_nth": "pe {nth} {day}", + "in_month": "în {month}", + "nth_1": "primul", + "nth_2": "al doilea", + "nth_3": "al treilea", + "nth_4": "al patrulea", + "nth_last": "ultimul", + "editor_freq_day": "Zi", + "editor_freq_week": "Săptămână", + "editor_freq_month": "Luna", + "editor_freq_year": "Anul", + "editor_repeats_on": "Se repetă pe", + "editor_every": "Fiecare", + "editor_unit_days": "zi(i)", + "editor_unit_weeks": "săptămână(e)", + "editor_unit_months": "lună(i)", + "editor_unit_years": "an(i)", + "editor_on_day": "în ziua", + "editor_on_the": "pe", + "editor_in": "în", + "editor_ends": "Sfârșit", + "editor_never": "Niciodată", + "editor_ends_on": "Pe", + "editor_ends_after": "După", + "editor_occurrences": "ocurențe" + }, + "recurrence_scope": { + "edit_title": "Editați evenimentul recurent", + "delete_title": "Șterge evenimentul recurent", + "description": "Acesta este un eveniment recurent. Ce evenimente doriți să modificați?", + "this_event": "Numai acest eveniment", + "this_and_future": "Acest eveniment și evenimentele următoare", + "all_events": "Toate evenimentele", + "cancel": "Anulează", + "save": "Salvare", + "delete": "Șterge" + }, + "alerts": { + "title": "Memento-uri", + "none": "Fără mementouri", + "at_time": "La momentul evenimentului", + "minutes_before": "{count, plural, one {# cu un minut înainte} few {# minute înainte de} other {# de minute înainte de}}", + "hours_before": "{count, plural, one {# cu o oră înainte} few {# cu câteva ore înainte} other {# de cu câteva ore înainte}}", + "days_before": "{count, plural, one {# cu o zi înainte} few {# zile înainte} other {# de zile înainte}}", + "weeks_before": "{count, plural, one {# cu o săptămână înainte} few {# cu câteva săptămâni înainte} other {# de cu câteva săptămâni înainte}}", + "unit_minutes_before": "minute înainte de", + "unit_hours_before": "cu câteva ore înainte", + "unit_days_before": "zile înainte", + "unit_weeks_before": "cu câteva săptămâni înainte", + "add": "Adăugați o notificare", + "remove": "Eliminare memento", + "amount": "Suma de reamintire", + "unit": "Unitatea de reamintire" + }, + "settings": { + "title": "Setări calendar", + "default_view": "Vizualizare implicită", + "week_starts_on": "Săptămâna începe pe", + "time_format": "Formatul orei", + "default_calendar": "Calendarul implicit", + "default_reminder": "Memento implicit", + "time_format_12h": "12 ore", + "time_format_24h": "24 de ore", + "notifications_enabled": "Notificări privind evenimentele", + "notifications_enabled_desc": "Afișați alerte pentru evenimentele viitoare din calendar", + "notification_sound": "Sunet de notificare", + "notification_sound_desc": "Redă un sunet pentru alertele din calendar", + "invitation_parsing": "Analizați invitațiile prin e-mail", + "invitation_parsing_desc": "Detectați invitațiile din calendar din atașamentele de e-mail și afișați acțiunile din calendar", + "show_time_in_month_view": "Afișați ora în vizualizarea lunară", + "show_time_in_month_view_desc": "Afișați orele evenimentelor în vizualizarea calendarului lunar", + "show_week_numbers": "Afișați numerele săptămânilor", + "show_week_numbers_desc": "Afișați numerele săptămânilor în mini-calendar", + "enable_tasks": "Activați sarcinile", + "enable_tasks_desc": "Afișați o vizualizare a sarcinilor în calendar pentru gestionarea sarcinilor de îndeplinit", + "show_tasks_on_calendar": "Afișați sarcinile în calendar", + "show_tasks_on_calendar_desc": "Afișați cartonașe cu sarcini în vizualizările calendarului zilnic și săptămânal", + "hover_preview": "Previzualizare la trecerea cursorului peste elemente", + "hover_preview_desc": "Afișați o fereastră pop-up cu detalii atunci când treceți cu mouse-ul peste evenimentele din calendar", + "hover_preview_instant": "Instant", + "hover_preview_delay_500ms": "Întârziere de 0,5 secunde", + "hover_preview_delay_1s": "Întârziere de 1 secundă", + "hover_preview_delay_2s": "Întârziere de 2 secunde", + "hover_preview_off": "Dezactivat", + "show_birthday_calendar": "Calendarul zilelor de naștere ale contactelor", + "show_birthday_calendar_desc": "Afișați un calendar virtual cu zilele de naștere ale contactelor dvs." + }, + "days": { + "monday": "Luni", + "tuesday": "Marți", + "wednesday": "Miercuri", + "thursday": "Joi", + "friday": "Vineri", + "saturday": "Sâmbătă", + "sunday": "Duminică", + "mon": "Luni", + "tue": "Marți", + "wed": "Miercuri", + "thu": "Joi", + "fri": "Vineri", + "sat": "Sâmbătă", + "sun": "Duminică" + }, + "months": { + "jan": "ianuarie", + "feb": "feb", + "mar": "Mar", + "apr": "Apr", + "may": "Poate", + "jun": "Iunie", + "jul": "iulie", + "aug": "aug", + "sep": "Sep", + "oct": "Oct", + "nov": "Nov", + "dec": "Dec" + }, + "notifications": { + "event_created": "Eveniment creat", + "event_updated": "Eveniment actualizat", + "event_deleted": "Eveniment șters", + "calendar_created": "Calendar creat", + "calendar_deleted": "Calendar șters", + "event_move_error": "Nu s-a putut muta evenimentul", + "event_resize_error": "Eșec la redimensionarea evenimentului", + "alert_title": "Eveniment viitor", + "alert_now": "Începând de acum", + "alert_in_minutes": "În {count} min", + "invitation_sent": "Invitații trimise", + "rsvp_updated": "Răspuns actualizat", + "rsvp_error": "Nu s-a putut actualiza răspunsul", + "event_duplicated": "Eveniment duplicat", + "event_error": "Nu s-a putut salva evenimentul", + "task_due": "Termen de finalizare a sarcinii", + "event_exported": "Eveniment exportat", + "title_copied": "Titlu copiat", + "link_copied": "Link copiat" + }, + "status": { + "loading_calendars": "Se încarcă calendarele...", + "loading_events": "Se încarcă evenimente..." + }, + "quick_create": { + "placeholder": "Titlul unui eveniment nou", + "aria_label": "Creare rapidă a unui eveniment" + }, + "nav_prev": "Anterior", + "nav_next": "Următorul", + "nav_open_menu": "Deschide meniul", + "import": { + "title": "Importare calendar", + "tab_file": "Fișier", + "tab_url": "URL", + "select_file": "Selectați fișierul .ics", + "drop_file": "sau trageți fișierul aici", + "supported_formats": "Suportă fișiere de tip „iCalendar” (.ics)", + "url_description": "Introduceți adresa URL a unui flux extern iCalendar (.ics) pentru a importa evenimente.", + "url_placeholder": "https://example.com/calendar.ics", + "url_hint": "Suportă adresele URL de tip CalDAV și iCalendar (.ics)", + "fetch": "Preluare", + "invalid_url": "Vă rugăm să introduceți o adresă de e-mail validă URL", + "url_fetch_failed": "Nu s-a putut prelua calendarul de la URL", + "parsing": "Se analizează fișierul de calendar...", + "parsed_events": "{count} evenimente găsite", + "no_events": "Nu s-au găsit evenimente în fișier", + "select_all": "Selectează tot", + "deselect_all": "Deselectați tot", + "target_calendar": "Import în calendar", + "import_button": "Importați elementele selectate", + "importing": "Se importă evenimente...", + "success": "{count} evenimentele au fost importate cu succes", + "error": "Importul calendarului a eșuat", + "file_too_large": "Fișierul depășește limita de 10 MB", + "invalid_format": "Format de fișier calendar invalid" + }, + "webcal_action": { + "title": "Deschideți linkul către calendar", + "description": "Cum ați dori să utilizați „{name}”?", + "import_title": "Importați o singură dată", + "import_description": "Preia evenimentele acum și copiază-le într-unul dintre calendarele tale.", + "subscribe_title": "Abonează-te", + "subscribe_description": "Sincronizați automat acest calendar ca un calendar separat.", + "cancel": "Anulează" + }, + "management": { + "title": "Gestionarea calendarului", + "description": "Creați, redenumiți și personalizați-vă calendarele. Faceți clic dreapta pe un calendar din bara laterală pentru a-i schimba rapid culoarea.", + "name": "Nume", + "name_placeholder": "Numele calendarului", + "color": "Culoare", + "change_color": "Schimbă culoarea", + "random_color": "Culoare aleatorie nouă", + "add_calendar": "Adăugați calendar", + "edit": "Editare", + "delete": "Șterge", + "save": "Salvare", + "create": "Creați", + "cancel": "Anulează", + "default": "Implicit", + "confirm_delete": "Doriți să ștergeți „{name}”? Toate evenimentele din acest calendar vor fi eliminate.", + "confirm_clear": "Ștergeți toate evenimentele din „{name}”? Această acțiune nu poate fi anulată.", + "set_default": "Setați ca implicită", + "default_updated": "Calendarul implicit a fost actualizat", + "error_default": "Nu s-a reușit setarea calendarului implicit", + "clear_events": "Ștergeți evenimentele", + "events_cleared": "{count} evenimente șterse", + "error_clear": "Nu s-au șters evenimentele din calendar", + "calendar_created": "Calendar creat", + "calendar_updated": "Calendar actualizat", + "calendar_deleted": "Calendar șters", + "color_updated": "Culoarea calendarului a fost actualizată", + "error_create": "Nu s-a putut crea calendarul", + "error_update": "Nu s-a putut actualiza calendarul", + "error_delete": "Nu s-a putut șterge calendarul", + "caldav_url": "CalDAV URL", + "copy_url": "Copiați CalDAV URL", + "url_copied": "CalDAV URL copiat în clipboard", + "share": "Partajare calendar", + "new_event_in_calendar": "Eveniment nou în acest calendar" + }, + "subscription": { + "title": "iCal Abonament", + "section_title": "iCal Abonamente", + "description": "Abonați-vă la un flux extern de tip iCalendar. Evenimentele vor fi sincronizate automat în propriul calendar. Suportă adresele URL de tip https:// și webcal://.", + "url_label": "URL de calendar", + "url_placeholder": "https://example.com/calendar.ics sau webcal://...", + "name_label": "Numele calendarului", + "name_placeholder": "de ex. Zile de sărbătoare legală", + "color_label": "Culoare", + "refresh_interval": "Interval de reîmprospătare", + "interval_15": "La fiecare 15 minute", + "interval_30": "La fiecare 30 de minute", + "interval_60": "La fiecare oră", + "interval_360": "La fiecare 6 ore", + "interval_1440": "În fiecare zi", + "subscribe": "Abonează-te", + "subscribing": "Abonare...", + "save": "Salvați modificările", + "saving": "Se salvează...", + "edit": "Editare", + "edit_title": "Editați abonamentul", + "updated": "Actualizare „{name}”", + "update_error": "Nu s-a putut actualiza abonamentul", + "invalid_url": "Vă rugăm să introduceți o adresă de e-mail validă URL", + "success": "Abonați la „{name}”", + "error": "Nu s-a putut adăuga abonamentul", + "refresh": "Reîmprospătează acum", + "refresh_success": "Abonamentul a fost actualizat", + "refresh_error": "Nu s-a reușit reîmprospătarea abonamentului", + "unsubscribe": "Dezabonare", + "confirm_delete": "Doriți să vă dezabonați de la „{name}”? Calendarul și toate evenimentele sale vor fi eliminate.", + "deleted": "Abonamentul a fost eliminat", + "delete_error": "Nu s-a putut anula abonamentul", + "last_refreshed": "Ultima actualizare: {time}" + }, + "tasks": { + "label": "Sarcini", + "no_tasks": "Nu există sarcini", + "no_title": "(Fără titlu)", + "mark_complete": "Marcați ca finalizat", + "mark_incomplete": "Marcați ca incomplet", + "filter_all": "Toate", + "filter_pending": "În așteptare", + "filter_completed": "Finalizat", + "filter_overdue": "Restant", + "show_completed": "Afișați cele finalizate", + "create": "Sarcină nouă", + "edit": "Editare sarcină", + "title_placeholder": "Titlul sarcinii", + "description_placeholder": "Adăugați o descriere...", + "due_date": "Termenul limită", + "include_time": "Includeți ora", + "priority": "Prioritate", + "priority_none": "Niciunul", + "priority_high": "Nivel ridicat", + "priority_medium": "Nivel mediu", + "priority_low": "Nivel scăzut", + "progress": "Stare", + "progress_needs_action": "Necesită acțiune", + "progress_in_process": "În curs de realizare", + "progress_completed": "Finalizat", + "progress_cancelled": "Anulat", + "calendar": "Calendar", + "alert": "Memento", + "alert_none": "Niciunul", + "alert_at_time": "La data scadenței", + "alert_5min": "Cu 5 minute înainte", + "alert_15min": "Cu 15 minute înainte", + "alert_30min": "Cu 30 de minute înainte", + "alert_1hr": "Cu o oră înainte", + "alert_1day": "Cu o zi înainte", + "delete": "Șterge", + "cancel": "Anulează", + "save": "Salvare", + "quick_add_placeholder": "Adăugați o sarcină...", + "due_today": "Astăzi", + "due_tomorrow": "Mâine", + "overdue": "Restant" + } + }, + "sharing": { + "title": "Distribuie „{name}”", + "description": "Acordați acces altor utilizatori sau grupuri de pe acest server. Modificările intră în vigoare imediat.", + "no_shares": "Nu a fost partajat încă cu nimeni.", + "add_person": "Adăugați o persoană sau un grup", + "search_placeholder": "Căutați după nume sau adresă de e-mail…", + "loading_principals": "Se încarcă utilizatorii…", + "no_principals": "Nu s-au găsit alți utilizatori sau grupuri.", + "no_match": "Nu s-au găsit rezultate.", + "remove": "Eliminați accesul", + "group": "Grup", + "share_added": "Acces acordat", + "share_updated": "Acces actualizat", + "share_removed": "Accesul a fost eliminat", + "share_failed": "Nu s-a putut actualiza partajarea", + "preset": { + "freeBusy": "Doar disponibilitate", + "read": "Doar citire", + "readWrite": "Citire și scriere", + "manager": "Manager", + "custom": "Personalizat" + } + }, + "advanced_search": { + "title": "Căutare avansată", + "from": "De la", + "from_placeholder": "Adresa de e-mail sau numele expeditorului", + "to": "Către", + "to_placeholder": "Adresa de e-mail sau numele destinatarului", + "subject": "Subiect", + "subject_placeholder": "Subiectul conține...", + "body": "Corpul mesajului", + "body_placeholder": "Corpul conține...", + "folder": "Dosar", + "all_folders": "Toate folderele", + "has_attachment": "Anexe", + "date_after": "După", + "date_before": "Înainte", + "starred": "Cu stea", + "unread": "Necitit", + "read": "Citiți", + "yes": "Da", + "no": "Nu", + "clear": "Ștergeți", + "clear_all": "Șterge tot", + "filters_active": "{count} filtru", + "filters_active_plural": "{count} filtre", + "toggle_filters": "Mai mult", + "search_hint": "Folosiți filtre avansate pentru o căutare precisă", + "advanced_filters_tooltip": "Filtre de căutare avansate", + "results_found": "{count, plural, =0 {Nu s-au găsit rezultate} one {# rezultat găsit} few {# rezultate găsite} other {# de rezultate găsite}}", + "results_found_more": "{count}+ rezultate găsite" + }, + "welcome": { + "title": "Bine ați venit în căsuța dvs. poștală", + "tip_compose": "Apăsați tasta c pentru a redacta un e-mail nou", + "tip_shortcuts": "Apăsați tasta „?” pentru a vedea toate comenzile rapide de la tastatură", + "tip_sidebar": "Găsiți Calendar, Contacte și Setări în meniul din bara laterală", + "tip_settings": "Personalizați-vă experiența în Setări", + "got_it": "Am înțeles", + "settings": "Setări", + "dismiss": "Ignoră", + "start_tour": "Începe turul" + }, + "demo_welcome": { + "title": "Bine ați venit la Bulwark Mail", + "description": "Explorați un client de e-mail web cu funcționalități complete – direct în browserul dvs. Toate datele rămân pe dispozitivul dvs., așa că nu ezitați să testați totul.", + "feature_email": "Citiți și redactați e-mailuri", + "feature_organize": "Etichete, stele și dosare", + "feature_shortcuts": "Comenzi rapide de la tastatură", + "feature_privacy": "Demo 100% privat", + "hint": "Faceți clic pe orice e-mail din partea stângă pentru a începe sau parcurgeți turul de mai jos." + }, + "files": { + "open_in_new_tab": "Deschide într-o filă nouă", + "title": "Fișiere", + "search_placeholder": "Căutare fișiere...", + "empty_state_title": "Nu există încă fișiere", + "empty_state_description": "Încărcați fișiere sau creați foldere pentru a începe", + "upload": "Încărcare", + "upload_files": "Încărcați fișiere", + "new_folder": "Dosar nou", + "new_folder_name": "Numele folderului", + "rename": "Redenumire", + "rename_title": "Redenumire", + "new_name": "Nume nou", + "delete": "Șterge", + "delete_confirm_title": "Ștergeți resursa", + "delete_confirm_message": "Sunteți sigur că doriți să ștergeți „{name}”? Această acțiune nu poate fi anulată.", + "download": "Descărcați", + "name": "Nume", + "size": "Dimensiune", + "modified": "Modificat", + "type": "Tastați", + "folder": "Dosar", + "file": "Fișier", + "parent_directory": "Directorul părinte", + "breadcrumb_root": "Acasă", + "other_accounts": "Alte conturi", + "no_accounts": "Nu există conturi conectate.", + "drop_files_here": "Trageți fișierele sau folderele aici pentru a le încărca", + "uploading": "Se încarcă...", + "upload_success": "{count, plural, one {1 fișier încărcat} few {# fișiere încărcate} other {# de fișiere încărcate}}", + "upload_error": "Nu s-a reușit încărcarea fișierului", + "create_folder_success": "Folder creat", + "create_folder_error": "Nu s-a putut crea folderul", + "delete_success": "Șters cu succes", + "delete_error": "Nu s-a putut șterge", + "rename_success": "Redenumit cu succes", + "rename_error": "Nu s-a putut redenumi", + "download_error": "Descărcarea a eșuat", + "not_available": "Stocarea fișierelor nu este disponibilă pe acest server", + "cancel": "Anulează", + "create": "Creați", + "save": "Salvare", + "no_results": "Nu există fișiere care să corespundă căutării dvs.", + "batch_delete_confirm_message": "Sunteți sigur că doriți să ștergeți {count, plural, one {1 element} few {# elemente} other {# de elemente}}? Această acțiune nu poate fi anulată.", + "batch_delete_success": "{count, plural, one {1 element șters} few {# elemente șterse} other {# de elemente șterse}}", + "grid_view": "Vizualizare tabelară", + "list_view": "Vizualizare listă", + "details": "Detalii", + "path": "Cale", + "preview": "Previzualizare", + "preview_error": "Nu s-a putut încărca previzualizarea", + "cut": "Tăiere", + "copy": "Copiere", + "paste": "Lipiți", + "move_success": "{count, plural, one {1 element mutat} few {# elemente mutate} other {# de elemente mutate}}", + "move_error": "Nu s-a putut muta", + "paste_success": "S-a lipit cu succes", + "paste_error": "Nu s-a putut insera", + "new_text_file": "Fișier text nou", + "file_name": "Numele fișierului", + "retry": "Reîncearcă", + "refresh": "Reîmprospătează", + "toggle_favorite": "Comută la favorite", + "duplicate": "Duplicare", + "duplicate_success": "Duplicat cu succes", + "duplicate_error": "Nu s-a putut duplica", + "share": "Distribuie", + "shared": "Partajat", + "shared_with_me": "Partajat cu mine", + "shared_by": "Distribuit de {name}", + "create_file_success": "Fișier creat", + "create_file_error": "Nu s-a putut crea fișierul", + "favorites": "Favorite", + "recent": "Recent", + "properties": "Proprietăți", + "open_folder": "Deschide folderul", + "upload_folder": "Încărcare folder", + "file_too_large": "„{name}” depășește dimensiunea maximă a fișierului ({max})", + "undo": "Anulează", + "undo_success": "Acțiune anulată", + "undo_error": "Nu s-a putut anula", + "toolbar": "Acțiuni asupra fișierelor", + "open_folder_tree": "Deschideți arborele de foldere", + "file_list": "Fișiere și foldere", + "context_menu": "Acțiuni", + "settings_title": "Setări fișier", + "settings_display": "Afișare", + "settings_default_view": "Vizualizare implicită", + "settings_default_view_desc": "Alegeți între afișarea sub formă de grilă și cea sub formă de listă", + "settings_default_sort": "Sortare implicită", + "settings_default_sort_desc": "Alegeți sortarea implicită pentru fișiere", + "settings_sort_direction": "Direcția de sortare", + "settings_sort_direction_desc": "Alegeți ordinea crescătoare sau descrescătoare", + "settings_ascending": "În ordine crescătoare", + "settings_descending": "Descendent", + "settings_icons": "Pictograme", + "settings_show_icons": "Afișați pictogramele fișierelor", + "settings_show_icons_desc": "Afișați pictograme lângă fișiere și foldere", + "settings_colored_icons": "Pictograme colorate", + "settings_colored_icons_desc": "Folosiți pictograme colorate în locul celor monocrome", + "settings_show_thumbnails": "Afișează miniaturi", + "settings_show_thumbnails_desc": "Afișați previzualizări ale imaginilor în locul pictogramelor pentru fișierele imagine", + "settings_behavior": "Comportament", + "settings_show_hidden": "Afișează fișierele ascunse", + "settings_show_hidden_desc": "Afișați fișierele și folderele care încep cu un punct", + "settings_folder_layout": "Navigarea în dosare", + "settings_folder_layout_desc": "Alegeți modul de afișare a folderelor: alături de fișiere sau într-o structură arborescentă din bara laterală", + "settings_folder_layout_inline": "În text", + "settings_folder_layout_sidebar": "Bara laterală", + "disabled_title": "Funcția de fișiere este dezactivată de administratorul dvs.", + "disabled_description": "Încărcarea fișierelor de dimensiuni mari prin intermediul WebDAV poate provoca instabilitate în Stalwart /RocksDB, inclusiv blocări din cauza epuizării memoriei și utilizare irecuperabilă a spațiului pe disc. Este posibil ca fișierele șterse să nu fie eliminate imediat din spațiul de stocare blob. Această funcție nu este recomandată pentru mediile de producție.", + "stability_warning": "Încărcarea fișierelor de dimensiuni mari poate provoca instabilitatea serverului. Este posibil ca fișierele șterse să nu fie eliminate imediat din spațiul de stocare. Utilizați cu precauție.", + "migration_title": "Se actualizează fișierele...", + "migration_description": "Organizarea dosarelor și a fișierelor în structura corespunzătoare. Această operațiune se efectuează o singură dată." + }, + "smime": { + "your_certificates": "Certificatele dvs.", + "your_certificates_desc": "Importați și gestionați certificatele S/MIME pentru semnarea și criptarea e-mailurilor", + "recipient_certificates": "Certificate ale destinatarilor", + "recipient_certificates_desc": "Certificate publice pentru criptarea e-mailurilor către destinatari", + "identity_bindings": "Combinații de taste pentru identitate", + "identity_bindings_desc": "Asociați certificatelS/MIMEe identităților dvs. de e-mail", + "defaults_title": "Setări implicite", + "defaults_desc": "Configurați comportamentul implicit de semnare și criptare", + "import_pkcs12": "Importați fișiere PKCS#12 (.p12/.pfx)", + "import_public_cert": "Importați certificatul", + "no_certificates": "Nu s-au importat încă certificate", + "no_recipient_certs": "Fără certificate ale destinatarilor", + "expires": "Expiră", + "expired": "Expirat", + "bound_to": "Legat de", + "no_key_bound": "Niciunul", + "lock": "Tasta de blocare", + "unlock": "Cheia de deblocare", + "details": "Vedeți detalii", + "delete": "Șterge", + "encrypt_by_default": "Criptați în mod implicit", + "encrypt_by_default_desc": "Criptați automat e-mailurile atunci când toți destinatarii dețin certificate", + "remember_unlocked": "Rețineți cheile deblocate", + "remember_unlocked_desc": "Păstrați tastele deblocate pe durata acestei sesiuni de navigare", + "sign_default_for": "Semnați în mod implicit pentru", + "enter_p12_passphrase": "Introduceți fraza de acces PKCS # 12", + "p12_passphrase_desc": "Introduceți fraza de acces care protejează acest fișier de certificat", + "enter_storage_passphrase": "Setați fraza de acces pentru stocare", + "storage_passphrase_desc": "Alegeți o frază de acces pentru a proteja această cheie stocată în browserul dvs.", + "next": "Următorul", + "import": "Import", + "unlock_key": "Cheia de deblocare", + "unlock_key_desc": "Introduceți fraza de acces la spațiul de stocare pentru a debloca această cheie în vederea semnării sau decriptării", + "passphrase_placeholder": "Introduceți fraza de acces", + "confirm_passphrase_placeholder": "Confirmă fraza de acces", + "passphrase_mismatch": "Frazele de acces nu se potrivesc", + "cancel": "Anulează", + "processing": "Se procesează…", + "close": "Închide", + "certificate_details": "Detalii certificat", + "cert_subject": "Subiect", + "cert_issuer": "Emitent", + "cert_email": "E-mail", + "cert_serial": "Număr de serie", + "cert_validity": "Valabilitate", + "cert_fingerprint": "Amprentă digitală (SHA-256)", + "cert_algorithm": "Algoritm", + "cert_capabilities": "Capacități", + "cert_source": "Sursă", + "cert_expired": "Acest certificat a expirat", + "cert_not_yet_valid": "Acest certificat nu este încă valabil", + "cap_sign": "Semnarea", + "cap_encrypt": "Criptare", + "cap_none": "Niciunul", + "show_passphrase": "Afișează fraza de acces", + "hide_passphrase": "Ascunde fraza de acces", + "sign_toggle": "Semn", + "encrypt_toggle": "Criptați", + "missing_recipient_certs": "Certificate lipsă pentru: {emails}", + "missing_sender_cert": "Nu există niciun certificat asociat acestei identități", + "status_encrypted_ok": "Acest mesaj a fost criptat", + "status_encrypted_no_key": "Acest mesaj este criptat, dar nu s-a găsit nicio cheie corespunzătoare", + "status_encrypted_failed": "Nu s-a reușit decriptarea acestui mesaj", + "status_signed_valid": "Semnătură verificată", + "status_signed_invalid": "Verificarea semnăturii a eșuat", + "status_signed_expired_cert": "Semnat cu un certificat expirat", + "status_signed_self_signed": "Semnătura este validă, dar certificatul este autosemnat (nu este de încredere)", + "status_signed_mismatch": "Semnătura este validă, dar semnatarul nu corespunde cu expeditorul", + "status_unsupported": "Format „S/MIME” neacceptat", + "auto_import_signer_certs": "Import automat al certificatelor semnatarilor", + "auto_import_signer_certs_desc": "Salvați automat certificatele din e-mailurile semnate și verificate pentru criptarea ulterioară", + "export": "Export", + "enter_export_passphrase": "Setați fraza de acces pentru export", + "export_passphrase_desc": "Alegeți o frază de acces pentru a proteja fișierul PKCS # 12 exportat", + "export_storage_desc": "Introduceți fraza de acces la spațiul de stocare pentru a decripta cheia în vederea exportului", + "incorrect_passphrase": "Parolă incorectă" + }, + "tour": { + "step_counter": "Pasul {current} din {total}", + "skip": "Sari peste turul de prezentare", + "back": "Înapoi", + "next": "Următorul", + "finish": "Finalizare", + "take_a_tour": "Faceți un tur al interfeței", + "restart_title": "Tur de prezentare", + "restart_desc": "Vizionați din nou prezentarea ghidată a interfeței", + "restart_button": "Reîncepe turul", + "show_on_new_devices_title": "Afișare pe dispozitive noi", + "show_on_new_devices_desc": "Redați din nou bannerul de bun venit și turul de prezentare la prima conectare pe un dispozitiv nou, chiar dacă le-ați parcurs deja în altă parte", + "sidebar_title": "Căsuțele dvs. de e-mail", + "sidebar_desc": "Aceasta este bara laterală cu dosare. Faceți clic pe orice căsuță poștală pentru a vizualiza e-mailurile din aceasta. Puteți crea dosare, muta e-mailuri între ele și vedeți dintr-o privire numărul de mesaje necitite.", + "compose_title": "Redactați un e-mail", + "compose_desc": "Faceți clic aici pentru a scrie un e-mail nou. Puteți adăuga destinatari, atașamente și puteți utiliza formatarea textului îmbogățit.", + "search_title": "Căutați în e-mailurile dvs.", + "search_desc": "Căutați după expeditor, subiect sau conținut. Faceți clic pe pictograma de filtrare pentru opțiuni avansate, cum ar fi intervalul de date, atașamentele și mesajele marcate cu stea.", + "email_list_title": "Lista dvs. de e-mailuri", + "email_list_desc": "Aici apar e-mailurile. Faceți clic pe unul pentru a-l citi în partea dreaptă. Utilizați caseta de selectare pentru a selecta mai multe e-mailuri, apoi mutați-le, ștergeți-le sau etichetați-le în bloc.", + "email_viewer_title": "Panoul de citire", + "email_viewer_desc": "E-mailul selectat se deschide aici. Răspundeți, redirecționați, arhivați sau ștergeți folosind butoanele din bara de instrumente. De asemenea, puteți marca e-mailurile cu stea sau adăuga etichete colorate.", + "keywords_title": "Etichete colorate", + "keywords_desc": "Organizați-vă e-mailurile cu etichete codificate pe culori. Trageți un e-mail peste o etichetă pentru a-l eticheta sau faceți clic dreapta pe un e-mail pentru a-i atribui etichete.", + "calendar_title": "Calendar", + "calendar_desc": "Treceți la calendar pentru a vă gestiona evenimentele. Creați evenimente, setați mementouri și vizualizați vizualizările pe zi, săptămână sau lună.", + "contacts_title": "Contacte", + "contacts_desc": "Aici se află agenda dvs. de adrese. Importați contacte, creați grupuri și faceți clic pe orice contact pentru a vedea detaliile complete ale acestuia.", + "settings_title": "Setări", + "settings_desc": "Personalizați totul: tema, densitatea, semnăturile, filtrele, comenzile rapide de la tastatură, setările implicite ale calendarului și multe altele.", + "shortcuts_title": "Comenzi rapide de la tastatură", + "shortcuts_desc": "Utilizatorii avansați adoră această funcție. Apăsați tasta „?” oricând pentru a vedea toate comenzile rapide disponibile. Puteți naviga, redacta și gestiona e-mailurile fără a atinge mouse-ul.", + "compose_open_title": "Fereastra de redactare", + "compose_open_desc": "Aceasta este fereastra de redactare a e-mailurilor. Adăugați destinatari, scrieți mesajul, atașați fișiere și folosiți formatarea de text îmbogățit. De asemenea, puteți salva ciorne și utiliza șabloane.", + "calendar_view_title": "Calendarul dvs.", + "calendar_view_desc": "Iată calendarul dvs. cu exemple de evenimente. Puteți comuta între vizualizările de zi, săptămână, lună și agendă folosind bara de instrumente.", + "create_event_title": "Creați un eveniment", + "create_event_desc": "Faceți clic pe acest buton pentru a crea un eveniment nou în calendar. Puteți seta un titlu, data, ora și puteți adăuga participanți.", + "event_modal_title": "Detalii despre eveniment", + "event_modal_desc": "Iată formularul evenimentului. Completați titlul, alegeți o dată și o oră, adăugați o locație sau participanți. Apăsați „Salvați” când ați terminat – sau închideți-l și continuați.", + "contacts_list_title": "Contactele tale", + "contacts_list_desc": "Iată contactele dvs. Faceți clic pe orice contact pentru a vedea detaliile complete ale acestuia în partea dreaptă. De asemenea, puteți crea contacte noi, importa vCard-uri sau organiza contactele în grupuri.", + "settings_tabs_title": "Meniul Setări", + "settings_tabs_desc": "Iată toate categoriile de setări. Personalizați aspectul, gestionați identitățile, configurați filtrele de e-mail, configurați calendarul și multe altele.", + "files_title": "Stocarea fișierelor", + "files_desc": "Browserul de fișiere vă permite să încărcați, să organizați și să partajați fișiere – ca un spațiu de stocare personal în cloud integrat în serviciul de e-mail.", + "demo_banner_title": "Controale demo", + "demo_banner_desc": "Vă aflați în modul demo – totul rămâne în browserul dvs. Apăsați „Resetare demo” oricând pentru a începe de la zero cu date de exemplu curate.", + "quota_title": "Utilizarea spațiului de stocare", + "quota_desc": "Urmăriți aici dimensiunea căsuței dvs. de e-mail. Cercul se umple pe măsură ce utilizați mai mult spațiu." + }, + "unified_mailbox": { + "search_unavailable": "Funcția de căutare nu este disponibilă în vizualizarea unificată" + }, + "quote_header": { + "reply_line": "Pe {date}, {from} a scris:", + "forwarded_separator": "---------- Mesaj redirecționat ----------", + "from_label": "De la", + "date_label": "Data", + "subject_label": "Subiect" + }, + "pwa_install": { + "title": "Instalați {appName}", + "description": "Instalați aplicația noastră pentru acces rapid și asistență offline.", + "not_now": "Nu acum", + "install": "Instalați", + "dont_remind": "Nu-mi mai reaminti", + "dismiss_aria": "Ignorați solicitarea de instalare" + } +} From 2fac6ebfb890795104e3ac7c65a9b8110379c770 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:13:31 +0200 Subject: [PATCH 123/437] test: add characterisation tests for untested integration seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golden-master tests pinning the CURRENT behavior of high-value modules that had no coverage — integration seams, security helpers, two API route handlers, and complex pure utils. 111 tests across 12 files. New tests: - auth-crypto / session-cookie: AES-256-GCM session encryption roundtrip, tamper/version/missing-secret handling; cookie-slot naming. - unified-mailbox: multi-account fan-out, sort, totals, per-account error isolation, personal-vs-shared JMAP target resolution, counts/roles. - account-state-manager: snapshot/restore across the six real Zustand stores; clearAllStores reset shape; evict. - mdn: RFC 5322 MDN assembly (CRLF, RFC2047, base64 wrap, headers). - tnef: winmail.dat binary parsing from hand-built fixtures. - download-filename / subject-prefix / birthday-calendar / eml-import: filename templating, multilingual prefix stripping, birthday event generation, .eml/.zip import. - webdav / caldav-discover route handlers: auth guards, path validation, upstream URL construction, candidate probing. - helpers/factories.ts: shared makeEmail/makeMailbox/makeFakeJmapClient. Tests follow the repo's existing patterns (route-import, fake IJMAPClient, fetch spy, real store singletons). Where current behavior looks buggy it is pinned and flagged with a // CHARACTERISATION: comment (see PR for the suspected-bugs list); no production code is changed. --- lib/__tests__/account-state-manager.test.ts | 128 ++++++++++++ lib/__tests__/auth-crypto.test.ts | 95 +++++++++ lib/__tests__/birthday-calendar.test.ts | 90 +++++++++ lib/__tests__/caldav-discover-route.test.ts | 85 ++++++++ lib/__tests__/download-filename.test.ts | 127 ++++++++++++ lib/__tests__/eml-import.test.ts | 51 +++++ lib/__tests__/helpers/factories.ts | 34 ++++ lib/__tests__/mdn.test.ts | 95 +++++++++ lib/__tests__/session-cookie.test.ts | 22 +++ lib/__tests__/subject-prefix.test.ts | 67 +++++++ lib/__tests__/tnef.test.ts | Bin 0 -> 4311 bytes lib/__tests__/unified-mailbox.test.ts | 205 ++++++++++++++++++++ lib/__tests__/webdav-route.test.ts | 133 +++++++++++++ 13 files changed, 1132 insertions(+) create mode 100644 lib/__tests__/account-state-manager.test.ts create mode 100644 lib/__tests__/auth-crypto.test.ts create mode 100644 lib/__tests__/birthday-calendar.test.ts create mode 100644 lib/__tests__/caldav-discover-route.test.ts create mode 100644 lib/__tests__/download-filename.test.ts create mode 100644 lib/__tests__/eml-import.test.ts create mode 100644 lib/__tests__/helpers/factories.ts create mode 100644 lib/__tests__/mdn.test.ts create mode 100644 lib/__tests__/session-cookie.test.ts create mode 100644 lib/__tests__/subject-prefix.test.ts create mode 100644 lib/__tests__/tnef.test.ts create mode 100644 lib/__tests__/unified-mailbox.test.ts create mode 100644 lib/__tests__/webdav-route.test.ts diff --git a/lib/__tests__/account-state-manager.test.ts b/lib/__tests__/account-state-manager.test.ts new file mode 100644 index 000000000..658fe2c59 --- /dev/null +++ b/lib/__tests__/account-state-manager.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + snapshotAccount, + restoreAccount, + clearAllStores, + evictAccount, + evictAll, +} from '@/lib/account-state-manager'; +import { useEmailStore } from '@/stores/email-store'; +import { useContactStore } from '@/stores/contact-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useFilterStore } from '@/stores/filter-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import { useVacationStore } from '@/stores/vacation-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { makeEmail, makeMailbox } from './helpers/factories'; + +// The store singletons and the module-level snapshot cache persist across +// tests; reset both around every test. +beforeEach(() => evictAll()); +afterEach(() => evictAll()); + +describe('snapshotAccount / restoreAccount', () => { + it('round-trips the captured fields across all six stores', () => { + useEmailStore.setState({ + emails: [makeEmail({ id: 'a1' })], + mailboxes: [makeMailbox({ id: 'a-in' })], + selectedMailbox: 'a-in', + searchQuery: 'queryA', + quota: { used: 1, total: 2 }, + }); + useContactStore.setState({ supportsSync: true }); + useCalendarStore.setState({ viewMode: 'week', supportsCalendar: true }); + useFilterStore.setState({ isSupported: true }); + useIdentityStore.setState({ preferredPrimaryId: 'idA' }); + useVacationStore.setState({ isEnabled: true }); + + snapshotAccount('A'); + + // Mutate everything to "account B" values. + useEmailStore.setState({ emails: [], selectedMailbox: 'b-in', searchQuery: 'queryB', quota: null }); + useContactStore.setState({ supportsSync: false }); + useCalendarStore.setState({ viewMode: 'month', supportsCalendar: false }); + useFilterStore.setState({ isSupported: false }); + useIdentityStore.setState({ preferredPrimaryId: 'idB' }); + useVacationStore.setState({ isEnabled: false }); + + expect(restoreAccount('A')).toBe(true); + + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['a1']); + expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); + expect(useEmailStore.getState().searchQuery).toBe('queryA'); + expect(useEmailStore.getState().quota).toEqual({ used: 1, total: 2 }); + expect(useContactStore.getState().supportsSync).toBe(true); + expect(useCalendarStore.getState().viewMode).toBe('week'); + expect(useFilterStore.getState().isSupported).toBe(true); + expect(useIdentityStore.getState().preferredPrimaryId).toBe('idA'); + expect(useVacationStore.getState().isEnabled).toBe(true); + }); + + it('CHARACTERISATION: only snapshotted fields are restored; others survive', () => { + // isLoading is NOT part of the email snapshot subset. + useEmailStore.setState({ selectedMailbox: 'a-in', isLoading: false }); + snapshotAccount('A'); + useEmailStore.setState({ selectedMailbox: 'b-in', isLoading: true }); + + restoreAccount('A'); + + expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); // restored + expect(useEmailStore.getState().isLoading).toBe(true); // NOT restored (merge) + }); + + it('CHARACTERISATION: snapshot stores array references, not deep clones', () => { + const arr = [makeEmail({ id: '1' })]; + useEmailStore.setState({ emails: arr }); + snapshotAccount('A'); + arr.push(makeEmail({ id: '2' })); // mutate the same array after snapshot + useEmailStore.setState({ emails: [] }); + + restoreAccount('A'); + // The post-snapshot mutation leaked into the snapshot. + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['1', '2']); + }); + + it('returns false and leaves stores untouched for an unknown account', () => { + useEmailStore.setState({ searchQuery: 'keep' }); + expect(restoreAccount('nope')).toBe(false); + expect(useEmailStore.getState().searchQuery).toBe('keep'); + }); +}); + +describe('clearAllStores', () => { + it('resets the email store to fresh empty collections', () => { + useEmailStore.setState({ + emails: [makeEmail({ id: 'x' })], + selectedEmailIds: new Set(['x']), + searchQuery: 'q', + tagCounts: { a: { total: 1, unread: 0 } }, + threadEmailsCache: new Map([['t', []]]), + }); + + clearAllStores(); + + const s = useEmailStore.getState(); + expect(s.emails).toEqual([]); + expect(s.searchQuery).toBe(''); + expect(s.selectedEmailIds.size).toBe(0); + expect(s.threadEmailsCache.size).toBe(0); + expect(s.tagCounts).toEqual({}); + expect(s.searchFilters).toEqual(DEFAULT_SEARCH_FILTERS); + }); +}); + +describe('evictAccount / evictAll', () => { + it('evictAccount drops a single snapshot', () => { + snapshotAccount('A'); + evictAccount('A'); + expect(restoreAccount('A')).toBe(false); + }); + + it('evictAll drops every snapshot', () => { + snapshotAccount('B'); + snapshotAccount('C'); + evictAll(); + expect(restoreAccount('B')).toBe(false); + expect(restoreAccount('C')).toBe(false); + }); +}); diff --git a/lib/__tests__/auth-crypto.test.ts b/lib/__tests__/auth-crypto.test.ts new file mode 100644 index 000000000..133aac23a --- /dev/null +++ b/lib/__tests__/auth-crypto.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + encryptSession, + decryptSession, + encryptPayload, + decryptPayload, +} from '@/lib/auth/crypto'; + +// crypto.ts derives its key solely from getSessionSecret(); mock that one seam +// so we control the secret without touching configManager / env-file lookups. +const { secretRef } = vi.hoisted(() => ({ secretRef: { value: 'x'.repeat(32) } })); +vi.mock('@/lib/auth/session-secret', () => ({ + getSessionSecret: () => secretRef.value, + hasSessionSecret: () => secretRef.value.length > 0, +})); +vi.mock('@/lib/logger', () => ({ + logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} }, +})); + +const SECRET = 'x'.repeat(32); + +beforeEach(() => { + secretRef.value = SECRET; +}); + +describe('encryptSession / decryptSession', () => { + it('round-trips a session', () => { + const token = encryptSession('https://mail.example.com', 'alice', 's3cret'); + expect(decryptSession(token)).toEqual({ + serverUrl: 'https://mail.example.com', + username: 'alice', + password: 's3cret', + }); + }); + + it('produces a base64 token with a random IV (two encrypts differ, both decrypt equal)', () => { + const a = encryptSession('https://x', 'u', 'p'); + const b = encryptSession('https://x', 'u', 'p'); + expect(a).not.toBe(b); + expect(Buffer.from(a, 'base64').toString('base64')).toBe(a); // valid base64 + expect(decryptSession(a)).toEqual(decryptSession(b)); + }); + + it('returns null (not throw) on a tampered auth tag', () => { + const token = encryptSession('https://x', 'u', 'p'); + const buf = Buffer.from(token, 'base64'); + buf[13] ^= 0xff; // flip a byte inside the GCM tag region (bytes 12..28) + expect(decryptSession(buf.toString('base64'))).toBeNull(); + }); + + it('returns null on a token shorter than IV+TAG', () => { + expect(decryptSession(Buffer.alloc(10).toString('base64'))).toBeNull(); + }); + + it('returns null when the version is not 1', () => { + const token = encryptPayload({ v: 2, serverUrl: 'https://x', username: 'u', password: 'p' }); + expect(decryptSession(token)).toBeNull(); + }); + + it('returns null when a required field is missing', () => { + const token = encryptPayload({ v: 1, serverUrl: 'https://x', username: 'u' }); + expect(decryptSession(token)).toBeNull(); + }); + + it('throws when no secret is configured', () => { + secretRef.value = ''; + expect(() => encryptSession('https://x', 'u', 'p')).toThrow('SESSION_SECRET not configured'); + }); + + it('throws when the secret is shorter than 32 characters', () => { + secretRef.value = 'tooshort'; + expect(() => encryptSession('https://x', 'u', 'p')).toThrow(/at least 32 characters/); + }); +}); + +describe('encryptPayload / decryptPayload', () => { + it('round-trips an arbitrary object', () => { + const token = encryptPayload({ a: 1, b: 'two', c: { nested: true } }); + expect(decryptPayload(token)).toEqual({ a: 1, b: 'two', c: { nested: true } }); + }); + + it('does NOT enforce the version/field guard that decryptSession applies', () => { + // CHARACTERISATION: decryptPayload returns whatever JSON parsed, with no + // v===1 / required-field validation (unlike decryptSession). + const token = encryptPayload({ v: 2, anything: 'goes' }); + expect(decryptPayload(token)).toEqual({ v: 2, anything: 'goes' }); + }); + + it('returns null on a tampered token', () => { + const token = encryptPayload({ a: 1 }); + const buf = Buffer.from(token, 'base64'); + buf[20] ^= 0xff; + expect(decryptPayload(buf.toString('base64'))).toBeNull(); + }); +}); diff --git a/lib/__tests__/birthday-calendar.test.ts b/lib/__tests__/birthday-calendar.test.ts new file mode 100644 index 000000000..cc14ad47e --- /dev/null +++ b/lib/__tests__/birthday-calendar.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import type { ContactCard } from '@/lib/jmap/types'; +import { + createBirthdayCalendar, + generateBirthdayEvents, + BIRTHDAY_CALENDAR_ID, + BIRTHDAY_CALENDAR_COLOR, +} from '@/lib/birthday-calendar'; + +const contact = (over: Record = {}): ContactCard => + ({ + id: 'c1', + '@type': 'Card', + name: { full: 'Alice Smith' }, + anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '1990-05-15' } }, + ...over, + } as unknown as ContactCard); + +describe('createBirthdayCalendar', () => { + it('returns the virtual calendar with defaults', () => { + const cal = createBirthdayCalendar(); + expect(cal).toMatchObject({ + id: BIRTHDAY_CALENDAR_ID, + name: 'Birthdays', + color: BIRTHDAY_CALENDAR_COLOR, + isSubscribed: true, + myRights: { mayReadItems: true, mayWriteAll: false, mayDelete: false }, + }); + }); + + it('honours name/color overrides', () => { + expect(createBirthdayCalendar('My BDays', '#fff')).toMatchObject({ name: 'My BDays', color: '#fff' }); + }); +}); + +describe('generateBirthdayEvents', () => { + it('emits one event per year in range, with age and stable ids', () => { + const events = generateBirthdayEvents([contact()], '2020-01-01', '2022-12-31'); + expect(events.map((e) => e.id)).toEqual([ + 'birthday-c1-b1-2020', + 'birthday-c1-b1-2021', + 'birthday-c1-b1-2022', + ]); + expect(events[0]).toMatchObject({ + uid: 'birthday-c1-b1', + title: '🎂 Alice Smith (30)', + start: '2020-05-15T00:00:00', + calendarIds: { [BIRTHDAY_CALENDAR_ID]: true }, + showWithoutTime: true, + }); + }); + + it('omits the age when the birthday has no year (partial date)', () => { + const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '--05-15' } } }); + const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31'); + expect(events).toHaveLength(1); + expect(events[0].title).toBe('🎂 Alice Smith'); + }); + + it('parses a Timestamp anniversary date', () => { + const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: { '@type': 'Timestamp', utc: '1985-03-10T00:00:00Z' } } } }); + const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31'); + expect(events[0].start).toBe('2021-03-10T00:00:00'); + }); + + it('parses a PartialDate anniversary date', () => { + const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: { month: 7, day: 4 } } } }); + const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31'); + expect(events[0].start).toBe('2021-07-04T00:00:00'); + }); + + it('clamps Feb 29 to Feb 28 in a non-leap year', () => { + const c = contact({ anniversaries: { b1: { '@type': 'Anniversary', kind: 'birth', date: '2000-02-29' } } }); + const events = generateBirthdayEvents([c], '2021-01-01', '2021-12-31'); + expect(events[0].start).toBe('2021-02-28T00:00:00'); + }); + + it('excludes occurrences outside the range', () => { + expect(generateBirthdayEvents([contact()], '2021-01-01', '2021-02-28')).toEqual([]); // May birthday + }); + + it('returns [] for an invalid range', () => { + expect(generateBirthdayEvents([contact()], 'not-a-date', '2021-12-31')).toEqual([]); + }); + + it('skips non-birth anniversaries', () => { + const c = contact({ anniversaries: { w1: { '@type': 'Anniversary', kind: 'wedding', date: '2010-06-01' } } }); + expect(generateBirthdayEvents([c], '2021-01-01', '2021-12-31')).toEqual([]); + }); +}); diff --git a/lib/__tests__/caldav-discover-route.test.ts b/lib/__tests__/caldav-discover-route.test.ts new file mode 100644 index 000000000..488af11e2 --- /dev/null +++ b/lib/__tests__/caldav-discover-route.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +vi.mock('next/server', () => ({ + NextResponse: { + json: (data: unknown, init?: { status?: number }) => ({ status: init?.status ?? 200, json: async () => data }), + }, + NextRequest: class {}, +})); +vi.mock('@/lib/logger', () => ({ logger: { warn: () => {}, error: () => {} } })); +vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() })); + +import { POST } from '@/app/api/caldav/discover/route'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; + +const mockCreds = getStalwartCredentials as unknown as Mock; +const CREDS = { serverUrl: 'https://mail.example.com', username: 'u', authHeader: 'Basic abc' }; + +let fetchSpy: Mock; + +function makeReq(body: unknown): Parameters[0] { + return { headers: { get: () => null }, json: async () => body } as unknown as Parameters[0]; +} +function read(res: unknown) { + return res as { status: number; json: () => Promise<{ wellKnownUrl: string; accounts: Record }> }; +} +const target = (c: string) => `https://mail.example.com/dav/cal/${c}`; + +beforeEach(() => { + mockCreds.mockResolvedValue(CREDS); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('POST /api/caldav/discover', () => { + it('401 without credentials', async () => { + mockCreds.mockResolvedValue(null); + const res = read(await POST(makeReq({ accounts: [] }))); + expect(res.status).toBe(401); + }); + + it('returns the .well-known url and resolves the first 207 candidate, skipping the rest', async () => { + fetchSpy.mockResolvedValue({ status: 207, headers: new Headers() }); + const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] }))); + const data = await res.json(); + + expect(data.wellKnownUrl).toBe('https://mail.example.com/.well-known/caldav'); + expect(data.accounts.A).toEqual({ url: target('c1'), resolvedAccount: 'c1' }); + expect(fetchSpy).toHaveBeenCalledTimes(1); // c2 never probed + expect(fetchSpy).toHaveBeenCalledWith(target('c1'), expect.objectContaining({ method: 'PROPFIND' })); + }); + + it('resolves a redirect Location relative to the probe URL', async () => { + fetchSpy.mockResolvedValue({ status: 302, headers: new Headers({ Location: '/dav/cal/real-home' }) }); + const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1'] }] }))); + const data = await res.json(); + expect(data.accounts.A).toEqual({ url: 'https://mail.example.com/dav/cal/real-home', resolvedAccount: 'c1' }); + }); + + it('returns null url when every candidate fails, but still 200', async () => { + fetchSpy.mockResolvedValue({ status: 404, headers: new Headers() }); + const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] }))); + expect(res.status).toBe(200); + expect((await res.json()).accounts.A).toEqual({ url: null, resolvedAccount: null }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('catches a probe error and continues to the next candidate', async () => { + fetchSpy + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ status: 207, headers: new Headers() }); + const res = read(await POST(makeReq({ accounts: [{ key: 'A', candidates: ['c1', 'c2'] }] }))); + expect((await res.json()).accounts.A).toEqual({ url: target('c2'), resolvedAccount: 'c2' }); + }); + + it('de-duplicates and trims candidates before probing', async () => { + fetchSpy.mockResolvedValue({ status: 404, headers: new Headers() }); + await POST(makeReq({ accounts: [{ key: 'A', candidates: [' c1 ', 'c1', '', 'c1'] }] })); + expect(fetchSpy).toHaveBeenCalledTimes(1); // collapsed to a single "c1" + expect(fetchSpy).toHaveBeenCalledWith(target('c1'), expect.anything()); + }); +}); diff --git a/lib/__tests__/download-filename.test.ts b/lib/__tests__/download-filename.test.ts new file mode 100644 index 000000000..455483c99 --- /dev/null +++ b/lib/__tests__/download-filename.test.ts @@ -0,0 +1,127 @@ +// Pin TZ so the local-time date rendering in dateParts is deterministic. +process.env.TZ = 'UTC'; + +import { describe, it, expect } from 'vitest'; +import type { Email } from '@/lib/jmap/types'; +import { + emailExportFilename, + attachmentDownloadFilename, + bundleExportFilename, + emailVars, + attachmentVars, + buildSampleEmail, +} from '@/lib/download-filename'; + +const makeEmail = (over: Partial): Email => + ({ id: 'e', receivedAt: '2026-05-22T14:05:33Z', from: [], to: [], subject: '', ...over } as unknown as Email); + +describe('emailExportFilename', () => { + it('renders the default template from the sample email (UTC)', () => { + expect(emailExportFilename(buildSampleEmail())).toBe( + '2026-05-22 14.05.33 (Alice Sender-Bob Recipient) Benachrichtigung von Ihrem Gerät.eml', + ); + }); + + it('applies lowercase + stripDiacritics + underscore-spaces transforms', () => { + expect( + emailExportFilename(buildSampleEmail(), { + template: '{from_name}-{subject}', + lowercase: true, + stripDiacritics: true, + spaceReplacement: 'underscore', + }), + ).toBe('alice_sender-benachrichtigung_von_ihrem_gerat.eml'); + }); + + it('falls back to "no subject" for an empty subject', () => { + expect(emailExportFilename(makeEmail({ subject: '' }), '{subject}')).toBe('no subject.eml'); + }); + + it('falls back to "email" when the template renders empty', () => { + expect(emailExportFilename(makeEmail({}), '{unknown_token}')).toBe('email.eml'); + }); + + it('CHARACTERISATION: per-token sanitise caps each value at 80 chars', () => { + // sanitizePart defaults to maxLen=80, applied per {token} during render — + // so a single long {subject} is truncated to 80 well before the 200 cap. + const out = emailExportFilename(makeEmail({ subject: 'a'.repeat(300) }), '{subject}'); + expect(out).toBe('a'.repeat(80) + '.eml'); + }); +}); + +describe('attachmentDownloadFilename', () => { + it('email===null: sanitises the raw attachment name, applying transforms', () => { + expect(attachmentDownloadFilename(null, { name: 'Report.PDF' })).toBe('Report.PDF'); + expect(attachmentDownloadFilename(null, { name: 'Report.PDF' }, { lowercase: true })).toBe('report.pdf'); + }); + + it('{filename} token preserves the original extension', () => { + expect( + attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.pdf' }, '{filename}'), + ).toBe('My Report.pdf'); + }); + + it('template without {ext}/{filename} appends the attachment extension', () => { + expect( + attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.PDF' }, '{name}'), + ).toBe('My Report.PDF'); + expect( + attachmentDownloadFilename(buildSampleEmail(), { name: 'My Report.PDF' }, { template: '{name}', lowercase: true }), + ).toBe('my report.pdf'); + }); + + it('attachment with no extension yields no trailing dot', () => { + expect(attachmentDownloadFilename(buildSampleEmail(), { name: 'noext' }, '{name}')).toBe('noext'); + }); + + it('sanitises path-traversal characters out of the name', () => { + const out = attachmentDownloadFilename(null, { name: '../../etc/passwd' }); + expect(out).not.toContain('/'); + // CHARACTERISATION: slashes → "_", then the leading "._-" run is stripped, + // so "../../etc/passwd" collapses to "etc_passwd". + expect(out).toBe('etc_passwd'); + }); +}); + +describe('bundleExportFilename', () => { + it('substitutes {count} and appends .zip', () => { + expect(bundleExportFilename(3, '{count}-emails', '2026-05-22T14:05:33Z')).toBe('3-emails.zip'); + }); + + it('uses the default template', () => { + expect(bundleExportFilename(5, {}, '2026-05-22T14:05:33Z')).toBe('emails-5.zip'); + }); +}); + +describe('emailVars (date + address labels)', () => { + it('returns the invalid-date sentinel for an unparseable date', () => { + const v = emailVars(makeEmail({ receivedAt: 'not-a-date', sentAt: undefined })); + expect(v.date).toBe('0000-00-00 00.00.00'); + expect(v.date_short).toBe('0000-00-00'); + expect(v.time).toBe('00.00.00'); + expect(v.year).toBe('0000'); + }); + + it('addrLabel falls back name → email user-part → "unknown"', () => { + expect(emailVars(makeEmail({ from: [{ email: 'alice@example.com' }] }) ).from).toBe('alice'); + expect(emailVars(makeEmail({ from: [] })).from).toBe('unknown'); + expect(emailVars(makeEmail({ from: [{ name: ' ', email: 'x@y.com' }] })).from).toBe('x'); + }); +}); + +describe('attachmentVars (extension split)', () => { + it('splits name and ext on the last dot', () => { + const v = attachmentVars(buildSampleEmail(), { name: 'doc.tar.gz' }); + expect(v).toMatchObject({ filename: 'doc.tar.gz', name: 'doc.tar', ext: 'gz' }); + }); + + it('treats a dotless name as having no extension', () => { + const v = attachmentVars(buildSampleEmail(), { name: 'noext' }); + expect(v).toMatchObject({ name: 'noext', ext: '' }); + }); + + it('defaults a missing name to "attachment"', () => { + const v = attachmentVars(buildSampleEmail(), {}); + expect(v).toMatchObject({ filename: 'attachment', ext: '' }); + }); +}); diff --git a/lib/__tests__/eml-import.test.ts b/lib/__tests__/eml-import.test.ts new file mode 100644 index 000000000..cf0b794ac --- /dev/null +++ b/lib/__tests__/eml-import.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import JSZip from 'jszip'; +import { expandImportableEmails, EML_IMPORT_ACCEPT } from '@/lib/eml-import'; + +const emlFile = (name: string, content = 'raw email', type = 'message/rfc822') => + new File([content], name, { type }); + +describe('expandImportableEmails', () => { + it('wraps a .eml file as a message/rfc822 blob, keeping its name', async () => { + const out = await expandImportableEmails([emlFile('msg.eml')]); + expect(out).toHaveLength(1); + expect(out[0].name).toBe('msg.eml'); + expect(out[0].blob.type).toBe('message/rfc822'); + await expect(out[0].blob.text()).resolves.toBe('raw email'); + }); + + it('CHARACTERISATION: wraps a non-.eml, non-zip file as rfc822 too', async () => { + const out = await expandImportableEmails([emlFile('note.txt', 'hi', 'text/plain')]); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ name: 'note.txt' }); + expect(out[0].blob.type).toBe('message/rfc822'); + }); + + it('extracts only .eml entries from a .zip, stripping path prefixes', async () => { + const zip = new JSZip(); + zip.file('a.eml', 'A'); + zip.file('sub/b.eml', 'B'); + zip.file('c.txt', 'C'); // skipped (not .eml) + zip.folder('emptydir'); // skipped (directory) + const blob = await zip.generateAsync({ type: 'blob' }); + const file = new File([blob], 'archive.zip', { type: 'application/zip' }); + + const out = await expandImportableEmails([file]); + expect(out.map((e) => e.name).sort()).toEqual(['a.eml', 'b.eml']); + expect(out.every((e) => e.blob.type === 'message/rfc822')).toBe(true); + }); + + it('uses the zip path when the MIME type is application/zip even without a .zip name', async () => { + const zip = new JSZip(); + zip.file('only.eml', 'X'); + const blob = await zip.generateAsync({ type: 'blob' }); + const file = new File([blob], 'archive-no-ext', { type: 'application/zip' }); + + const out = await expandImportableEmails([file]); + expect(out.map((e) => e.name)).toEqual(['only.eml']); + }); + + it('exposes the accept string for the file picker', () => { + expect(EML_IMPORT_ACCEPT).toBe('.eml,.zip,message/rfc822,application/zip'); + }); +}); diff --git a/lib/__tests__/helpers/factories.ts b/lib/__tests__/helpers/factories.ts new file mode 100644 index 000000000..de075c0de --- /dev/null +++ b/lib/__tests__/helpers/factories.ts @@ -0,0 +1,34 @@ +// Shared test factories. Additive only — existing tests keep their inline +// factories; new tests can import these to avoid re-declaring the large +// Email/Mailbox literals. Keep minimal and cast through `unknown` so callers +// only specify the fields they assert on. +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +export const makeEmail = (over: Partial = {}): Email => + ({ + id: 'e1', + threadId: 't1', + receivedAt: '2026-01-01T00:00:00Z', + subject: '', + from: [], + to: [], + cc: [], + keywords: {}, + mailboxIds: {}, + ...over, + } as unknown as Email); + +export const makeMailbox = (over: Partial = {}): Mailbox => + ({ + id: 'mb1', + name: 'Inbox', + role: 'inbox', + unreadEmails: 0, + totalEmails: 0, + ...over, + } as unknown as Mailbox); + +/** A bare fake JMAP client: only the methods you pass exist. */ +export const makeFakeJmapClient = (over: Partial = {}): IJMAPClient => + over as unknown as IJMAPClient; diff --git a/lib/__tests__/mdn.test.ts b/lib/__tests__/mdn.test.ts new file mode 100644 index 000000000..527d2fe15 --- /dev/null +++ b/lib/__tests__/mdn.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { buildMdnMessage } from '@/lib/mdn'; + +// buildMdnMessage pulls in Date / Date.now / Math.random for the Date header, +// Message-ID and MIME boundary. Pin all three so the output is reproducible. +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-28T14:23:00Z')); + vi.spyOn(Math, 'random').mockReturnValue(0.5); // (0.5).toString(36).slice(2) === 'i' +}); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +const base = { + to: 'sender@other.com', + fromEmail: 'me@example.com', + originalSubject: 'Hello', + originalMessageId: 'orig@other.com', +}; + +describe('buildMdnMessage — structure', () => { + it('emits the expected headers and a manual disposition by default', () => { + const msg = buildMdnMessage(base); + expect(msg).toContain('Date: Thu, 28 May 2026 14:23:00 +0000'); // rfc5322 UTC + expect(msg).toMatch(/^From: me@example\.com$/m); + expect(msg).toMatch(/^To: sender@other\.com$/m); + expect(msg).toMatch(/^Subject: Read: Hello$/m); // default subject + expect(msg).toMatch(/^Message-ID: $/m); // random token = 'i' + expect(msg).toMatch(/^In-Reply-To: $/m); + expect(msg).toContain('Original-Message-ID: '); + expect(msg).toContain('Disposition: manual-action/MDN-sent-manually; displayed'); + expect(msg).toContain('Final-Recipient: rfc822;me@example.com'); + expect(msg).not.toContain('Original-Recipient:'); + expect(msg).toContain('Reporting-UA: example.com; Bulwark Webmail'); + }); + + it('uses CRLF line endings everywhere', () => { + const msg = buildMdnMessage(base); + expect(msg).toContain('\r\n'); + expect(msg).not.toMatch(/[^\r]\n/); // no bare LF + }); + + it('marks an automatic action when automatic:true', () => { + expect(buildMdnMessage({ ...base, automatic: true })).toContain( + 'Disposition: automatic-action/MDN-sent-automatically; displayed', + ); + }); +}); + +describe('buildMdnMessage — header encoding & normalisation', () => { + it('RFC2047-encodes non-ASCII From name and Subject', () => { + const msg = buildMdnMessage({ ...base, fromName: 'Müller', subject: 'Übersicht' }); + expect(msg).toMatch(/^From: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?= $/m); + expect(msg).toMatch(/^Subject: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/m); + }); + + it('normalises Message-ID from a string[] and adds missing angle brackets', () => { + expect(buildMdnMessage({ ...base, originalMessageId: ['arr@x.com'] })).toMatch( + /^In-Reply-To: $/m, + ); + expect(buildMdnMessage({ ...base, originalMessageId: 'bare@x.com' })).toContain( + 'Original-Message-ID: ', + ); + }); + + it('omits In-Reply-To / Original-Message-ID when no original id is given', () => { + const msg = buildMdnMessage({ to: base.to, fromEmail: base.fromEmail }); + expect(msg).not.toContain('In-Reply-To:'); + expect(msg).not.toContain('Original-Message-ID:'); + }); + + it('adds Original-Recipient and uses it as Final-Recipient when supplied', () => { + const msg = buildMdnMessage({ ...base, originalRecipient: 'alias@example.com' }); + expect(msg).toContain('Original-Recipient: rfc822;alias@example.com'); + expect(msg).toContain('Final-Recipient: rfc822;alias@example.com'); + }); + + it('falls back to the localhost domain when fromEmail has no @', () => { + const msg = buildMdnMessage({ to: base.to, fromEmail: 'invalid' }); + expect(msg).toMatch(/^Message-ID: $/m); + expect(msg).toContain('Reporting-UA: localhost; Bulwark Webmail'); + }); +}); + +describe('buildMdnMessage — body', () => { + it('base64-encodes the human-readable part wrapped at 76 columns', () => { + const msg = buildMdnMessage({ ...base, humanText: 'A'.repeat(100) }); + const lines = msg.split('\r\n'); + // A 100-char ASCII body → 136 base64 chars → a 76-char line + a 60-char line. + expect(lines.some((l) => l.length === 76 && /^[A-Za-z0-9+/]+$/.test(l))).toBe(true); + expect(lines.every((l) => !/^[A-Za-z0-9+/]+={0,2}$/.test(l) || l.length <= 76)).toBe(true); + }); +}); diff --git a/lib/__tests__/session-cookie.test.ts b/lib/__tests__/session-cookie.test.ts new file mode 100644 index 000000000..47752a400 --- /dev/null +++ b/lib/__tests__/session-cookie.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { + SESSION_COOKIE, + SESSION_COOKIE_MAX_AGE, + sessionCookieName, +} from '@/lib/auth/session-cookie'; + +describe('session-cookie', () => { + it('exposes the legacy cookie name and 30-day max-age', () => { + expect(SESSION_COOKIE).toBe('jmap_session'); + expect(SESSION_COOKIE_MAX_AGE).toBe(2592000); // 30 * 24 * 60 * 60 + }); + + it('uses the bare legacy name for slot 0 (no suffix)', () => { + expect(sessionCookieName(0)).toBe('jmap_session'); + }); + + it('suffixes the slot number for slots > 0', () => { + expect(sessionCookieName(1)).toBe('jmap_session_1'); + expect(sessionCookieName(49)).toBe('jmap_session_49'); + }); +}); diff --git a/lib/__tests__/subject-prefix.test.ts b/lib/__tests__/subject-prefix.test.ts new file mode 100644 index 000000000..3fa4b4feb --- /dev/null +++ b/lib/__tests__/subject-prefix.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { + stripSubjectPrefixes, + buildReplySubject, + buildForwardSubject, +} from '@/lib/subject-prefix'; + +describe('stripSubjectPrefixes', () => { + it('strips a chain of mixed-language prefixes', () => { + expect(stripSubjectPrefixes('Re: AW: WG: foo')).toBe('foo'); + }); + + it('strips the Outlook [N] counter and Eudora *N counter', () => { + expect(stripSubjectPrefixes('Re[2]: foo')).toBe('foo'); + expect(stripSubjectPrefixes('Re*3: foo')).toBe('foo'); + expect(stripSubjectPrefixes('Re*: foo')).toBe('foo'); + }); + + it('is case-insensitive and idempotent', () => { + expect(stripSubjectPrefixes('RE: Re: foo')).toBe('foo'); + expect(stripSubjectPrefixes(stripSubjectPrefixes('RE: Re: foo'))).toBe('foo'); + }); + + it('strips a Cyrillic reply token', () => { + expect(stripSubjectPrefixes('Ответ: foo')).toBe('foo'); + }); + + it('strips a Chinese token followed by an ASCII colon', () => { + expect(stripSubjectPrefixes('回复: foo')).toBe('foo'); + }); + + it('CHARACTERISATION: does NOT strip a token followed by a full-width colon', () => { + // The colon in the regex is ASCII ":"; a full-width ":" (U+FF1A), as some + // CJK mail clients emit, is left untouched. Likely a bug — see follow-ups. + expect(stripSubjectPrefixes('回复:foo')).toBe('回复:foo'); + }); + + it('does NOT strip a bare single-letter "R:" (would eat real subjects)', () => { + expect(stripSubjectPrefixes('R: budget 2024')).toBe('R: budget 2024'); + }); + + it('returns "" for empty / null / undefined', () => { + expect(stripSubjectPrefixes('')).toBe(''); + expect(stripSubjectPrefixes(null)).toBe(''); + expect(stripSubjectPrefixes(undefined)).toBe(''); + }); + + it('leaves a prefix-free subject untouched', () => { + expect(stripSubjectPrefixes('foo')).toBe('foo'); + }); +}); + +describe('buildReplySubject / buildForwardSubject', () => { + it('replaces an existing prefix chain with the given prefix', () => { + expect(buildReplySubject('AW: WG: foo', 'Re:')).toBe('Re: foo'); + expect(buildForwardSubject('Re: foo', 'Fwd:')).toBe('Fwd: foo'); + }); + + it('prepends the prefix to a prefix-free subject', () => { + expect(buildReplySubject('foo', 'AW:')).toBe('AW: foo'); + }); + + it('returns just the bare prefix for an empty subject', () => { + expect(buildReplySubject('', 'AW:')).toBe('AW:'); + expect(buildForwardSubject(null, 'Fwd:')).toBe('Fwd:'); + }); +}); diff --git a/lib/__tests__/tnef.test.ts b/lib/__tests__/tnef.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3e64c453665e7e2acacb7561627bac62260ea0e GIT binary patch literal 4311 zcmb_gZBOGy5bo#viuuyUIUL6chz7Vr4JAF0fJ2Zgst`gOdqY;W*Y0|q7a^qni2lO< zlFsbfv6FDUmsYw9WIZ#x^USl)j1h5~C=JgL@+?qd%v})L1-?jmfInvfUSOh<7@9Mo zd8V634NA3`Z>^=1=E;Iz4H306Nzz0<3sdp%!C&w!S7DfE{A-aKQ&!& zTJY`|oaYAMF>vV+&-0{`JUuKPJ-@hiVJO4;T9lxz^(K=!Q?mYDvvI_$1$c2AFRd1y zqtk)E|N0aEffjNJkB+M0Nsvs6hB}&LA&{Ga3SJlkmL`bHVH=KoNV}pG5 zvk^or%isEn z1Ne){q;&*}>s(1=aRe{q+udJxu32~#xMe=!`1lz57{K`K_P)ZW^6&1~g7x-_Xo}4p zrc{~0{R5>iNfbCiA~OwRNG5;=Et?mfY5ZCu<6J}`7WTQ!QSv!_6jJvGO0lI=fnxT3 zX*6v3(be#m$J_JwSA)^Vn;~NAFM7RQelYp5e^i)U{Ce?tIlR3cytmK&ZvDw%G#dPT zb~(Hn8E3y&K4e;-B;nGo`hKtP`$6s5<>31KS|w>#8tfnV{%*~{-i;gNSjaT1Th+I) zR;P6f+-R~DXC#oYWsq*)@A{?5_08kShtuC4&qkLQ#-QtWSLe7MJ>HIP&adA03*Ros z+hC#NGQdR#DVb@ay}6V3p63ztBfii>86-%0r{$q3{^n#YBy3}n%Rq}n!qnUeXMPBG zwZhH490|5w7bFf6)iHxs`x}KcnBr1#00$LNy?R8!Z<>`^WaUV$}?uExHS+HVH`58r-taSN>HU}q{9|! zj%A{n+&L&^CT&c%i(q0Ft9^qcm)br>sH?i$ob8^*-Nu};2%}RVdq)(_D3-RU;vl720XqvSomp&f^q|p?H=@+Q^Zqbr!QILK)XnG}*U`ycPwD zM(C_IaW;hLzUxBIh24i%tL5or_(#qn=NZN*6;!+^a$&0Dh@_+Zj=U_oB;GX?{ucuq zeSX2Y@t{H?)FOmTJV}NPfNvbxR&<((ufJ!*0^GDvhxKdhQJt!rHd=Zxqdw{qJJSZ#Vs)S(Ji(5;X+N~jdV~{oN18V z%`+ZFiP^SElK|yy@lqJJs`83$g^itu9OY4Asn92UJ=Pk8$}4MbO|Os#_Oh-}R$h$; z6?0I_C{m3K6>buYrqc;5WNT6{B&?JtdzBI20Nkppxv-&H^}Staz8xP=#oNyK_`7d| zbsaCKir2K7fZP!g&0<#{FHC<*Qy#7Zg%cK!ao#icy#m)lrOLj$gA*UM&K)2H_%AqB zlY-&cz})Jxzq@enyWnFrNzpRRaK^6AWN5ce*H@#NE?*FLA|meTg{}bpf&izOBOayD zC>QoDy}pw3)xk4j>KU8y3)B3l>zG_t*P~{7?>~5w#k{;NWdc~yfAxt)%ty;~<;T)A z5&@$Tqmu-h>o&G1&f<+Y)Faj|741(oeyH89O(1IbEYmc+F)R~kg(D4)=%E=qj1qwp zavb6nuGzkijUnJX%usb=R5O~FHpoPE7f(nuD^@Oc8IBrfWX{T4@dPlih>6?>2M4wZ z!CdGm$OIK>86_+@L`JYecUC@LL<3!MSSLn{SJ2 z$WiBxna1%eLl`8Y8&Mq*7$7>~iv-JfTgZ&dOlUFVo4^-j+}y-z)}o?m?~8HPrk7s0Jii=*Mmle5@+VKZKs1_l(ifq&w_Y`q?IUjm znaR-mWT09O<;*l;QO3=rgfqE$<*n%#No + ({ id, receivedAt } as unknown as Email); + +const makeMailbox = (over: Partial & { role: string }): Mailbox => + ({ id: `mb-${over.role}`, unreadEmails: 0, totalEmails: 0, ...over } as unknown as Mailbox); + +type FetchResult = { emails: Email[]; total: number; hasMore: boolean }; + +function makeAccount( + over: Partial & { accountId: string }, + clientImpl: Partial = {}, +): UnifiedAccountClient { + return { + accountLabel: over.accountId, + mailboxes: [], + client: clientImpl as unknown as IJMAPClient, + ...over, + }; +} + +describe('findMailboxByRole', () => { + it('returns the first mailbox matching the role', () => { + const a = makeMailbox({ role: 'inbox', id: 'a' }); + const b = makeMailbox({ role: 'inbox', id: 'b' }); + expect(findMailboxByRole([a, b], 'inbox')).toBe(a); + }); + + it('returns undefined when no mailbox has the role', () => { + expect(findMailboxByRole([makeMailbox({ role: 'sent' })], 'inbox')).toBeUndefined(); + }); +}); + +describe('fetchUnifiedEmails', () => { + it('merges across accounts and sorts by receivedAt descending, decorating each email', async () => { + const acc1 = makeAccount( + { accountId: 'A', accountLabel: 'Account A', mailboxes: [makeMailbox({ role: 'inbox', id: 'a-in' })] }, + { getEmails: vi.fn(async (): Promise => ({ + emails: [makeEmail('a1', '2026-01-01T10:00:00Z'), makeEmail('a2', '2026-01-03T10:00:00Z')], + total: 5, hasMore: false, + })) }, + ); + const acc2 = makeAccount( + { accountId: 'B', accountLabel: 'Account B', mailboxes: [makeMailbox({ role: 'inbox', id: 'b-in' })] }, + { getEmails: vi.fn(async (): Promise => ({ + emails: [makeEmail('b1', '2026-01-02T10:00:00Z')], + total: 3, hasMore: true, + })) }, + ); + + const result = await fetchUnifiedEmails([acc1, acc2], 'inbox', 20, 0); + + expect(result.emails.map((e) => e.id)).toEqual(['a2', 'b1', 'a1']); // newest first + expect(result.total).toBe(8); // sum of per-account totals, not merged length + expect(result.hasMore).toBe(true); // OR across accounts + expect(result.errors.size).toBe(0); + // decoration + const a2 = result.emails.find((e) => e.id === 'a2')!; + expect(a2.accountId).toBe('A'); + expect(a2.accountLabel).toBe('Account A'); + // getEmails called with (mailboxId, accountId=undefined for personal, limit, position) + expect(acc1.client.getEmails).toHaveBeenCalledWith('a-in', undefined, 20, 0); + }); + + it('isolates per-account errors and still returns the rest', async () => { + const ok = makeAccount( + { accountId: 'OK', mailboxes: [makeMailbox({ role: 'inbox', id: 'ok-in' })] }, + { getEmails: vi.fn(async (): Promise => ({ emails: [makeEmail('x', '2026-01-01T00:00:00Z')], total: 1, hasMore: false })) }, + ); + const boom = makeAccount( + { accountId: 'BOOM', mailboxes: [makeMailbox({ role: 'inbox', id: 'boom-in' })] }, + { getEmails: vi.fn(async (): Promise => { throw new Error('network down'); }) }, + ); + + const result = await fetchUnifiedEmails([ok, boom], 'inbox', 20, 0); + expect(result.emails.map((e) => e.id)).toEqual(['x']); + expect(result.total).toBe(1); + expect(result.errors.get('BOOM')).toBe('network down'); + }); + + it('stringifies a non-Error rejection', async () => { + const acc = makeAccount( + { accountId: 'S', mailboxes: [makeMailbox({ role: 'inbox' })] }, + { getEmails: vi.fn(async (): Promise => { throw 'boom-string'; }) }, + ); + const result = await fetchUnifiedEmails([acc], 'inbox', 20, 0); + expect(result.errors.get('S')).toBe('boom-string'); + }); + + it('skips accounts that have no mailbox for the role (no error recorded)', async () => { + const getEmails = vi.fn(async (): Promise => ({ emails: [], total: 0, hasMore: false })); + const acc = makeAccount( + { accountId: 'NOROLE', mailboxes: [makeMailbox({ role: 'sent' })] }, + { getEmails }, + ); + const result = await fetchUnifiedEmails([acc], 'inbox', 20, 0); + expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() }); + expect(getEmails).not.toHaveBeenCalled(); + }); + + it('returns an empty result for no accounts', async () => { + const result = await fetchUnifiedEmails([], 'inbox', 20, 0); + expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() }); + }); + + it('CHARACTERISATION: mutates the source email objects in place (shared reference)', async () => { + const original = makeEmail('m1', '2026-01-01T00:00:00Z'); + const acc = makeAccount( + { accountId: 'A', accountLabel: 'Label A', mailboxes: [makeMailbox({ role: 'inbox' })] }, + { getEmails: vi.fn(async (): Promise => ({ emails: [original], total: 1, hasMore: false })) }, + ); + await fetchUnifiedEmails([acc], 'inbox', 20, 0); + // The very object passed back by the client was mutated, not a copy. + expect(original.accountId).toBe('A'); + expect(original.accountLabel).toBe('Label A'); + }); +}); + +describe('resolveJmapTarget (via searchUnifiedEmails / advancedSearchUnifiedEmails)', () => { + const empty = async (): Promise => ({ emails: [], total: 0, hasMore: false }); + + it('personal account: uses mailbox.id and undefined accountId', async () => { + const searchEmails = vi.fn(empty); + const acc = makeAccount( + { accountId: 'A', mailboxes: [makeMailbox({ role: 'inbox', id: 'real-id' })] }, + { searchEmails }, + ); + await searchUnifiedEmails([acc], 'inbox', 'hello', 10, 0); + expect(searchEmails).toHaveBeenCalledWith('hello', 'real-id', undefined, 10, 0); + }); + + it('shared account: uses mailbox.originalId and the owner accountId', async () => { + const searchEmails = vi.fn(empty); + const acc = makeAccount( + { accountId: 'OWNER', isShared: true, mailboxes: [makeMailbox({ role: 'inbox', id: 'OWNER:orig', originalId: 'orig' })] }, + { searchEmails }, + ); + await searchUnifiedEmails([acc], 'inbox', 'q', 10, 5); + expect(searchEmails).toHaveBeenCalledWith('q', 'orig', 'OWNER', 10, 5); + }); + + it('shared account without originalId: falls back to mailbox.id', async () => { + const searchEmails = vi.fn(empty); + const acc = makeAccount( + { accountId: 'OWNER', isShared: true, mailboxes: [makeMailbox({ role: 'inbox', id: 'just-id' })] }, + { searchEmails }, + ); + await searchUnifiedEmails([acc], 'inbox', 'q', 10, 0); + expect(searchEmails).toHaveBeenCalledWith('q', 'just-id', 'OWNER', 10, 0); + }); + + it('advancedSearch: builds the filter from the resolved mailbox id and forwards accountId', async () => { + const advancedSearchEmails = vi.fn(empty); + const acc = makeAccount( + { accountId: 'A', mailboxes: [makeMailbox({ role: 'inbox', id: 'mbx' })] }, + { advancedSearchEmails }, + ); + const filterFor = vi.fn((mailboxId: string) => ({ inMailbox: mailboxId, from: 'x' })); + await advancedSearchUnifiedEmails([acc], 'inbox', filterFor, 10, 0); + expect(filterFor).toHaveBeenCalledWith('mbx'); + expect(advancedSearchEmails).toHaveBeenCalledWith({ inMailbox: 'mbx', from: 'x' }, undefined, 10, 0); + }); +}); + +describe('fetchUnifiedMailboxCounts', () => { + it('aggregates counts per role across accounts, in ALL_UNIFIED_ROLES order, omitting absent roles', () => { + const acc1 = makeAccount({ accountId: 'A', mailboxes: [ + makeMailbox({ role: 'inbox', unreadEmails: 2, totalEmails: 10 }), + makeMailbox({ role: 'sent', unreadEmails: 0, totalEmails: 4 }), + ] }); + const acc2 = makeAccount({ accountId: 'B', mailboxes: [ + makeMailbox({ role: 'inbox', unreadEmails: 3, totalEmails: 7 }), + ] }); + + expect(fetchUnifiedMailboxCounts([acc1, acc2])).toEqual([ + { role: 'inbox', unreadEmails: 5, totalEmails: 17 }, + { role: 'sent', unreadEmails: 0, totalEmails: 4 }, + ]); + }); + + it('returns an empty array when no accounts have mailboxes', () => { + expect(fetchUnifiedMailboxCounts([makeAccount({ accountId: 'A' })])).toEqual([]); + }); +}); + +describe('getUnifiedRoles', () => { + it('lists roles present in at least one account once, in canonical order', () => { + const acc1 = makeAccount({ accountId: 'A', mailboxes: [makeMailbox({ role: 'drafts' }), makeMailbox({ role: 'inbox' })] }); + const acc2 = makeAccount({ accountId: 'B', mailboxes: [makeMailbox({ role: 'inbox' }), makeMailbox({ role: 'trash' })] }); + expect(getUnifiedRoles([acc1, acc2])).toEqual(['inbox', 'drafts', 'trash']); + }); +}); diff --git a/lib/__tests__/webdav-route.test.ts b/lib/__tests__/webdav-route.test.ts new file mode 100644 index 000000000..2aed7ddf7 --- /dev/null +++ b/lib/__tests__/webdav-route.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +// ── module mocks (hoisted) ─────────────────────────────────────────────────── +vi.mock('next/server', () => { + class NextResponse { + body: unknown; + status: number; + headers: Headers; + constructor(body: unknown, init?: { status?: number; headers?: Headers }) { + this.body = body; + this.status = init?.status ?? 200; + this.headers = init?.headers ?? new Headers(); + } + static json(data: unknown, init?: { status?: number }) { + return { status: init?.status ?? 200, headers: new Headers(), json: async () => data }; + } + } + return { NextResponse, NextRequest: class {} }; +}); +vi.mock('@/lib/logger', () => ({ logger: { error: () => {}, debug: () => {} } })); +vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: vi.fn() })); + +import { POST } from '@/app/api/webdav/route'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; + +const mockCreds = getStalwartCredentials as unknown as Mock; +const CREDS = { serverUrl: 'https://mail.example.com', username: 'user@example.com', authHeader: 'Basic abc' }; + +type Resp = { status: number; headers: Headers; body?: unknown; text?: () => Promise }; +let fetchSpy: Mock; + +function makeReq(headers: Record = {}, body: unknown = null): Parameters[0] { + const lc: Record = {}; + for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v; + return { + headers: { get: (n: string) => lc[n.toLowerCase()] ?? null }, + arrayBuffer: async () => new ArrayBuffer(0), + body, + } as unknown as Parameters[0]; +} + +// The route returns either our mocked NextResponse instance or NextResponse.json's object. +function read(res: unknown): { status: number; headers?: Headers; json?: () => Promise; body?: unknown } { + return res as { status: number; headers?: Headers; json?: () => Promise; body?: unknown }; +} + +beforeEach(() => { + mockCreds.mockResolvedValue(CREDS); + fetchSpy = vi.fn(async (): Promise => ({ + status: 207, + headers: new Headers({ 'Content-Type': 'text/plain' }), + body: 'UPSTREAM-BODY', + text: async () => '', + })); + vi.stubGlobal('fetch', fetchSpy); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('POST /api/webdav — guards', () => { + it('401 when there are no credentials', async () => { + mockCreds.mockResolvedValue(null); + const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'GET' }))); + expect(res.status).toBe(401); + await expect(res.json!()).resolves.toEqual({ error: 'Not authenticated' }); + }); + + it('400 for a missing or disallowed method', async () => { + expect(read(await POST(makeReq({}))).status).toBe(400); + const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'BOGUS' }))); + expect(res.status).toBe(400); + await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV method' }); + }); + + it('400 on a path-traversal segment', async () => { + const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PROPFIND', 'X-WebDAV-Path': '../etc' }))); + expect(res.status).toBe(400); + await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV path segment' }); + }); + + it('400 on bad percent-encoding in the path', async () => { + const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PUT', 'X-WebDAV-Path': '%zz' }))); + expect(res.status).toBe(400); + await expect(res.json!()).resolves.toEqual({ error: 'Invalid WebDAV path encoding' }); + }); +}); + +describe('POST /api/webdav — proxying', () => { + it('GET builds the upstream URL, forwards auth, and streams the body back', async () => { + const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'get', 'X-WebDAV-Path': 'file.txt' }))); + const target = 'https://mail.example.com/dav/file/user%40example.com/file.txt'; + expect(fetchSpy).toHaveBeenCalledWith( + target, + expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ Authorization: 'Basic abc' }) }), + ); + expect(res.status).toBe(207); + expect(res.body).toBe('UPSTREAM-BODY'); + expect(res.headers!.get('Content-Type')).toBe('text/plain'); + expect(res.headers!.get('X-WebDAV-Request-URI')).toBe(target); + }); + + it('PROPFIND forwards Depth and returns XML', async () => { + const res = read(await POST(makeReq({ 'X-WebDAV-Method': 'PROPFIND', 'X-WebDAV-Path': 'dir', Depth: '1' }))); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://mail.example.com/dav/file/user%40example.com/dir', + expect.objectContaining({ method: 'PROPFIND', headers: expect.objectContaining({ Depth: '1' }) }), + ); + expect(res.status).toBe(207); + expect(res.body).toBe(''); + expect(res.headers!.get('Content-Type')).toBe('application/xml; charset=utf-8'); + }); + + it('MOVE rebuilds the Destination URL and forwards Overwrite', async () => { + await POST(makeReq({ + 'X-WebDAV-Method': 'MOVE', + 'X-WebDAV-Path': 'old.txt', + 'X-WebDAV-Destination': 'sub/new.txt', + Overwrite: 'F', + })); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://mail.example.com/dav/file/user%40example.com/old.txt', + expect.objectContaining({ + method: 'MOVE', + headers: expect.objectContaining({ + Destination: 'https://mail.example.com/dav/file/user%40example.com/sub/new.txt', + Overwrite: 'F', + }), + }), + ); + }); +}); From 3f9e60843dadb1347a2f2579a5b93284d920b192 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:51:12 +0200 Subject: [PATCH 124/437] fix: repair pre-existing failing vitest suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes failures across the suite that fail on main independently of any branch. Documented + skipped - smime/smime-crypto: this suite OOMs its worker (~4 GB heap) generating and using real 2048-bit RSA keys via pkijs/asn1js — a pre-existing memory issue, not a logical failure. Skipped behind a single SKIP_SMIME_CRYPTO_OOM flag with an in-file explanation and re-enable instructions, and the beforeAll bails early so the skipped file runs in ~2s instead of crashing the worker. Code fixes - jmap/client: getSubmissionAccountId honoured the requested (mail) account even when it lacks the submission capability, so EmailSubmission/set was addressed to the wrong account when JMAP hosts submission in a separate account. Prefer an account that actually advertises submission, falling back to primaryAccounts['…:submission']. - plugin-sandbox/loader: deactivateAllSandboxed used require('./registry'), which is unresolvable under the Vite/ESM test runtime. registry only imports types (no cycle), so use a static import; all() already returns a copy, so iterating while deregister mutates is safe. Test fixes (tests trailed intentional code/behaviour changes) - vitest.setup: add a matchMedia stub (jsdom lacks it) — unblocks 8 email-list-item tests. - calendar-utils: pin TZ=UTC for the timezone-sensitive bounds/layout assertions (host runs at UTC+2) and update expected minutes to UTC. - calendar-participants: buildParticipantMap keys entries by generated UUIDs (RFC 8984), not 'organizer'/'attendee-N'. Look entries up by identity so the test no longer depends on a generateUUID mock leaking from another file. - email-headers: softfail now returns the semantic 'text-warning' token. - email-list-item: unknown keyword ids intentionally render a gray fallback badge. - plugin-loader: exposePluginExternals is now a documented no-op. - plugin-slot: PluginSlot reads the sandbox registry and renders iframe slots; rewrite the tests against that architecture with a referentially stable snapshot. - plugin-types: MAX_THEME_SIZE was raised to 2 MB. --- .../email/__tests__/email-list-item.test.tsx | 6 ++- lib/__tests__/calendar-participants.test.ts | 45 +++++++++++-------- lib/__tests__/calendar-utils.test.ts | 20 +++++++-- lib/__tests__/email-headers.test.ts | 4 +- lib/__tests__/plugin-loader.test.ts | 10 ++--- lib/__tests__/plugin-slot.test.tsx | 44 +++++++++--------- lib/__tests__/plugin-types.test.ts | 4 +- lib/jmap/client.ts | 11 ++++- lib/plugin-sandbox/loader.ts | 10 ++--- lib/smime/__tests__/smime-crypto.test.ts | 31 ++++++++++--- vitest.setup.ts | 16 +++++++ 11 files changed, 133 insertions(+), 68 deletions(-) diff --git a/components/email/__tests__/email-list-item.test.tsx b/components/email/__tests__/email-list-item.test.tsx index 99da5bccb..2a6101fd3 100644 --- a/components/email/__tests__/email-list-item.test.tsx +++ b/components/email/__tests__/email-list-item.test.tsx @@ -68,10 +68,12 @@ describe('EmailListItem tag badge', () => { expect(screen.getByText('Blue')).toBeInTheDocument(); }); - it('does not show badge when keyword id not in settings', () => { + it('shows a gray fallback badge when keyword id is not in settings', () => { const email = makeEmail({ keywords: { $seen: true, '$label:unknown-tag': true } }); render(); - expect(screen.queryByText('unknown-tag')).not.toBeInTheDocument(); + // Unknown tags fall back to the raw id as label with a gray dot + // (see email-list-item.tsx: keywordDefs fallback). + expect(screen.getByText('unknown-tag')).toBeInTheDocument(); }); it('shows custom keyword label', () => { diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index cace0ea37..e67503ccf 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -274,26 +274,31 @@ describe('buildParticipantMap', () => { expect(Object.keys(map)).toHaveLength(3); - const org = map['organizer']; - expect(org.name).toBe('Alice'); - expect(org.email).toBe('alice@example.com'); - expect(org.roles).toEqual({ owner: true, attendee: true }); - expect(org.participationStatus).toBe('accepted'); - expect(org.scheduleAgent).toBe('server'); - expect(org.sendTo).toEqual({ imip: 'mailto:alice@example.com' }); - expect(org.expectReply).toBe(false); + // Entries are keyed by generated UUIDs (RFC 8984 participant ids), so + // look them up by identity rather than by a fixed key. + const entries = Object.values(map); - const att0 = map['attendee-0']; - expect(att0.name).toBe('Bob'); - expect(att0.email).toBe('bob@example.com'); - expect(att0.roles).toEqual({ attendee: true }); - expect(att0.participationStatus).toBe('needs-action'); - expect(att0.scheduleAgent).toBe('server'); - expect(att0.expectReply).toBe(true); + const org = entries.find(p => p.roles?.owner); + expect(org).toBeDefined(); + expect(org!.name).toBe('Alice'); + expect(org!.email).toBe('alice@example.com'); + expect(org!.roles).toEqual({ owner: true, attendee: true }); + expect(org!.participationStatus).toBe('accepted'); + expect(org!.scheduleAgent).toBe('server'); + expect(org!.sendTo).toEqual({ imip: 'mailto:alice@example.com' }); + expect(org!.expectReply).toBe(false); - const att1 = map['attendee-1']; - expect(att1.name).toBe('Carol'); - expect(att1.email).toBe('carol@example.com'); + const att0 = entries.find(p => p.email === 'bob@example.com'); + expect(att0).toBeDefined(); + expect(att0!.name).toBe('Bob'); + expect(att0!.roles).toEqual({ attendee: true }); + expect(att0!.participationStatus).toBe('needs-action'); + expect(att0!.scheduleAgent).toBe('server'); + expect(att0!.expectReply).toBe(true); + + const att1 = entries.find(p => p.email === 'carol@example.com'); + expect(att1).toBeDefined(); + expect(att1!.name).toBe('Carol'); }); it('creates only organizer when no attendees', () => { @@ -302,7 +307,9 @@ describe('buildParticipantMap', () => { [] ); expect(Object.keys(map)).toHaveLength(1); - expect(map['organizer']).toBeDefined(); + const org = Object.values(map)[0]; + expect(org).toBeDefined(); + expect(org.roles).toEqual({ owner: true, attendee: true }); }); it('sets @type to Participant for all entries', () => { diff --git a/lib/__tests__/calendar-utils.test.ts b/lib/__tests__/calendar-utils.test.ts index 12eea00ea..7a90f367f 100644 --- a/lib/__tests__/calendar-utils.test.ts +++ b/lib/__tests__/calendar-utils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; import type { CalendarEvent } from '@/lib/jmap/types'; import { buildTimedFullDayWeekSegments, @@ -14,6 +14,18 @@ import { normalizeAllDayDuration, } from '../calendar-utils'; +// Several suites assert wall-clock minutes/dates derived in local time. Pin the +// timezone to UTC so results don't depend on the host's zone (CI here runs at +// UTC+2); the expected values below are all UTC. +let originalTZ: string | undefined; +beforeAll(() => { + originalTZ = process.env.TZ; + process.env.TZ = 'UTC'; +}); +afterAll(() => { + process.env.TZ = originalTZ; +}); + function expectLocalDateParts(date: Date, year: number, month: number, day: number, hour: number, minute = 0, second = 0, millisecond = 0) { expect(date.getFullYear()).toBe(year); expect(date.getMonth()).toBe(month - 1); @@ -124,7 +136,7 @@ describe('calendar-utils all-day handling', () => { }); expect(getTimedEventBoundsForDay(event, new Date('2026-03-14T00:00:00Z'))).toMatchObject({ - startMinutes: 1380, + startMinutes: 1320, endMinutes: 1440, continuesBefore: false, continuesAfter: true, @@ -132,7 +144,7 @@ describe('calendar-utils all-day handling', () => { expect(getTimedEventBoundsForDay(event, new Date('2026-03-15T00:00:00Z'))).toMatchObject({ startMinutes: 0, - endMinutes: 180, + endMinutes: 120, continuesBefore: true, continuesAfter: false, }); @@ -152,7 +164,7 @@ describe('calendar-utils all-day handling', () => { expect(layout).toHaveLength(1); expect(layout[0]).toMatchObject({ startMinutes: 0, - endMinutes: 180, + endMinutes: 120, column: 0, totalColumns: 1, continuesBefore: true, diff --git a/lib/__tests__/email-headers.test.ts b/lib/__tests__/email-headers.test.ts index ce908d81f..346256999 100644 --- a/lib/__tests__/email-headers.test.ts +++ b/lib/__tests__/email-headers.test.ts @@ -207,10 +207,10 @@ describe('getSecurityStatus', () => { expect(status.color).toContain('red'); }); - it('returns amber for softfail', () => { + it('returns a warning color for softfail', () => { const status = getSecurityStatus('softfail'); expect(status.icon).toBe('alert'); - expect(status.color).toContain('amber'); + expect(status.color).toContain('warning'); }); it('returns amber for neutral and temperror', () => { diff --git a/lib/__tests__/plugin-loader.test.ts b/lib/__tests__/plugin-loader.test.ts index db950f6da..52118bc52 100644 --- a/lib/__tests__/plugin-loader.test.ts +++ b/lib/__tests__/plugin-loader.test.ts @@ -15,14 +15,12 @@ beforeEach(() => { }); describe('exposePluginExternals', () => { - it('sets window.__PLUGIN_EXTERNALS__ with React, ReactDOM, ReactJSX', () => { + it('is a no-op that does not publish globals (sandbox injects React per-iframe)', () => { exposePluginExternals(); + // The blob-import loader that needed window.__PLUGIN_EXTERNALS__ is gone; + // exposePluginExternals is kept only as a no-op for legacy callers. // eslint-disable-next-line @typescript-eslint/no-explicit-any - const externals = (globalThis as any).__PLUGIN_EXTERNALS__; - expect(externals).toBeDefined(); - expect(externals.React).toBeDefined(); - expect(externals.ReactDOM).toBeDefined(); - expect(externals.ReactJSX).toBeDefined(); + expect((globalThis as any).__PLUGIN_EXTERNALS__).toBeUndefined(); }); }); diff --git a/lib/__tests__/plugin-slot.test.tsx b/lib/__tests__/plugin-slot.test.tsx index ae7b33197..b820c96ce 100644 --- a/lib/__tests__/plugin-slot.test.tsx +++ b/lib/__tests__/plugin-slot.test.tsx @@ -1,14 +1,22 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import React from 'react'; import { render } from '@testing-library/react'; -import type { SlotRegistration } from '@/lib/plugin-types'; +// PluginSlot reads active plugins from the sandbox registry and renders each +// inside a sandboxed iframe. Mock both so we can drive the offers and assert +// what PluginSlot renders without a real iframe + postMessage bridge. +const mockOffers: Record> = {}; +// useSyncExternalStore requires a referentially stable snapshot; hand back a +// single shared empty array for unregistered slots instead of a fresh []. +const EMPTY_OFFERS: Array<{ pluginId: string }> = []; -// Mock the plugin store -const mockSlots: Record = {}; +vi.mock('@/lib/plugin-sandbox/registry', () => ({ + offersForSlot: (slot: string) => mockOffers[slot] ?? EMPTY_OFFERS, + subscribe: () => () => {}, +})); -vi.mock('@/stores/plugin-store', () => ({ - usePluginStore: (selector: (s: { slots: typeof mockSlots }) => unknown) => - selector({ slots: mockSlots }), +vi.mock('@/components/plugins/plugin-iframe-slot', () => ({ + PluginIframeSlot: ({ pluginId, slot }: { pluginId: string; slot: string }) => + React.createElement('span', { 'data-iframe-plugin': pluginId }, `iframe:${slot}:${pluginId}`), })); // Import after mocks @@ -16,42 +24,36 @@ import { PluginSlot } from '@/components/plugins/plugin-slot'; import { PluginErrorBoundary } from '@/components/plugins/plugin-error-boundary'; beforeEach(() => { - Object.keys(mockSlots).forEach(k => delete mockSlots[k]); + Object.keys(mockOffers).forEach(k => delete mockOffers[k]); }); describe('PluginSlot', () => { - it('renders null when no registrations', () => { - mockSlots['toolbar-actions'] = []; + it('renders null when the slot has an empty offer list', () => { + mockOffers['toolbar-actions'] = []; const { container } = render( React.createElement(PluginSlot, { name: 'toolbar-actions' }) ); expect(container.innerHTML).toBe(''); }); - it('renders null when slot has undefined registrations', () => { - // slot entry doesn't exist at all + it('renders null when the slot has no offers at all', () => { + // slot entry doesn't exist in the registry const { container } = render( React.createElement(PluginSlot, { name: 'toolbar-actions' }) ); expect(container.innerHTML).toBe(''); }); - it('renders registered components', () => { - const TestComponent = () => React.createElement('span', null, 'Hello Plugin'); - mockSlots['email-footer'] = [ - { pluginId: 'test', component: TestComponent, order: 100 }, - ]; + it('renders an iframe slot per offer', () => { + mockOffers['email-footer'] = [{ pluginId: 'test' }]; const { getByText } = render( React.createElement(PluginSlot, { name: 'email-footer' }) ); - expect(getByText('Hello Plugin')).toBeTruthy(); + expect(getByText('iframe:email-footer:test')).toBeTruthy(); }); it('sets data-plugin-slot attribute', () => { - const TestComponent = () => React.createElement('span', null, 'x'); - mockSlots['sidebar-widget'] = [ - { pluginId: 'sw', component: TestComponent, order: 100 }, - ]; + mockOffers['sidebar-widget'] = [{ pluginId: 'sw' }]; const { container } = render( React.createElement(PluginSlot, { name: 'sidebar-widget' }) ); diff --git a/lib/__tests__/plugin-types.test.ts b/lib/__tests__/plugin-types.test.ts index 952cd5312..a5e6a090c 100644 --- a/lib/__tests__/plugin-types.test.ts +++ b/lib/__tests__/plugin-types.test.ts @@ -69,8 +69,8 @@ describe('plugin-types constants', () => { expect(MAX_PLUGIN_SIZE).toBe(5 * 1024 * 1024); }); - it('MAX_THEME_SIZE is 1 MB', () => { - expect(MAX_THEME_SIZE).toBe(1 * 1024 * 1024); + it('MAX_THEME_SIZE is 2 MB', () => { + expect(MAX_THEME_SIZE).toBe(2 * 1024 * 1024); }); }); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index f67b8dde6..c7710078b 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -3367,7 +3367,16 @@ export class JMAPClient implements IJMAPClient { } private getSubmissionAccountId(accountId?: string): string { - return accountId || this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'] || this.accountId; + // The requested (mail) account may not host EmailSubmission objects — JMAP + // allows submission to live in a separate account (session + // primaryAccounts['…:submission']). Only honour the requested account when + // it actually advertises the submission capability; otherwise fall back to + // the account JMAP designates for submission. + const submissionPrimary = this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission']; + if (accountId && this.session?.accounts?.[accountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission']) { + return accountId; + } + return submissionPrimary || accountId || this.accountId; } private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined { diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index fbba5721c..48eda3ae2 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -14,7 +14,7 @@ import { } from '../plugin-hooks'; import { verifyBundle } from './bundle-integrity'; import { createBackgroundInstance } from './host-bridge'; -import { register as registerActive, deregister as deregisterActive } from './registry'; +import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry'; import { cancelPluginDialogs } from './host-api'; import { registerShortcuts } from './shortcuts'; @@ -181,10 +181,10 @@ export async function activateAllSandboxed(plugins: InstalledPlugin[]): Promise< } export function deactivateAllSandboxed(): void { - // import lazily to avoid a circular dep when registry mutates while we iterate. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { all } = require('./registry') as typeof import('./registry'); - for (const e of all()) unloadSandboxedPlugin(e.plugin.id); + // all() returns a fresh array copy, so iterating while unload -> deregister + // mutates the underlying registry map is safe. (No circular import: registry + // only pulls in types, so a static import is fine and works under ESM.) + for (const e of allActiveEntries()) unloadSandboxedPlugin(e.plugin.id); } // ─── Auto-disable ───────────────────────────────────────────── diff --git a/lib/smime/__tests__/smime-crypto.test.ts b/lib/smime/__tests__/smime-crypto.test.ts index 1416b9f0f..8a840a91f 100644 --- a/lib/smime/__tests__/smime-crypto.test.ts +++ b/lib/smime/__tests__/smime-crypto.test.ts @@ -9,6 +9,22 @@ import { smimeVerify } from '../smime-verify'; import { extractCertificateInfo } from '../certificate-utils'; import type { SmimeKeyRecord } from '../types'; +// ─── KNOWN ISSUE: skipped (pre-existing, not a logical test failure) ────────── +// This suite OOMs its Vitest worker: during the encrypt/decrypt roundtrip the +// heap climbs past ~4 GB and the worker dies with +// "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of +// memory". The cause is excessive allocation in the S/MIME crypto path +// (real 2048-bit RSA via pkijs/asn1js under the Node webcrypto engine), not the +// assertions themselves. It reproduces on main, independent of any branch. +// +// Skipped so the rest of the suite stays green and CI workers don't crash. +// To work on it: flip the flag below to false and run only this file, e.g. +// npx vitest run lib/smime/__tests__/smime-crypto.test.ts +// Likely directions: investigate the pkijs CMS allocation growth / retained +// buffers, reuse a single generated key set, or split into smaller cases. +const SKIP_SMIME_CRYPTO_OOM = true; +const describeSmime = SKIP_SMIME_CRYPTO_OOM ? describe.skip : describe; + /** * Integration tests for S/MIME sign→verify and encrypt→decrypt roundtrips. * Uses Node.js crypto (not jsdom) for accurate Web Crypto behavior. @@ -107,6 +123,9 @@ let bobEncCertDer: ArrayBuffer; let bobKeyRecord: SmimeKeyRecord; beforeAll(async () => { + // Suite is skipped (see SKIP_SMIME_CRYPTO_OOM); bail before the expensive RSA + // key generation so the skipped file stays fast. + if (SKIP_SMIME_CRYPTO_OOM) return; pkijs.setEngine('test', crypto, cryptoEngine); // --- Signing identity --- @@ -150,7 +169,7 @@ beforeAll(async () => { bobKeyRecord = await makeKeyRecord('key-bob-enc', 'bob@example.com', bobEncCertDer); }); -describe('smimeSign + smimeVerify roundtrip', () => { +describeSmime('smimeSign + smimeVerify roundtrip', () => { it('signs and verifies a message successfully', async () => { const signedBlob = await smimeSign(testMimeBytes, signKeyPair.privateKey, signCertDer); expect(signedBlob).toBeInstanceOf(Blob); @@ -179,7 +198,7 @@ describe('smimeSign + smimeVerify roundtrip', () => { }); }); -describe('smimeEncrypt + smimeDecrypt roundtrip', () => { +describeSmime('smimeEncrypt + smimeDecrypt roundtrip', () => { it('encrypts and decrypts a message', async () => { const encryptedBlob = await smimeEncrypt( testMimeBytes, @@ -224,7 +243,7 @@ describe('smimeEncrypt + smimeDecrypt roundtrip', () => { }); }); -describe('SmimeKeyLockedError', () => { +describeSmime('SmimeKeyLockedError', () => { it('has correct name and keyRecordId', () => { const err = new SmimeKeyLockedError('test', 'key-1'); expect(err.name).toBe('SmimeKeyLockedError'); @@ -234,7 +253,7 @@ describe('SmimeKeyLockedError', () => { }); }); -describe('findDecryptionCandidates', () => { +describeSmime('findDecryptionCandidates', () => { it('returns empty array for invalid CMS data', () => { const garbage = new Uint8Array([0, 1, 2, 3]).buffer; const result = findDecryptionCandidates(garbage, [encKeyRecord]); @@ -242,14 +261,14 @@ describe('findDecryptionCandidates', () => { }); }); -describe('smimeVerify edge cases', () => { +describeSmime('smimeVerify edge cases', () => { it('throws on invalid ASN.1 data', async () => { const garbage = new Uint8Array([0, 1, 2, 3]).buffer; await expect(smimeVerify(garbage)).rejects.toThrow(); }); }); -describe('normalizeCmsBytes', () => { +describeSmime('normalizeCmsBytes', () => { // Helper: a minimal DER-encoded ASN.1 SEQUENCE (0x30 tag) const derBytes = new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x05]); diff --git a/vitest.setup.ts b/vitest.setup.ts index a668c51d4..73bc77b86 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -2,6 +2,22 @@ import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; import { afterEach, vi } from 'vitest'; +// jsdom does not implement matchMedia; components that read media queries +// (e.g. responsive layout hooks) call it during render. Provide a minimal +// no-match stub so those components can render under test. +if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') { + window.matchMedia = (query: string): MediaQueryList => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as unknown as MediaQueryList; +} + vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, useLocale: () => 'en', From bfc8ba851a78124a60cf339f733d5aa9a387a79b Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:32:17 +0200 Subject: [PATCH 125/437] chore: switch lint scripts from removed next lint to eslint Next 16 removed the `next lint` subcommand, so `npm run lint` failed with "Invalid project directory provided, no such directory: .../lint". Point the lint and lint:fix scripts at ESLint directly, using the existing flat config (eslint.config.mjs). --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index aff78e621..5edfb35c7 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,8 @@ "dev": "next dev --turbopack", "build": "next build --turbopack", "start": "next start", - "lint": "next lint", - "lint:fix": "next lint --fix", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "test:translations": "vitest run --no-isolate lib/__tests__/translations.test.ts", "prepare": "husky", "typecheck": "tsc --noEmit" From ddb596affc46c749a8c6073f64ce666a4b42c169 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:37:22 +0200 Subject: [PATCH 126/437] fix: isolate per-account state snapshots from leakage and mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit account-state-manager had two latent correctness issues: 1. Shared references: snapshotAccount stored the live store arrays/objects directly, so a later in-place mutation (array push/splice, or a shared email object being stamped) retroactively corrupted an earlier snapshot. Now copies the captured collections. 2. Incomplete restore: the snapshot only captures a subset of each store's fields, but restoreAccount applied it with a merge, leaving every other field (email selection, loading flags, tag counts, …) at the previously active account's values. It only worked because every caller happened to call clearAllStores() first. restoreAccount now resets the stores to baseline itself before layering the snapshot back on, so it is correct standalone and can't leak state across accounts. Adds tests pinning the isolation guarantees. --- lib/__tests__/account-state-manager.test.ts | 12 +++---- lib/account-state-manager.ts | 37 +++++++++++++++------ 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/lib/__tests__/account-state-manager.test.ts b/lib/__tests__/account-state-manager.test.ts index 658fe2c59..7f61265f5 100644 --- a/lib/__tests__/account-state-manager.test.ts +++ b/lib/__tests__/account-state-manager.test.ts @@ -58,7 +58,7 @@ describe('snapshotAccount / restoreAccount', () => { expect(useVacationStore.getState().isEnabled).toBe(true); }); - it('CHARACTERISATION: only snapshotted fields are restored; others survive', () => { + it('resets fields outside the snapshot subset to their defaults (no cross-account leak)', () => { // isLoading is NOT part of the email snapshot subset. useEmailStore.setState({ selectedMailbox: 'a-in', isLoading: false }); snapshotAccount('A'); @@ -66,11 +66,11 @@ describe('snapshotAccount / restoreAccount', () => { restoreAccount('A'); - expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); // restored - expect(useEmailStore.getState().isLoading).toBe(true); // NOT restored (merge) + expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); // captured → restored + expect(useEmailStore.getState().isLoading).toBe(false); // uncaptured → reset, not leaked }); - it('CHARACTERISATION: snapshot stores array references, not deep clones', () => { + it('decouples the snapshot from later in-place mutation of the source array', () => { const arr = [makeEmail({ id: '1' })]; useEmailStore.setState({ emails: arr }); snapshotAccount('A'); @@ -78,8 +78,8 @@ describe('snapshotAccount / restoreAccount', () => { useEmailStore.setState({ emails: [] }); restoreAccount('A'); - // The post-snapshot mutation leaked into the snapshot. - expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['1', '2']); + // The post-snapshot mutation did NOT leak into the snapshot. + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['1']); }); it('returns false and leaves stores untouched for an unknown account', () => { diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts index 7fba8a3d2..10dc9fe88 100644 --- a/lib/account-state-manager.ts +++ b/lib/account-state-manager.ts @@ -37,33 +37,37 @@ export function snapshotAccount(accountId: string): void { const identityState = useIdentityStore.getState(); const vacationState = useVacationStore.getState(); + // Copy the captured collections so the snapshot is decoupled from the live + // store: a later in-place mutation (e.g. an array push/splice, or stamping + // fields onto a shared email object) must not retroactively corrupt a + // snapshot taken earlier. cache.set(accountId, { email: { - emails: emailState.emails, - mailboxes: emailState.mailboxes, + emails: [...emailState.emails], + mailboxes: [...emailState.mailboxes], selectedEmail: emailState.selectedEmail, selectedMailbox: emailState.selectedMailbox, searchQuery: emailState.searchQuery, - quota: emailState.quota, + quota: emailState.quota ? { ...emailState.quota } : emailState.quota, }, contact: { - contacts: contactState.contacts, - addressBooks: contactState.addressBooks, + contacts: [...contactState.contacts], + addressBooks: [...contactState.addressBooks], supportsSync: contactState.supportsSync, }, calendar: { - calendars: calendarState.calendars, - events: calendarState.events, - selectedCalendarIds: calendarState.selectedCalendarIds, + calendars: [...calendarState.calendars], + events: [...calendarState.events], + selectedCalendarIds: [...calendarState.selectedCalendarIds], viewMode: calendarState.viewMode, supportsCalendar: calendarState.supportsCalendar, }, filter: { - rules: filterState.rules, + rules: [...filterState.rules], isSupported: filterState.isSupported, }, identity: { - identities: identityState.identities, + identities: [...identityState.identities], preferredPrimaryId: identityState.preferredPrimaryId, }, vacation: { @@ -73,11 +77,22 @@ export function snapshotAccount(accountId: string): void { }); } -/** Restore cached store states for the given account. Returns false if no cache exists. */ +/** + * Restore cached store states for the given account. Returns false if no cache + * exists. + * + * The snapshot only captures a subset of each store's fields (the loaded data), + * so we reset every store to its baseline first. Without this, fields outside + * the captured subset (e.g. email selection, loading flags, tag counts) would + * carry over from whatever account was active, leaking state across accounts. + * `setState` merges, so the captured fields are then layered back on top. + */ export function restoreAccount(accountId: string): boolean { const snapshot = cache.get(accountId); if (!snapshot) return false; + clearAllStores(); + useEmailStore.setState(snapshot.email); useContactStore.setState(snapshot.contact); useCalendarStore.setState(snapshot.calendar); From 5306f7c548658485ef2a4a22426e7e8d96b67a7c Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:45:12 +0200 Subject: [PATCH 127/437] fix: cap filename tokens at the full 200-char limit, not 80 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderRaw (and the attachment-template renderer) sanitised each {token} with sanitizePart's default 80-char cap, so a single long token such as {subject} was truncated to 80 — well before the documented 200-char filename limit, which was therefore unreachable per token. Introduce a FILENAME_MAX_LEN (200) constant and use it for the per-token cap so the overall limit governs. Adds tests. --- lib/__tests__/download-filename.test.ts | 8 ++++---- lib/download-filename.ts | 21 +++++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/lib/__tests__/download-filename.test.ts b/lib/__tests__/download-filename.test.ts index 455483c99..7302713fb 100644 --- a/lib/__tests__/download-filename.test.ts +++ b/lib/__tests__/download-filename.test.ts @@ -41,11 +41,11 @@ describe('emailExportFilename', () => { expect(emailExportFilename(makeEmail({}), '{unknown_token}')).toBe('email.eml'); }); - it('CHARACTERISATION: per-token sanitise caps each value at 80 chars', () => { - // sanitizePart defaults to maxLen=80, applied per {token} during render — - // so a single long {subject} is truncated to 80 well before the 200 cap. + it('lets a single long token reach the 200-char filename cap', () => { + // Each {token} is now capped at FILENAME_MAX_LEN (200), so the overall + // filename limit governs instead of an earlier 80-char per-token cap. const out = emailExportFilename(makeEmail({ subject: 'a'.repeat(300) }), '{subject}'); - expect(out).toBe('a'.repeat(80) + '.eml'); + expect(out).toBe('a'.repeat(200) + '.eml'); }); }); diff --git a/lib/download-filename.ts b/lib/download-filename.ts index cbf4cad14..11dcc00e5 100644 --- a/lib/download-filename.ts +++ b/lib/download-filename.ts @@ -64,6 +64,11 @@ export const BUNDLE_TOKENS: { token: string; description: string }[] = [ { token: "day", description: "2-digit day" }, ]; +// Overall cap for a generated filename stem. Also used as the per-token cap so +// a single long token (e.g. {subject}) isn't truncated earlier than the final +// filename would be. +const FILENAME_MAX_LEN = 200; + function sanitizePart(input: string, maxLen = 80): string { const cleaned = input .replace(SAFE_CHARS, "_") @@ -173,7 +178,7 @@ function renderRaw(template: string, vars: Record): string { return template.replace(/\{(\w+)\}/g, (_, key: string) => { const value = vars[key]; if (value === undefined) return ""; - return sanitizePart(value); + return sanitizePart(value, FILENAME_MAX_LEN); }); } @@ -184,9 +189,9 @@ export function emailExportFilename( const opts = typeof options === "string" ? { template: options } : options; const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE; const rendered = renderRaw(template, emailVars(email)); - const cleaned = sanitizePart(rendered, 200); + const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN); const transformed = applyTransforms(cleaned, opts); - const stem = transformed.slice(0, 200) || "email"; + const stem = transformed.slice(0, FILENAME_MAX_LEN) || "email"; return `${stem}.eml`; } @@ -199,7 +204,7 @@ export function attachmentDownloadFilename( const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE; if (!email) { const filename = (attachment.name || "attachment").trim(); - const cleaned = sanitizePart(filename, 200) || "attachment"; + const cleaned = sanitizePart(filename, FILENAME_MAX_LEN) || "attachment"; return applyTransforms(cleaned, opts) || cleaned; } const vars = attachmentVars(email, attachment); @@ -208,10 +213,10 @@ export function attachmentDownloadFilename( if (value === undefined) return ""; // Preserve dots in {filename} so the original extension survives the // sanitiser (it strips trailing dots otherwise). - return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value); + return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value, FILENAME_MAX_LEN); }); const templateMentionsExt = /\{(ext|filename)\}/.test(template); - const cleaned = sanitizePart(rendered, 200) || "attachment"; + const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN) || "attachment"; if (templateMentionsExt) { return applyTransforms(cleaned, opts) || cleaned; } @@ -235,9 +240,9 @@ export function bundleExportFilename( const opts = typeof options === "string" ? { template: options } : options; const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE; const rendered = renderRaw(template, bundleVars(count, iso)); - const cleaned = sanitizePart(rendered, 200); + const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN); const transformed = applyTransforms(cleaned, opts); - const stem = transformed.slice(0, 200) || "emails"; + const stem = transformed.slice(0, FILENAME_MAX_LEN) || "emails"; return `${stem}.zip`; } From d0ed4b4dfe9ebe52ae21e5228f60f2da29a69a9e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 22 Jun 2026 00:07:23 +0200 Subject: [PATCH 128/437] fix: block remaining email tracking vectors #457 --- components/email/email-viewer.tsx | 154 ++++++----------- lib/__tests__/email-sanitization.test.ts | 206 +++++++++++++++++++++++ lib/email-sanitization.ts | 155 +++++++++++++++++ 3 files changed, 411 insertions(+), 104 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 239c86f8f..e8b8aadd2 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -5,7 +5,7 @@ import DOMPurify from "dompurify"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; -import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; +import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { withBasePath } from "@/lib/browser-navigation"; import { Button } from "@/components/ui/button"; @@ -2462,7 +2462,7 @@ export function EmailViewer({ // Sanitize and prepare email HTML content const emailContent = useMemo(() => { - if (!email) return { html: "", isHtml: false, hasStyleTag: false }; + if (!email) return { html: "", isHtml: false, hasStyleTag: false, externalBlocked: false }; // Check if we have body values if (email.bodyValues) { @@ -2530,38 +2530,14 @@ export function EmailViewer({ } DOMPurify.addHook('afterSanitizeAttributes', (node) => { - const htmlNode = node as HTMLElement; - if (shouldBlockExternal) { - if (node.tagName === 'IMG') { - const src = node.getAttribute('src'); - if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) { - node.setAttribute('data-blocked-src', src); - node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4='); - node.setAttribute('alt', ''); - htmlNode.style.display = 'none'; - blockedExternalContent = true; - } - } - - const bgAttr = node.getAttribute?.('background'); - if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) { - node.setAttribute('data-blocked-background', bgAttr); - node.removeAttribute('background'); + // Blocks every external-resource vector (img src incl. + // whitespace/newline tricks, srcset, ,