fix: time out stalled JMAP requests so sends can't hang forever #702

This commit is contained in:
Linus Rath
2026-08-05 17:19:10 +02:00
parent e738941950
commit 0fe484efda
29 changed files with 282 additions and 14 deletions
+10
View File
@@ -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;
}
};
+37 -4
View File
@@ -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<Promise<string | null> | 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<ComposerAttachment[]>(() => {
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 = () => {
+7
View File
@@ -52,6 +52,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const handleSend = useCallback(async (sendData: Parameters<NonNullable<React.ComponentProps<typeof EmailComposer>['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]);
+86 -1
View File
@@ -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<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('The operation was aborted.', 'AbortError'));
});
});
}
// Minimal valid JMAP session response
function makeSession(overrides?: Record<string, unknown>) {
@@ -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();
+118 -9
View File
@@ -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<typeof fetch>[1]): Promise<Response> {
/**
* `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<typeof fetch>[1],
headers: Record<string, string>,
timeoutMs: number,
): Promise<Response> {
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<typeof fetch>[1],
opts?: { timeoutMs?: number },
): Promise<Response> {
// 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<string, string>, '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<string, string>, '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<string, string>, '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<string, string>, '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<Blob> {
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<ArrayBuffer> {
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}`);
}
+1
View File
@@ -646,6 +646,7 @@
"drop_files": "أفلت الملفات لإرفاقها",
"show_less": "إظهار أقل",
"send_failed": "فشل إرسال الرسالة",
"send_timeout": "توقف الخادم عن الاستجابة. تحقق من مجلد المُرسَل قبل إعادة الإرسال — فقد تكون الرسالة قد أُرسلت بالفعل.",
"continue_draft": "متابعة المسودة",
"close_draft_title": "حفظ المسودة أم تجاهلها؟",
"close_draft_message": "لديك تغييرات غير محفوظة. هل تريد حفظها كمسودة أم تجاهلها؟",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -646,6 +646,7 @@
"drop_files": "فایل‌ها را اینجا رها کنید",
"show_less": "نمایش کمتر",
"send_failed": "ارسال ایمیل ناموفق بود",
"send_timeout": "سرور دیگر پاسخ نمی‌دهد. پیش از ارسال دوباره، پوشهٔ ارسال‌شده‌ها را بررسی کنید — ممکن است پیام ارسال شده باشد.",
"continue_draft": "ادامه پیش‌نویس",
"close_draft_title": "ذخیره یا دور انداختن پیش‌نویس؟",
"close_draft_message": "تغییرات ذخیره نشده دارید. ذخیره یا دور انداختن؟",
+1
View File
@@ -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 ?",
+1
View File
@@ -595,6 +595,7 @@
"drop_files": "שחרר קבצים לצירוף",
"show_less": "הצג פחות",
"send_failed": "שליחת האימייל נכשלה",
"send_timeout": "השרת הפסיק להגיב. בדקו את תיקיית הנשלחים לפני שליחה חוזרת — ייתכן שההודעה כבר נשלחה.",
"continue_draft": "המשך טיוטה",
"close_draft_title": "לשמור או למחוק טיוטה?",
"close_draft_message": "יש לך שינויים שלא נשמרו. האם תרצה לשמור את זה כטיוטה או למחוק אותו?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -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": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
+1
View File
@@ -643,6 +643,7 @@
"drop_files": "첨부할 파일을 여기에 놓아주세요",
"show_less": "간략히 보기",
"send_failed": "메일 전송에 실패했어요",
"send_timeout": "서버가 응답을 멈췄습니다. 다시 보내기 전에 보낸 편지함을 확인하세요. 메시지가 이미 발송되었을 수 있습니다.",
"continue_draft": "이어서 작성하기",
"close_draft_title": "임시보관함에 저장할까요?",
"close_draft_message": "저장되지 않은 내용이 있어요. 임시보관함에 저장할까요, 아니면 삭제할까요?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -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ć?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -643,6 +643,7 @@
"drop_files": "Перетащите файлы для прикрепления",
"show_less": "Свернуть",
"send_failed": "Не удалось отправить письмо",
"send_timeout": "Сервер перестал отвечать. Прежде чем отправлять снова, проверьте папку «Отправленные» — письмо могло уже уйти.",
"continue_draft": "Продолжить черновик",
"close_draft_title": "Сохранить или удалить черновик?",
"close_draft_message": "Есть несохранённые изменения. Сохранить как черновик или удалить?",
+1
View File
@@ -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ť?",
+1
View File
@@ -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?",
+1
View File
@@ -643,6 +643,7 @@
"drop_files": "Перетягніть файли, щоб прикріпити",
"show_less": "Показувати менше",
"send_failed": "Не вдалося надіслати електронний лист",
"send_timeout": "Сервер перестав відповідати. Перш ніж надсилати знову, перевірте теку «Надіслані» — лист міг уже піти.",
"continue_draft": "Продовжити чернетку",
"close_draft_title": "Зберегти чи видалити чернетку?",
"close_draft_message": "У вас є незбережені зміни. Зберегти це як чернетку чи видалити?",
+1
View File
@@ -643,6 +643,7 @@
"drop_files": "拖放要附加的文件",
"show_less": "收起",
"send_failed": "发送邮件失败",
"send_timeout": "服务器已停止响应。再次发送前请检查“已发送”文件夹——邮件可能已经发出。",
"continue_draft": "继续编辑草稿",
"close_draft_title": "保存或丢弃草稿?",
"close_draft_message": "您有未保存的更改。要将其保存为草稿还是丢弃?",