diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index 662ba5e1..7717b199 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -15,7 +15,7 @@ import { sendEmail, patchKeywordsForEmails, SendUnconfirmedError } from '../../a import { jmapClient } from '../../api/jmap-client'; import { pruneEmailSubmissions } from '../../api/submissions'; import { buildReplyRecipients } from '../../lib/reply-recipients'; -import { partlyRefusedWarning, sendFailureMessage } from '../../lib/send-failure-message'; +import { composerSendFailureMessage, partlyRefusedWarning } from '../../lib/send-failure-message'; import { buildReplySubject } from '../../lib/subject-prefix'; import { computeReplyThreadingHeaders } from '../../lib/email-threading'; import { resolveReplyIdentity } from '../../lib/reply-identity'; @@ -255,7 +255,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF ); return; } - Alert.alert(t('email_composer.send_failed', 'Failed to send'), sendFailureMessage(err, t)); + Alert.alert(t('email_composer.send_failed', 'Failed to send'), composerSendFailureMessage(err, t)); } finally { setSending(false); } diff --git a/src/lib/__tests__/send-failure-message.test.ts b/src/lib/__tests__/send-failure-message.test.ts index bf397d84..f6decc23 100644 --- a/src/lib/__tests__/send-failure-message.test.ts +++ b/src/lib/__tests__/send-failure-message.test.ts @@ -1,10 +1,15 @@ import { describe, it, expect } from 'vitest'; -import { RequestTimeoutError } from '../../api/jmap-client'; +import fs from 'node:fs'; +import path from 'node:path'; +import { NetworkError, RequestTimeoutError } from '../../api/jmap-client'; +import { useNetworkStore } from '../../stores/network-store'; import { JMAPMethodError, NoLongerScheduledError, RecipientsRefusedError, ScheduleTooLateError, SubmissionQuotaError, formatRefusedRecipients, } from '../../api/jmap-result'; -import { partlyRefusedWarning, rescheduleFailureNotice, sendFailureMessage } from '../send-failure-message'; +import { + composerSendFailureMessage, partlyRefusedWarning, rescheduleFailureNotice, sendFailureMessage, +} from '../send-failure-message'; // The English fallback stands in for the catalog. const t = (key: string, fallback?: string, params?: Record) => @@ -97,3 +102,34 @@ describe('recipients the server refused', () => { }); }); }); + +describe('composerSendFailureMessage (offline)', () => { + const OFFLINE = /You seem to be offline\. The message is still here/; + + it('says offline instead of the transport error', () => { + expect(composerSendFailureMessage(new NetworkError('Network request failed'), t)).toMatch(OFFLINE); + expect(composerSendFailureMessage(new TypeError('Network request failed'), t)).toMatch(OFFLINE); + }); + + it('says offline when the device reports no connection', () => { + useNetworkStore.setState({ online: false }); + try { + expect(composerSendFailureMessage(new Error('boom'), t)).toMatch(OFFLINE); + } finally { + useNetworkStore.setState({ online: true }); + } + }); + + it("keeps the server's words and the timeout advice when online", () => { + expect(composerSendFailureMessage(new Error('Relay denied'), t)).toBe('Relay denied'); + expect(composerSendFailureMessage(new RequestTimeoutError(30_000), t)).toMatch(/Check your Sent folder/); + }); + + it('is what the composer and quick reply show', () => { + for (const rel of ['../../screens/ComposeScreen.tsx', '../../components/email/QuickReplyBox.tsx']) { + const source = fs.readFileSync(path.resolve(__dirname, rel), 'utf8'); + expect(source).toMatch(/Alert\.alert\(t\('email_composer\.send_failed', '[^']+'\), composerSendFailureMessage\((e|err), t\)\);/); + expect(source).not.toMatch(/[^r]sendFailureMessage\(/); + } + }); +}); diff --git a/src/lib/send-failure-message.ts b/src/lib/send-failure-message.ts index 502b4578..4b7b4d87 100644 --- a/src/lib/send-failure-message.ts +++ b/src/lib/send-failure-message.ts @@ -1,4 +1,5 @@ -import { RequestTimeoutError } from '../api/jmap-client'; +import { NetworkError, RequestTimeoutError } from '../api/jmap-client'; +import { useNetworkStore } from '../stores/network-store'; import { NoLongerScheduledError, RecipientsRefusedError, ScheduleTooLateError, SubmissionQuotaError, formatRefusedRecipients, type RefusedRecipient, @@ -62,6 +63,27 @@ export function sendFailureMessage(error: unknown, t: Translate): string { : t('notifications.error_sending', 'Failed to send email'); } +/** + * sendFailureMessage for a message the user is writing (composer, quick + * reply): a send that never reached the server because the device is + * offline says so, and that the text is still there, instead of "Network + * request failed" (webmail email-composer.tsx send_offline). The server is + * unreachable when the transport failed (the client's NetworkError, or a + * bare fetch TypeError) or the device reports no connection. + */ +export function composerSendFailureMessage(error: unknown, t: Translate): string { + const offline = error instanceof NetworkError + || error instanceof TypeError + || !useNetworkStore.getState().online; + if (offline && !(error instanceof RequestTimeoutError)) { + return t( + 'email_composer.send_offline', + 'You seem to be offline. The message is still here, send it again once you are back online.', + ); + } + return sendFailureMessage(error, t); +} + /** * The title and message for a "Send now" or reschedule that failed: most * failures get `failTitle` and sendFailureMessage (webmail diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index e4482a3e..5506b13d 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -48,7 +48,7 @@ import { } from '../api/email'; import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client'; import { pruneEmailSubmissions } from '../api/submissions'; -import { partlyRefusedWarning, sendFailureMessage } from '../lib/send-failure-message'; +import { composerSendFailureMessage, partlyRefusedWarning } from '../lib/send-failure-message'; import { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients'; import { buildReplySubject, buildForwardSubject } from '../lib/subject-prefix'; import { computeReplyThreadingHeaders, generateMessageId, stripMessageIdBrackets } from '../lib/email-threading'; @@ -2559,7 +2559,7 @@ export default function ComposeScreen({ route, navigation }: Props) { ); return; } - Alert.alert(t('email_composer.send_failed', 'Send failed'), sendFailureMessage(e, t)); + Alert.alert(t('email_composer.send_failed', 'Send failed'), composerSendFailureMessage(e, t)); } finally { setSending(false); }