mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
fix: time out stalled JMAP requests so sends can't hang forever #702
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -646,6 +646,7 @@
|
||||
"drop_files": "أفلت الملفات لإرفاقها",
|
||||
"show_less": "إظهار أقل",
|
||||
"send_failed": "فشل إرسال الرسالة",
|
||||
"send_timeout": "توقف الخادم عن الاستجابة. تحقق من مجلد المُرسَل قبل إعادة الإرسال — فقد تكون الرسالة قد أُرسلت بالفعل.",
|
||||
"continue_draft": "متابعة المسودة",
|
||||
"close_draft_title": "حفظ المسودة أم تجاهلها؟",
|
||||
"close_draft_message": "لديك تغييرات غير محفوظة. هل تريد حفظها كمسودة أم تجاهلها؟",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -646,6 +646,7 @@
|
||||
"drop_files": "فایلها را اینجا رها کنید",
|
||||
"show_less": "نمایش کمتر",
|
||||
"send_failed": "ارسال ایمیل ناموفق بود",
|
||||
"send_timeout": "سرور دیگر پاسخ نمیدهد. پیش از ارسال دوباره، پوشهٔ ارسالشدهها را بررسی کنید — ممکن است پیام ارسال شده باشد.",
|
||||
"continue_draft": "ادامه پیشنویس",
|
||||
"close_draft_title": "ذخیره یا دور انداختن پیشنویس؟",
|
||||
"close_draft_message": "تغییرات ذخیره نشده دارید. ذخیره یا دور انداختن؟",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -595,6 +595,7 @@
|
||||
"drop_files": "שחרר קבצים לצירוף",
|
||||
"show_less": "הצג פחות",
|
||||
"send_failed": "שליחת האימייל נכשלה",
|
||||
"send_timeout": "השרת הפסיק להגיב. בדקו את תיקיית הנשלחים לפני שליחה חוזרת — ייתכן שההודעה כבר נשלחה.",
|
||||
"continue_draft": "המשך טיוטה",
|
||||
"close_draft_title": "לשמור או למחוק טיוטה?",
|
||||
"close_draft_message": "יש לך שינויים שלא נשמרו. האם תרצה לשמור את זה כטיוטה או למחוק אותו?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
|
||||
|
||||
@@ -643,6 +643,7 @@
|
||||
"drop_files": "첨부할 파일을 여기에 놓아주세요",
|
||||
"show_less": "간략히 보기",
|
||||
"send_failed": "메일 전송에 실패했어요",
|
||||
"send_timeout": "서버가 응답을 멈췄습니다. 다시 보내기 전에 보낸 편지함을 확인하세요. 메시지가 이미 발송되었을 수 있습니다.",
|
||||
"continue_draft": "이어서 작성하기",
|
||||
"close_draft_title": "임시보관함에 저장할까요?",
|
||||
"close_draft_message": "저장되지 않은 내용이 있어요. 임시보관함에 저장할까요, 아니면 삭제할까요?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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ć?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -643,6 +643,7 @@
|
||||
"drop_files": "Перетащите файлы для прикрепления",
|
||||
"show_less": "Свернуть",
|
||||
"send_failed": "Не удалось отправить письмо",
|
||||
"send_timeout": "Сервер перестал отвечать. Прежде чем отправлять снова, проверьте папку «Отправленные» — письмо могло уже уйти.",
|
||||
"continue_draft": "Продолжить черновик",
|
||||
"close_draft_title": "Сохранить или удалить черновик?",
|
||||
"close_draft_message": "Есть несохранённые изменения. Сохранить как черновик или удалить?",
|
||||
|
||||
@@ -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ť?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -643,6 +643,7 @@
|
||||
"drop_files": "Перетягніть файли, щоб прикріпити",
|
||||
"show_less": "Показувати менше",
|
||||
"send_failed": "Не вдалося надіслати електронний лист",
|
||||
"send_timeout": "Сервер перестав відповідати. Перш ніж надсилати знову, перевірте теку «Надіслані» — лист міг уже піти.",
|
||||
"continue_draft": "Продовжити чернетку",
|
||||
"close_draft_title": "Зберегти чи видалити чернетку?",
|
||||
"close_draft_message": "У вас є незбережені зміни. Зберегти це як чернетку чи видалити?",
|
||||
|
||||
@@ -643,6 +643,7 @@
|
||||
"drop_files": "拖放要附加的文件",
|
||||
"show_less": "收起",
|
||||
"send_failed": "发送邮件失败",
|
||||
"send_timeout": "服务器已停止响应。再次发送前请检查“已发送”文件夹——邮件可能已经发出。",
|
||||
"continue_draft": "继续编辑草稿",
|
||||
"close_draft_title": "保存或丢弃草稿?",
|
||||
"close_draft_message": "您有未保存的更改。要将其保存为草稿还是丢弃?",
|
||||
|
||||
Reference in New Issue
Block a user