fix: say that a send failed because the device is offline and the message is still there

This commit is contained in:
Linus Rath
2026-10-31 03:52:14 +01:00
parent ae2eb46b29
commit a43b55ec25
4 changed files with 65 additions and 7 deletions
+2 -2
View File
@@ -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);
}
+38 -2
View File
@@ -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<string, unknown>) =>
@@ -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\(/);
}
});
});
+23 -1
View File
@@ -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
+2 -2
View File
@@ -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);
}