diff --git a/locales/rn/en.json b/locales/rn/en.json index 1d5a6813..dacf2c97 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -298,6 +298,7 @@ "group_chip": "{name} ({count})", "identity_unavailable": "Identity unavailable", "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_save_body": "You started this message in {account}. Switch back to that account to save it.", "login_switched_title": "Account switched", @@ -343,6 +344,7 @@ "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.", "upload_failed_short": "Upload failed", + "upload_login_switched": "Not uploaded: the account was switched", "uploading": "Uploading...", "uploading_pct": "Uploading {pct}%", "use_template": "Template", @@ -448,6 +450,7 @@ "pdf_unreadable": "This PDF can't be shown here.", "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}", "remove_color": "Remove tag", "replied": "Replied", diff --git a/src/components/email/QuickReplyBox.tsx b/src/components/email/QuickReplyBox.tsx index 2da499b9..bbd0bddc 100644 --- a/src/components/email/QuickReplyBox.tsx +++ b/src/components/email/QuickReplyBox.tsx @@ -9,6 +9,8 @@ import { useLocaleStore } from '../../stores/locale-store'; import { useEmailStore } from '../../stores/email-store'; import { toast } from '../../stores/toast-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 { jmapClient } from '../../api/jmap-client'; import { pruneEmailSubmissions } from '../../api/submissions'; @@ -55,6 +57,11 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF const [sending, setSending] = React.useState(false); 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]; if (!from?.email || email.keywords?.$draft) return null; @@ -62,6 +69,13 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF const send = async () => { const body = text.trim(); 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); // 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 @@ -135,7 +149,10 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent, onF }); void pruneEmailSubmissions(); 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 */ } onSent?.({ ...email, keywords: { ...email.keywords, $answered: true } }); setText(''); diff --git a/src/lib/__tests__/compose-login.test.ts b/src/lib/__tests__/compose-login.test.ts new file mode 100644 index 00000000..cc22a26a --- /dev/null +++ b/src/lib/__tests__/compose-login.test.ts @@ -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; + }); +}); diff --git a/src/lib/compose-login.ts b/src/lib/compose-login.ts new file mode 100644 index 00000000..ae8d0897 --- /dev/null +++ b/src/lib/compose-login.ts @@ -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>> { + 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>> { + assertComposerLogin(owner); + return uploadBytes(bytes, type, accountId); +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 6e6ba072..bee59575 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -47,7 +47,6 @@ import { } from '../api/email'; import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client'; import { pruneEmailSubmissions } from '../api/submissions'; -import { uploadBlob, uploadBytes } from '../api/blob'; import { partlyRefusedWarning, sendFailureMessage } from '../lib/send-failure-message'; import { buildReplyRecipients, type ReplySource } from '../lib/reply-recipients'; import { buildReplySubject, buildForwardSubject } from '../lib/subject-prefix'; @@ -66,7 +65,9 @@ import { findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, } from '../lib/reply-identity'; import { shouldBlockEditorRemoteImages } from '../lib/editor-html'; -import { composerLoginMoved, servedLoginOf } from '../lib/draft-login'; +import { + ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin, +} from '../lib/compose-login'; import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody, } from '../lib/draft-part-blobs'; @@ -824,7 +825,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const localId = `seed-${a.blobId}`; try { 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 }); } catch (e) { 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'); map.set(cid, `data:${mime};base64,${bytesToBase64(bytes)}`); 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, { localId: `cid-${cid}`, name: att.name || 'image', @@ -1125,10 +1126,12 @@ export default function ComposeScreen({ route, navigation }: Props) { const sendingRef = React.useRef(false); // The login this message was started in (see lib/draft-login). const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId); - const loginMoved = () => composerLoginMoved(ownerLoginRef.current, { - activeAccountId: useAccountStore.getState().activeAccountId, - servedLogin: servedLoginOf(jmapClient), - }); + const loginMoved = () => loginMovedFrom(ownerLoginRef.current); + /** The address of the login this message was started in, for the "switch back" alerts. */ + 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 [savingDraft, setSavingDraft] = React.useState(false); @@ -1351,6 +1354,8 @@ export default function ComposeScreen({ route, navigation }: Props) { html = (await editorRef.current?.getHtml()) ?? html; } catch { /* the last change message is the best we have */ } if (html !== latestRef.current.bodyHtml) setBodyHtml(html); + // Reading the editor gave a switch time to land. + if (loginMoved()) return draftIdRef.current; } const snapshot = snapshotOf({ 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 // closing now would drop the text as if it had been saved. if (loginMoved()) { - const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined; Alert.alert( t('email_composer.login_switched_title', 'Account switched'), t( 'email_composer.login_switched_save_body', 'You started this message in {account}. Switch back to that account to save it.', - { account: owner?.email || owner?.displayName || '' }, + { account: ownerAccountLabel() }, ), ); return; @@ -1622,9 +1626,24 @@ export default function ComposeScreen({ route, navigation }: Props) { 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) => { 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, onProgress: (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) { 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 }); Alert.alert( 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 }) => { + if (refuseAttachAfterSwitch()) return; if (!checkAttachmentSize(asset.size, false)) return; void startUpload(addUploadEntry({ ...asset, inline: false })); }; @@ -1665,6 +1687,7 @@ export default function ComposeScreen({ route, navigation }: Props) { }, []); const pickPhotoAttachments = async () => { + if (refuseAttachAfterSwitch()) return; // The system photo picker needs no media permission (and Play rejects // READ_MEDIA_IMAGES for picking a photo now and then). const result = await ImagePicker.launchImageLibraryAsync({ @@ -1687,6 +1710,7 @@ export default function ComposeScreen({ route, navigation }: Props) { }; const takePhotoAttachment = async () => { + if (refuseAttachAfterSwitch()) return; const perm = await ImagePicker.requestCameraPermissionsAsync(); if (!perm.granted) { Alert.alert( @@ -1708,6 +1732,7 @@ export default function ComposeScreen({ route, navigation }: Props) { }; const pickFileAttachments = async () => { + if (refuseAttachAfterSwitch()) return; let result: DocumentPicker.DocumentPickerResult; try { 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 }>) => { + if (refuseAttachAfterSwitch()) return; for (const asset of assets) { const mime = asset.mimeType ?? 'image/jpeg'; 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 () => { + if (refuseAttachAfterSwitch()) return; // The system photo picker needs no media permission (and Play rejects // READ_MEDIA_IMAGES for picking a photo now and then). const result = await ImagePicker.launchImageLibraryAsync({ @@ -2133,18 +2160,19 @@ export default function ComposeScreen({ route, navigation }: Props) { const performSendInner = async (holdForSeconds?: number, scheduledAt?: Date) => { if (!primaryIdentity || !sentMailbox) return; - if (loginMoved()) { - const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined; + const refuseSendAfterSwitch = (): boolean => { + if (!loginMoved()) return false; Alert.alert( t('email_composer.login_switched_title', 'Account switched'), t( 'email_composer.login_switched_body', '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` // 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) { 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); try { @@ -2208,9 +2239,12 @@ export default function ComposeScreen({ route, navigation }: Props) { const { title, message } = partlyRefusedWarning(refused, t); 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 // effort - the message already left. - if (replyTo?.originalEmailId) { + if (replyTo?.originalEmailId && sameLogin) { void patchKeywordsForEmails( [replyTo.originalEmailId], { [mode === 'forward' ? '$forwarded' : '$answered']: true }, @@ -2224,7 +2258,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const contacts = useContactsStore.getState(); for (const r of [...outgoing.to, ...(outgoing.cc ?? [])]) { 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); } }