From b58c9dd719ff2788252698ed6f3d9800ff12fe30 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 22:38:43 +0100
Subject: [PATCH 1/9] fix: hide the custom From in the composer when the
organisation turns it off
---
.../custom-from-third-party-wiring.test.ts | 8 +++
src/lib/__tests__/compose-sender.test.ts | 38 ++++++++++++
src/lib/compose-sender.ts | 52 ++++++++++++++++
src/screens/ComposeScreen.tsx | 59 +++++++++----------
4 files changed, 125 insertions(+), 32 deletions(-)
create mode 100644 src/lib/__tests__/compose-sender.test.ts
create mode 100644 src/lib/compose-sender.ts
diff --git a/src/components/__tests__/custom-from-third-party-wiring.test.ts b/src/components/__tests__/custom-from-third-party-wiring.test.ts
index 7d648ed3..4df4945a 100644
--- a/src/components/__tests__/custom-from-third-party-wiring.test.ts
+++ b/src/components/__tests__/custom-from-third-party-wiring.test.ts
@@ -20,6 +20,14 @@ describe('customFromEnabled', () => {
it('matches only exact identities in the composer when custom From is off', () => {
expect(read('screens/ComposeScreen.tsx')).toMatch(/matchMode: customFromEnabled \? replyIdentityMatch : 'exact',/);
});
+
+ it('offers the custom From in the sender sheet only when allowed, and sends without it otherwise', () => {
+ const src = read('screens/ComposeScreen.tsx');
+ expect(src).toMatch(/\{customFromEnabled && \(\s+\s+\{t\('email_composer\.from_override\.toggle_off'/);
+ expect(src).toMatch(/\{overrideEnabled && customFromEnabled && \(/);
+ expect(src).toMatch(/if \(overrideEnabled && customFromEnabled\) \{/);
+ expect(src).toMatch(/composeSenderAddress\(identity, \{ fromOverride, customFromEnabled, subAddressTag, subAddressDelimiter \}\)/);
+ });
});
describe('no third-party traffic', () => {
diff --git a/src/lib/__tests__/compose-sender.test.ts b/src/lib/__tests__/compose-sender.test.ts
new file mode 100644
index 00000000..bfe47496
--- /dev/null
+++ b/src/lib/__tests__/compose-sender.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import { activeFromOverride, composeSenderAddress } from '../compose-sender';
+import type { Identity } from '../../api/types';
+
+const identity: Identity = { id: 'i1', name: 'Ada Lovelace', email: 'ada@example.org', mayDelete: true };
+const override = { name: 'Support', email: 'support@example.org' };
+
+describe('composeSenderAddress', () => {
+ it('writes the custom From and keeps the identity as envelope sender', () => {
+ expect(composeSenderAddress(identity, { fromOverride: override, customFromEnabled: true, subAddressTag: '' })).toEqual({
+ from: { name: 'Support', email: 'support@example.org' },
+ envelopeMailFrom: 'ada@example.org',
+ });
+ });
+
+ it('sends from the identity when the organisation turned custom From off', () => {
+ expect(composeSenderAddress(identity, { fromOverride: override, customFromEnabled: false, subAddressTag: '' })).toEqual({
+ from: { name: 'Ada Lovelace', email: 'ada@example.org' },
+ envelopeMailFrom: undefined,
+ });
+ });
+
+ it('keeps the sub-address tag when custom From is off', () => {
+ const { from, envelopeMailFrom } = composeSenderAddress(identity, {
+ fromOverride: override, customFromEnabled: false, subAddressTag: 'shop', subAddressDelimiter: '+',
+ });
+ expect(from).toEqual({ name: 'Ada Lovelace', email: 'ada+shop@example.org' });
+ expect(envelopeMailFrom).toBe('ada@example.org');
+ });
+});
+
+describe('activeFromOverride', () => {
+ it('drops an override when custom From is off', () => {
+ expect(activeFromOverride(override, true)).toBe(override);
+ expect(activeFromOverride(override, false)).toBeNull();
+ expect(activeFromOverride(null, true)).toBeNull();
+ });
+});
diff --git a/src/lib/compose-sender.ts b/src/lib/compose-sender.ts
new file mode 100644
index 00000000..c55bfb86
--- /dev/null
+++ b/src/lib/compose-sender.ts
@@ -0,0 +1,52 @@
+import type { EmailAddress, Identity } from '../api/types';
+import { generateSubAddress } from './sub-addressing';
+import { sanitizeDisplayName } from './rfc5322-mailbox';
+
+export interface FromOverride {
+ name: string;
+ email: string;
+}
+
+/**
+ * The composer's custom From, or null when the organisation turned custom
+ * From off (`customFromEnabled`, webmail email-composer.tsx
+ * fromOverrideEnabled). An override set earlier, by hand or by the reply
+ * identity match, then no longer applies.
+ */
+export function activeFromOverride(
+ override: FromOverride | null,
+ customFromEnabled: boolean,
+): FromOverride | null {
+ return customFromEnabled && override ? override : null;
+}
+
+/**
+ * The From header and envelope sender of an outgoing message: the custom From
+ * when one applies, else the identity's address with its sub-address tag. A
+ * From that isn't the identity's own address still goes out through the
+ * identity's envelope sender.
+ */
+export function composeSenderAddress(
+ identity: Identity,
+ opts: {
+ fromOverride: FromOverride | null;
+ customFromEnabled: boolean;
+ subAddressTag: string;
+ subAddressDelimiter?: string;
+ },
+): { from: EmailAddress; envelopeMailFrom?: string } {
+ const override = activeFromOverride(opts.fromOverride, opts.customFromEnabled);
+ let from: EmailAddress;
+ if (override?.email.trim()) {
+ const overrideName = sanitizeDisplayName(override.name);
+ from = overrideName ? { name: overrideName, email: override.email.trim() } : { email: override.email.trim() };
+ } else {
+ const name = sanitizeDisplayName(identity.name);
+ const email = opts.subAddressTag
+ ? generateSubAddress(identity.email, opts.subAddressTag, opts.subAddressDelimiter)
+ : identity.email;
+ from = name ? { name, email } : { email };
+ }
+ const envelopeMailFrom = from.email.toLowerCase() !== identity.email.toLowerCase() ? identity.email : undefined;
+ return { from, envelopeMailFrom };
+}
diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx
index 737e5c1f..e2a11735 100644
--- a/src/screens/ComposeScreen.tsx
+++ b/src/screens/ComposeScreen.tsx
@@ -117,7 +117,7 @@ import {
import {
generateSubAddress, extractDomain, suggestTagsForDomain, getTagValidationError, MAX_TAG_LENGTH,
} from '../lib/sub-addressing';
-import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
+import { activeFromOverride, composeSenderAddress } from '../lib/compose-sender';
import type { EmailAddress, FileNode, Identity } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
@@ -1425,21 +1425,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
// ── Outgoing message assembly ─────────────────────────────────────────
- const senderAddress = React.useCallback((identity: Identity): { from: EmailAddress; envelopeMailFrom?: string } => {
- const name = sanitizeDisplayName(identity.name);
- let from: EmailAddress;
- if (fromOverride?.email.trim()) {
- const overrideName = sanitizeDisplayName(fromOverride.name);
- from = overrideName ? { name: overrideName, email: fromOverride.email.trim() } : { email: fromOverride.email.trim() };
- } else {
- const email = subAddressTag ? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter) : identity.email;
- from = name ? { name, email } : { email };
- }
- // A From that isn't the identity's own address still goes out through
- // the identity's envelope sender.
- const envelopeMailFrom = from.email.toLowerCase() !== identity.email.toLowerCase() ? identity.email : undefined;
- return { from, envelopeMailFrom };
- }, [fromOverride, subAddressTag, subAddressDelimiter]);
+ // Without the organisation's custom From, an override set earlier no longer applies.
+ const senderAddress = React.useCallback(
+ (identity: Identity): { from: EmailAddress; envelopeMailFrom?: string } =>
+ composeSenderAddress(identity, { fromOverride, customFromEnabled, subAddressTag, subAddressDelimiter }),
+ [fromOverride, customFromEnabled, subAddressTag, subAddressDelimiter],
+ );
+ const shownFromOverride = activeFromOverride(fromOverride, customFromEnabled);
const buildOutgoing = React.useCallback((identity: Identity, liveHtml: string, opts: { forDraft: boolean }): OutgoingEmail => {
const { from, envelopeMailFrom } = senderAddress(identity);
@@ -2361,9 +2353,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
const [overrideEmail, setOverrideEmail] = React.useState('');
const openFromOptions = () => {
setTagDraft(subAddressTag);
- setOverrideEnabled(!!fromOverride);
- setOverrideName(fromOverride?.name ?? primaryIdentity?.name ?? '');
- setOverrideEmail(fromOverride?.email ?? primaryIdentity?.email ?? '');
+ setOverrideEnabled(!!shownFromOverride);
+ setOverrideName(shownFromOverride?.name ?? primaryIdentity?.name ?? '');
+ setOverrideEmail(shownFromOverride?.email ?? primaryIdentity?.email ?? '');
setFromOptionsOpen(true);
};
const tagError = tagDraft ? getTagValidationError(tagDraft) : null;
@@ -2376,7 +2368,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
return out.slice(0, 5);
}, [finalTo]);
const applyFromOptions = () => {
- if (overrideEnabled) {
+ if (overrideEnabled && customFromEnabled) {
const email = overrideEmail.trim();
if (!isValidEmail(email)) {
Alert.alert(t('email_composer.from_override.email_label', 'From email address'), t('identities.form.email_invalid', 'Please enter a valid email address'));
@@ -3147,7 +3139,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
disabled={identities.length <= 1 && !primaryIdentity}
style={styles.fieldContent}
>
-
+
{fromDisplay}
{identities.length > 1 && }
@@ -3160,7 +3152,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
accessibilityRole="button"
accessibilityLabel={t('email_composer.from_options', 'Sender options')}
>
-
+
)}
@@ -3683,7 +3675,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
{t('email_composer.from', 'From')}
- {!overrideEnabled && (
+ {!(overrideEnabled && customFromEnabled) && (
<>
{t('identities.sub_address.popover_title', 'Add Sub-Address Tag')}
)}
-
- {t('email_composer.from_override.toggle_off', 'Override')}
-
-
- {overrideEnabled && (
+ {/* The organisation can turn custom From off (webmail email-composer.tsx customFromEnabled). */}
+ {customFromEnabled && (
+
+ {t('email_composer.from_override.toggle_off', 'Override')}
+
+
+ )}
+ {overrideEnabled && customFromEnabled && (
<>
{t('email_composer.from_override.toggle_tooltip', 'Edit the From name and address freely. Mail is still sent through your identity - only the visible From header changes.')}
Date: Mon, 2 Nov 2026 22:45:04 +0100
Subject: [PATCH 2/9] fix: keep remote content blocked until the webmail's
config says third-party traffic is allowed
---
src/stores/__tests__/policy-store.test.ts | 66 +++++++++++++++++-
src/stores/policy-store.ts | 83 +++++++++++++++++------
2 files changed, 128 insertions(+), 21 deletions(-)
diff --git a/src/stores/__tests__/policy-store.test.ts b/src/stores/__tests__/policy-store.test.ts
index 85bb7970..e5a5bcd8 100644
--- a/src/stores/__tests__/policy-store.test.ts
+++ b/src/stores/__tests__/policy-store.test.ts
@@ -27,7 +27,7 @@ import { useAuthStore } from '../auth-store';
import { useAccountStore, type AccountEntry } from '../account-store';
import { useSettingsStore, resetPolicyRulesForTests } from '../settings-store';
import { useToastStore } from '../toast-store';
-import { startPolicySync, usePolicyStore, settingKeyFor } from '../policy-store';
+import { noThirdPartyTraffic, startPolicySync, usePolicyStore, settingKeyFor } from '../policy-store';
const WEBMAIL = 'https://webmail.example.org';
const ADA = 'ada@example.com@mail.example.com';
@@ -81,7 +81,7 @@ beforeEach(() => {
defaultAccountId: ADA,
});
useAuthStore.setState({ activeAccountId: ADA, session: null });
- usePolicyStore.setState({ byAccount: {}, accountId: null, effective: null, links: {} });
+ usePolicyStore.setState({ byAccount: {}, configByAccount: {}, accountId: null, effective: null, links: {}, configPending: true });
useSettingsStore.setState({ density: 'regular', markAsReadDelay: 0, explicitSettings: [], hydrated: true });
useToastStore.setState({ toasts: [] });
});
@@ -196,6 +196,68 @@ describe('startPolicySync', () => {
});
});
+describe('no third-party traffic', () => {
+ const mode = () => noThirdPartyTraffic(usePolicyStore.getState());
+
+ it('counts as on until the config of the account\'s webmail is known', async () => {
+ usePolicyStore.getState().activate(ADA);
+ expect(mode()).toBe(true);
+ await usePolicyStore.getState().refresh(ADA);
+ expect(mode()).toBe(false);
+ });
+
+ it('keeps it on when the config says so', async () => {
+ configs[WEBMAIL] = { noThirdPartyTraffic: true };
+ await usePolicyStore.getState().refresh(ADA);
+ expect(mode()).toBe(true);
+ });
+
+ it('is off for an account without a webmail', () => {
+ useAccountStore.getState().updateAccount(ADA, { webmailUrl: undefined });
+ usePolicyStore.getState().activate(ADA);
+ expect(mode()).toBe(false);
+ });
+
+ it('uses the cached config on an offline start', async () => {
+ await usePolicyStore.getState().refresh(ADA);
+ usePolicyStore.setState({ links: {}, configPending: true });
+ mockFetch.mockRejectedValue(new Error('Network request failed'));
+ usePolicyStore.getState().activate(ADA);
+ await usePolicyStore.getState().refresh(ADA);
+ expect(mode()).toBe(false);
+ expect(usePolicyStore.getState().links).toEqual({ helpUrl: 'https://help.example.org' });
+ });
+
+ it('uses the links a policy cached by an earlier version holds', async () => {
+ await usePolicyStore.getState().refresh(ADA);
+ const cached = usePolicyStore.getState().byAccount[ADA];
+ usePolicyStore.setState({ byAccount: { [ADA]: { ...cached, links: { noThirdPartyTraffic: true } } }, configByAccount: {} });
+ usePolicyStore.getState().activate(ADA);
+ expect(mode()).toBe(true);
+ usePolicyStore.setState({ byAccount: { [ADA]: { ...cached, links: {} } } });
+ usePolicyStore.getState().activate(ADA);
+ expect(mode()).toBe(false);
+ });
+
+ it('reads the config of a webmail without the policy route', async () => {
+ mockFetch.mockImplementation(async (url: string) => (url.endsWith('/api/config')
+ ? response(url, {})
+ : ({ ok: false, status: 404, headers: { get: () => null } } as unknown as Response)));
+ usePolicyStore.getState().activate(ADA);
+ await usePolicyStore.getState().refresh(ADA);
+ expect(mode()).toBe(false);
+ });
+
+ it('stays on when only the policy answered', async () => {
+ mockFetch.mockImplementation(async (url: string) => (url.endsWith('/api/config')
+ ? Promise.reject(new Error('timeout'))
+ : response(url, policies[WEBMAIL])));
+ await usePolicyStore.getState().refresh(ADA);
+ expect(usePolicyStore.getState().effective).not.toBeNull();
+ expect(mode()).toBe(true);
+ });
+});
+
describe('settingKeyFor', () => {
it('maps the webmail names of settings to the app keys', () => {
expect(settingKeyFor('firstDayOfWeek')).toBe('calendarFirstDayOfWeek');
diff --git a/src/stores/policy-store.ts b/src/stores/policy-store.ts
index 50115421..78807fe4 100644
--- a/src/stores/policy-store.ts
+++ b/src/stores/policy-store.ts
@@ -47,12 +47,20 @@ export interface CachedPolicy {
partial: boolean;
fetchedAt: number;
/** The webmail it came from; an account that moved to another drops it. */
+ webmailUrl: string;
+ /** Only in policies cached before configByAccount; the config now has its own entry. */
+ links?: WebmailLinks;
+}
+
+/** The last answer of the webmail's /api/config, kept apart from the policy (an older webmail has no policy route). */
+export interface CachedConfig {
webmailUrl: string;
links: WebmailLinks;
}
interface PolicyState {
byAccount: Record;
+ configByAccount: Record;
/** The account `effective` was resolved for. */
accountId: string | null;
/** The active account's policy with its scopes applied; null without a webmail. */
@@ -60,6 +68,12 @@ interface PolicyState {
/** What `effective` does to the settings. */
rules: SettingsRules;
links: WebmailLinks;
+ /**
+ * The active account has a webmail whose /api/config hasn't been read
+ * yet (nor cached). Its "no third-party traffic" mode counts as on
+ * meanwhile, as in the webmail (hooks/use-third-party-mode.ts).
+ */
+ configPending: boolean;
/** Resolve and apply the cached policy of this account (the active one). */
activate: (accountId: string | null) => void;
@@ -195,10 +209,13 @@ export const usePolicyStore = create()(
persist(
(set, get) => ({
byAccount: {},
+ configByAccount: {},
accountId: null,
effective: null,
rules: EMPTY_SETTINGS_RULES,
links: {},
+ // Until the cache is read and the active account known.
+ configPending: true,
activate: (accountId) => {
const account = accountId ? useAccountStore.getState().getAccountById(accountId) : undefined;
@@ -207,7 +224,16 @@ export const usePolicyStore = create()(
const usable = account?.webmailUrl && cached && cached.webmailUrl === account.webmailUrl ? cached : null;
const effective = usable && account ? resolvePolicy(usable.policy, policySubjectFor(account)) : null;
const rules = rulesFor(effective);
- set({ accountId: accountId ?? null, effective, rules, links: usable?.links ?? {} });
+ const config = accountId && account?.webmailUrl ? get().configByAccount[accountId] : undefined;
+ // A policy cached before the config had its own entry came with the config's links.
+ const links = config?.webmailUrl === account?.webmailUrl && config ? config.links : usable?.links;
+ set({
+ accountId: accountId ?? null,
+ effective,
+ rules,
+ links: links ?? {},
+ configPending: !!account?.webmailUrl && !links,
+ });
useSettingsStore.getState().applyPolicyRules(rules);
},
@@ -220,21 +246,27 @@ export const usePolicyStore = create()(
if (!account || !webmailUrl) return;
const auth = await bearerFor(account);
const [fetched, links] = await Promise.all([fetchOrgPolicy(webmailUrl, auth), fetchWebmailLinks(webmailUrl)]);
- if (!fetched) return; // offline or no policy route: keep what is cached
+ // Offline: keep what is cached. A webmail without the policy route
+ // still has its config.
+ if (!fetched && !links) return;
// Signed out of the account meanwhile.
if (!useAccountStore.getState().getAccountById(accountId)) return;
const previous = get().byAccount[accountId];
+ const legacyLinks = previous?.webmailUrl === webmailUrl ? previous.links : undefined;
set({
- byAccount: {
- ...get().byAccount,
- [accountId]: {
- policy: fetched.policy,
- partial: fetched.partial,
- fetchedAt: Date.now(),
- webmailUrl,
- links: links ?? (previous?.webmailUrl === webmailUrl ? previous.links : {}),
+ ...(fetched ? {
+ byAccount: {
+ ...get().byAccount,
+ [accountId]: { policy: fetched.policy, partial: fetched.partial, fetchedAt: Date.now(), webmailUrl },
},
- },
+ } : {}),
+ // Config links cached inside an older policy entry move to their own.
+ ...(links || (fetched && legacyLinks) ? {
+ configByAccount: {
+ ...get().configByAccount,
+ [accountId]: { webmailUrl, links: (links ?? legacyLinks)! },
+ },
+ } : {}),
});
if (useAuthStore.getState().activeAccountId === accountId) get().activate(accountId);
})().finally(() => refreshes.delete(accountId));
@@ -244,15 +276,18 @@ export const usePolicyStore = create()(
prune: (accountIds) => {
const keep = new Set(accountIds);
- const current = get().byAccount;
- if (Object.keys(current).every((id) => keep.has(id))) return;
- set({ byAccount: Object.fromEntries(Object.entries(current).filter(([id]) => keep.has(id))) });
+ const { byAccount, configByAccount } = get();
+ if ([...Object.keys(byAccount), ...Object.keys(configByAccount)].every((id) => keep.has(id))) return;
+ set({
+ byAccount: Object.fromEntries(Object.entries(byAccount).filter(([id]) => keep.has(id))),
+ configByAccount: Object.fromEntries(Object.entries(configByAccount).filter(([id]) => keep.has(id))),
+ });
},
}),
{
name: 'org-policy-cache',
storage: createPersistStorage(),
- partialize: (state) => ({ byAccount: state.byAccount }) as unknown as PolicyState,
+ partialize: (state) => ({ byAccount: state.byAccount, configByAccount: state.configByAccount }) as unknown as PolicyState,
},
),
);
@@ -270,10 +305,15 @@ export function useSettingLocked(key: string): boolean {
/**
* Whether the active account's webmail runs without third-party traffic
* (webmail hooks/use-third-party-mode.ts): remote content in mail never
- * loads, whatever the setting or a trusted sender says.
+ * loads, whatever the setting or a trusted sender says. On while the
+ * config is still unknown, so nothing reaches out before it says it may.
*/
+export function noThirdPartyTraffic(state: Pick): boolean {
+ return state.configPending || state.links.noThirdPartyTraffic === true;
+}
+
export function useNoThirdPartyTraffic(): boolean {
- return usePolicyStore((s) => s.links.noThirdPartyTraffic === true);
+ return usePolicyStore(noThirdPartyTraffic);
}
/** Whether the active account's policy hides a setting (by the app's key). */
@@ -301,7 +341,8 @@ export function startPolicySync(): () => void {
});
});
- let activeId: string | null = null;
+ // Undefined until the first activate, which also settles configPending without an account.
+ let activeId: string | null | undefined;
let fetchedFor: string | null = null;
const follow = () => {
const auth = useAuthStore.getState();
@@ -337,7 +378,11 @@ export function startPolicySync(): () => void {
const before = id ? prev.accounts.find((a) => a.id === id) : undefined;
if (id && now?.webmailUrl && now.webmailUrl !== before?.webmailUrl) {
fetchedFor = null;
- whenHydrated(follow);
+ // Its config is unknown until the fetch answers.
+ whenHydrated(() => {
+ usePolicyStore.getState().activate(id);
+ follow();
+ });
}
});
From f173358e86818155e1723a050aec26fede38543e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 22:45:04 +0100
Subject: [PATCH 3/9] fix: show no sender favicons when the webmail allows no
third-party traffic
---
src/components/SenderAvatar.tsx | 7 ++++++-
.../__tests__/custom-from-third-party-wiring.test.ts | 6 ++++++
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/src/components/SenderAvatar.tsx b/src/components/SenderAvatar.tsx
index 618dada8..0bf36f7b 100644
--- a/src/components/SenderAvatar.tsx
+++ b/src/components/SenderAvatar.tsx
@@ -18,6 +18,7 @@ import { useContactPhotoSource } from '../lib/use-contact-photo';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
import { useAccountStore } from '../stores/account-store';
+import { useNoThirdPartyTraffic } from '../stores/policy-store';
import { useColors } from '../theme/colors';
import { ShieldAlert } from './icons';
@@ -61,6 +62,10 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
const c = useColors();
const warned = !!senderWarning;
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;
+ // Favicons come from an outside host (DuckDuckGo), which the webmail's
+ // "no third-party traffic" mode rules out (webmail lib/outgoing-hosts.ts).
+ // BIMI logos come from the webmail itself.
+ const noThirdParty = useNoThirdPartyTraffic();
// Compared shallowly, so only avatars whose photo changed re-render when the
// contacts change; the index behind it is built once per contacts array.
const indexedPhoto = useContactsStore(
@@ -94,7 +99,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
senderWarning,
hasPhoto: !disableImages && !!photoSource,
showBrand,
- showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
+ showFavicon: senderFavicons && !noThirdParty && !!faviconDomain && !imgError && !domainFailed,
});
const handleError = React.useCallback(() => {
diff --git a/src/components/__tests__/custom-from-third-party-wiring.test.ts b/src/components/__tests__/custom-from-third-party-wiring.test.ts
index 4df4945a..7235a859 100644
--- a/src/components/__tests__/custom-from-third-party-wiring.test.ts
+++ b/src/components/__tests__/custom-from-third-party-wiring.test.ts
@@ -54,6 +54,12 @@ describe('no third-party traffic', () => {
expect(src).toMatch(/t\('third_party\.remote_content_blocked'/);
});
+ it('shows no sender favicons from the outside host', () => {
+ const src = read('components/SenderAvatar.tsx');
+ expect(src).toMatch(/const noThirdParty = useNoThirdPartyTraffic\(\);/);
+ expect(src).toMatch(/showFavicon: senderFavicons && !noThirdParty && /);
+ });
+
it('keeps remote images blocked in booking cards and quoted originals', () => {
expect(read('components/email/structured-mail/StructuredMailBanner.tsx')).toMatch(/\(\) => !remoteBlockedByOrg && \(policy === 'allow' \|\|/);
const compose = read('screens/ComposeScreen.tsx');
From 762ec6bab063f218c86926bc2d409086d9b5d4a1 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 22:50:45 +0100
Subject: [PATCH 4/9] fix: leave archive off the android widget when the
organisation turns archiving off
---
src/widgets/__tests__/actions.test.ts | 7 +++
src/widgets/__tests__/archive-policy.test.ts | 50 ++++++++++++++++++++
src/widgets/__tests__/layouts.test.ts | 14 ++++++
src/widgets/actions.ts | 8 +++-
src/widgets/build.ts | 26 ++++++++++
src/widgets/layouts/mail.tsx | 14 ++++--
src/widgets/snapshot.ts | 5 ++
7 files changed, 118 insertions(+), 6 deletions(-)
create mode 100644 src/widgets/__tests__/archive-policy.test.ts
diff --git a/src/widgets/__tests__/actions.test.ts b/src/widgets/__tests__/actions.test.ts
index a6a7ff7d..8a436e21 100644
--- a/src/widgets/__tests__/actions.test.ts
+++ b/src/widgets/__tests__/actions.test.ts
@@ -178,4 +178,11 @@ describe('widget archive', () => {
calls.moves[0].resolve(true);
await tap;
});
+
+ it('does nothing for a tap on an older drawing once the organisation turned archiving off', async () => {
+ await saveSnapshot({ ...snapshotOf(server.inbox), archiveEnabled: false });
+ await handleWidgetAction('archive', { id: 'm1', accountId: 'acc' }, 7);
+ expect(calls.moves).toEqual([]);
+ expect(await shown()).toEqual(['m1', 'm2', 'm3']);
+ });
});
diff --git a/src/widgets/__tests__/archive-policy.test.ts b/src/widgets/__tests__/archive-policy.test.ts
new file mode 100644
index 00000000..04411c14
--- /dev/null
+++ b/src/widgets/__tests__/archive-policy.test.ts
@@ -0,0 +1,50 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { readArchiveEnabled } from '../build';
+
+// The organisation's archiveActionEnabled gate reaches the widgets through
+// the policy the app cached (stores/policy-store.ts, key org-policy-cache).
+
+const WEBMAIL = 'https://webmail.example.org';
+const account = { id: 'acc', email: 'ada@example.org', webmailUrl: WEBMAIL };
+
+function cache(features: Record, webmailUrl = WEBMAIL, scopes: unknown[] = []) {
+ return AsyncStorage.setItem('org-policy-cache', JSON.stringify({
+ state: {
+ byAccount: {
+ acc: {
+ policy: { restrictions: {}, features, defaults: {}, themePolicy: {}, scopes },
+ partial: false,
+ fetchedAt: 1,
+ webmailUrl,
+ },
+ },
+ },
+ version: 0,
+ }));
+}
+
+beforeEach(async () => {
+ await AsyncStorage.clear();
+});
+
+describe('readArchiveEnabled', () => {
+ it('is off when the cached policy turns archiving off', async () => {
+ await cache({ archiveActionEnabled: false });
+ expect(await readArchiveEnabled(account)).toBe(false);
+ });
+
+ it('is on when the policy allows it, without a policy and without a webmail', async () => {
+ expect(await readArchiveEnabled(account)).toBe(true);
+ await cache({ archiveActionEnabled: true });
+ expect(await readArchiveEnabled(account)).toBe(true);
+ await cache({ archiveActionEnabled: false });
+ expect(await readArchiveEnabled({ ...account, webmailUrl: undefined })).toBe(true);
+ expect(await readArchiveEnabled(undefined)).toBe(true);
+ });
+
+ it('ignores a policy cached from another webmail', async () => {
+ await cache({ archiveActionEnabled: false }, 'https://elsewhere.example.org');
+ expect(await readArchiveEnabled(account)).toBe(true);
+ });
+});
diff --git a/src/widgets/__tests__/layouts.test.ts b/src/widgets/__tests__/layouts.test.ts
index 00033fdd..74f2d19a 100644
--- a/src/widgets/__tests__/layouts.test.ts
+++ b/src/widgets/__tests__/layouts.test.ts
@@ -159,6 +159,20 @@ describe('widget layouts', () => {
for (const w of catalog.widgets) build(w.name, { ...sample, images }, w.cols, w.rows);
});
+ it('leaves Archive off the triage card when the organisation turned archiving off', () => {
+ const actions = (s: WidgetSnapshot, cols: number) => {
+ const trees = renderFor('TriageWidget', s, { width: dp(cols), height: dp(2), widgetId: 1 }, {}, PREVIEW_NOW);
+ const tree = 'light' in trees ? trees.light : trees;
+ return [...JSON.stringify(buildWidgetTree(tree)).matchAll(/"clickAction":"(\w+)"/g)].map((m) => m[1]);
+ };
+ expect(actions(sample, 4)).toContain('archive');
+ for (const cols of [3, 4, 5]) {
+ const shown = actions({ ...sample, archiveEnabled: false }, cols);
+ expect(shown).not.toContain('archive');
+ expect(shown).toContain('trash');
+ }
+ });
+
it('builds the triage card on a message that is gone', () => {
build('TriageWidget', sample, 4, 2, { triageId: 'no-such-message' });
build('TriageWidget', { ...sample, mail: { ...sample.mail, inbox: [] } }, 4, 2, { triageId: 'm1' });
diff --git a/src/widgets/actions.ts b/src/widgets/actions.ts
index b9ffea6a..d514122e 100644
--- a/src/widgets/actions.ts
+++ b/src/widgets/actions.ts
@@ -94,7 +94,13 @@ export async function handleWidgetAction(name: string, data: Data, widgetId: num
case 'markRead': {
const id = str(data.id);
if (!id) return;
- const subject = (await currentView()).mail.inbox.find((m) => m.id === id)?.subject ?? '';
+ const view = await currentView();
+ // A drawing from before the organisation turned archiving off.
+ if (name === 'archive' && view.archiveEnabled === false) {
+ await redrawAll();
+ return;
+ }
+ const subject = view.mail.inbox.find((m) => m.id === id)?.subject ?? '';
// The triage card moves on to the next unread message, not back to
// the first one.
if (name !== 'markRead') await moveTriagePointer(widgetId, id);
diff --git a/src/widgets/build.ts b/src/widgets/build.ts
index ed5478f5..7c0ae1e1 100644
--- a/src/widgets/build.ts
+++ b/src/widgets/build.ts
@@ -16,6 +16,7 @@ import { getEventEndDate, getEventStartDate } from '../lib/calendar-utils';
import { getUserParticipantId, getUserStatus } from '../lib/calendar-participants';
import { getBirthday, getContactDisplayName, getDateParts } from '../lib/contact-utils';
import { notificationLocale } from '../lib/push-background-task';
+import { isFeatureEnabled, resolvePolicy, sanitizePolicy } from '../lib/org-policy';
import { useSettingsStore } from '../stores/settings-store';
import { useKeywordsStore, keywordToken } from '../stores/keywords-store';
import { LIGHT_COLORS } from '../theme/tokens';
@@ -46,6 +47,30 @@ interface RegistryAccount {
displayName?: string;
username?: string;
avatarColor?: string;
+ webmailUrl?: string;
+}
+
+/**
+ * Whether the account's organisation lets it archive (policy
+ * archiveActionEnabled), from the policy the app cached for it
+ * (stores/policy-store.ts, which this runtime may not have loaded). On
+ * without a webmail or a cached policy from it.
+ */
+export async function readArchiveEnabled(account: RegistryAccount | undefined): Promise {
+ if (!account?.webmailUrl) return true;
+ try {
+ const raw = await AsyncStorage.getItem('org-policy-cache');
+ const byAccount = raw
+ ? (JSON.parse(raw) as { state?: { byAccount?: Record } }).state?.byAccount
+ : undefined;
+ const cached = byAccount?.[account.id];
+ const policy = cached?.webmailUrl === account.webmailUrl ? sanitizePolicy(cached.policy) : null;
+ if (!policy) return true;
+ const email = (account.email || account.username || '').trim().toLowerCase();
+ return isFeatureEnabled(resolvePolicy(policy, { email, groups: [] }), 'archiveActionEnabled');
+ } catch {
+ return true;
+ }
}
export async function readRegistry(): Promise<{ accounts: RegistryAccount[]; activeAccountId: string | null }> {
@@ -390,6 +415,7 @@ async function buildSnapshot(previous: WidgetSnapshot): Promise
hour12: timeFormat === '12h',
weekStart: calendarFirstDayOfWeek === 0 ? 0 : 1,
activeAccountId,
+ archiveEnabled: await readArchiveEnabled(accounts.find((a) => a.id === activeAccountId)),
};
if (previous.activeAccountId !== activeAccountId) {
// Nothing of another account's data may linger in a widget.
diff --git a/src/widgets/layouts/mail.tsx b/src/widgets/layouts/mail.tsx
index ff925653..4af81a33 100644
--- a/src/widgets/layouts/mail.tsx
+++ b/src/widgets/layouts/mail.tsx
@@ -112,9 +112,11 @@ export const TriageLayout: Layout = ({ s, p, f, now, width, height, widgetId, lo
trash: f.t('widgets.mail.delete', 'Delete'),
reply: f.t('widgets.mail.reply', 'Reply'),
};
- const room = width - 2 - 24 - 32 - 18;
- const allFit = buttonWidth(labels.archive) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room;
- const replyFits = 2 * 32 + buttonWidth(labels.reply) <= room;
+ // No Archive button when the organisation turned archiving off.
+ const archive = s.archiveEnabled !== false;
+ const room = width - 2 - 24 - 32 - (archive ? 18 : 12);
+ const allFit = (archive ? buttonWidth(labels.archive) : 0) + buttonWidth(labels.trash) + buttonWidth(labels.reply) <= room;
+ const replyFits = (archive ? 2 : 1) * 32 + buttonWidth(labels.reply) <= room;
return (
// The buttons draw 32dp tall in a 48dp tap area (slop 8), which takes the
// gap above them and 8dp of the bottom padding.
@@ -146,8 +148,10 @@ export const TriageLayout: Layout = ({ s, p, f, now, width, height, widgetId, lo
{notice ? : null}
-
-
+ {archive ? (
+
+ ) : null}
+ {archive ? : null}
diff --git a/src/widgets/snapshot.ts b/src/widgets/snapshot.ts
index 1ba18b3b..fb12e916 100644
--- a/src/widgets/snapshot.ts
+++ b/src/widgets/snapshot.ts
@@ -200,6 +200,11 @@ export interface WidgetSnapshot {
accounts: AccountSummary[];
/** The signed-in account the single-account widgets show. */
activeAccountId: string | null;
+ /**
+ * False when the active account's organisation turned archiving off
+ * (policy archiveActionEnabled); absent counts as on.
+ */
+ archiveEnabled?: boolean;
mail: {
folders: FolderCount[];
inbox: MailItem[];
From 4d21adcec6b39d7a21e3773b06883459fe5b0661 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 23:02:29 +0100
Subject: [PATCH 5/9] fix: keep an unsaved draft on the device when the session
expires and save it to drafts later
---
locales/rn/ar.json | 6 +-
locales/rn/ca.json | 6 +-
locales/rn/cs.json | 6 +-
locales/rn/da.json | 6 +-
locales/rn/de.json | 6 +-
locales/rn/en.json | 4 +
locales/rn/es.json | 6 +-
locales/rn/et.json | 6 +-
locales/rn/fa.json | 6 +-
locales/rn/fr.json | 6 +-
locales/rn/he.json | 6 +-
locales/rn/hu.json | 6 +-
locales/rn/it.json | 6 +-
locales/rn/ja.json | 6 +-
locales/rn/ko.json | 6 +-
locales/rn/lv.json | 6 +-
locales/rn/mn.json | 6 +-
locales/rn/nb.json | 6 +-
locales/rn/nl.json | 6 +-
locales/rn/pl.json | 6 +-
locales/rn/pt.json | 6 +-
locales/rn/ro.json | 6 +-
locales/rn/ru.json | 6 +-
locales/rn/sk.json | 6 +-
locales/rn/th.json | 6 +-
locales/rn/tr.json | 6 +-
locales/rn/uk.json | 6 +-
locales/rn/zh-TW.json | 6 +-
locales/rn/zh.json | 6 +-
src/lib/compose-rescue.ts | 130 ++++++++++++++++++
src/screens/ComposeScreen.tsx | 79 ++++++++++-
.../__tests__/compose-rescue-wiring.test.ts | 31 +++++
.../__tests__/outbox-rescued-drafts.test.ts | 108 +++++++++++++++
src/stores/auth-store.ts | 3 +
src/stores/outbox-store.ts | 36 +++++
35 files changed, 526 insertions(+), 33 deletions(-)
create mode 100644 src/lib/compose-rescue.ts
create mode 100644 src/screens/__tests__/compose-rescue-wiring.test.ts
create mode 100644 src/stores/__tests__/outbox-rescued-drafts.test.ts
diff --git a/locales/rn/ar.json b/locales/rn/ar.json
index 6d511c2a..a818145e 100644
--- a/locales/rn/ar.json
+++ b/locales/rn/ar.json
@@ -421,7 +421,11 @@
"undo_send_other_login": "أُرسلت هذه الرسالة من حساب آخر",
"undo_send_other_login_body": "عُد إلى {account} للتراجع عنها أو إرسالها الآن. ستُرسل عند انتهاء المهلة.",
"upload_login_switched": "لم يتم الرفع: تم تبديل الحساب",
- "use_template": "قالب"
+ "use_template": "قالب",
+ "draft_kept_title": "حُفظت على هذا الجهاز",
+ "draft_kept_body": "سينتقل إلى المسودات عندما تسجّل الدخول وتتصل بالإنترنت مجددًا.",
+ "rescued_draft_title": "حُفظت في المسودات",
+ "rescued_draft_body": "تعذّر حفظ رسالة كنت تكتبها في وقت سابق. إنها الآن في المسودات."
},
"email_list": {
"cross_account_move_offline": "يتطلب النقل بين الحسابات اتصالًا بالإنترنت",
diff --git a/locales/rn/ca.json b/locales/rn/ca.json
index ab98f05a..33b10519 100644
--- a/locales/rn/ca.json
+++ b/locales/rn/ca.json
@@ -426,7 +426,11 @@
"undo_send_other_login": "Aquest missatge s'ha enviat des d'un altre compte",
"undo_send_other_login_body": "Torneu a {account} per desfer-lo o enviar-lo ara. Sortirà quan s'acabi el retard.",
"upload_login_switched": "No s'ha pujat: s'ha canviat de compte",
- "use_template": "Plantilla"
+ "use_template": "Plantilla",
+ "draft_kept_title": "Desat en aquest dispositiu",
+ "draft_kept_body": "Anirà a Esborranys quan tornis a tenir la sessió iniciada i connexió.",
+ "rescued_draft_title": "Desat a Esborranys",
+ "rescued_draft_body": "Abans no s'ha pogut desar un missatge que escrivies. Ara és a Esborranys."
},
"email_list": {
"cross_account_move_offline": "Per moure entre comptes cal connexió",
diff --git a/locales/rn/cs.json b/locales/rn/cs.json
index 7fcb02f1..4e55fb51 100644
--- a/locales/rn/cs.json
+++ b/locales/rn/cs.json
@@ -426,7 +426,11 @@
"undo_send_other_login": "Tato zpráva byla odeslána z jiného účtu",
"undo_send_other_login_body": "Chcete-li odeslání vrátit nebo zprávu odeslat hned, přepněte zpět na {account}. Odejde po uplynutí prodlevy.",
"upload_login_switched": "Nenahráno: účet byl přepnut",
- "use_template": "Šablona"
+ "use_template": "Šablona",
+ "draft_kept_title": "Uloženo v tomto zařízení",
+ "draft_kept_body": "Do Konceptů se uloží, až budete znovu přihlášeni a online.",
+ "rescued_draft_title": "Uloženo do Konceptů",
+ "rescued_draft_body": "Zprávu, kterou jste psali, se dříve nepodařilo uložit. Teď je v Konceptech."
},
"email_list": {
"cross_account_move_offline": "Přesun mezi účty vyžaduje připojení",
diff --git a/locales/rn/da.json b/locales/rn/da.json
index efdb45f0..1d3ca801 100644
--- a/locales/rn/da.json
+++ b/locales/rn/da.json
@@ -423,7 +423,11 @@
"undo_send_other_login": "Denne besked blev sendt fra en anden konto",
"undo_send_other_login_body": "Skift tilbage til {account} for at fortryde den eller sende den nu. Den sendes, når forsinkelsen er udløbet.",
"upload_login_switched": "Ikke uploadet: kontoen blev skiftet",
- "use_template": "Skabelon"
+ "use_template": "Skabelon",
+ "draft_kept_title": "Gemt på denne enhed",
+ "draft_kept_body": "Den kommer i Kladder, når du er logget ind og online igen.",
+ "rescued_draft_title": "Gemt i Kladder",
+ "rescued_draft_body": "En besked, du skrev, kunne ikke gemmes tidligere. Den ligger nu i Kladder."
},
"email_list": {
"cross_account_move_offline": "Flytning mellem konti kræver forbindelse",
diff --git a/locales/rn/de.json b/locales/rn/de.json
index 0984c211..87f59eb1 100644
--- a/locales/rn/de.json
+++ b/locales/rn/de.json
@@ -431,7 +431,11 @@
"undo_send_other_login": "Diese Nachricht wurde von einem anderen Konto gesendet",
"undo_send_other_login_body": "Wechseln Sie zurück zu {account}, um das Senden rückgängig zu machen oder sofort zu senden. Sie geht raus, wenn die Verzögerung abläuft.",
"upload_login_switched": "Nicht hochgeladen: Das Konto wurde gewechselt",
- "use_template": "Vorlage"
+ "use_template": "Vorlage",
+ "draft_kept_title": "Auf diesem Gerät gespeichert",
+ "draft_kept_body": "Er kommt in die Entwürfe, sobald Sie wieder angemeldet und online sind.",
+ "rescued_draft_title": "In Entwürfen gespeichert",
+ "rescued_draft_body": "Eine Nachricht, die Sie geschrieben haben, konnte vorhin nicht gespeichert werden. Sie ist jetzt in den Entwürfen."
},
"email_list": {
"cross_account_move_offline": "Verschieben zwischen Konten erfordert eine Verbindung",
diff --git a/locales/rn/en.json b/locales/rn/en.json
index 3f93b8cf..c040cfe3 100644
--- a/locales/rn/en.json
+++ b/locales/rn/en.json
@@ -352,6 +352,8 @@
"attachment_reminder_body": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
"attachment_reminder_title": "Forgot an attachment?",
"attachments_too_large_total": "The attachments would exceed the {max} this server allows per message.",
+ "draft_kept_body": "It goes to Drafts once you are signed in and online again.",
+ "draft_kept_title": "Saved on this device",
"editor_unavailable": "Could not read the message content. Copy your text, then close and reopen the composer.",
"files_picker": {
"attach": "{count, plural, =0 {Attach} one {Attach 1 file} other {Attach # files}}",
@@ -421,6 +423,8 @@
"recipients_partly_refused_title": "Not sent to everyone",
"recipients_refused": "Not sent: the server refused {recipients}.",
"remove_attachment": "Remove attachment",
+ "rescued_draft_body": "A message you were writing couldn't be saved earlier. It is in Drafts now.",
+ "rescued_draft_title": "Saved to Drafts",
"save_draft": "Save Draft",
"save_failed_no_drafts": "There is no Drafts folder to save this draft to.",
"save_failed_no_identity": "Your sender identities could not be loaded, so this draft cannot be saved. Check your connection, or copy your text before closing.",
diff --git a/locales/rn/es.json b/locales/rn/es.json
index 67e31fcf..7465030e 100644
--- a/locales/rn/es.json
+++ b/locales/rn/es.json
@@ -432,7 +432,11 @@
"undo_send_other_login": "Este mensaje se envió desde otra cuenta",
"undo_send_other_login_body": "Vuelve a {account} para deshacerlo o enviarlo ahora. Se enviará cuando termine la espera.",
"upload_login_switched": "No se ha subido: se cambió de cuenta",
- "use_template": "Plantilla"
+ "use_template": "Plantilla",
+ "draft_kept_title": "Guardado en este dispositivo",
+ "draft_kept_body": "Pasará a Borradores cuando vuelvas a tener la sesión iniciada y conexión.",
+ "rescued_draft_title": "Guardado en Borradores",
+ "rescued_draft_body": "Antes no se pudo guardar un mensaje que escribías. Ahora está en Borradores."
},
"email_list": {
"cross_account_move_offline": "Mover entre cuentas requiere conexión",
diff --git a/locales/rn/et.json b/locales/rn/et.json
index 1a371ff1..a32cca48 100644
--- a/locales/rn/et.json
+++ b/locales/rn/et.json
@@ -1252,7 +1252,11 @@
"use_template": "Mall",
"validation": {
"subject_required": "Lisa teema"
- }
+ },
+ "draft_kept_title": "Salvestatud selles seadmes",
+ "draft_kept_body": "See läheb mustanditesse, kui oled jälle sisse logitud ja võrgus.",
+ "rescued_draft_title": "Salvestatud mustanditesse",
+ "rescued_draft_body": "Kirjutatud sõnumit ei õnnestunud varem salvestada. See on nüüd mustandites."
},
"email_list": {
"cross_account_move_offline": "Kontode vahel teisaldamiseks on vaja ühendust",
diff --git a/locales/rn/fa.json b/locales/rn/fa.json
index d057e491..402d7bef 100644
--- a/locales/rn/fa.json
+++ b/locales/rn/fa.json
@@ -420,7 +420,11 @@
"undo_send_other_login": "این پیام از حساب دیگری ارسال شده است",
"undo_send_other_login_body": "برای بازگردانی یا ارسال فوری آن، به {account} برگردید. پس از پایان تأخیر ارسال میشود.",
"upload_login_switched": "بارگذاری نشد: حساب عوض شد",
- "use_template": "قالب"
+ "use_template": "قالب",
+ "draft_kept_title": "روی این دستگاه ذخیره شد",
+ "draft_kept_body": "وقتی دوباره وارد شوید و آنلاین باشید، به پیشنویسها میرود.",
+ "rescued_draft_title": "در پیشنویسها ذخیره شد",
+ "rescued_draft_body": "پیامی که مینوشتید قبلاً ذخیره نشد. اکنون در پیشنویسها است."
},
"email_list": {
"cross_account_move_offline": "انتقال بین حسابها به اتصال اینترنت نیاز دارد",
diff --git a/locales/rn/fr.json b/locales/rn/fr.json
index 464c6ccc..e506305f 100644
--- a/locales/rn/fr.json
+++ b/locales/rn/fr.json
@@ -430,7 +430,11 @@
"undo_send_other_login": "Ce message a été envoyé depuis un autre compte",
"undo_send_other_login_body": "Revenez à {account} pour l'annuler ou l'envoyer maintenant. Il partira à la fin du délai.",
"upload_login_switched": "Non téléversé : le compte a été changé",
- "use_template": "Modèle"
+ "use_template": "Modèle",
+ "draft_kept_title": "Enregistré sur cet appareil",
+ "draft_kept_body": "Il ira dans les Brouillons dès que vous serez de nouveau connecté et en ligne.",
+ "rescued_draft_title": "Enregistré dans les Brouillons",
+ "rescued_draft_body": "Un message que vous écriviez n'a pas pu être enregistré plus tôt. Il est maintenant dans les Brouillons."
},
"email_list": {
"cross_account_move_offline": "Le déplacement entre comptes nécessite une connexion",
diff --git a/locales/rn/he.json b/locales/rn/he.json
index fc880fe0..a4ff0e4b 100644
--- a/locales/rn/he.json
+++ b/locales/rn/he.json
@@ -424,7 +424,11 @@
"undo_send_other_login": "ההודעה הזו נשלחה מחשבון אחר",
"undo_send_other_login_body": "חזור אל {account} כדי לבטל אותה או לשלוח אותה עכשיו. היא תישלח כשההשהיה תסתיים.",
"upload_login_switched": "לא הועלה: החשבון הוחלף",
- "use_template": "תבנית"
+ "use_template": "תבנית",
+ "draft_kept_title": "נשמר במכשיר הזה",
+ "draft_kept_body": "הוא יעבור לטיוטות כשתתחבר שוב ותהיה מחובר לרשת.",
+ "rescued_draft_title": "נשמר בטיוטות",
+ "rescued_draft_body": "לא ניתן היה לשמור קודם הודעה שכתבת. היא נמצאת עכשיו בטיוטות."
},
"email_list": {
"cross_account_move_offline": "העברה בין חשבונות דורשת חיבור",
diff --git a/locales/rn/hu.json b/locales/rn/hu.json
index 9d3fe34a..3779ea46 100644
--- a/locales/rn/hu.json
+++ b/locales/rn/hu.json
@@ -422,7 +422,11 @@
"undo_send_other_login": "Ezt az üzenetet egy másik fiókból küldted",
"undo_send_other_login_body": "Válts vissza erre a fiókra: {account}, hogy visszavond vagy most elküldd. A késleltetés végén kimegy.",
"upload_login_switched": "Nincs feltöltve: fiókot váltottál",
- "use_template": "Sablon"
+ "use_template": "Sablon",
+ "draft_kept_title": "Mentve ezen az eszközön",
+ "draft_kept_body": "A Piszkozatok közé kerül, amint újra bejelentkezik és online lesz.",
+ "rescued_draft_title": "Mentve a Piszkozatok közé",
+ "rescued_draft_body": "Egy üzenetet, amelyet írt, korábban nem sikerült menteni. Most a Piszkozatok között van."
},
"email_list": {
"cross_account_move_offline": "A fiókok közötti áthelyezéshez kapcsolat szükséges",
diff --git a/locales/rn/it.json b/locales/rn/it.json
index cc9065c1..12fe9982 100644
--- a/locales/rn/it.json
+++ b/locales/rn/it.json
@@ -428,7 +428,11 @@
"undo_send_other_login": "Questo messaggio è stato inviato da un altro account",
"undo_send_other_login_body": "Torna a {account} per annullarlo o inviarlo subito. Parte al termine del ritardo.",
"upload_login_switched": "Non caricato: l'account è stato cambiato",
- "use_template": "Modello"
+ "use_template": "Modello",
+ "draft_kept_title": "Salvato su questo dispositivo",
+ "draft_kept_body": "Andrà nelle Bozze appena sarai di nuovo connesso e online.",
+ "rescued_draft_title": "Salvato nelle Bozze",
+ "rescued_draft_body": "Prima non è stato possibile salvare un messaggio che stavi scrivendo. Ora è nelle Bozze."
},
"email_list": {
"cross_account_move_offline": "Per spostare tra account serve una connessione",
diff --git a/locales/rn/ja.json b/locales/rn/ja.json
index cfe62450..0805a25d 100644
--- a/locales/rn/ja.json
+++ b/locales/rn/ja.json
@@ -426,7 +426,11 @@
"undo_send_other_login": "このメッセージは別のアカウントから送信されました",
"undo_send_other_login_body": "取り消すか今すぐ送信するには、{account} に戻してください。待機時間が終わると送信されます。",
"upload_login_switched": "アップロードされていません:アカウントが切り替わりました",
- "use_template": "テンプレート"
+ "use_template": "テンプレート",
+ "draft_kept_title": "このデバイスに保存しました",
+ "draft_kept_body": "再びサインインしてオンラインになると、下書きに保存されます。",
+ "rescued_draft_title": "下書きに保存しました",
+ "rescued_draft_body": "書いていたメッセージを先ほど保存できませんでした。今は下書きにあります。"
},
"email_list": {
"cross_account_move_offline": "アカウント間の移動には接続が必要です",
diff --git a/locales/rn/ko.json b/locales/rn/ko.json
index bc8c3724..efa74b23 100644
--- a/locales/rn/ko.json
+++ b/locales/rn/ko.json
@@ -424,7 +424,11 @@
"undo_send_other_login": "이 메시지는 다른 계정에서 보냈어요",
"undo_send_other_login_body": "취소하거나 지금 보내려면 {account}(으)로 다시 전환해 주세요. 대기 시간이 끝나면 전송돼요.",
"upload_login_switched": "업로드되지 않음: 계정이 전환되었어요",
- "use_template": "템플릿"
+ "use_template": "템플릿",
+ "draft_kept_title": "이 기기에 저장됨",
+ "draft_kept_body": "다시 로그인하고 온라인이 되면 임시 보관함으로 옮겨집니다.",
+ "rescued_draft_title": "임시 보관함에 저장됨",
+ "rescued_draft_body": "작성하던 메시지를 앞서 저장하지 못했습니다. 이제 임시 보관함에 있습니다."
},
"email_list": {
"cross_account_move_offline": "계정 간 이동은 인터넷 연결이 필요해요",
diff --git a/locales/rn/lv.json b/locales/rn/lv.json
index 7b4d1f2b..cf3abae3 100644
--- a/locales/rn/lv.json
+++ b/locales/rn/lv.json
@@ -425,7 +425,11 @@
"undo_send_other_login": "Šī vēstule tika nosūtīta no cita konta",
"undo_send_other_login_body": "Pārslēdzieties atpakaļ uz {account}, lai to atsauktu vai nosūtītu tagad. Tā tiks nosūtīta, kad beigsies aizture.",
"upload_login_switched": "Nav augšupielādēts: konts tika pārslēgts",
- "use_template": "Veidne"
+ "use_template": "Veidne",
+ "draft_kept_title": "Saglabāts šajā ierīcē",
+ "draft_kept_body": "Tas nonāks melnrakstos, kad atkal būsiet pierakstījies un tiešsaistē.",
+ "rescued_draft_title": "Saglabāts melnrakstos",
+ "rescued_draft_body": "Ziņojumu, ko rakstījāt, iepriekš neizdevās saglabāt. Tagad tas ir melnrakstos."
},
"email_list": {
"cross_account_move_offline": "Pārvietošanai starp kontiem nepieciešams savienojums",
diff --git a/locales/rn/mn.json b/locales/rn/mn.json
index 94d80221..0d2f45bc 100644
--- a/locales/rn/mn.json
+++ b/locales/rn/mn.json
@@ -421,7 +421,11 @@
"undo_send_other_login": "Энэ мессежийг өөр бүртгэлээс илгээсэн",
"undo_send_other_login_body": "Үүнийг буцаах эсвэл одоо илгээхийн тулд {account} бүртгэл рүү буцаж шилжинэ үү. Саатал дуусахад илгээгдэнэ.",
"upload_login_switched": "Байршуулагдаагүй: бүртгэл солигдсон",
- "use_template": "Загвар"
+ "use_template": "Загвар",
+ "draft_kept_title": "Энэ төхөөрөмж дээр хадгалсан",
+ "draft_kept_body": "Дахин нэвтэрч, онлайн болмогц Ноорог руу орно.",
+ "rescued_draft_title": "Ноорогт хадгалсан",
+ "rescued_draft_body": "Таны бичиж байсан захидлыг өмнө нь хадгалж чадсангүй. Одоо Ноорогт байна."
},
"email_list": {
"cross_account_move_offline": "Бүртгэл хооронд зөөхөд холболт шаардлагатай",
diff --git a/locales/rn/nb.json b/locales/rn/nb.json
index d7cc52d6..8a2e4e98 100644
--- a/locales/rn/nb.json
+++ b/locales/rn/nb.json
@@ -423,7 +423,11 @@
"undo_send_other_login": "Denne meldingen ble sendt fra en annen konto",
"undo_send_other_login_body": "Bytt tilbake til {account} for å angre eller sende den nå. Den sendes når forsinkelsen er over.",
"upload_login_switched": "Ikke lastet opp: kontoen ble byttet",
- "use_template": "Mal"
+ "use_template": "Mal",
+ "draft_kept_title": "Lagret på denne enheten",
+ "draft_kept_body": "Den havner i Utkast når du er logget inn og på nett igjen.",
+ "rescued_draft_title": "Lagret i Utkast",
+ "rescued_draft_body": "En melding du skrev, kunne ikke lagres tidligere. Den ligger nå i Utkast."
},
"email_list": {
"cross_account_move_offline": "Du må være tilkoblet for å flytte mellom kontoer",
diff --git a/locales/rn/nl.json b/locales/rn/nl.json
index 19def41d..c371d0c7 100644
--- a/locales/rn/nl.json
+++ b/locales/rn/nl.json
@@ -426,7 +426,11 @@
"undo_send_other_login": "Dit bericht is verzonden vanuit een ander account",
"undo_send_other_login_body": "Ga terug naar {account} om het ongedaan te maken of nu te verzenden. Het gaat de deur uit wanneer de vertraging voorbij is.",
"upload_login_switched": "Niet geüpload: er is van account gewisseld",
- "use_template": "Sjabloon"
+ "use_template": "Sjabloon",
+ "draft_kept_title": "Opgeslagen op dit apparaat",
+ "draft_kept_body": "Het gaat naar Concepten zodra je weer bent ingelogd en online bent.",
+ "rescued_draft_title": "Opgeslagen in Concepten",
+ "rescued_draft_body": "Een bericht dat je schreef, kon eerder niet worden opgeslagen. Het staat nu in Concepten."
},
"email_list": {
"cross_account_move_offline": "Verplaatsen tussen accounts vereist een verbinding",
diff --git a/locales/rn/pl.json b/locales/rn/pl.json
index afd5f9d4..82bbdb35 100644
--- a/locales/rn/pl.json
+++ b/locales/rn/pl.json
@@ -426,7 +426,11 @@
"undo_send_other_login": "Ta wiadomość została wysłana z innego konta",
"undo_send_other_login_body": "Przełącz się z powrotem na {account}, aby cofnąć wysłanie lub wysłać ją teraz. Zostanie wysłana po upływie opóźnienia.",
"upload_login_switched": "Nie przesłano: przełączono konto",
- "use_template": "Szablon"
+ "use_template": "Szablon",
+ "draft_kept_title": "Zapisano na tym urządzeniu",
+ "draft_kept_body": "Trafi do Wersji roboczych, gdy znów się zalogujesz i będziesz online.",
+ "rescued_draft_title": "Zapisano w Wersjach roboczych",
+ "rescued_draft_body": "Wiadomości, którą pisałeś, nie udało się wcześniej zapisać. Jest teraz w Wersjach roboczych."
},
"email_list": {
"cross_account_move_offline": "Przenoszenie między kontami wymaga połączenia",
diff --git a/locales/rn/pt.json b/locales/rn/pt.json
index b490f2c2..a33bcb75 100644
--- a/locales/rn/pt.json
+++ b/locales/rn/pt.json
@@ -429,7 +429,11 @@
"undo_send_other_login": "Esta mensagem foi enviada de outra conta",
"undo_send_other_login_body": "Volte para {account} para desfazer ou enviar agora. Ela sai quando o atraso terminar.",
"upload_login_switched": "Não enviado: a conta foi trocada",
- "use_template": "Modelo"
+ "use_template": "Modelo",
+ "draft_kept_title": "Guardado neste dispositivo",
+ "draft_kept_body": "Vai para Rascunhos assim que voltar a ter sessão iniciada e ligação.",
+ "rescued_draft_title": "Guardado em Rascunhos",
+ "rescued_draft_body": "Não foi possível guardar antes uma mensagem que estava a escrever. Está agora em Rascunhos."
},
"email_list": {
"cross_account_move_offline": "Mover entre contas requer conexão",
diff --git a/locales/rn/ro.json b/locales/rn/ro.json
index 80fae9b5..ac9da850 100644
--- a/locales/rn/ro.json
+++ b/locales/rn/ro.json
@@ -426,7 +426,11 @@
"undo_send_other_login": "Acest mesaj a fost trimis din alt cont",
"undo_send_other_login_body": "Reveniți la {account} pentru a anula trimiterea sau a-l trimite acum. Mesajul pleacă la sfârșitul întârzierii.",
"upload_login_switched": "Neîncărcat: contul a fost schimbat",
- "use_template": "Șablon"
+ "use_template": "Șablon",
+ "draft_kept_title": "Salvat pe acest dispozitiv",
+ "draft_kept_body": "Ajunge în Ciorne după ce te conectezi din nou și ești online.",
+ "rescued_draft_title": "Salvat în Ciorne",
+ "rescued_draft_body": "Un mesaj pe care îl scriai nu a putut fi salvat mai devreme. Acum este în Ciorne."
},
"email_list": {
"cross_account_move_offline": "Mutarea între conturi necesită conexiune",
diff --git a/locales/rn/ru.json b/locales/rn/ru.json
index dbc93570..668c5868 100644
--- a/locales/rn/ru.json
+++ b/locales/rn/ru.json
@@ -429,7 +429,11 @@
"undo_send_other_login": "Это письмо отправлено из другого аккаунта",
"undo_send_other_login_body": "Вернитесь в аккаунт {account}, чтобы отменить отправку или отправить письмо сейчас. Оно уйдёт, когда закончится задержка.",
"upload_login_switched": "Не загружено: аккаунт сменён",
- "use_template": "Шаблон"
+ "use_template": "Шаблон",
+ "draft_kept_title": "Сохранено на этом устройстве",
+ "draft_kept_body": "Оно попадёт в черновики, когда вы снова войдёте и будете в сети.",
+ "rescued_draft_title": "Сохранено в черновиках",
+ "rescued_draft_body": "Сообщение, которое вы писали, раньше не удалось сохранить. Теперь оно в черновиках."
},
"email_list": {
"cross_account_move_offline": "Для перемещения между аккаунтами нужно подключение",
diff --git a/locales/rn/sk.json b/locales/rn/sk.json
index a8fe02c4..dc950cc6 100644
--- a/locales/rn/sk.json
+++ b/locales/rn/sk.json
@@ -425,7 +425,11 @@
"undo_send_other_login": "Táto správa bola odoslaná z iného účtu",
"undo_send_other_login_body": "Ak ju chcete vrátiť alebo odoslať hneď, prepnite sa späť na účet {account}. Odíde po uplynutí oneskorenia.",
"upload_login_switched": "Nenahrané: účet bol prepnutý",
- "use_template": "Šablóna"
+ "use_template": "Šablóna",
+ "draft_kept_title": "Uložené v tomto zariadení",
+ "draft_kept_body": "Do Konceptov sa uloží, keď budete znova prihlásení a online.",
+ "rescued_draft_title": "Uložené do Konceptov",
+ "rescued_draft_body": "Správu, ktorú ste písali, sa predtým nepodarilo uložiť. Teraz je v Konceptoch."
},
"email_list": {
"cross_account_move_offline": "Presun medzi účtami vyžaduje pripojenie",
diff --git a/locales/rn/th.json b/locales/rn/th.json
index f4a4ebe7..d8046018 100644
--- a/locales/rn/th.json
+++ b/locales/rn/th.json
@@ -393,7 +393,11 @@
"use_template": "เทมเพลต",
"validation": {
"subject_required": "เพิ่มเรื่อง"
- }
+ },
+ "draft_kept_title": "บันทึกไว้ในอุปกรณ์นี้แล้ว",
+ "draft_kept_body": "จะย้ายไปที่ฉบับร่างเมื่อคุณลงชื่อเข้าใช้และออนไลน์อีกครั้ง",
+ "rescued_draft_title": "บันทึกในฉบับร่างแล้ว",
+ "rescued_draft_body": "ก่อนหน้านี้บันทึกข้อความที่คุณเขียนไม่ได้ ตอนนี้อยู่ในฉบับร่างแล้ว"
},
"email_list": {
"cross_account_move_offline": "การย้ายระหว่างบัญชีต้องใช้การเชื่อมต่อ",
diff --git a/locales/rn/tr.json b/locales/rn/tr.json
index 66ab5c47..42de84a8 100644
--- a/locales/rn/tr.json
+++ b/locales/rn/tr.json
@@ -427,7 +427,11 @@
"undo_send_other_login": "Bu ileti başka bir hesaptan gönderildi",
"undo_send_other_login_body": "Geri almak veya hemen göndermek için {account} hesabına geri dönün. İleti, gecikme süresi dolunca gönderilir.",
"upload_login_switched": "Yüklenmedi: hesap değiştirildi",
- "use_template": "Şablon"
+ "use_template": "Şablon",
+ "draft_kept_title": "Bu cihaza kaydedildi",
+ "draft_kept_body": "Yeniden oturum açıp çevrimiçi olduğunuzda Taslaklar'a gider.",
+ "rescued_draft_title": "Taslaklar'a kaydedildi",
+ "rescued_draft_body": "Yazdığınız bir ileti daha önce kaydedilemedi. Artık Taslaklar'da."
},
"email_list": {
"cross_account_move_offline": "Hesaplar arasında taşımak için bağlantı gerekiyor",
diff --git a/locales/rn/uk.json b/locales/rn/uk.json
index 0540a277..8074e8be 100644
--- a/locales/rn/uk.json
+++ b/locales/rn/uk.json
@@ -428,7 +428,11 @@
"undo_send_other_login": "Цей лист надіслано з іншого облікового запису",
"undo_send_other_login_body": "Поверніться до {account}, щоб скасувати надсилання або надіслати лист зараз. Він піде, коли закінчиться затримка.",
"upload_login_switched": "Не завантажено: обліковий запис змінено",
- "use_template": "Шаблон"
+ "use_template": "Шаблон",
+ "draft_kept_title": "Збережено на цьому пристрої",
+ "draft_kept_body": "Він потрапить у чернетки, щойно ви знову ввійдете й будете в мережі.",
+ "rescued_draft_title": "Збережено в чернетках",
+ "rescued_draft_body": "Повідомлення, яке ви писали, раніше не вдалося зберегти. Тепер воно в чернетках."
},
"email_list": {
"cross_account_move_offline": "Для переміщення між обліковими записами потрібне з'єднання",
diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json
index 11007890..e64df039 100644
--- a/locales/rn/zh-TW.json
+++ b/locales/rn/zh-TW.json
@@ -425,7 +425,11 @@
"undo_send_other_login": "這封郵件是從另一個帳號寄出的",
"undo_send_other_login_body": "切換回 {account} 即可取消傳送或立即寄出。延遲結束後郵件就會寄出。",
"upload_login_switched": "未上傳:已切換帳號",
- "use_template": "範本"
+ "use_template": "範本",
+ "draft_kept_title": "已儲存在此裝置上",
+ "draft_kept_body": "重新登入並連上網路後,它會儲存到草稿匣。",
+ "rescued_draft_title": "已儲存到草稿匣",
+ "rescued_draft_body": "你之前寫的一封郵件當時未能儲存,現在已在草稿匣中。"
},
"email_list": {
"cross_account_move_offline": "在帳號之間移動需要網路連線",
diff --git a/locales/rn/zh.json b/locales/rn/zh.json
index 220da131..91f87be6 100644
--- a/locales/rn/zh.json
+++ b/locales/rn/zh.json
@@ -425,7 +425,11 @@
"undo_send_other_login": "这封邮件是从另一个账户发送的",
"undo_send_other_login_body": "切换回 {account} 即可撤销或立即发送。延迟结束后邮件将发出。",
"upload_login_switched": "未上传:账户已切换",
- "use_template": "模板"
+ "use_template": "模板",
+ "draft_kept_title": "已保存在此设备上",
+ "draft_kept_body": "重新登录并联网后,它会保存到草稿箱。",
+ "rescued_draft_title": "已保存到草稿箱",
+ "rescued_draft_body": "你之前写的一封邮件当时未能保存,现在已在草稿箱中。"
},
"email_list": {
"cross_account_move_offline": "在账户之间移动需要网络连接",
diff --git a/src/lib/compose-rescue.ts b/src/lib/compose-rescue.ts
new file mode 100644
index 00000000..12a736ba
--- /dev/null
+++ b/src/lib/compose-rescue.ts
@@ -0,0 +1,130 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import type { OutgoingEmail, SavedDraftVersion } from '../api/email';
+
+// A composer whose draft can't reach the server (the session expired, or the
+// connection is gone) keeps it on the device here, so closing the composer or
+// losing the app process doesn't lose the text. Once the account's session is
+// up again (the Outbox flush: session up, server checked, online), each kept
+// draft goes to the account's Drafts and the user is told it is there. A
+// composer still open saves its own draft and is skipped.
+
+const KEY_PREFIX = 'compose-rescue:v1:';
+
+export interface RescuedDraft {
+ /** The composer it came from. */
+ id: string;
+ savedAt: number;
+ /** The message as the composer built it for a draft. */
+ outgoing: OutgoingEmail;
+ draftsMailboxId: string;
+ /** JMAP account of a group's Drafts; absent for the login's own. */
+ jmapAccountId?: string;
+ /** The draft version saved before, replaced when this one is saved. */
+ previousDraftId?: string;
+ previousDraftAccountId?: string;
+}
+
+/** The server didn't take the draft, and it was kept on the device instead. */
+export class DraftKeptOnDeviceError extends Error {
+ constructor(readonly reason: unknown) {
+ super(reason instanceof Error ? reason.message : String(reason));
+ this.name = 'DraftKeptOnDeviceError';
+ }
+}
+
+const key = (accountId: string) => `${KEY_PREFIX}${accountId}`;
+
+async function read(accountId: string): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(key(accountId));
+ const parsed = raw ? JSON.parse(raw) : [];
+ return Array.isArray(parsed) ? (parsed as RescuedDraft[]) : [];
+ } catch {
+ return [];
+ }
+}
+
+async function write(accountId: string, drafts: RescuedDraft[]): Promise {
+ if (drafts.length === 0) await AsyncStorage.removeItem(key(accountId));
+ else await AsyncStorage.setItem(key(accountId), JSON.stringify(drafts));
+}
+
+// Reads and writes of one account's list run one after another.
+const queues = new Map>();
+function serial(accountId: string, fn: () => Promise): Promise {
+ const run = (queues.get(accountId) ?? Promise.resolve()).catch(() => undefined).then(fn);
+ queues.set(accountId, run);
+ return run;
+}
+
+/** Keep (or replace) a composer's draft on the device for an account (app registry id). */
+export function rescueDraft(accountId: string, draft: RescuedDraft): Promise {
+ return serial(accountId, async () => {
+ const drafts = (await read(accountId)).filter((d) => d.id !== draft.id);
+ await write(accountId, [...drafts, draft]);
+ }).catch((err) => console.warn('[compose-rescue] keep failed', err));
+}
+
+/** Drop a composer's kept draft: it reached the server, was sent or discarded. */
+export function forgetRescuedDraft(accountId: string, id: string): Promise {
+ return serial(accountId, async () => {
+ const drafts = await read(accountId);
+ if (drafts.some((d) => d.id === id)) await write(accountId, drafts.filter((d) => d.id !== id));
+ }).catch(() => undefined);
+}
+
+/** Drop every kept draft of an account that was signed out. */
+export function clearRescuedDrafts(accountId: string): Promise {
+ return serial(accountId, () => AsyncStorage.removeItem(key(accountId))).catch(() => undefined);
+}
+
+export function rescuedDrafts(accountId: string): Promise {
+ return serial(accountId, () => read(accountId));
+}
+
+// Composers open in this process, which save their own drafts.
+const openComposers = new Set();
+
+export function composerOpened(id: string): void {
+ openComposers.add(id);
+}
+
+export function composerClosed(id: string): void {
+ openComposers.delete(id);
+}
+
+export interface FlushDeps {
+ save: (draft: RescuedDraft) => Promise;
+ /** Called once per flush that saved at least one draft. */
+ saved: (count: number) => void;
+}
+
+const flushing = new Set();
+
+/**
+ * Save the kept drafts of an account to its Drafts. Call only while the
+ * client serves that account's session. A draft the server doesn't take
+ * stays for the next try. Resolves to how many were saved.
+ */
+export async function flushRescuedDrafts(accountId: string, deps: FlushDeps): Promise {
+ if (flushing.has(accountId)) return 0;
+ flushing.add(accountId);
+ let count = 0;
+ try {
+ for (const draft of await rescuedDrafts(accountId)) {
+ if (openComposers.has(draft.id)) continue;
+ try {
+ await deps.save(draft);
+ } catch (err) {
+ console.warn('[compose-rescue] save failed', err);
+ continue;
+ }
+ await forgetRescuedDraft(accountId, draft.id);
+ count += 1;
+ }
+ } finally {
+ flushing.delete(accountId);
+ }
+ if (count > 0) deps.saved(count);
+ return count;
+}
diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx
index e2a11735..bfd65d20 100644
--- a/src/screens/ComposeScreen.tsx
+++ b/src/screens/ComposeScreen.tsx
@@ -98,7 +98,11 @@ import { isPickerReopenTooSoon, needsRecipientPicker } from '../lib/recipient-pi
import { useOutboxStore } from '../stores/outbox-store';
import { useNetworkStore } from '../stores/network-store';
import { useAuthStore } from '../stores/auth-store';
-import { isTransientNetworkError } from '../lib/network-error';
+import { isAuthError, isTransientNetworkError } from '../lib/network-error';
+import { generateUUID } from '../lib/uuid';
+import {
+ DraftKeptOnDeviceError, composerClosed, composerOpened, forgetRescuedDraft, rescueDraft,
+} from '../lib/compose-rescue';
import { classifySendFailure, shouldQueueFailedSend } from '../lib/send-queue';
import {
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
@@ -1283,6 +1287,29 @@ export default function ComposeScreen({ route, navigation }: Props) {
const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined;
return owner?.email || owner?.displayName || '';
};
+ // While the server can't take the draft (the session expired, or offline)
+ // it is kept on the device and goes to Drafts once the session is back
+ // (lib/compose-rescue), also when the composer was closed or the app
+ // ended meanwhile.
+ const [composeId] = React.useState(() => `compose-${generateUUID()}`);
+ const composeIdRef = React.useRef(composeId);
+ /** The last save went to the device, not the server. */
+ const keptOnDeviceRef = React.useRef(false);
+ /** Closed with the draft kept on the device: it stays for the Outbox flush. */
+ const closedWithKeptDraftRef = React.useRef(false);
+ React.useEffect(() => {
+ const id = composeIdRef.current;
+ const login = ownerLoginRef.current;
+ composerOpened(id);
+ return () => {
+ composerClosed(id);
+ if (!login) return;
+ // Sent, queued, discarded or saved: nothing left to keep. Torn down
+ // without leaving (the app's stack was reset): the kept draft stays.
+ if (allowLeaveRef.current && !closedWithKeptDraftRef.current) void forgetRescuedDraft(login, id);
+ else if (keptOnDeviceRef.current) void useOutboxStore.getState().flush();
+ };
+ }, []);
const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle');
const [savingDraft, setSavingDraft] = React.useState(false);
@@ -1617,8 +1644,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
if (snapshot === lastSavedRef.current) return draftIdRef.current;
if (snapshot === baselineRef.current && !draftIdRef.current) return null;
setDraftStatus('saving');
+ let outgoing: OutgoingEmail | null = null;
+ let saveIn: { accountId: string | undefined; draftsMailboxId: string } = { accountId: undefined, draftsMailboxId: draftsMailbox.id };
try {
- const outgoing = buildOutgoing(identity, html, { forDraft: true });
+ outgoing = buildOutgoing(identity, html, { forDraft: true });
// A draft From a group identity is saved in the group's Drafts, like
// its Sent copy (#1090); one without a Drafts folder there in the own.
const target = await sendTargetFor(identity, outgoing.from[0]?.email ?? identity.email, mailboxes);
@@ -1626,9 +1655,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
setDraftStatus('idle');
return draftIdRef.current;
}
- const saveIn = target.accountId && target.draftsMailboxId
- ? { accountId: target.accountId, draftsMailboxId: target.draftsMailboxId }
- : { accountId: undefined, draftsMailboxId: draftsMailbox.id };
+ if (target.accountId && target.draftsMailboxId) {
+ saveIn = { accountId: target.accountId, draftsMailboxId: target.draftsMailboxId };
+ }
const saved = await saveDraftVersion(
outgoing,
saveIn.draftsMailboxId,
@@ -1651,9 +1680,29 @@ export default function ComposeScreen({ route, navigation }: Props) {
// after the next pull-to-refresh.
const thread = draftThreadOf({ replyTo, draft });
if (thread) void refreshDraftThread(thread);
+ if (keptOnDeviceRef.current && ownerLoginRef.current) {
+ keptOnDeviceRef.current = false;
+ void forgetRescuedDraft(ownerLoginRef.current, composeIdRef.current);
+ }
return id;
} catch (err) {
setDraftStatus('failed');
+ // The server can't be reached or no longer takes the sign-in: keep
+ // the draft on the device instead (lib/compose-rescue).
+ if (outgoing && ownerLoginRef.current && !loginMoved() && (isAuthError(err) || isTransientNetworkError(err))) {
+ keptOnDeviceRef.current = true;
+ await rescueDraft(ownerLoginRef.current, {
+ id: composeIdRef.current,
+ savedAt: Date.now(),
+ outgoing,
+ draftsMailboxId: saveIn.draftsMailboxId,
+ jmapAccountId: saveIn.accountId,
+ previousDraftId: draftIdRef.current ?? undefined,
+ // Undefined is the login's own account (the getter throws without a session).
+ previousDraftAccountId: draftIdRef.current ? draftAccountRef.current : undefined,
+ });
+ throw new DraftKeptOnDeviceError(err);
+ }
throw err;
}
};
@@ -1701,6 +1750,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSnapshot, draftsMailbox?.id, primaryIdentity?.id]);
+ // A draft kept on the device while the composer stayed open goes to the
+ // server once the session (or the connection) is back.
+ React.useEffect(() => {
+ if (!sessionUp || !online || !keptOnDeviceRef.current || sendingRef.current) return;
+ saveDraftRef.current({ live: true }).catch((err) => {
+ console.warn('[compose] saving the kept draft failed', err);
+ });
+ }, [sessionUp, online]);
+
// ── Close guard ──────────────────────────────────────────────────────
// Why a draft can't be saved right now: the identities didn't load (e.g.
@@ -1741,6 +1799,17 @@ export default function ComposeScreen({ route, navigation }: Props) {
allowLeaveRef.current = true;
proceed();
} catch (err) {
+ if (err instanceof DraftKeptOnDeviceError) {
+ // Kept on the device: it goes to Drafts once the session is back.
+ closedWithKeptDraftRef.current = true;
+ allowLeaveRef.current = true;
+ toast.info(
+ t('email_composer.draft_kept_title', 'Saved on this device'),
+ { message: t('email_composer.draft_kept_body', 'It goes to Drafts once you are signed in and online again.') },
+ );
+ proceed();
+ return;
+ }
// The server refused the draft: keep the text on screen rather than
// dropping it with nothing but an error (#702).
Alert.alert(
diff --git a/src/screens/__tests__/compose-rescue-wiring.test.ts b/src/screens/__tests__/compose-rescue-wiring.test.ts
new file mode 100644
index 00000000..73b4c4f4
--- /dev/null
+++ b/src/screens/__tests__/compose-rescue-wiring.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+
+// An expired session (or no connection) used to leave the composer's draft
+// only in memory: Save Draft failed, and the text was lost with the app. The
+// composer now keeps it on the device (lib/compose-rescue). No React Native
+// renderer in the test env, so this checks the wiring.
+
+const src = fs.readFileSync(path.resolve(__dirname, '..', 'ComposeScreen.tsx'), 'utf8');
+
+describe('composer draft kept on the device', () => {
+ it('keeps a draft the server could not take because of the session or the connection', () => {
+ expect(src).toMatch(/\(isAuthError\(err\) \|\| isTransientNetworkError\(err\)\)\) \{\s+keptOnDeviceRef\.current = true;\s+await rescueDraft\(ownerLoginRef\.current, \{/);
+ expect(src).toMatch(/throw new DraftKeptOnDeviceError\(err\);/);
+ });
+
+ it('closes on Save Draft once the draft is kept, and says where it goes', () => {
+ expect(src).toMatch(/if \(err instanceof DraftKeptOnDeviceError\) \{\s+\/\/[^\n]*\n\s+closedWithKeptDraftRef\.current = true;\s+allowLeaveRef\.current = true;/);
+ expect(src).toMatch(/t\('email_composer\.draft_kept_title'/);
+ });
+
+ it('drops the kept copy once the server has the draft, or the message is sent or discarded', () => {
+ expect(src).toMatch(/keptOnDeviceRef\.current = false;\s+void forgetRescuedDraft\(ownerLoginRef\.current, composeIdRef\.current\);/);
+ expect(src).toMatch(/if \(allowLeaveRef\.current && !closedWithKeptDraftRef\.current\) void forgetRescuedDraft\(login, id\);/);
+ });
+
+ it('saves a kept draft of an open composer once the session is back', () => {
+ expect(src).toMatch(/if \(!sessionUp \|\| !online \|\| !keptOnDeviceRef\.current \|\| sendingRef\.current\) return;/);
+ });
+});
diff --git a/src/stores/__tests__/outbox-rescued-drafts.test.ts b/src/stores/__tests__/outbox-rescued-drafts.test.ts
new file mode 100644
index 00000000..737cbefb
--- /dev/null
+++ b/src/stores/__tests__/outbox-rescued-drafts.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// A composer whose draft couldn't reach the server (expired session, offline)
+// kept it on the device (lib/compose-rescue). The Outbox flush, which runs
+// once the account's session is up again, saves it to Drafts and says so.
+
+vi.mock('@react-native-community/netinfo', () => ({
+ default: {
+ addEventListener: () => () => undefined,
+ fetch: async () => ({ isConnected: true, isInternetReachable: true }),
+ },
+}));
+
+const client = vi.hoisted(() => ({ served: null as string | null }));
+vi.mock('../../api/jmap-client', () => ({
+ jmapClient: {
+ get isConnected() { return client.served !== null; },
+ get username() { return client.served ? client.served.split('@')[0] : null; },
+ get serverUrl() { return client.served ? `https://${client.served.split('@')[1]}` : null; },
+ },
+}));
+
+const saveDraftVersion = vi.fn(async (..._a: any[]) => ({ id: 'd2', attachments: [] }));
+vi.mock('../../api/email', () => ({
+ saveDraftVersion: (...a: any[]) => saveDraftVersion(...a),
+ patchKeywordsForEmails: vi.fn(async () => undefined),
+ setEmailMailboxes: vi.fn(async () => undefined),
+ destroyEmails: vi.fn(async () => undefined),
+}));
+
+import { useOutboxStore } from '../outbox-store';
+import { useNetworkStore } from '../network-store';
+import { useToastStore } from '../toast-store';
+import {
+ composerClosed, composerOpened, forgetRescuedDraft, rescueDraft, rescuedDrafts, type RescuedDraft,
+} from '../../lib/compose-rescue';
+
+const ACCOUNT = 'user1@mail.example.org';
+
+function kept(id: string, extra: Partial = {}): RescuedDraft {
+ return {
+ id,
+ savedAt: 1,
+ outgoing: { from: [{ email: 'user1@example.org' }], to: [{ email: 'bob@example.org' }], subject: 'Plans', htmlBody: 'Hi
' } as never,
+ draftsMailboxId: 'drafts',
+ previousDraftId: 'd1',
+ ...extra,
+ };
+}
+
+const flushed = async () => {
+ await useOutboxStore.getState().flush();
+ // The kept drafts are saved beside the queue's own flush.
+ await vi.waitFor(async () => {
+ expect((await rescuedDrafts(ACCOUNT)).filter((d) => !d.id.startsWith('open'))).toEqual([]);
+ });
+};
+
+beforeEach(async () => {
+ vi.clearAllMocks();
+ client.served = ACCOUNT;
+ useNetworkStore.setState({ online: true, connected: true });
+ useToastStore.setState({ toasts: [] });
+ for (const d of await rescuedDrafts(ACCOUNT)) await forgetRescuedDraft(ACCOUNT, d.id);
+ await useOutboxStore.getState().setAccount(null);
+ await useOutboxStore.getState().setAccount(ACCOUNT);
+ await useOutboxStore.getState().clear();
+ await useOutboxStore.getState().resume(ACCOUNT);
+ // Let the kept-draft pass of that flush finish before the test keeps any.
+ await new Promise((r) => setTimeout(r, 20));
+ saveDraftVersion.mockClear();
+});
+
+describe('drafts kept on the device', () => {
+ it('go to Drafts, replacing the version saved before, once the session is back', async () => {
+ await rescueDraft(ACCOUNT, kept('c1'));
+ await flushed();
+ expect(saveDraftVersion).toHaveBeenCalledWith(
+ expect.objectContaining({ subject: 'Plans' }), 'drafts', 'd1', undefined, undefined,
+ );
+ await vi.waitFor(() => expect(useToastStore.getState().toasts.map((t) => t.title)).toContain('Saved to Drafts'));
+ });
+
+ it('stay while the client serves another account, or the server refuses them', async () => {
+ await rescueDraft(ACCOUNT, kept('c1'));
+ client.served = 'other@mail.example.org';
+ await useOutboxStore.getState().flush();
+ expect(saveDraftVersion).not.toHaveBeenCalled();
+ expect(await rescuedDrafts(ACCOUNT)).toHaveLength(1);
+
+ client.served = ACCOUNT;
+ saveDraftVersion.mockRejectedValueOnce(new Error('Session expired'));
+ await useOutboxStore.getState().flush();
+ await vi.waitFor(() => expect(saveDraftVersion).toHaveBeenCalledTimes(1));
+ expect(await rescuedDrafts(ACCOUNT)).toHaveLength(1);
+ });
+
+ it('skip a composer that is still open, which saves its own draft', async () => {
+ composerOpened('open-1');
+ await rescueDraft(ACCOUNT, kept('open-1'));
+ await rescueDraft(ACCOUNT, kept('c2', { previousDraftId: undefined }));
+ await flushed();
+ expect(saveDraftVersion).toHaveBeenCalledTimes(1);
+ expect(saveDraftVersion.mock.calls[0][2]).toBeUndefined();
+ expect((await rescuedDrafts(ACCOUNT)).map((d) => d.id)).toEqual(['open-1']);
+ composerClosed('open-1');
+ });
+});
diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts
index 07380e49..060f6169 100644
--- a/src/stores/auth-store.ts
+++ b/src/stores/auth-store.ts
@@ -1,4 +1,5 @@
import { forgetCachedIdentities } from '../lib/identity-cache';
+import { clearRescuedDrafts } from '../lib/compose-rescue';
import { create } from 'zustand';
import {
jmapClient,
@@ -178,6 +179,8 @@ function clearDeviceWideAccountData(): void {
// data goes too.
async function forgetRemovedAccount(accountId: string): Promise {
void forgetCachedIdentities(accountId);
+ // Drafts its composers kept on the device while signed out of the server.
+ void clearRescuedDrafts(accountId);
try {
const feeds = useCalendarSubscriptionsStore;
// On a cold start the stored list may not have been read yet; written
diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts
index a052bb2c..d87ccc6e 100644
--- a/src/stores/outbox-store.ts
+++ b/src/stores/outbox-store.ts
@@ -449,6 +449,9 @@ export const useOutboxStore = create((set, get) => ({
if (get().paused || !get().resumed || !get().hydrated) return;
const accountId = get().activeAccountId;
if (!accountId) return;
+ // Drafts a composer kept on the device while the server couldn't take
+ // them (lib/compose-rescue) go to Drafts now.
+ if (clientServes(accountId) && useNetworkStore.getState().online) void saveRescuedDrafts(accountId);
if (get().entries.length === 0 && !hasWaitingSends()) return;
// Need a live client that's actually serving this account, and a network.
if (!clientServes(accountId)) return;
@@ -759,6 +762,39 @@ async function afterQueuedSend(send: QueuedSend, outcome: QueuedSendOutcomeLike)
}
}
+/** Save the drafts composers kept on the device (lib/compose-rescue) and say where they went. */
+async function saveRescuedDrafts(accountId: string): Promise {
+ try {
+ const { flushRescuedDrafts, rescuedDrafts } = await import('../lib/compose-rescue');
+ if ((await rescuedDrafts(accountId)).length === 0) return;
+ const { saveDraftVersion } = await import('../api/email');
+ await flushRescuedDrafts(accountId, {
+ save: async (draft) => {
+ // The client moved on to another account meanwhile.
+ if (!clientServes(accountId)) throw new Error('Account switched');
+ return saveDraftVersion(
+ draft.outgoing,
+ draft.draftsMailboxId,
+ draft.previousDraftId,
+ draft.jmapAccountId,
+ draft.previousDraftAccountId,
+ );
+ },
+ saved: () => {
+ void (async () => {
+ const [{ toast }, { useLocaleStore }] = await Promise.all([import('./toast-store'), import('./locale-store')]);
+ const t = useLocaleStore.getState().t;
+ toast.info(t('email_composer.rescued_draft_title', 'Saved to Drafts'), {
+ message: t('email_composer.rescued_draft_body', "A message you were writing couldn't be saved earlier. It is in Drafts now."),
+ });
+ })().catch(() => undefined);
+ },
+ });
+ } catch (err) {
+ console.warn('[outbox] could not save kept drafts', err);
+ }
+}
+
/** Taking back a message found to have gone out already: nothing became a draft. */
async function notifyAlreadySent(send: QueuedSend): Promise {
try {
From ac312352ba0c304e7cc5890029221bba01a158e7 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 23:05:32 +0100
Subject: [PATCH 6/9] fix: offer and keep a key from a message only when it
names the sender alone #58
---
src/lib/crypto/__tests__/helpers.ts | 6 ++--
.../__tests__/secure-message-keys.test.ts | 28 +++++++++++++++++++
src/lib/crypto/keyring.ts | 13 +++++----
src/lib/crypto/pgp.ts | 11 ++++++++
src/lib/crypto/secure-message.ts | 4 +--
5 files changed, 52 insertions(+), 10 deletions(-)
diff --git a/src/lib/crypto/__tests__/helpers.ts b/src/lib/crypto/__tests__/helpers.ts
index 984c4960..c82228c2 100644
--- a/src/lib/crypto/__tests__/helpers.ts
+++ b/src/lib/crypto/__tests__/helpers.ts
@@ -43,7 +43,7 @@ export interface TestCert {
const engine = () => new pkijs.CryptoEngine({ name: 'test', crypto: globalThis.crypto });
/** A self-signed RSA-2048 S/MIME certificate for `email`. */
-export async function makeSmimeCert(email: string, opts: { notAfter?: Date } = {}): Promise {
+export async function makeSmimeCert(email: string, opts: { notAfter?: Date; extraEmails?: string[] } = {}): Promise {
const subtle = globalThis.crypto.subtle;
const keys = await subtle.generateKey(
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
@@ -67,7 +67,9 @@ export async function makeSmimeCert(email: string, opts: { notAfter?: Date } = {
new pkijs.Extension({ extnID: '2.5.29.15', critical: true, extnValue: ku.toBER(false), parsedValue: ku }),
new pkijs.Extension({
extnID: '2.5.29.17',
- extnValue: new pkijs.GeneralNames({ names: [new pkijs.GeneralName({ type: 1, value: email })] }).toSchema().toBER(false),
+ extnValue: new pkijs.GeneralNames({
+ names: [email, ...(opts.extraEmails ?? [])].map((value) => new pkijs.GeneralName({ type: 1, value })),
+ }).toSchema().toBER(false),
}),
];
await cert.subjectPublicKeyInfo.importKey(keys.publicKey, engine());
diff --git a/src/lib/crypto/__tests__/secure-message-keys.test.ts b/src/lib/crypto/__tests__/secure-message-keys.test.ts
index 7ba6ec67..92151e68 100644
--- a/src/lib/crypto/__tests__/secure-message-keys.test.ts
+++ b/src/lib/crypto/__tests__/secure-message-keys.test.ts
@@ -75,6 +75,21 @@ describe('a PGP key in a message', () => {
expect(stored()).toEqual([]);
}, 60_000);
+ it('is neither stored nor offered when it also names another address', async () => {
+ // Mallory's own address and the boss's on one key: saved for Mallory,
+ // it would be encrypted to for the boss and verify mail forged from them.
+ const { privateKey, publicKey } = await openpgp.generateKey({
+ type: 'ecc', curve: 'curve25519Legacy',
+ userIDs: [{ name: 'Mallory', email: mallory.email }, { name: 'Boss', email: 'boss@example.org' }],
+ format: 'armored',
+ });
+ const signer = await openpgp.readPrivateKey({ armoredKey: privateKey });
+ const res = await openSecureMessage(await inlineSigned(mallory.email, signer, publicKey), mallory.email, keyringAccess());
+ expect(res?.offeredKey).toBeUndefined();
+ await expect(savePgpKeyFor(publicKey, mallory.email)).rejects.toThrow(/not for mallory@example.org alone/);
+ expect(stored()).toEqual([]);
+ }, 60_000);
+
it('is neither stored nor offered when the signature fails', async () => {
await importPgpKey(alice.publicKey);
const res = await openSecureMessage(await inlineSigned(alice.email, alice.unlocked, alice.publicKey, true), alice.email, keyringAccess());
@@ -119,6 +134,19 @@ describe('an S/MIME signer certificate', () => {
expect(stored()).toEqual([]);
}, 60_000);
+ it('is not kept when it also names another address', async () => {
+ const both = await makeSmimeCert('mallory@example.org', { extraEmails: ['boss@example.org'] });
+ const raw = await buildSecureMessage(
+ { from: { email: both.email }, to: [{ email: 'bob@example.org' }], subject: 's', textBody: 'signed' },
+ { mode: 'smime', sign: true, encrypt: false },
+ { smime: { signer: { pkcs8: both.pkcs8, certificate: both.certificate, chain: [], algorithm: 'RSA-2048' } } },
+ );
+ const res = await openSecureMessage(raw, both.email, keyringAccess());
+ expect(res?.status.signature).toBe('valid');
+ await new Promise((r) => setTimeout(r, 50));
+ expect(stored()).toEqual([]);
+ }, 60_000);
+
it('is kept when it names the From address', async () => {
await openSecureMessage(await signed(), signer.email, keyringAccess());
await vi.waitFor(() => expect(stored()).toEqual([expect.objectContaining({ kind: 'smime', emails: [signer.email], source: 'message' })]));
diff --git a/src/lib/crypto/keyring.ts b/src/lib/crypto/keyring.ts
index d13d022a..e31fd728 100644
--- a/src/lib/crypto/keyring.ts
+++ b/src/lib/crypto/keyring.ts
@@ -9,7 +9,7 @@
import type * as OpenPgp from 'openpgp';
import { useCryptoKeysStore, upsertRecord, keysForEmail, isRecordCurrent, type CryptoKeyRecord, type PgpKeyRecord, type SmimeCertRecord } from '../../stores/crypto-keys-store';
import { setSecret, getSecret, deleteSecret } from './secret-store';
-import { inspectKey, unlockPrivateKey, privateKeyNeedsPassphrase, type PgpKeyInfo } from './pgp';
+import { inspectKey, keyNamesOnly, unlockPrivateKey, privateKeyNeedsPassphrase, type PgpKeyInfo } from './pgp';
import { base64Decode, base64Encode } from './mime';
const pgpSecretId = (id: string) => `pgp.${id}`;
@@ -227,10 +227,8 @@ export function allPgpPublicKeys(): string[] {
export async function savePgpKeyFor(armored: string, email: string): Promise {
const info = await inspectKey(armored);
if (info.isPrivate) throw new Error('Only a public key can be saved from a message');
- const address = email.trim().toLowerCase();
- if (!address || !info.userIDs.some((u) => u.email.trim().toLowerCase() === address)) {
- throw new Error('The key is not for ' + email);
- }
+ // Only a key for the sender alone (see keyNamesOnly).
+ if (!keyNamesOnly(info, email)) throw new Error('The key is not for ' + email + ' alone');
const known = records().find((k): k is PgpKeyRecord => k.kind === 'pgp' && k.id === info.fingerprint.toLowerCase());
if (known) return known;
const record = pgpRecord(info, false, 'message');
@@ -250,7 +248,10 @@ export async function rememberSignerCertificate(der: Uint8Array, fromEmail: stri
const { describeCertificate } = await import('./smime/certificate');
const info = await describeCertificate(der);
const from = fromEmail.trim().toLowerCase();
- if (!from || !info.emailAddresses.some((e) => e.toLowerCase() === from)) return;
+ // Only one for the sender alone: a certificate that also names another
+ // address would be encrypted to for it (see keyNamesOnly).
+ const named = info.emailAddresses.map((e) => e.trim().toLowerCase()).filter(Boolean);
+ if (!from || named.length === 0 || !named.every((e) => e === from)) return;
if (records().some((k) => k.kind === 'smime' && k.id === info.fingerprint)) return;
upsert(smimeRecord(info, der, [], false, 'message'));
} catch {
diff --git a/src/lib/crypto/pgp.ts b/src/lib/crypto/pgp.ts
index 1475167e..74c6d873 100644
--- a/src/lib/crypto/pgp.ts
+++ b/src/lib/crypto/pgp.ts
@@ -26,6 +26,17 @@ export interface PgpKeyInfo {
algorithm: string;
}
+/**
+ * Whether a key names `email` and no other address. A key that arrives in a
+ * message is offered and saved only then: one that also named another
+ * address would be encrypted to for it and would verify mail forged from it.
+ */
+export function keyNamesOnly(info: Pick, email: string): boolean {
+ const address = email.trim().toLowerCase();
+ const named = info.userIDs.map((u) => u.email.trim().toLowerCase()).filter(Boolean);
+ return !!address && named.length > 0 && named.every((e) => e === address);
+}
+
export type PgpSignatureStatus = 'none' | 'valid' | 'invalid' | 'unknown';
export interface PgpSignatureSummary {
diff --git a/src/lib/crypto/secure-message.ts b/src/lib/crypto/secure-message.ts
index c2671ce6..23f7deda 100644
--- a/src/lib/crypto/secure-message.ts
+++ b/src/lib/crypto/secure-message.ts
@@ -9,7 +9,7 @@ import type { BodyPart, Email } from '../../api/types';
import { binaryToBytes, bytesToBinary, decodeBody, findNode, parseEntity, type MimeNode } from './mime';
import {
detectPgp, decryptMessage, verifyDetached, verifyCleartext, encryptionKeyIds, keyHasId, canonicalSignedBytes,
- extractPublicKeyBlock, inspectKey, PgpArgon2UnsupportedError, type PgpSignatureSummary,
+ extractPublicKeyBlock, inspectKey, keyNamesOnly, PgpArgon2UnsupportedError, type PgpSignatureSummary,
} from './pgp';
import type { SmimeKeyMaterial, SmimeSignatureStatus } from './smime/cms';
@@ -220,7 +220,7 @@ async function withOfferedKey(result: SecureResult, text: string | null | undefi
try {
const info = await inspectKey(block);
if (info.isPrivate) return result;
- if (!info.userIDs.some((u) => u.email.trim().toLowerCase() === from)) return result;
+ if (!keyNamesOnly(info, from)) return result;
return { ...result, offeredKey: { armored: info.publicKeyArmored, fingerprint: info.fingerprint.toLowerCase(), email: from } };
} catch {
// a broken embedded key: nothing to offer
From a0a5aa3f38490ed56c884dd8b247c0d5091661d8 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 23:06:05 +0100
Subject: [PATCH 7/9] docs: describe ios push and client certificates as they
are now #3
---
docs/ios-release.md | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/docs/ios-release.md b/docs/ios-release.md
index 972ffe89..b02271b2 100644
--- a/docs/ios-release.md
+++ b/docs/ios-release.md
@@ -158,13 +158,14 @@ Holder has to accept an updated agreement under Business in App Store Connect.
## What does not work on iOS yet
-- **Push notifications.** The push path is a hand-written Android module talking
- to FCM (`BulwarkFcmModule`, `BulwarkMessagingService`). iOS needs an APNs key
- and an equivalent native module; until then `push-notifications.ts` short-
- circuits and the app polls like a device with push disabled.
-- **Client certificates.** `BulwarkClientCertModule` is Android-only.
- `secureFetch` falls back to plain `fetch`, so cert-authenticated servers will
- not connect.
+- **Client certificates, partly (#3).** The app imports a .p12 into its own
+ keychain (plugins/ios-client-cert) and every request of the app answers the
+ server's certificate request with it. Two places can't: the notification
+ service extension (plugins/ios-push) uses a plain URLSession, so behind
+ client certificate authentication notifications show only the placeholder
+ text and push renewal fails; and sign-in through the server's page runs in
+ the system browser, which can't use the app's certificate (password sign-in
+ in the app works). Not yet run on a device.
- **In-app updates.** Hidden on purpose - see `supportsSideloadUpdates` in
[src/lib/platform-capabilities.ts](../src/lib/platform-capabilities.ts).
Downloading and installing a binary is forbidden by App Store Review guideline
From 78b1838d14dfece5175741a7157e247330d0e81b Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 23:17:30 +0100
Subject: [PATCH 8/9] fix: cancel a send later on the server when it is taken
back from the outbox after an unanswered attempt
---
.../live-stalwart-send-queue.test.ts | 45 ++++++++++++-
src/api/__tests__/queued-send.test.ts | 42 ++++++++++---
src/api/queued-send.ts | 49 ++++++++++++---
.../__tests__/outbox-send-queue.test.ts | 63 ++++++++++++++++---
src/stores/outbox-store.ts | 62 ++++++++++++++++--
5 files changed, 230 insertions(+), 31 deletions(-)
diff --git a/src/api/__tests__/live-stalwart-send-queue.test.ts b/src/api/__tests__/live-stalwart-send-queue.test.ts
index f14b0881..8525c29e 100644
--- a/src/api/__tests__/live-stalwart-send-queue.test.ts
+++ b/src/api/__tests__/live-stalwart-send-queue.test.ts
@@ -16,9 +16,10 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { JMAPClient } from '../jmap-client';
-import { destroyEmails, getMailboxes, saveDraftVersion, sendEmail } from '../email';
+import { cancelScheduledSend, destroyEmails, getMailboxes, saveDraftVersion, sendEmail } from '../email';
import { getIdentities } from '../identity';
-import { findCopiesByMessageId, runQueuedSend } from '../queued-send';
+import { findCopiesByMessageId, runQueuedSend, takeBackQueuedSend } from '../queued-send';
+import { cancelScheduledToDrafts } from '../../lib/compose-scheduled';
import { generateMessageId } from '../../lib/email-threading';
import { newQueuedSend } from '../../lib/send-queue';
import type { Identity, Mailbox } from '../types';
@@ -125,4 +126,44 @@ live('live Stalwart: offline send queue', () => {
const res = await singleton.request([['Email/get', { accountId: singleton.accountId, ids: [copies[0].id], properties: ['mailboxIds'] }, '0']]);
expect(res.methodResponses[0][1].list[0].mailboxIds[sent]).toBe(true);
}, 60_000);
+
+ // A "send later" whose answer was lost: the server holds it, and taking it
+ // back from the Outbox must cancel it there, not only drop the queue entry.
+ const submissionOf = async (emailId: string) => {
+ const res = await singleton.request([
+ ['EmailSubmission/query', { accountId: singleton.accountId, filter: { emailIds: [emailId] } }, '0'],
+ ['EmailSubmission/get', {
+ accountId: singleton.accountId, '#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' }, properties: ['id', 'undoStatus'],
+ }, '1'],
+ ], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
+ return res.methodResponses[1][1].list as Array<{ id: string; undoStatus: string }>;
+ };
+
+ it('finds a held send an unanswered attempt left, cancels it and moves it back to Drafts', async () => {
+ const outgoing = message('held');
+ const at = new Date(Date.now() + 3600_000).toISOString();
+ const first = await sendEmail(outgoing, identity.id, sent, 3600, { draftsMailboxId: drafts });
+ expect(first.scheduled).toBe(true);
+ const queued = newQueuedSend('q4', { ...payloadFor(outgoing), scheduledAt: at }, Date.now(), true);
+ const found = await takeBackQueuedSend(queued, boxes);
+ expect(found).toEqual({ kind: 'held', accountId: singleton.accountId, emailId: first.emailId, emailSubmissionId: first.emailSubmissionId });
+ if (found.kind !== 'held') return;
+ const { inDrafts } = await cancelScheduledToDrafts(found, boxes, singleton.accountId);
+ expect(inDrafts).toBe(true);
+ // Cancelled, and its record removed.
+ expect((await submissionOf(first.emailId!)).filter((x) => x.undoStatus === 'pending')).toEqual([]);
+ const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
+ expect(copies.map((c) => c.id)).toEqual([first.emailId]);
+ expect(copies[0].keywords?.$draft).toBe(true);
+ }, 60_000);
+
+ it('says released for a send the server let go, and the cancel is refused', async () => {
+ const outgoing = message('released');
+ const first = await sendEmail(outgoing, identity.id, sent, undefined, { draftsMailboxId: drafts });
+ const found = await takeBackQueuedSend(newQueuedSend('q5', payloadFor(outgoing), Date.now(), true), boxes);
+ expect(found).toEqual({ kind: 'released' });
+ // What the Outbox meets when the server lets a held send go between the lookup and the cancel.
+ const subs = await submissionOf(first.emailId!);
+ if (subs[0]) await expect(cancelScheduledSend(subs[0].id)).rejects.toMatchObject({ name: 'JMAPSetError' });
+ }, 60_000);
});
diff --git a/src/api/__tests__/queued-send.test.ts b/src/api/__tests__/queued-send.test.ts
index 333b2c1f..a3817e88 100644
--- a/src/api/__tests__/queued-send.test.ts
+++ b/src/api/__tests__/queued-send.test.ts
@@ -18,7 +18,7 @@ vi.mock('../../lib/compose-send-account', () => ({
}));
import { jmapClient } from '../jmap-client';
-import { findCopiesByMessageId, runQueuedSend, wasQueuedSendSent } from '../queued-send';
+import { findCopiesByMessageId, runQueuedSend, takeBackQueuedSend } from '../queued-send';
import { newQueuedSend, type QueuedSendPayload } from '../../lib/send-queue';
const mockRequest = jmapClient.request as ReturnType;
@@ -85,27 +85,49 @@ describe('runQueuedSend', () => {
});
});
-describe('wasQueuedSendSent', () => {
- it('finds a copy an earlier attempt sent, and removes the old draft', async () => {
- mockRequest.mockResolvedValueOnce(found([
- { id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
- { id: 'sent-1', keywords: { $seen: true }, messageId: ['m1@x.de'] },
- ]));
- expect(await wasQueuedSendSent(newQueuedSend('q', PAYLOAD, 0, true), [])).toBe(true);
+const submissions = (list: Array<{ id: string; emailId: string; undoStatus: string }>) => ({
+ methodResponses: [
+ ['EmailSubmission/query', { ids: list.map((x) => x.id) }, '0'],
+ ['EmailSubmission/get', { list }, '1'],
+ ],
+});
+
+describe('takeBackQueuedSend', () => {
+ const sentCopy = () => found([
+ { id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
+ { id: 'sent-1', keywords: { $seen: true }, messageId: ['m1@x.de'] },
+ ]);
+
+ it('finds the submission an earlier attempt left held, to be cancelled', async () => {
+ mockRequest.mockResolvedValueOnce(sentCopy());
+ mockRequest.mockResolvedValueOnce(submissions([{ id: 'sub-1', emailId: 'sent-1', undoStatus: 'pending' }]));
+ expect(await takeBackQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), [])).toEqual({
+ kind: 'held', accountId: 'acc-1', emailId: 'sent-1', emailSubmissionId: 'sub-1',
+ });
+ expect(mockRequest.mock.calls[1][0][0]).toEqual(
+ ['EmailSubmission/query', { accountId: 'acc-1', filter: { emailIds: ['sent-1'] } }, '0'],
+ );
expect(destroyEmails).toHaveBeenCalledWith(['draft-1'], 'acc-1');
});
+ it('says released when the server let the copy go already', async () => {
+ mockRequest.mockResolvedValueOnce(sentCopy());
+ mockRequest.mockResolvedValueOnce(submissions([{ id: 'sub-1', emailId: 'sent-1', undoStatus: 'final' }]));
+ expect(await takeBackQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), [])).toEqual({ kind: 'released' });
+ });
+
it('says not sent when only drafts are there, and removes nothing', async () => {
mockRequest.mockResolvedValueOnce(found([
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
{ id: 'left-1', keywords: { $draft: true, $seen: true }, messageId: ['m1@x.de'] },
]));
- expect(await wasQueuedSendSent(newQueuedSend('q', PAYLOAD, 0, true), [])).toBe(false);
+ expect(await takeBackQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), [])).toEqual({ kind: 'notSent' });
expect(destroyEmails).not.toHaveBeenCalled();
+ expect(mockRequest).toHaveBeenCalledTimes(1);
});
it('does not look for a message no attempt reached the server with', async () => {
- expect(await wasQueuedSendSent(newQueuedSend('q', PAYLOAD, 0, false), [])).toBe(false);
+ expect(await takeBackQueuedSend(newQueuedSend('q', PAYLOAD, 0, false), [])).toEqual({ kind: 'notSent' });
expect(mockRequest).not.toHaveBeenCalled();
});
});
diff --git a/src/api/queued-send.ts b/src/api/queued-send.ts
index c7641987..51752054 100644
--- a/src/api/queued-send.ts
+++ b/src/api/queued-send.ts
@@ -4,6 +4,7 @@
import { jmapClient } from './jmap-client';
import { destroyEmails, sendEmail, type SendEmailResult } from './email';
import { requireMethodResult } from './jmap-result';
+import { SUBMISSION_ONLY_USING } from './submissions';
import type { Mailbox } from './types';
import { sendTargetFor } from '../lib/compose-send-account';
import { supportsSubmissionExtension } from '../lib/submission-options';
@@ -108,14 +109,48 @@ async function earlierAttempt(send: QueuedSend, r: Resolved): Promise {
- if (!send.maybeSent || !send.payload.outgoing.messageId) return false;
- const verdict = await earlierAttempt(send, await queuedSendTarget(send, mailboxes));
- return verdict.kind === 'sent';
+export type QueuedSendTakeBack =
+ | { kind: 'notSent' }
+ | { kind: 'held'; accountId: string; emailId: string; emailSubmissionId: string }
+ | { kind: 'released' };
+
+/**
+ * Before a queued message is taken back (moved to Drafts or discarded): a
+ * message no attempt reached the server with is never looked up. One that
+ * went out is looked for among the submissions of its account, so a held one
+ * can still be cancelled.
+ */
+export async function takeBackQueuedSend(send: QueuedSend, mailboxes: Mailbox[]): Promise {
+ if (!send.maybeSent || !send.payload.outgoing.messageId) return { kind: 'notSent' };
+ const resolved = await queuedSendTarget(send, mailboxes);
+ const verdict = await earlierAttempt(send, resolved);
+ if (verdict.kind !== 'sent') return { kind: 'notSent' };
+ const accountId = resolved.accountId;
+ // EmailSubmission/query by emailIds finds it on 0.16.27 and 1.0.1.
+ const res = await jmapClient.request([
+ ['EmailSubmission/query', { accountId, filter: { emailIds: [verdict.copyId] } }, '0'],
+ ['EmailSubmission/get', {
+ accountId,
+ '#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' },
+ properties: ['id', 'emailId', 'undoStatus'],
+ }, '1'],
+ ], SUBMISSION_ONLY_USING);
+ requireMethodResult(res, '0', 'EmailSubmission/query');
+ const list = (requireMethodResult(res, '1', 'EmailSubmission/get').list ?? []) as Array<{
+ id: string; emailId?: string; undoStatus?: string;
+ }>;
+ const pending = list.find((sub) => sub.emailId === verdict.copyId && sub.undoStatus === 'pending');
+ return pending
+ ? { kind: 'held', accountId, emailId: verdict.copyId, emailSubmissionId: pending.id }
+ : { kind: 'released' };
}
export async function runQueuedSend(send: QueuedSend, mailboxes: Mailbox[], now = Date.now()): Promise {
diff --git a/src/stores/__tests__/outbox-send-queue.test.ts b/src/stores/__tests__/outbox-send-queue.test.ts
index c7555456..b5ef8ff6 100644
--- a/src/stores/__tests__/outbox-send-queue.test.ts
+++ b/src/stores/__tests__/outbox-send-queue.test.ts
@@ -10,6 +10,7 @@ vi.mock('../../api/jmap-client', () => ({
get isConnected() { return client.served !== null; },
get username() { return client.served ? client.served.split('@')[0] : null; },
get serverUrl() { return client.served ? `https://${client.served.split('@')[1]}` : null; },
+ accountId: 'acc-own',
},
}));
@@ -22,16 +23,21 @@ vi.mock('../../api/email', () => ({
archiveEmails: vi.fn(),
unprefixMailboxId: (id: string) => id,
saveDraftVersion: (...a: any[]) => saveDraftVersion(...a),
+ cancelScheduledSend: (...a: any[]) => cancelScheduledSend(...a),
+ restoreEmailToDraft: (...a: any[]) => restoreEmailToDraft(...a),
}));
+const cancelScheduledSend = vi.fn(async (..._a: any[]) => undefined);
+const restoreEmailToDraft = vi.fn(async (..._a: any[]) => undefined);
const runQueuedSend = vi.fn(async (..._a: any[]): Promise => ({ kind: 'sent', result: { scheduled: false } }));
-const wasQueuedSendSent = vi.fn(async (..._a: any[]) => false);
+const takeBackQueuedSend = vi.fn(async (..._a: any[]): Promise => ({ kind: 'notSent' }));
vi.mock('../../api/queued-send', () => ({
runQueuedSend: (...a: any[]) => runQueuedSend(...a),
- wasQueuedSendSent: (...a: any[]) => wasQueuedSendSent(...a),
+ takeBackQueuedSend: (...a: any[]) => takeBackQueuedSend(...a),
}));
+const MAILBOXES = [{ id: 'sent', role: 'sent' }, { id: 'drafts', role: 'drafts' }];
vi.mock('../email-store', () => ({
- useEmailStore: { getState: () => ({ mailboxes: [{ id: 'sent', role: 'sent' }] }) },
+ useEmailStore: { getState: () => ({ mailboxes: MAILBOXES }) },
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../toast-store', () => ({ toast }));
@@ -256,9 +262,9 @@ describe('offline send queue', () => {
it('does not turn a message an earlier attempt sent into a draft, and says it was sent', async () => {
const id = useOutboxStore.getState().enqueueSend(payload('went-out'), { maybeSent: true })!;
- wasQueuedSendSent.mockResolvedValueOnce(true);
+ takeBackQueuedSend.mockResolvedValueOnce({ kind: 'released' });
expect(await useOutboxStore.getState().cancelSend(id, { keepAsDraft: true })).toBe(true);
- expect(wasQueuedSendSent).toHaveBeenCalledWith(expect.objectContaining({ id, maybeSent: true }), [{ id: 'sent', role: 'sent' }]);
+ expect(takeBackQueuedSend).toHaveBeenCalledWith(expect.objectContaining({ id, maybeSent: true }), MAILBOXES);
expect(saveDraftVersion).not.toHaveBeenCalled();
expect(useOutboxStore.getState().sends).toEqual([]);
await flushed();
@@ -269,23 +275,64 @@ describe('offline send queue', () => {
it('moves a marked message to Drafts when no earlier attempt sent it, and looks only for marked ones', async () => {
const marked = useOutboxStore.getState().enqueueSend(payload('marked'), { maybeSent: true })!;
expect(await useOutboxStore.getState().cancelSend(marked, { keepAsDraft: true })).toBe(true);
- expect(wasQueuedSendSent).toHaveBeenCalledTimes(1);
+ expect(takeBackQueuedSend).toHaveBeenCalledTimes(1);
expect(saveDraftVersion).toHaveBeenCalledTimes(1);
const plain = useOutboxStore.getState().enqueueSend(payload('plain'))!;
expect(await useOutboxStore.getState().cancelSend(plain, { keepAsDraft: true })).toBe(true);
- expect(wasQueuedSendSent).toHaveBeenCalledTimes(1);
+ expect(takeBackQueuedSend).toHaveBeenCalledTimes(1);
expect(saveDraftVersion).toHaveBeenCalledTimes(2);
});
it('keeps a marked message in the Outbox when the lookup fails', async () => {
const id = useOutboxStore.getState().enqueueSend(payload('unknown'), { maybeSent: true })!;
- wasQueuedSendSent.mockRejectedValueOnce(named('NetworkError', 'Network request failed'));
+ takeBackQueuedSend.mockRejectedValueOnce(named('NetworkError', 'Network request failed'));
expect(await useOutboxStore.getState().cancelSend(id, { keepAsDraft: true })).toBe(false);
expect(saveDraftVersion).not.toHaveBeenCalled();
expect(useOutboxStore.getState().sends[0]).toMatchObject({ id, status: 'failed' });
});
+ it('cancels a send later that an earlier attempt left held on the server, and moves it back to Drafts', async () => {
+ const id = useOutboxStore.getState().enqueueSend(payload('held'), { maybeSent: true })!;
+ takeBackQueuedSend.mockResolvedValueOnce({ kind: 'held', accountId: 'acc-own', emailId: 'copy-1', emailSubmissionId: 'sub-1' });
+ expect(await useOutboxStore.getState().cancelSend(id, { keepAsDraft: true })).toBe(true);
+ expect(cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'acc-own');
+ expect(restoreEmailToDraft).toHaveBeenCalledWith('copy-1', 'drafts', 'sent', 'acc-own');
+ expect(saveDraftVersion).not.toHaveBeenCalled();
+ expect(useOutboxStore.getState().sends).toEqual([]);
+ await flushed();
+ expect(toast.info).toHaveBeenCalledWith('Send cancelled', { message: 'The message will not be sent. It goes back to your Drafts.' });
+ expect(patchKeywordsForEmails).not.toHaveBeenCalled();
+ });
+
+ it('cancels the held send on a discard too, and says the copy stays in Sent', async () => {
+ const id = useOutboxStore.getState().enqueueSend(payload('held'), { maybeSent: true })!;
+ takeBackQueuedSend.mockResolvedValueOnce({ kind: 'held', accountId: 'acc-own', emailId: 'copy-1', emailSubmissionId: 'sub-1' });
+ expect(await useOutboxStore.getState().cancelSend(id)).toBe(true);
+ expect(cancelScheduledSend).toHaveBeenCalledWith('sub-1', 'acc-own');
+ expect(restoreEmailToDraft).not.toHaveBeenCalled();
+ await flushed();
+ expect(toast.info).toHaveBeenCalledWith('Send cancelled', { message: 'The message will not be delivered. A copy stays in your Sent folder.' });
+ });
+
+ it('says it was sent when the server let the held send go before the cancel', async () => {
+ const { JMAPSetError } = await import('../../api/jmap-result');
+ const id = useOutboxStore.getState().enqueueSend(payload('too-late'), { maybeSent: true })!;
+ takeBackQueuedSend.mockResolvedValueOnce({ kind: 'held', accountId: 'acc-own', emailId: 'copy-1', emailSubmissionId: 'sub-1' });
+ cancelScheduledSend.mockRejectedValueOnce(new JMAPSetError('cannotUnsend', 'already delivered'));
+ expect(await useOutboxStore.getState().cancelSend(id, { keepAsDraft: true })).toBe(true);
+ expect(restoreEmailToDraft).not.toHaveBeenCalled();
+ await flushed();
+ expect(toast.info).toHaveBeenCalledWith('This message was already sent', { message: 'too-late' });
+ });
+
+ it('discards a message no attempt reached the server with without asking it', async () => {
+ const id = useOutboxStore.getState().enqueueSend(payload('plain'))!;
+ expect(await useOutboxStore.getState().cancelSend(id)).toBe(true);
+ expect(takeBackQueuedSend).not.toHaveBeenCalled();
+ expect(useOutboxStore.getState().sends).toEqual([]);
+ });
+
it('drops the queued messages with the rest when the server behind the account changed', async () => {
useOutboxStore.getState().enqueueSend(payload());
expect(await useOutboxStore.getState().clearAccount(ACCOUNT)).toBe(1);
diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts
index d87ccc6e..5f1371fe 100644
--- a/src/stores/outbox-store.ts
+++ b/src/stores/outbox-store.ts
@@ -573,18 +573,44 @@ export const useOutboxStore = create((set, get) => ({
if (!accountId || !send || !canCancel(send)) return false;
// Out of the queue first, so a flush can't send it while it is saved.
removeSend(accountId, id);
- if (!opts?.keepAsDraft) return true;
+ // Nothing reached the server: a discard needs no lookup.
+ if (!opts?.keepAsDraft && !send.maybeSent) return true;
const p = send.payload;
try {
// An attempt that may have reached the server is looked for first, as
// before a resend: a message that went out must not come back as a
- // draft.
+ // draft. One the server still holds for later (a "send later") is
+ // cancelled there, or it would go out all the same.
if (send.maybeSent) {
- const [{ wasQueuedSendSent }, { useEmailStore }] = await Promise.all([
+ const [{ takeBackQueuedSend }, { useEmailStore }] = await Promise.all([
import('../api/queued-send'),
import('./email-store'),
]);
- if (await wasQueuedSendSent(send, useEmailStore.getState().mailboxes)) {
+ const mailboxes = useEmailStore.getState().mailboxes;
+ const found = await takeBackQueuedSend(send, mailboxes);
+ let released = found.kind === 'released';
+ if (found.kind === 'held') {
+ const [{ cancelScheduledToDrafts }, { cancelScheduledSend }, { JMAPSetError }] = await Promise.all([
+ import('../lib/compose-scheduled'),
+ import('../api/email'),
+ import('../api/jmap-result'),
+ ]);
+ try {
+ if (opts?.keepAsDraft) {
+ const { inDrafts } = await cancelScheduledToDrafts(found, mailboxes, jmapClient.accountId);
+ void notifySendCancelled(inDrafts ? 'drafts' : 'notMoved');
+ } else {
+ await cancelScheduledSend(found.emailSubmissionId, found.accountId);
+ void notifySendCancelled('sent');
+ }
+ return true;
+ } catch (err) {
+ // The server refused the cancel: it let the message go meanwhile.
+ if (!(err instanceof JMAPSetError)) throw err;
+ released = true;
+ }
+ }
+ if (released) {
if (p.original) {
patchKeywordsForEmails([p.original.emailId], { [p.original.keyword]: true }, p.original.accountId).catch(() => undefined);
}
@@ -592,6 +618,7 @@ export const useOutboxStore = create((set, get) => ({
return true;
}
}
+ if (!opts?.keepAsDraft) return true;
if (!p.draftsMailboxId) throw new Error('No Drafts folder');
const { saveDraftVersion } = await import('../api/email');
await saveDraftVersion(p.outgoing, p.draftsMailboxId, p.draftId, undefined, p.draftAccountId);
@@ -795,6 +822,33 @@ async function saveRescuedDrafts(accountId: string): Promise {
}
}
+/**
+ * Taking back a message the server held for later: the held send was
+ * cancelled. Its copy went back to Drafts, stayed in Sent because the move
+ * failed, or stays in Sent on a discard.
+ */
+async function notifySendCancelled(where: 'drafts' | 'notMoved' | 'sent'): Promise {
+ try {
+ const [{ toast }, { useLocaleStore }] = await Promise.all([import('./toast-store'), import('./locale-store')]);
+ const t = useLocaleStore.getState().t;
+ if (where === 'drafts') {
+ toast.info(t('scheduled.send_cancelled', 'Send cancelled'), {
+ message: t('scheduled.cancel_to_drafts_body', 'The message will not be sent. It goes back to your Drafts.'),
+ });
+ } else if (where === 'notMoved') {
+ toast.info(t('scheduled.cancelled_not_moved', 'Send cancelled, but not moved to Drafts'), {
+ message: t('scheduled.cancelled_not_moved_body', 'The message will not be sent. It is still in Sent.'),
+ });
+ } else {
+ toast.info(t('scheduled.send_cancelled', 'Send cancelled'), {
+ message: t('scheduled.cancel_body', 'The message will not be delivered. A copy stays in your Sent folder.'),
+ });
+ }
+ } catch (err) {
+ console.warn('[outbox] could not report a cancelled send', err);
+ }
+}
+
/** Taking back a message found to have gone out already: nothing became a draft. */
async function notifyAlreadySent(send: QueuedSend): Promise {
try {
From b9c3f7940127c50d71dc3de281e06e8b52ee4cfa Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 2 Nov 2026 23:40:07 +0100
Subject: [PATCH 9/9] fix: send a quick reply with the first tap while the
keyboard is open
---
.../settings/__tests__/keyboard-persist-taps.test.ts | 2 ++
src/screens/EmailThreadScreen.tsx | 4 +++-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/components/settings/__tests__/keyboard-persist-taps.test.ts b/src/components/settings/__tests__/keyboard-persist-taps.test.ts
index 103db3bf..a2c695ad 100644
--- a/src/components/settings/__tests__/keyboard-persist-taps.test.ts
+++ b/src/components/settings/__tests__/keyboard-persist-taps.test.ts
@@ -26,6 +26,8 @@ const GRIDS = new Set(['MonthScrollView.tsx', 'TimeGridScrollView.tsx', 'WeekVie
const files = [
path.join(SRC, 'screens', 'SettingsScreen.tsx'),
path.join(SRC, 'screens', 'FilesScreen.tsx'),
+ // The reader's page pager sits around the quick reply box.
+ path.join(SRC, 'screens', 'EmailThreadScreen.tsx'),
...walk(path.join(SRC, 'components', 'settings')),
...walk(path.join(SRC, 'components', 'calendar')).filter((f) => !GRIDS.has(path.basename(f))),
...walk(path.join(SRC, 'components', 'files')),
diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx
index 3aa9d2b9..2bee6f76 100644
--- a/src/screens/EmailThreadScreen.tsx
+++ b/src/screens/EmailThreadScreen.tsx
@@ -835,6 +835,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
keyExtractor={(item) => item.id}
horizontal
pagingEnabled
+ // Else the first tap on the quick reply's Send only closes the keyboard.
+ keyboardShouldPersistTaps="handled"
showsHorizontalScrollIndicator={false}
initialScrollIndex={initialIndexRef.current}
getItemLayout={(_, index) => ({ length: pageWidth, offset: pageWidth * index, index })}
@@ -1467,7 +1469,7 @@ function MoreMenuSheet({
-
+
{canArchive && (
}