fix: keep attachments, sends and reply flags of an open draft out of a login switched to meanwhile

This commit is contained in:
Linus Rath
2026-10-29 02:59:42 +01:00
parent e81f00714c
commit 4e57b19112
5 changed files with 185 additions and 20 deletions
+3
View File
@@ -298,6 +298,7 @@
"group_chip": "{name} ({count})", "group_chip": "{name} ({count})",
"identity_unavailable": "Identity unavailable", "identity_unavailable": "Identity unavailable",
"image_load_failed": "Could not load image", "image_load_failed": "Could not load image",
"login_switched_attach_body": "You started this message in {account}. Switch back to that account to attach files.",
"login_switched_body": "You started this message in {account}. Switch back to that account to send it.", "login_switched_body": "You started this message in {account}. Switch back to that account to send it.",
"login_switched_save_body": "You started this message in {account}. Switch back to that account to save it.", "login_switched_save_body": "You started this message in {account}. Switch back to that account to save it.",
"login_switched_title": "Account switched", "login_switched_title": "Account switched",
@@ -343,6 +344,7 @@
"undo_send_other_login": "This message was sent from another account", "undo_send_other_login": "This message was sent from another account",
"undo_send_other_login_body": "Switch back to {account} to undo it or send it now. It goes out when the delay ends.", "undo_send_other_login_body": "Switch back to {account} to undo it or send it now. It goes out when the delay ends.",
"upload_failed_short": "Upload failed", "upload_failed_short": "Upload failed",
"upload_login_switched": "Not uploaded: the account was switched",
"uploading": "Uploading...", "uploading": "Uploading...",
"uploading_pct": "Uploading {pct}%", "uploading_pct": "Uploading {pct}%",
"use_template": "Template", "use_template": "Template",
@@ -448,6 +450,7 @@
"pdf_unreadable": "This PDF can't be shown here.", "pdf_unreadable": "This PDF can't be shown here.",
"unsupported": "No preview for this file type." "unsupported": "No preview for this file type."
}, },
"quick_reply_login_switched": "This message is in another account. Switch back to it to reply.",
"received_via": "to {name}", "received_via": "to {name}",
"remove_color": "Remove tag", "remove_color": "Remove tag",
"replied": "Replied", "replied": "Replied",
+18 -1
View File
@@ -9,6 +9,8 @@ import { useLocaleStore } from '../../stores/locale-store';
import { useEmailStore } from '../../stores/email-store'; import { useEmailStore } from '../../stores/email-store';
import { toast } from '../../stores/toast-store'; import { toast } from '../../stores/toast-store';
import { currentSendLogin, useSendUndoStore } from '../../stores/send-undo-store'; import { currentSendLogin, useSendUndoStore } from '../../stores/send-undo-store';
import { useAccountStore } from '../../stores/account-store';
import { loginMovedFrom } from '../../lib/compose-login';
import { sendEmail, patchKeywordsForEmails } from '../../api/email'; import { sendEmail, patchKeywordsForEmails } from '../../api/email';
import { jmapClient } from '../../api/jmap-client'; import { jmapClient } from '../../api/jmap-client';
import { pruneEmailSubmissions } from '../../api/submissions'; import { pruneEmailSubmissions } from '../../api/submissions';
@@ -55,6 +57,11 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
const [sending, setSending] = React.useState(false); const [sending, setSending] = React.useState(false);
React.useEffect(() => { setText(''); }, [email.id]); React.useEffect(() => { setText(''); }, [email.id]);
// The login the message on screen belongs to: the one active when it was
// shown. A switch while it stays on screen must not send the reply, or
// flag the original, through the other login (lib/compose-login).
const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId);
React.useEffect(() => { ownerLoginRef.current = useAccountStore.getState().activeAccountId; }, [email.id]);
const from = email.from?.[0]; const from = email.from?.[0];
if (!from?.email || email.keywords?.$draft) return null; if (!from?.email || email.keywords?.$draft) return null;
@@ -62,6 +69,13 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
const send = async () => { const send = async () => {
const body = text.trim(); const body = text.trim();
if (!body || sending) return; if (!body || sending) return;
if (loginMovedFrom(ownerLoginRef.current)) {
Alert.alert(
t('email_composer.login_switched_title', 'Account switched'),
t('email_viewer.quick_reply_login_switched', 'This message is in another account. Switch back to it to reply.'),
);
return;
}
const ownEmails = identities.map((i) => i.email).filter(Boolean); const ownEmails = identities.map((i) => i.email).filter(Boolean);
// The own identity the message was delivered to (or, for our own message, // The own identity the message was delivered to (or, for our own message,
// the one that sent it). Never the catch-all From rewrite: this box has no // the one that sent it). Never the catch-all From rewrite: this box has no
@@ -135,7 +149,10 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF
}); });
void pruneEmailSubmissions(); void pruneEmailSubmissions();
try { try {
await patchKeywordsForEmails([email.id], { $answered: true }, jmapAccountId); // After a switch during the send the id names another message.
if (!loginMovedFrom(ownerLoginRef.current)) {
await patchKeywordsForEmails([email.id], { $answered: true }, jmapAccountId);
}
} catch { /* the reply is out; the flag is cosmetic */ } } catch { /* the reply is out; the flag is cosmetic */ }
onSent?.({ ...email, keywords: { ...email.keywords, $answered: true } }); onSent?.({ ...email, keywords: { ...email.keywords, $answered: true } });
setText(''); setText('');
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { client, blob } = vi.hoisted(() => ({
client: {
isConnected: true,
username: 'me@example.com' as string | null,
serverUrl: 'https://mail.example.com' as string | null,
},
blob: {
uploadBlob: vi.fn(async () => ({ blobId: 'b1', size: 3, type: 'text/plain' })),
uploadBytes: vi.fn(async () => ({ blobId: 'b2', size: 3, type: 'text/plain' })),
},
}));
vi.mock('../../api/jmap-client', () => ({ jmapClient: client }));
vi.mock('../../api/blob', () => blob);
import { useAccountStore } from '../../stores/account-store';
import {
ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
} from '../compose-login';
const LOGIN = 'me@example.com@mail.example.com';
beforeEach(() => {
vi.clearAllMocks();
client.serverUrl = 'https://mail.example.com';
useAccountStore.setState({ activeAccountId: LOGIN });
});
describe('composer writes after an account switch', () => {
it('uploads into the login the message was started in', async () => {
await expect(uploadFileForLogin(LOGIN, 'file:///a.txt', 'text/plain')).resolves.toMatchObject({ blobId: 'b1' });
await expect(uploadBytesForLogin(LOGIN, new Uint8Array([1]), 'text/plain')).resolves.toMatchObject({ blobId: 'b2' });
expect(blob.uploadBlob).toHaveBeenCalledWith('file:///a.txt', 'text/plain', undefined);
});
it("uploads nothing into another login's blob store", async () => {
useAccountStore.setState({ activeAccountId: 'me@example.com@other.example.org' });
client.serverUrl = 'https://other.example.org';
await expect(uploadFileForLogin(LOGIN, 'file:///a.txt', 'text/plain')).rejects.toBeInstanceOf(ComposerLoginMovedError);
await expect(uploadBytesForLogin(LOGIN, new Uint8Array([1]), 'text/plain')).rejects.toBeInstanceOf(ComposerLoginMovedError);
expect(blob.uploadBlob).not.toHaveBeenCalled();
expect(blob.uploadBytes).not.toHaveBeenCalled();
});
it('counts a switch the client already serves before the registry says so', () => {
client.serverUrl = 'https://other.example.org';
expect(loginMovedFrom(LOGIN)).toBe(true);
});
it('writes while the client is still connecting the same login', () => {
client.isConnected = false;
expect(loginMovedFrom(LOGIN)).toBe(false);
client.isConnected = true;
});
});
+55
View File
@@ -0,0 +1,55 @@
// Writes the composer and the quick reply make on behalf of the login they
// were opened in (see lib/draft-login). Each goes through the JMAP client
// of the active login, so a switch while one is on screen (a notification
// tap, a deep link) would send it to the other server: an attachment
// uploaded into the other login's blob store, a reply flagged `$answered`
// on whatever message has the original's id there. These check the login
// right before the write, after every await that could let a switch in.
import { uploadBlob, uploadBytes, type UploadBlobOptions } from '../api/blob';
import { jmapClient } from '../api/jmap-client';
import { useAccountStore } from '../stores/account-store';
import { composerLoginMoved, servedLoginOf } from './draft-login';
/** A write refused because another login is active than the one it belongs to. */
export class ComposerLoginMovedError extends Error {
constructor() {
super('The account was switched');
this.name = 'ComposerLoginMovedError';
}
}
/** Whether a write for `owner` would now reach another login. */
export function loginMovedFrom(owner: string | null | undefined): boolean {
return composerLoginMoved(owner, {
activeAccountId: useAccountStore.getState().activeAccountId,
servedLogin: servedLoginOf(jmapClient),
});
}
/** Throws ComposerLoginMovedError when a write for `owner` would reach another login. */
export function assertComposerLogin(owner: string | null | undefined): void {
if (loginMovedFrom(owner)) throw new ComposerLoginMovedError();
}
/** Upload a picked file for a message of `owner`, or refuse once another login is active. */
export async function uploadFileForLogin(
owner: string | null | undefined,
uri: string,
type: string,
options?: UploadBlobOptions,
): Promise<Awaited<ReturnType<typeof uploadBlob>>> {
assertComposerLogin(owner);
return uploadBlob(uri, type, options);
}
/** Upload bytes (a re-uploaded forwarded part) for a message of `owner`, or refuse. */
export async function uploadBytesForLogin(
owner: string | null | undefined,
bytes: Uint8Array,
type: string,
accountId?: string,
): Promise<Awaited<ReturnType<typeof uploadBytes>>> {
assertComposerLogin(owner);
return uploadBytes(bytes, type, accountId);
}
+53 -19
View File
@@ -47,7 +47,6 @@ import {
} from '../api/email'; } from '../api/email';
import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client'; import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client';
import { pruneEmailSubmissions } from '../api/submissions'; import { pruneEmailSubmissions } from '../api/submissions';
import { uploadBlob, uploadBytes } from '../api/blob';
import { partlyRefusedWarning, sendFailureMessage } from '../lib/send-failure-message'; import { partlyRefusedWarning, sendFailureMessage } from '../lib/send-failure-message';
import { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients'; import { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients';
import { buildReplySubject, buildForwardSubject } from '../lib/subject-prefix'; import { buildReplySubject, buildForwardSubject } from '../lib/subject-prefix';
@@ -66,7 +65,9 @@ import {
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
} from '../lib/reply-identity'; } from '../lib/reply-identity';
import { shouldBlockEditorRemoteImages } from '../lib/editor-html'; import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
import { composerLoginMoved, servedLoginOf } from '../lib/draft-login'; import {
ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
} from '../lib/compose-login';
import { import {
initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody, initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody,
} from '../lib/draft-part-blobs'; } from '../lib/draft-part-blobs';
@@ -824,7 +825,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const localId = `seed-${a.blobId}`; const localId = `seed-${a.blobId}`;
try { try {
const buf = await jmapClient.fetchBlobArrayBuffer(a.blobId, a.name, a.type, seedOwnerAccountId); const buf = await jmapClient.fetchBlobArrayBuffer(a.blobId, a.name, a.type, seedOwnerAccountId);
const up = await uploadBytes(new Uint8Array(buf), a.type || 'application/octet-stream'); const up = await uploadBytesForLogin(ownerLoginRef.current, new Uint8Array(buf), a.type || 'application/octet-stream');
if (!cancelled) updateAttachment(localId, { blobId: up.blobId, size: up.size, uploading: false }); if (!cancelled) updateAttachment(localId, { blobId: up.blobId, size: up.size, uploading: false });
} catch (e) { } catch (e) {
if (!cancelled) { if (!cancelled) {
@@ -857,7 +858,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const mime = att.type?.toLowerCase().startsWith('image/') ? att.type : (sniffImageMime(bytes) ?? 'image/png'); const mime = att.type?.toLowerCase().startsWith('image/') ? att.type : (sniffImageMime(bytes) ?? 'image/png');
map.set(cid, `data:${mime};base64,${bytesToBase64(bytes)}`); map.set(cid, `data:${mime};base64,${bytesToBase64(bytes)}`);
let blobId = att.blobId; let blobId = att.blobId;
if (seedOwnerAccountId) blobId = (await uploadBytes(bytes, mime)).blobId; if (seedOwnerAccountId) blobId = (await uploadBytesForLogin(ownerLoginRef.current, bytes, mime)).blobId;
inlineRegistryRef.current.set(cid, { inlineRegistryRef.current.set(cid, {
localId: `cid-${cid}`, localId: `cid-${cid}`,
name: att.name || 'image', name: att.name || 'image',
@@ -1125,10 +1126,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
const sendingRef = React.useRef(false); const sendingRef = React.useRef(false);
// The login this message was started in (see lib/draft-login). // The login this message was started in (see lib/draft-login).
const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId); const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId);
const loginMoved = () => composerLoginMoved(ownerLoginRef.current, { const loginMoved = () => loginMovedFrom(ownerLoginRef.current);
activeAccountId: useAccountStore.getState().activeAccountId, /** The address of the login this message was started in, for the "switch back" alerts. */
servedLogin: servedLoginOf(jmapClient), const ownerAccountLabel = () => {
}); const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined;
return owner?.email || owner?.displayName || '';
};
const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle'); const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle');
const [savingDraft, setSavingDraft] = React.useState(false); const [savingDraft, setSavingDraft] = React.useState(false);
@@ -1351,6 +1354,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
html = (await editorRef.current?.getHtml()) ?? html; html = (await editorRef.current?.getHtml()) ?? html;
} catch { /* the last change message is the best we have */ } } catch { /* the last change message is the best we have */ }
if (html !== latestRef.current.bodyHtml) setBodyHtml(html); if (html !== latestRef.current.bodyHtml) setBodyHtml(html);
// Reading the editor gave a switch time to land.
if (loginMoved()) return draftIdRef.current;
} }
const snapshot = snapshotOf({ const snapshot = snapshotOf({
to: finalTo, cc: finalCc, bcc: finalBcc, subject, to: finalTo, cc: finalCc, bcc: finalBcc, subject,
@@ -1446,13 +1451,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
// Another login is active: saving writes nothing (saveDraftOnce), so // Another login is active: saving writes nothing (saveDraftOnce), so
// closing now would drop the text as if it had been saved. // closing now would drop the text as if it had been saved.
if (loginMoved()) { if (loginMoved()) {
const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined;
Alert.alert( Alert.alert(
t('email_composer.login_switched_title', 'Account switched'), t('email_composer.login_switched_title', 'Account switched'),
t( t(
'email_composer.login_switched_save_body', 'email_composer.login_switched_save_body',
'You started this message in {account}. Switch back to that account to save it.', 'You started this message in {account}. Switch back to that account to save it.',
{ account: owner?.email || owner?.displayName || '' }, { account: ownerAccountLabel() },
), ),
); );
return; return;
@@ -1622,9 +1626,24 @@ export default function ComposeScreen({ route, navigation }: Props) {
return entry; return entry;
}; };
// Attachments go into the blob store of the login the message belongs to;
// once another login is active they would land in that one's.
const refuseAttachAfterSwitch = (): boolean => {
if (!loginMoved()) return false;
Alert.alert(
t('email_composer.login_switched_title', 'Account switched'),
t(
'email_composer.login_switched_attach_body',
'You started this message in {account}. Switch back to that account to attach files.',
{ account: ownerAccountLabel() },
),
);
return true;
};
const startUpload = async (entry: AttachmentEntry) => { const startUpload = async (entry: AttachmentEntry) => {
try { try {
const { blobId, size, type } = await uploadBlob(entry.uri, entry.type, { const { blobId, size, type } = await uploadFileForLogin(ownerLoginRef.current, entry.uri, entry.type, {
signal: entry.abort?.signal, signal: entry.abort?.signal,
onProgress: (sent, total) => { onProgress: (sent, total) => {
if (total > 0) updateAttachment(entry.localId, { progress: Math.min(1, sent / total) }); if (total > 0) updateAttachment(entry.localId, { progress: Math.min(1, sent / total) });
@@ -1640,7 +1659,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
}); });
} catch (e) { } catch (e) {
if (e instanceof Error && e.name === 'AbortError') return; // removed by the user if (e instanceof Error && e.name === 'AbortError') return; // removed by the user
const message = e instanceof Error ? e.message : t('email_composer.upload_failed_short', 'Upload failed'); const message = e instanceof ComposerLoginMovedError
? t('email_composer.upload_login_switched', 'Not uploaded: the account was switched')
: e instanceof Error ? e.message : t('email_composer.upload_failed_short', 'Upload failed');
updateAttachment(entry.localId, { uploading: false, progress: undefined, error: message }); updateAttachment(entry.localId, { uploading: false, progress: undefined, error: message });
Alert.alert( Alert.alert(
t('email_composer.upload_failed', 'Failed to upload {filename}', { filename: entry.name }), t('email_composer.upload_failed', 'Failed to upload {filename}', { filename: entry.name }),
@@ -1650,6 +1671,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}; };
const addFileAsset = (asset: { name: string; type: string; size: number; uri: string }) => { const addFileAsset = (asset: { name: string; type: string; size: number; uri: string }) => {
if (refuseAttachAfterSwitch()) return;
if (!checkAttachmentSize(asset.size, false)) return; if (!checkAttachmentSize(asset.size, false)) return;
void startUpload(addUploadEntry({ ...asset, inline: false })); void startUpload(addUploadEntry({ ...asset, inline: false }));
}; };
@@ -1665,6 +1687,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}, []); }, []);
const pickPhotoAttachments = async () => { const pickPhotoAttachments = async () => {
if (refuseAttachAfterSwitch()) return;
// The system photo picker needs no media permission (and Play rejects // The system photo picker needs no media permission (and Play rejects
// READ_MEDIA_IMAGES for picking a photo now and then). // READ_MEDIA_IMAGES for picking a photo now and then).
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
@@ -1687,6 +1710,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}; };
const takePhotoAttachment = async () => { const takePhotoAttachment = async () => {
if (refuseAttachAfterSwitch()) return;
const perm = await ImagePicker.requestCameraPermissionsAsync(); const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm.granted) { if (!perm.granted) {
Alert.alert( Alert.alert(
@@ -1708,6 +1732,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}; };
const pickFileAttachments = async () => { const pickFileAttachments = async () => {
if (refuseAttachAfterSwitch()) return;
let result: DocumentPicker.DocumentPickerResult; let result: DocumentPicker.DocumentPickerResult;
try { try {
result = await DocumentPicker.getDocumentAsync({ multiple: true, copyToCacheDirectory: true }); result = await DocumentPicker.getDocumentAsync({ multiple: true, copyToCacheDirectory: true });
@@ -1736,6 +1761,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
]; ];
const insertInlineImages = async (assets: Array<{ uri: string; mimeType?: string | null; fileName?: string | null; fileSize?: number | null }>) => { const insertInlineImages = async (assets: Array<{ uri: string; mimeType?: string | null; fileName?: string | null; fileSize?: number | null }>) => {
if (refuseAttachAfterSwitch()) return;
for (const asset of assets) { for (const asset of assets) {
const mime = asset.mimeType ?? 'image/jpeg'; const mime = asset.mimeType ?? 'image/jpeg';
const fallbackName = asset.fileName || `image-${Date.now()}.${mime.split('/')[1] ?? 'jpg'}`; const fallbackName = asset.fileName || `image-${Date.now()}.${mime.split('/')[1] ?? 'jpg'}`;
@@ -1763,6 +1789,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}; };
const insertInlineImage = async () => { const insertInlineImage = async () => {
if (refuseAttachAfterSwitch()) return;
// The system photo picker needs no media permission (and Play rejects // The system photo picker needs no media permission (and Play rejects
// READ_MEDIA_IMAGES for picking a photo now and then). // READ_MEDIA_IMAGES for picking a photo now and then).
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
@@ -2133,18 +2160,19 @@ export default function ComposeScreen({ route, navigation }: Props) {
const performSendInner = async (holdForSeconds?: number, scheduledAt?: Date) => { const performSendInner = async (holdForSeconds?: number, scheduledAt?: Date) => {
if (!primaryIdentity || !sentMailbox) return; if (!primaryIdentity || !sentMailbox) return;
if (loginMoved()) { const refuseSendAfterSwitch = (): boolean => {
const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined; if (!loginMoved()) return false;
Alert.alert( Alert.alert(
t('email_composer.login_switched_title', 'Account switched'), t('email_composer.login_switched_title', 'Account switched'),
t( t(
'email_composer.login_switched_body', 'email_composer.login_switched_body',
'You started this message in {account}. Switch back to that account to send it.', 'You started this message in {account}. Switch back to that account to send it.',
{ account: owner?.email || owner?.displayName || '' }, { account: ownerAccountLabel() },
), ),
); );
return; return true;
} };
if (refuseSendAfterSwitch()) return;
// Read the body straight from the editor DOM at send time. The `change` // Read the body straight from the editor DOM at send time. The `change`
// messages that feed `bodyHtml` are async and best-effort — trusting them // messages that feed `bodyHtml` are async and best-effort — trusting them
@@ -2180,6 +2208,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
if (inflightSaveRef.current) { if (inflightSaveRef.current) {
try { await inflightSaveRef.current; } catch { /* stale version stays; cleaned up below */ } try { await inflightSaveRef.current; } catch { /* stale version stays; cleaned up below */ }
} }
// The confirmations above wait for the user, long enough for a
// notification tap to switch logins.
if (refuseSendAfterSwitch()) return;
setSending(true); setSending(true);
try { try {
@@ -2208,9 +2239,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
const { title, message } = partlyRefusedWarning(refused, t); const { title, message } = partlyRefusedWarning(refused, t);
toast.warning(title, { message, duration: 10_000 }); toast.warning(title, { message, duration: 10_000 });
} }
// A switch during the send: the original's id and the trusted senders
// book below are the other login's now.
const sameLogin = !loginMoved();
// Flag the original so the list shows the reply/forward arrow; best // Flag the original so the list shows the reply/forward arrow; best
// effort - the message already left. // effort - the message already left.
if (replyTo?.originalEmailId) { if (replyTo?.originalEmailId && sameLogin) {
void patchKeywordsForEmails( void patchKeywordsForEmails(
[replyTo.originalEmailId], [replyTo.originalEmailId],
{ [mode === 'forward' ? '$forwarded' : '$answered']: true }, { [mode === 'forward' ? '$forwarded' : '$answered']: true },
@@ -2224,7 +2258,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const contacts = useContactsStore.getState(); const contacts = useContactsStore.getState();
for (const r of [...outgoing.to, ...(outgoing.cc ?? [])]) { for (const r of [...outgoing.to, ...(outgoing.cc ?? [])]) {
settings.addTrustedSender(r.email); settings.addTrustedSender(r.email);
if (isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts)) { if (sameLogin && isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts)) {
contacts.addToTrustedSendersBook(r.name ? `${r.name} <${r.email}>` : r.email).catch(() => undefined); contacts.addToTrustedSendersBook(r.name ? `${r.name} <${r.email}>` : r.email).catch(() => undefined);
} }
} }