From 0fe484efda3a30755791d9bdcdbbce0185d69a03 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:19:10 +0200 Subject: [PATCH] fix: time out stalled JMAP requests so sends can't hang forever #702 --- app/(main)/[locale]/page.tsx | 10 ++ components/email/email-composer.tsx | 41 +++++- components/pro/pro-compose-tab-body.tsx | 7 + lib/__tests__/jmap-client-resilience.test.ts | 87 ++++++++++++- lib/jmap/client.ts | 127 +++++++++++++++++-- locales/ar/common.json | 1 + locales/ca/common.json | 1 + 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/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/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/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh/common.json | 1 + 29 files changed, 282 insertions(+), 14 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 5f03ea1d7..7e56e095d 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1230,11 +1230,18 @@ export default function Home() { }) => { if (!client) return; + // Separates "the submission failed" from "a follow-up step failed". Only the + // former may propagate: the composer treats a resolved onSend as a delivered + // message and wipes its fields, so swallowing a real send failure here lost + // the user's message without so much as a toast (#702). + let submitted = false; + try { 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, { requestReadReceipt: data.requestReadReceipt }); + submitted = true; setShowComposer(false); if (result.filingError) { // The mail went out, but a post-send step (filing to Sent / @@ -1299,6 +1306,9 @@ export default function Home() { } } catch (error) { console.error("Failed to send email:", error); + // Post-send bookkeeping - refreshing Sent, flagging the original as + // answered, re-reading the thread - is cosmetic once the mail is out. + if (!submitted) throw error; } }; diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 0479f1830..d1c649b1c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -39,6 +39,7 @@ import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature import { findComposeIdentityId, findDraftIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; +import { RequestTimeoutError } from "@/lib/jmap/client"; import { rewriteCidImagesForEditor, replaceInlineImagePlaceholders, @@ -508,6 +509,10 @@ export function EmailComposer({ // timer + send button) serialize instead of issuing parallel destroy/create // requests with the same draftId. See bug #303. const inflightSaveRef = useRef | null>(null); + // Whether the most recent save attempt failed. saveDraftOnce swallows its + // errors and returns null - the same value it returns for an empty draft - + // so this is the only way to distinguish the two. + const saveFailedRef = useRef(false); const [attachments, setAttachments] = useState(() => { if (mode === 'forward' && replyTo?.attachments?.length) { return replyTo.attachments @@ -1480,6 +1485,7 @@ export function EmailComposer({ setDraftId(savedDraftId); lastSavedDataRef.current = currentData; setSaveStatus('saved'); + saveFailedRef.current = false; // Reset status after 2 seconds setTimeout(() => setSaveStatus('idle'), 2000); @@ -1487,6 +1493,10 @@ export function EmailComposer({ return savedDraftId; } catch (error) { console.error('Failed to save draft:', error); + // A null return means both "nothing worth saving" and "the save broke", + // so the outcome is recorded here for callers that have to tell them + // apart - closing the composer on a failed save is worth a warning. + saveFailedRef.current = true; setSaveStatus('error'); setTimeout(() => setSaveStatus('idle'), 3000); return null; @@ -1973,7 +1983,14 @@ export function EmailComposer({ stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; } catch (err) { debug.error('Failed to send email:', err); - toast.error(err instanceof Error ? err.message : t('send_failed')); + // A timeout is not a clean failure: the submission may have reached the + // server and gone out, with only the answer lost. Saying "send failed" + // would invite a re-send and a duplicate, so point at Sent instead (#702). + toast.error( + err instanceof RequestTimeoutError + ? t('send_timeout') + : err instanceof Error ? err.message : t('send_failed') + ); } finally { isSendingRef.current = false; setIsSending(false); @@ -2034,9 +2051,25 @@ export function EmailComposer({ if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } - await saveDraft(); - stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; - onClose?.(); + // Close whatever the save does. When the request stalled, this await never + // settled and onClose never ran, so the composer stayed mounted and the next + // close attempt just re-opened this dialog - Discard was the only way out + // (#702). Requests carry a deadline now, so a broken save resolves. + try { + await saveDraft(); + if (saveFailedRef.current) { + // The server never took the draft. Clearing stateRef here would drop the + // message on the floor with nothing but a toast, so leave the text in + // place and let the unmount stash hand it to the host's "continue draft" + // slot - the same path an implicit close uses. + toast.error(t('save_failed')); + explicitCloseRef.current = false; + } else { + stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; + } + } finally { + onClose?.(); + } }; const handleDiscardAndClose = () => { diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index 039331bf3..5bf91a7bb 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -52,6 +52,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { const handleSend = useCallback(async (sendData: Parameters['onSend']>>[0]) => { if (!client) return; + // See the inline composer's handleEmailSend: a resolved onSend tells the + // composer the message is gone and it clears itself, so a genuine send + // failure has to propagate rather than be logged away (#702). + let submitted = false; try { const result = await sendEmail( client, @@ -71,6 +75,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { sendData.delayedUntil, sendData.envelopeMailFrom, ); + submitted = true; if (result.scheduled) { await handleScheduledSendCreated(); @@ -116,6 +121,8 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { closeTab(tabIdRef.current); } catch (error) { console.error('Failed to send email:', error); + // Post-send bookkeeping failing is cosmetic; the mail is already out. + if (!submitted) throw error; toast.error(t('notifications.error_sending')); } }, [client, sendEmail, closeTab, data.sourceEmailId, data.mode, t, handleScheduledSendCreated, refreshCurrentMailbox]); diff --git a/lib/__tests__/jmap-client-resilience.test.ts b/lib/__tests__/jmap-client-resilience.test.ts index a213a0490..ed8abf516 100644 --- a/lib/__tests__/jmap-client-resilience.test.ts +++ b/lib/__tests__/jmap-client-resilience.test.ts @@ -1,5 +1,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { JMAPClient } from '../jmap/client'; +import { JMAPClient, RequestTimeoutError } from '../jmap/client'; + +/** + * A connection that accepts the request and then goes silent: the promise never + * settles on its own, exactly like `fetch` over a socket the network has torn + * down underneath it. Only an abort ends it - which is what the deadline does. + */ +function stalledFetch() { + return (_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }); +} // Minimal valid JMAP session response function makeSession(overrides?: Record) { @@ -95,6 +109,77 @@ describe('JMAPClient resilience', () => { }); }); + describe('authenticatedFetch - stalled request deadline', () => { + const downloadCalls = () => + fetchSpy.mock.calls.filter(([url]: unknown[]) => String(url).includes('/jmap/download/')); + + it('rejects a request that never produces response headers', async () => { + const client = await createConnectedClient(); + fetchSpy.mockImplementation(stalledFetch()); + + const pending = client.ping(); + const assertion = expect(pending).rejects.toBeInstanceOf(RequestTimeoutError); + await vi.advanceTimersByTimeAsync(30_000); + await assertion; + }); + + it('does not replay a timed-out request', async () => { + // The network-error path retries once, but a timeout may mean the server + // received and acted on the request - replaying an EmailSubmission/set + // there would send the message twice. Counted on the download URL so the + // keep-alive ping, which fires against the API URL while the clock runs, + // stays out of the tally. + const client = await createConnectedClient(); + fetchSpy.mockImplementation(stalledFetch()); + + const pending = client.fetchBlob('blob-1', 'big.zip', 'application/zip'); + const assertion = expect(pending).rejects.toBeInstanceOf(RequestTimeoutError); + await vi.advanceTimersByTimeAsync(300_000); + await assertion; + // Well past the 1s the retry path would have waited. + await vi.advanceTimersByTimeAsync(5_000); + + expect(downloadCalls()).toHaveLength(1); + }); + + it('clears the deadline once the response arrives, leaving the body alone', async () => { + const client = await createConnectedClient(); + const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }; + let capturedSignal: AbortSignal | undefined; + + fetchSpy.mockImplementation((_url: string, init?: RequestInit) => { + capturedSignal = init?.signal ?? undefined; + return Promise.resolve(mockFetchResponse(200, echoResponse)); + }); + + await expect(client.ping()).resolves.toBeUndefined(); + + // Long after the deadline would have fired, a streaming body handed back + // to the caller (SSE) must still be readable. + await vi.advanceTimersByTimeAsync(120_000); + expect(capturedSignal?.aborted).toBe(false); + }); + + it('gives blob transfers a longer budget than ordinary requests', async () => { + const client = await createConnectedClient(); + fetchSpy.mockImplementation(stalledFetch()); + + let outcome: unknown; + const pending = client + .fetchBlob('blob-1', 'big.zip', 'application/zip') + .catch((error) => { outcome = error; }); + + // A large attachment on a slow uplink is legitimately still moving well + // past the deadline that applies to an ordinary API call. + await vi.advanceTimersByTimeAsync(60_000); + expect(outcome).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(300_000); + await pending; + expect(outcome).toBeInstanceOf(RequestTimeoutError); + }); + }); + describe('authenticatedFetch - basic auth 401 session refresh', () => { it('refreshes session and retries on 401 for API requests', async () => { const client = await createConnectedClient(); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 63e368fa5..1584d8786 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -60,6 +60,25 @@ export class RateLimitError extends Error { } } +/** + * A request produced no response headers within its deadline. + * + * `fetch()` has no timeout of its own, and a half-dead connection - the OS or a + * NAT dropped the socket while the browser still believes it is usable - never + * rejects. iOS hits this constantly: it freezes a backgrounded tab (and, more + * aggressively, a home-screen web app), and on resume WebKit reuses pooled + * connections the network has already torn down. The request bytes leave, the + * server may even act on them, but the response never comes back. Without a + * deadline the caller's promise stays pending forever, so the composer's Send + * button stays disabled and "Save draft" never closes the composer (#702). + */ +export class RequestTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Request timed out after ${Math.round(timeoutMs / 1000)}s`); + this.name = 'RequestTimeoutError'; + } +} + // JMAP protocol types - these are intentionally flexible due to server variations interface JMAPSession { // The authenticated login (JMAP spec Session.username) — server-confirmed, @@ -541,6 +560,18 @@ type SubmissionCapability = { export class JMAPClient implements IJMAPClient { private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000; + /** + * How long a request may go without producing response headers. Generous + * enough that a slow mobile link or a busy server still completes, short + * enough that a dead connection surfaces as an error the user can retry + * rather than a permanently spinning UI. + */ + private static readonly REQUEST_TIMEOUT_MS = 30_000; + /** + * Blob transfers answer only once the payload has moved, so a large + * attachment on a slow uplink legitimately outlives the normal deadline. + */ + private static readonly TRANSFER_TIMEOUT_MS = 300_000; private serverUrl: string; private username: string; @@ -669,7 +700,56 @@ export class JMAPClient implements IJMAPClient { return this.serverUrl; } - private async authenticatedFetch(url: string, init?: Parameters[1]): Promise { + /** + * `fetch` with a deadline on the response *headers*. + * + * The timer is cleared as soon as the fetch settles, so it never touches the + * body: long-lived streams (SSE) and slow blob transfers keep working once + * the server has started answering. What it does catch is the stalled case - + * a connection that accepts the request and then goes silent - which fetch + * itself would leave pending indefinitely. + * + * A caller-supplied `init.signal` still aborts the request (and, for SSE, the + * stream) at any time; it is chained into the internal controller rather than + * replaced. + */ + private async timedFetch( + url: string, + init: Parameters[1], + headers: Record, + timeoutMs: number, + ): Promise { + const controller = new AbortController(); + const external = init?.signal ?? undefined; + if (external) { + if (external.aborted) controller.abort(external.reason); + else external.addEventListener('abort', () => controller.abort(external.reason), { once: true }); + } + + // Tracked separately from the signal: the abort reason is what distinguishes + // "we gave up" from "the caller cancelled", and callers must be able to tell + // those apart (a cancelled send is not a failed send). + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + + try { + return await fetch(url, { ...init, headers, signal: controller.signal }); + } catch (error) { + if (timedOut) throw new RequestTimeoutError(timeoutMs); + throw error; + } finally { + clearTimeout(timer); + } + } + + private async authenticatedFetch( + url: string, + init?: Parameters[1], + opts?: { timeoutMs?: number }, + ): Promise { // Short-circuit: if rate-limited, reject immediately without sending a request if (this.isRateLimited()) { const remaining = this.rateLimitedUntil - Date.now(); @@ -677,16 +757,22 @@ export class JMAPClient implements IJMAPClient { throw new RateLimitError(remaining); } + const timeoutMs = opts?.timeoutMs ?? JMAPClient.REQUEST_TIMEOUT_MS; const headers = { ...init?.headers as Record, 'Authorization': this.authHeader }; let response: Response; try { - response = await fetch(url, { ...init, headers }); + response = await this.timedFetch(url, init, headers, timeoutMs); } catch (error) { // Network error: retry once after brief delay (transient proxy/connection issues) if (this.reconnecting) throw error; + // A timeout is NOT retried. The request may well have reached the server + // and been acted on - only the answer was lost - and these bodies are not + // idempotent: replaying an EmailSubmission/set would send the mail twice. + // Surface it instead so the caller can report a failure the user can act on. + if (error instanceof RequestTimeoutError) throw error; await new Promise(r => setTimeout(r, 1000)); - response = await fetch(url, { ...init, headers }); + response = await this.timedFetch(url, init, headers, timeoutMs); } // Handle 429 rate limiting - stop immediately, do not retry @@ -702,7 +788,7 @@ export class JMAPClient implements IJMAPClient { if (newToken) { this.updateAccessToken(newToken); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; - response = await fetch(url, { ...init, headers: retryHeaders }); + response = await this.timedFetch(url, init, retryHeaders, timeoutMs); } } else if (this.authMode === 'basic' && !this.reconnecting && url !== `${this.serverUrl}/.well-known/jmap`) { // JMAP session may have expired - re-establish and retry once @@ -711,7 +797,7 @@ export class JMAPClient implements IJMAPClient { await this.refreshSession(); this.connectionChangeCallback?.(true); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; - response = await fetch(url, { ...init, headers: retryHeaders }); + response = await this.timedFetch(url, init, retryHeaders, timeoutMs); } catch { // Session refresh failed - if TOTP was used, try re-auth with fresh TOTP if (this.onTotpRequired && this.basePassword) { @@ -722,7 +808,7 @@ export class JMAPClient implements IJMAPClient { await this.refreshSession(); this.connectionChangeCallback?.(true); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; - response = await fetch(url, { ...init, headers: retryHeaders }); + response = await this.timedFetch(url, init, retryHeaders, timeoutMs); } } catch { // TOTP re-auth also failed - return original 401 @@ -3463,7 +3549,7 @@ export class JMAPClient implements IJMAPClient { method: 'POST', headers: { 'Content-Type': file.type || 'application/octet-stream' }, body: file, - }); + }, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to upload file: ${response.status} - ${errorText}`); @@ -3618,7 +3704,7 @@ export class JMAPClient implements IJMAPClient { async fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise { const url = this.getBlobDownloadUrl(blobId, name, type, accountId); - const response = await this.authenticatedFetch(url, {}); + const response = await this.authenticatedFetch(url, {}, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS }); if (!response.ok) { throw new Error(`Failed to fetch blob: ${response.status}`); } @@ -6337,12 +6423,35 @@ export class JMAPClient implements IJMAPClient { } } + /** + * Drop and rebuild an SSE stream that has gone quiet past the ping deadline. + * + * The ping monitor already does this, but it is a `setInterval`, and a + * suspended tab - an iOS home-screen web app in particular - freezes its + * timers along with everything else. Coming back to a page whose push + * connection died while it slept, the monitor needs up to a full tick just to + * notice, and the connection it is holding is one the OS already tore down. + * Checking on the way back in makes the recovery immediate rather than + * leaving the app on a socket that will never deliver another byte. + */ + private recycleStaleSSE(): void { + if (this.intentionallyDisconnected) return; + if (!this.sseAbortController) return; + if (Date.now() - this.lastSSEActivity <= JMAPClient.SSE_PING_TIMEOUT) return; + + this.stopSSEPingMonitor(); + this.sseAbortController.abort(); + this.sseAbortController = null; + this.scheduleSSEReconnect(); + } + private setupBrowserEventListeners(): void { if (typeof document !== 'undefined') { this.visibilityHandler = () => { if (!document.hidden) { // Tab became visible - immediately check for state changes this.checkForStateChanges(); + this.recycleStaleSSE(); } }; document.addEventListener('visibilitychange', this.visibilityHandler); @@ -6465,7 +6574,7 @@ export class JMAPClient implements IJMAPClient { /** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */ async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise { const url = this.getBlobDownloadUrl(blobId, name, type, accountId); - const response = await this.authenticatedFetch(url, {}); + const response = await this.authenticatedFetch(url, {}, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS }); if (!response.ok) { throw new Error(`Failed to fetch blob: ${response.status}`); } diff --git a/locales/ar/common.json b/locales/ar/common.json index 67c7a25f5..f01446730 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -646,6 +646,7 @@ "drop_files": "أفلت الملفات لإرفاقها", "show_less": "إظهار أقل", "send_failed": "فشل إرسال الرسالة", + "send_timeout": "توقف الخادم عن الاستجابة. تحقق من مجلد المُرسَل قبل إعادة الإرسال — فقد تكون الرسالة قد أُرسلت بالفعل.", "continue_draft": "متابعة المسودة", "close_draft_title": "حفظ المسودة أم تجاهلها؟", "close_draft_message": "لديك تغييرات غير محفوظة. هل تريد حفظها كمسودة أم تجاهلها؟", diff --git a/locales/ca/common.json b/locales/ca/common.json index a2da0f2f0..b2415c452 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -646,6 +646,7 @@ "drop_files": "Deixeu anar els fitxers per adjuntar-los", "show_less": "Mostra'n menys", "send_failed": "No s'ha pogut enviar el correu", + "send_timeout": "El servidor ha deixat de respondre. Comproveu la carpeta Enviats abans de tornar a enviar: el missatge pot haver sortit.", "continue_draft": "Continua l'esborrany", "close_draft_title": "Voleu desar o descartar l'esborrany?", "close_draft_message": "Teniu canvis sense desar. Voleu desar-los com a esborrany o descartar-los?", diff --git a/locales/cs/common.json b/locales/cs/common.json index 3025a07d8..79aec0196 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -643,6 +643,7 @@ "drop_files": "Přetáhněte soubory sem a připojte je", "show_less": "Zobrazit méně", "send_failed": "Odeslání zprávy selhalo", + "send_timeout": "Server přestal odpovídat. Než odešlete znovu, zkontrolujte složku Odeslané – zpráva už mohla odejít.", "continue_draft": "Pokračovat v úpravě konceptu", "close_draft_title": "Uložit nebo zahodit koncept?", "close_draft_message": "Máte neuložené změny. Chcete je uložit jako koncept, nebo zahodit?", diff --git a/locales/da/common.json b/locales/da/common.json index 8503e48b5..04e634e60 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -645,6 +645,7 @@ "drop_files": "Træk filer hertil for at vedhæfte", "show_less": "Vis mindre", "send_failed": "Kunne ikke sende e-mail", + "send_timeout": "Serveren holdt op med at svare. Tjek mappen Sendt, før du sender igen — beskeden er måske allerede afsendt.", "continue_draft": "Fortsæt kladde", "close_draft_title": "Gem eller kassér kladde?", "close_draft_message": "Du har ugemte ændringer. Vil du gemme dette som en kladde eller kassere det?", diff --git a/locales/de/common.json b/locales/de/common.json index bfa1814d0..eefe0e83d 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -650,6 +650,7 @@ "smime_unlock_message": "Geben Sie die Passphrase ein, um Ihren S/MIME-Schlüssel zu entsperren.", "smime_unlock_button": "Entsperren", "smime_passphrase_placeholder": "S/MIME-Passphrase", + "send_timeout": "Der Server antwortet nicht mehr. Prüfen Sie den Ordner „Gesendet“, bevor Sie erneut senden – die Nachricht wurde möglicherweise bereits verschickt.", "continue_draft": "Entwurf fortsetzen", "close_draft_title": "Entwurf speichern oder verwerfen?", "close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?", diff --git a/locales/en/common.json b/locales/en/common.json index c05a71cbd..20756d238 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -646,6 +646,7 @@ "drop_files": "Drop files to attach", "show_less": "Show less", "send_failed": "Failed to send email", + "send_timeout": "The server stopped responding. Check your Sent folder before sending again — the message may have gone out.", "continue_draft": "Continue draft", "close_draft_title": "Save or discard draft?", "close_draft_message": "You have unsaved changes. Would you like to save this as a draft or discard it?", diff --git a/locales/es/common.json b/locales/es/common.json index 25a53a64f..812972862 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -650,6 +650,7 @@ "smime_unlock_message": "Introduce la contraseña para desbloquear tu clave S/MIME.", "smime_unlock_button": "Desbloquear", "smime_passphrase_placeholder": "Contraseña S/MIME", + "send_timeout": "El servidor dejó de responder. Compruebe la carpeta Enviados antes de volver a enviar: el mensaje puede haber salido.", "continue_draft": "Continuar borrador", "close_draft_title": "¿Guardar o descartar borrador?", "close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?", diff --git a/locales/fa/common.json b/locales/fa/common.json index c8f472bf7..c46679470 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -646,6 +646,7 @@ "drop_files": "فایل‌ها را اینجا رها کنید", "show_less": "نمایش کمتر", "send_failed": "ارسال ایمیل ناموفق بود", + "send_timeout": "سرور دیگر پاسخ نمی‌دهد. پیش از ارسال دوباره، پوشهٔ ارسال‌شده‌ها را بررسی کنید — ممکن است پیام ارسال شده باشد.", "continue_draft": "ادامه پیش‌نویس", "close_draft_title": "ذخیره یا دور انداختن پیش‌نویس؟", "close_draft_message": "تغییرات ذخیره نشده دارید. ذخیره یا دور انداختن؟", diff --git a/locales/fr/common.json b/locales/fr/common.json index e174bbc96..97da02401 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -650,6 +650,7 @@ "smime_unlock_message": "Entrez la phrase secrète pour déverrouiller votre clé S/MIME.", "smime_unlock_button": "Déverrouiller", "smime_passphrase_placeholder": "Phrase secrète S/MIME", + "send_timeout": "Le serveur ne répond plus. Vérifiez le dossier Envoyés avant de renvoyer : le message est peut-être déjà parti.", "continue_draft": "Continuer le brouillon", "close_draft_title": "Enregistrer ou supprimer le brouillon ?", "close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?", diff --git a/locales/he/common.json b/locales/he/common.json index b250cd928..4eea36799 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -595,6 +595,7 @@ "drop_files": "שחרר קבצים לצירוף", "show_less": "הצג פחות", "send_failed": "שליחת האימייל נכשלה", + "send_timeout": "השרת הפסיק להגיב. בדקו את תיקיית הנשלחים לפני שליחה חוזרת — ייתכן שההודעה כבר נשלחה.", "continue_draft": "המשך טיוטה", "close_draft_title": "לשמור או למחוק טיוטה?", "close_draft_message": "יש לך שינויים שלא נשמרו. האם תרצה לשמור את זה כטיוטה או למחוק אותו?", diff --git a/locales/hu/common.json b/locales/hu/common.json index f1320a7bc..dffbb1c38 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -646,6 +646,7 @@ "drop_files": "Fájlok ejtése a csatoláshoz", "show_less": "Kevesebb mutatása", "send_failed": "Nem sikerült elküldeni az e-mailt", + "send_timeout": "A kiszolgáló nem válaszol többé. Újraküldés előtt ellenőrizze az Elküldött mappát – az üzenet lehet, hogy már kiment.", "continue_draft": "Piszkozat folytatása", "close_draft_title": "Piszkozat mentése vagy elvetése?", "close_draft_message": "Nem mentett változtatások vannak. Szeretnéd piszkozatként menteni vagy elvetni?", diff --git a/locales/it/common.json b/locales/it/common.json index d469e1f87..d397a69bd 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -650,6 +650,7 @@ "smime_unlock_message": "Inserisci la passphrase per sbloccare la tua chiave S/MIME.", "smime_unlock_button": "Sblocca", "smime_passphrase_placeholder": "Passphrase S/MIME", + "send_timeout": "Il server ha smesso di rispondere. Controlli la cartella Posta inviata prima di inviare di nuovo: il messaggio potrebbe essere già partito.", "continue_draft": "Continua bozza", "close_draft_title": "Salvare o eliminare la bozza?", "close_draft_message": "Hai modifiche non salvate. Vuoi salvare come bozza o eliminare?", diff --git a/locales/ja/common.json b/locales/ja/common.json index 36054845e..648efffee 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -650,6 +650,7 @@ "smime_unlock_message": "S/MIME 鍵を解除するためのパスフレーズを入力してください。", "smime_unlock_button": "ロック解除", "smime_passphrase_placeholder": "S/MIME パスフレーズ", + "send_timeout": "サーバーが応答しなくなりました。再送信する前に送信済みフォルダーを確認してください。メッセージはすでに送信されている可能性があります。", "continue_draft": "下書きを続ける", "close_draft_title": "下書きを保存または破棄しますか?", "close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?", diff --git a/locales/ko/common.json b/locales/ko/common.json index 85c57ab64..38bce8b71 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -643,6 +643,7 @@ "drop_files": "첨부할 파일을 여기에 놓아주세요", "show_less": "간략히 보기", "send_failed": "메일 전송에 실패했어요", + "send_timeout": "서버가 응답을 멈췄습니다. 다시 보내기 전에 보낸 편지함을 확인하세요. 메시지가 이미 발송되었을 수 있습니다.", "continue_draft": "이어서 작성하기", "close_draft_title": "임시보관함에 저장할까요?", "close_draft_message": "저장되지 않은 내용이 있어요. 임시보관함에 저장할까요, 아니면 삭제할까요?", diff --git a/locales/lv/common.json b/locales/lv/common.json index ab3f02b33..1e3f4a86c 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -643,6 +643,7 @@ "drop_files": "Velciet failus šeit, lai pievienotu", "show_less": "Rādīt mazāk", "send_failed": "Neizdevās nosūtīt vēstuli", + "send_timeout": "Serveris pārstāja atbildēt. Pirms sūtīt vēlreiz, pārbaudiet mapi Nosūtītie — ziņojums, iespējams, jau ir aizsūtīts.", "continue_draft": "Turpināt melnrakstu", "close_draft_title": "Saglabāt vai atmest melnrakstu?", "close_draft_message": "Ir nesaglabātas izmaiņas. Vai vēlaties saglabāt kā melnrakstu vai atmest?", diff --git a/locales/nl/common.json b/locales/nl/common.json index 6d6e15878..59a79db2e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -650,6 +650,7 @@ "smime_unlock_message": "Voer de wachtwoordzin in om uw S/MIME-sleutel te ontgrendelen.", "smime_unlock_button": "Ontgrendelen", "smime_passphrase_placeholder": "S/MIME-wachtwoordzin", + "send_timeout": "De server reageert niet meer. Controleer de map Verzonden voordat u opnieuw verstuurt — het bericht is mogelijk al verzonden.", "continue_draft": "Concept voortzetten", "close_draft_title": "Concept opslaan of verwijderen?", "close_draft_message": "U heeft niet-opgeslagen wijzigingen. Wilt u dit als concept opslaan of verwijderen?", diff --git a/locales/pl/common.json b/locales/pl/common.json index 7d66f712a..faaca28db 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -643,6 +643,7 @@ "drop_files": "Upuść pliki, aby załączyć", "show_less": "Pokaż mniej", "send_failed": "Nie udało się wysłać wiadomości", + "send_timeout": "Serwer przestał odpowiadać. Przed ponownym wysłaniem sprawdź folder Wysłane — wiadomość mogła już zostać wysłana.", "continue_draft": "Kontynuuj szkic", "close_draft_title": "Zapisać czy odrzucić szkic?", "close_draft_message": "Masz niezapisane zmiany. Czy chcesz zapisać jako szkic czy odrzucić?", diff --git a/locales/pt/common.json b/locales/pt/common.json index 855126088..ca4ab329b 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -650,6 +650,7 @@ "smime_unlock_message": "Insira a frase secreta para desbloquear sua chave S/MIME.", "smime_unlock_button": "Desbloquear", "smime_passphrase_placeholder": "Frase secreta S/MIME", + "send_timeout": "O servidor deixou de responder. Verifique a pasta Enviados antes de enviar novamente — a mensagem pode já ter saído.", "continue_draft": "Continuar rascunho", "close_draft_title": "Salvar ou descartar rascunho?", "close_draft_message": "Você tem alterações não salvas. Deseja salvar como rascunho ou descartar?", diff --git a/locales/ro/common.json b/locales/ro/common.json index 30ff81d02..7da59a55f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -646,6 +646,7 @@ "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", + "send_timeout": "Serverul a încetat să răspundă. Verificați folderul Trimise înainte de a trimite din nou — mesajul poate să fi plecat deja.", "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?", diff --git a/locales/ru/common.json b/locales/ru/common.json index 15b160f18..6b7d6440d 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -643,6 +643,7 @@ "drop_files": "Перетащите файлы для прикрепления", "show_less": "Свернуть", "send_failed": "Не удалось отправить письмо", + "send_timeout": "Сервер перестал отвечать. Прежде чем отправлять снова, проверьте папку «Отправленные» — письмо могло уже уйти.", "continue_draft": "Продолжить черновик", "close_draft_title": "Сохранить или удалить черновик?", "close_draft_message": "Есть несохранённые изменения. Сохранить как черновик или удалить?", diff --git a/locales/sk/common.json b/locales/sk/common.json index 65ebcefe0..d17133639 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -646,6 +646,7 @@ "drop_files": "Presuňte súbory sem na priloženie", "show_less": "Zobraziť menej", "send_failed": "Odoslanie e-mailu zlyhalo", + "send_timeout": "Server prestal odpovedať. Pred opätovným odoslaním skontrolujte priečinok Odoslané — správa už mohla odísť.", "continue_draft": "Pokračovať v koncepte", "close_draft_title": "Uložiť alebo zahoditť koncept?", "close_draft_message": "Máte neuložené zmeny. Chcete ich uložiť ako koncept alebo zahoditť?", diff --git a/locales/tr/common.json b/locales/tr/common.json index 9b0b1785d..5764b4279 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -646,6 +646,7 @@ "drop_files": "Eklemek için dosyaları buraya bırakın", "show_less": "Daha az göster", "send_failed": "E-posta gönderilemedi", + "send_timeout": "Sunucu yanıt vermeyi bıraktı. Yeniden göndermeden önce Gönderilenler klasörünü kontrol edin — ileti gönderilmiş olabilir.", "continue_draft": "Taslağa devam et", "close_draft_title": "Taslak kaydedilsin mi yoksa silinsin mi?", "close_draft_message": "Kaydedilmemiş değişiklikleriniz var. Bunu taslak olarak kaydetmek ister misiniz yoksa vazgeçmek mi?", diff --git a/locales/uk/common.json b/locales/uk/common.json index bed8f2edb..488a43e3e 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -643,6 +643,7 @@ "drop_files": "Перетягніть файли, щоб прикріпити", "show_less": "Показувати менше", "send_failed": "Не вдалося надіслати електронний лист", + "send_timeout": "Сервер перестав відповідати. Перш ніж надсилати знову, перевірте теку «Надіслані» — лист міг уже піти.", "continue_draft": "Продовжити чернетку", "close_draft_title": "Зберегти чи видалити чернетку?", "close_draft_message": "У вас є незбережені зміни. Зберегти це як чернетку чи видалити?", diff --git a/locales/zh/common.json b/locales/zh/common.json index 54475a387..288db067d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -643,6 +643,7 @@ "drop_files": "拖放要附加的文件", "show_less": "收起", "send_failed": "发送邮件失败", + "send_timeout": "服务器已停止响应。再次发送前请检查“已发送”文件夹——邮件可能已经发出。", "continue_draft": "继续编辑草稿", "close_draft_title": "保存或丢弃草稿?", "close_draft_message": "您有未保存的更改。要将其保存为草稿还是丢弃?",