diff --git a/locales/rn/en.json b/locales/rn/en.json index d4449b41..0bf4dc5c 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -298,6 +298,8 @@ "group_chip": "{name} ({count})", "identity_unavailable": "Identity unavailable", "image_load_failed": "Could not load image", + "login_switched_body": "You started this message in {account}. Switch back to that account to send it.", + "login_switched_title": "Account switched", "no_recipient": "(no recipient)", "no_sent_folder": "No Sent folder found", "permission_camera": "Camera permission is required to take a photo.", diff --git a/src/api/__tests__/blob.test.ts b/src/api/__tests__/blob.test.ts index 7fa818a3..5869c34e 100644 --- a/src/api/__tests__/blob.test.ts +++ b/src/api/__tests__/blob.test.ts @@ -69,6 +69,15 @@ describe('blob operations', () => { expect(url).toContain('my%20file%20(1).pdf'); }); + + // A part named like a path: an encoded slash (%2F) in the URL path is + // refused or decoded by common reverse proxies, so inline images went + // blank (webmail #1371). + it('keeps only the last path component of a part name', () => { + expect(getDownloadUrl('b1', '/images/banner.jpg', 'image/jpeg')).toContain('/b1/banner.jpg?'); + expect(getDownloadUrl('b1', 'C:\\pics\\logo.png', 'image/png')).toContain('/b1/logo.png?'); + expect(getDownloadUrl('b1', 'dir/', 'image/png')).toContain('/b1/download?'); + }); }); }); diff --git a/src/api/__tests__/email-destroy-partial.test.ts b/src/api/__tests__/email-destroy-partial.test.ts new file mode 100644 index 00000000..fde642bb --- /dev/null +++ b/src/api/__tests__/email-destroy-partial.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + getMaxObjectsInGet: vi.fn(() => 500), + getMaxObjectsInSet: vi.fn(() => 2), + }, +})); + +import { jmapClient } from '../jmap-client'; +import { destroyEmails } from '../email'; +import { failedIdsOf } from '../jmap-result'; + +const mockRequest = jmapClient.request as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('destroyEmails', () => { + it('sends every slice and names only the refused messages', async () => { + mockRequest.mockImplementation(async ([[, args]]: Array<[string, { destroy: string[] }, string]>) => { + const refused: string[] = args.destroy.filter((id) => id === 'e2'); + const gone: string[] = args.destroy.filter((id) => id === 'e4'); + return { + methodResponses: [['Email/set', { + destroyed: args.destroy.filter((id) => !refused.includes(id) && !gone.includes(id)), + notDestroyed: Object.fromEntries([ + ...refused.map((id) => [id, { type: 'forbidden' }]), + ...gone.map((id) => [id, { type: 'notFound' }]), + ]), + }, '0']], + }; + }); + const err = await destroyEmails(['e1', 'e2', 'e3', 'e4']).then(() => null, (e: unknown) => e); + expect(mockRequest).toHaveBeenCalledTimes(2); + // A message that is already gone counts as deleted. + expect(failedIdsOf(err)).toEqual(['e2']); + }); +}); diff --git a/src/api/__tests__/email-inline-parts.test.ts b/src/api/__tests__/email-inline-parts.test.ts new file mode 100644 index 00000000..7a5ad182 --- /dev/null +++ b/src/api/__tests__/email-inline-parts.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + getMaxCallsInRequest: vi.fn(() => 16), + getMaxObjectsInGet: vi.fn(() => 500), + getMaxObjectsInSet: vi.fn(() => 500), + }, +})); + +import { jmapClient } from '../jmap-client'; +import { getFullEmail, getFullEmails, saveDraftVersion } from '../email'; + +const mockRequest = jmapClient.request as ReturnType; + +// A draft with an inline image as Stalwart 1.0.0 returns it (measured +// 2026-10-29): the image sits in mixed next to the alternative, so it is in +// textBody and htmlBody but not in `attachments`. +const IMAGE = { partId: '4', blobId: 'b-img', type: 'image/png', disposition: 'inline', cid: 'img1@live.test', name: 'img1.png', size: 70 }; +const on10 = { + id: 'd1', + attachments: [], + textBody: [{ partId: '2', blobId: 'b-text', type: 'text/plain' }, IMAGE], + htmlBody: [{ partId: '3', blobId: 'b-html', type: 'text/html' }, IMAGE], +}; +// The same message on 0.16.27: the image is in all three lists. +const on016 = { ...on10, attachments: [IMAGE] }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('inline images that Stalwart 1.0 lists only in the body lists', () => { + it('getFullEmail takes them into attachments', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['Email/get', { list: [on10], notFound: [] }, '0']] }); + const email = await getFullEmail('d1'); + expect(email.attachments).toEqual([IMAGE]); + }); + + it('getFullEmails does too, and lists a 0.16 part once', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Email/get', { list: [on10, { ...on016, id: 'd2' }], notFound: [] }, '0']], + }); + const [a, b] = await getFullEmails(['d1', 'd2']); + expect(a.attachments?.map((x) => x.cid)).toEqual(['img1@live.test']); + expect(b.attachments).toHaveLength(1); + }); + + it('saveDraftVersion reads the new version\'s inline part back from the body lists', async () => { + mockRequest.mockImplementation(async (calls: Array<[string, Record, string]>) => { + const get = calls.find(([m]) => m === 'Email/get')![1]; + expect(get.properties).toEqual(expect.arrayContaining(['attachments', 'textBody', 'htmlBody'])); + return { + methodResponses: [ + ['Email/set', { created: { draft: { id: 'd1' } } }, '0'], + ['Email/get', { list: [on10] }, '1'], + ], + }; + }); + const saved = await saveDraftVersion({ + from: [{ email: 'me@example.org' }], to: [], subject: 's', + htmlBody: '

', + attachments: [{ blobId: 'up-1', type: 'image/png', name: 'img1.png', disposition: 'inline', cid: 'img1@live.test' }], + }, 'drafts'); + expect(saved.attachments.map((a) => a.blobId)).toEqual(['b-img']); + }); +}); diff --git a/src/api/__tests__/jmap-client-hardening.test.ts b/src/api/__tests__/jmap-client-hardening.test.ts index c8ab6008..a3c732b9 100644 --- a/src/api/__tests__/jmap-client-hardening.test.ts +++ b/src/api/__tests__/jmap-client-hardening.test.ts @@ -194,6 +194,8 @@ describe('JMAPClient hardening', () => { const session = await client.connect('https://mail.example.com', 'user', 'pass'); expect(session.apiUrl).toBe('https://mail.example.com/jmap/'); expect(client.getBlobDownloadUrl('b1', 'n')).toBe('https://mail.example.com/download/acc-1/b1/n?type=application%2Foctet-stream'); + // A part name that is a path keeps its last component (webmail #1371). + expect(client.getBlobDownloadUrl('b1', '/images/n.jpg')).toBe('https://mail.example.com/download/acc-1/b1/n.jpg?type=application%2Foctet-stream'); }); it('refetches the final URL when a redirect dropped the Authorization header', async () => { diff --git a/src/api/__tests__/live-stalwart-mail.test.ts b/src/api/__tests__/live-stalwart-mail.test.ts new file mode 100644 index 00000000..8c1b7666 --- /dev/null +++ b/src/api/__tests__/live-stalwart-mail.test.ts @@ -0,0 +1,382 @@ +/** + * Live checks of mail, submissions, contacts, search, Sieve and identities + * against a real Stalwart server, for the differences between 0.16 and 1.0. + * + * Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and + * `BULWARK_LIVE_PASS` are set. `BULWARK_LIVE_USER2` / `BULWARK_LIVE_PASS2` + * name a second login on the same server for the multi-account check. + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \ + * BULWARK_LIVE_USER=e2e-phone-light@example.org BULWARK_LIVE_PASS=… \ + * npx vitest run src/api/__tests__/live-stalwart-mail.test.ts + * + * Everything it creates is removed again. Mail goes to the user's own + * address; nothing depends on delivery (SMTP may be down on a test server). + * + * Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0 (2026-10-29). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { + createMailbox, + deleteMailbox, + destroyEmails, + emptyMailbox, + getEmailFlags, + getFullEmails, + getMailboxChanges, + getMailboxesWithState, + getMailboxes, + importEmailBlob, + listScheduledEmails, + cancelScheduledSend, + rescheduleScheduledSend, + restoreEmailToDraft, + patchKeywordsForEmails, + searchEmails, + sendEmail, + MAILBOX_PROPERTIES, +} from '../email'; +import { uploadBytes } from '../blob'; +import { getIdentities, updateIdentity } from '../identity'; +import { isJMAPSetError } from '../jmap-result'; +import { pruneEmailSubmissions } from '../submissions'; +import { listPrincipals } from '../files'; +import { createContact, deleteContacts, getAddressBooks, getContact } from '../contacts'; +import { + createSieveScript, deleteSieveScript, getSieveScriptContent, getSieveScripts, validateSieveScript, +} from '../sieve'; +import { inlineContactMediaForExport, writeContactWithNewPhoto } from '../../lib/contact-media'; +import { generateVCard, parseVCard } from '../../lib/vcard'; +import type { ContactCard } from '../types'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const USER2 = process.env.BULWARK_LIVE_USER2; +const PASS2 = process.env.BULWARK_LIVE_PASS2 ?? PASS; +const live = SERVER && PASS ? describe : describe.skip; + +const RUN = Date.now().toString(36); +const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +let client: JMAPClient; +let singleton: JMAPClient; + +async function useLogin(login: JMAPClient): Promise { + singleton.restoreSnapshot(login.snapshot()); +} + +/** Stalwart 1.0 advertises JMAP Metadata, 0.16 does not. */ +const is10 = () => !!client.currentSession?.capabilities?.['urn:ietf:params:jmap:metadata']; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Poll until `check` holds; Stalwart indexes new mail in the background. */ +async function eventually(read: () => Promise, check: (v: T) => boolean, ms = 15_000): Promise { + const until = Date.now() + ms; + let value = await read(); + while (!check(value) && Date.now() < until) { + await sleep(500); + value = await read(); + } + return value; +} + +async function importMessage(mailboxId: string, subject: string, body: string, keywords: Record = {}) { + const raw = `From: Partner \r\nTo: ${USER}\r\nSubject: ${subject}\r\n` + + `Date: ${new Date().toUTCString()}\r\nMessage-ID: <${subject.replace(/\W/g, '')}-${RUN}@live.test>\r\n` + + `MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n${body}\r\n`; + const { blobId } = await uploadBytes(new TextEncoder().encode(raw), 'message/rfc822'); + return importEmailBlob(blobId, mailboxId, keywords); +} + +/** Every message (any folder) whose subject carries `tag`. */ +async function messagesWith(tag: string): Promise { + const res = await client.request([ + ['Email/query', { accountId: client.accountId, filter: { subject: tag } }, '0'], + ]); + return (res.methodResponses[0][1].ids as string[]) ?? []; +} + +async function submissionExists(id: string): Promise { + const res = await client.request( + [['EmailSubmission/get', { accountId: client.accountId, ids: [id], properties: ['id'] }, '0']], + ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission'], + ); + return ((res.methodResponses[0][1].list as unknown[]) ?? []).length > 0; +} + +live('live Stalwart: mail, contacts, search (0.16 and 1.0)', () => { + let drafts = ''; + let sent = ''; + let scratch = ''; + const mailTag = `bulwark-live-${RUN}`; + + beforeAll(async () => { + const mod = await import('../jmap-client'); + singleton = mod.jmapClient as unknown as JMAPClient; + client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + await useLogin(client); + const boxes = await getMailboxes(); + drafts = boxes.find((b) => b.role === 'drafts')?.id ?? ''; + sent = boxes.find((b) => b.role === 'sent')?.id ?? ''; + scratch = await createMailbox({ name: `live-mail-${RUN}` }); + }, 60_000); + + afterAll(async () => { + await useLogin(client); + // Sends land in Sent/Drafts (and the inbox when delivered to self). + const leftovers = await messagesWith(mailTag).catch(() => [] as string[]); + if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined); + if (scratch) { + await emptyMailbox(scratch).catch(() => undefined); + await deleteMailbox(scratch).catch(() => undefined); + } + }, 60_000); + + describe('keywords', () => { + it('refuses more than 100 keywords on 1.0 without touching the ones the message has', async () => { + const id = await importMessage(scratch, `kw many ${mailTag}`, 'x', { $flagged: true }); + const many = Object.fromEntries(Array.from({ length: 101 }, (_, i) => [`$label:k${i}`, true])); + const write = patchKeywordsForEmails([id], many); + if (is10()) { + const err = await write.then(() => null, (e: unknown) => e); + expect(isJMAPSetError(err)).toBe(true); + expect((err as { type?: string }).type).toBe('tooManyKeywords'); + } else { + await write; + } + const [flags] = (await getEmailFlags([id])).list; + expect(flags.keywords.$flagged).toBe(true); + const labels = Object.keys(flags.keywords).filter((k) => k.startsWith('$label:')); + // 0.16 takes them and silently keeps 100 keywords in all. + if (is10()) expect(labels).toHaveLength(0); + else expect(Object.keys(flags.keywords).length).toBeLessThanOrEqual(100); + }, 60_000); + + it('refuses a keyword over 128 bytes on 1.0; 0.16 cuts it', async () => { + const id = await importMessage(scratch, `kw long ${mailTag}`, 'x', { $flagged: true }); + const long = `$label:${'x'.repeat(122)}`; // 129 bytes + const write = patchKeywordsForEmails([id], { [long]: true }); + if (is10()) { + const err = await write.then(() => null, (e: unknown) => e); + expect(isJMAPSetError(err)).toBe(true); + expect((err as { type?: string }).type).toBe('invalidProperties'); + } else { + await write; + } + const [flags] = (await getEmailFlags([id])).list; + expect(flags.keywords.$flagged).toBe(true); + // Neither server stores the 129-byte keyword as sent. + expect(flags.keywords[long]).toBeUndefined(); + }, 60_000); + }); + + describe('submissions', () => { + it('destroys the record of an immediate send right away', async () => { + const [identity] = await getIdentities(); + const result = await sendEmail( + { from: [{ email: USER }], to: [{ email: USER }], subject: `now ${mailTag}`, textBody: 'live' }, + identity.id, sent, undefined, { draftsMailboxId: drafts }, + ); + expect(result.emailSubmissionId).toBeTruthy(); + expect(await submissionExists(result.emailSubmissionId!)).toBe(false); + const [filed] = await getFullEmails([result.emailId!]); + expect(filed.mailboxIds[sent]).toBe(true); + }, 60_000); + + it('lists, reschedules, cancels and restores a delayed send', async () => { + const [identity] = await getIdentities(); + const result = await sendEmail( + { from: [{ email: USER }], to: [{ email: USER }], subject: `later ${mailTag}`, textBody: 'live' }, + identity.id, sent, 3600, { draftsMailboxId: drafts }, + ); + expect(result.scheduled).toBe(true); + const first = (await listScheduledEmails()).find((s) => s.emailSubmissionId === result.emailSubmissionId); + expect(first?.emailId).toBe(result.emailId); + + const moved = await rescheduleScheduledSend(first!, 7200); + expect(moved.emailSubmissionId).toBeTruthy(); + expect(moved.emailSubmissionId).not.toBe(first!.emailSubmissionId); + const listed = await listScheduledEmails(); + expect(listed.some((s) => s.emailSubmissionId === first!.emailSubmissionId)).toBe(false); + const second = listed.find((s) => s.emailSubmissionId === moved.emailSubmissionId); + expect(new Date(second!.sendAt).getTime()).toBeGreaterThan(new Date(first!.sendAt).getTime() + 3_000_000); + + await cancelScheduledSend(second!.emailSubmissionId); + expect((await listScheduledEmails()).some((s) => s.emailId === result.emailId)).toBe(false); + expect(await submissionExists(second!.emailSubmissionId)).toBe(false); + + await restoreEmailToDraft(result.emailId!, drafts, sent); + const [restored] = await getFullEmails([result.emailId!]); + expect(restored.mailboxIds[drafts]).toBe(true); + expect(restored.mailboxIds[sent]).toBeUndefined(); + expect(restored.keywords.$draft).toBe(true); + }, 90_000); + + it('sends a delayed message now', async () => { + const [identity] = await getIdentities(); + const result = await sendEmail( + { from: [{ email: USER }], to: [{ email: USER }], subject: `send now ${mailTag}`, textBody: 'live' }, + identity.id, sent, 3600, { draftsMailboxId: drafts }, + ); + const held = (await listScheduledEmails()).find((s) => s.emailSubmissionId === result.emailSubmissionId)!; + const now = await rescheduleScheduledSend(held, 0); + expect(now.emailSubmissionId).toBeTruthy(); + const later = (await listScheduledEmails()).filter((s) => s.emailId === result.emailId); + // Only the one-second replacement can still be listed, never the hour-long hold. + expect(later.every((s) => s.emailSubmissionId === now.emailSubmissionId)).toBe(true); + const [filed] = await getFullEmails([result.emailId!]); + expect(filed.mailboxIds[sent]).toBe(true); + }, 90_000); + + it('prunes final submission records', async () => { + await sleep(3_000); + expect(await pruneEmailSubmissions({ force: true })).toBeGreaterThanOrEqual(0); + }, 60_000); + }); + + describe('contacts', () => { + const created: string[] = []; + afterAll(async () => { + await useLogin(client); + if (created.length) await deleteContacts(created).catch(() => undefined); + }); + + it('saves a photo as a blob on 1.0 and as a data: URI on 0.16, and exports and re-imports it as vCard', async () => { + const [book] = await getAddressBooks(); + const card: Partial = { + '@type': 'Card', version: '1.0', kind: 'individual', + name: { components: [{ kind: 'given', value: `Live${RUN}` }, { kind: 'surname', value: 'Photo' }] }, + media: { p1: { '@type': 'Media', kind: 'photo', mediaType: 'image/png', uri: `data:image/png;base64,${PNG_BASE64}` } }, + } as Partial; + const saved = await writeContactWithNewPhoto(undefined, card, 'p1', (c) => createContact(c, book.id)); + created.push(saved.id); + const photo = Object.values(saved.media ?? {})[0]; + if (is10()) expect(photo?.blobId).toBeTruthy(); + else expect(photo?.uri).toContain('base64,'); + + const [exported] = await inlineContactMediaForExport([saved]); + const exportedPhoto = Object.values(exported.media ?? {})[0]; + expect(exportedPhoto?.uri?.replace(/^data:[^,]*,/, '')).toBe(PNG_BASE64); + + const vcf = generateVCard([exported]); + expect(vcf).toMatch(/PHOTO/); + // As the import sheet does (contacts-store importContacts): the parsed + // card's client-side id and books go, its UID stays. Both servers + // refuse a second card with the same UID, so the original goes first. + const { id: _id, addressBookIds: _books, ...parsed } = parseVCard(vcf)[0]; + await deleteContacts([saved.id]); + const imported = await createContact(parsed, book.id); + created.push(imported.id); + const back = await getContact(imported.id); + const backPhoto = Object.values(back?.media ?? {})[0]; + expect(backPhoto?.blobId || backPhoto?.uri).toBeTruthy(); + const [reexported] = await inlineContactMediaForExport([back!]); + expect(Object.values(reexported.media ?? {})[0]?.uri?.replace(/^data:[^,]*,/, '')).toBe(PNG_BASE64); + }, 60_000); + + it('reports the directory as off on 1.0 instead of failing', async () => { + const listing = await listPrincipals(); + if (is10()) { + expect(listing.status).toBe('restricted'); + expect(listing.directoryQueriesOff).toBe(true); + // What 1.0 still lists: at least the user's own principal. + expect(listing.principals.length).toBeGreaterThan(0); + } else { + expect(['ok', 'restricted']).toContain(listing.status); + } + }, 60_000); + + (USER2 ? it : it.skip)('writes a card through its own login when the ids repeat across logins', async () => { + const second = new JMAPClient(); + await second.connect(SERVER!, USER2!, PASS2); + const make = async (who: string) => { + const [book] = await getAddressBooks(); + return createContact({ + '@type': 'Card', version: '1.0', kind: 'individual', + name: { components: [{ kind: 'given', value: `${who}${RUN}` }] }, + } as Partial, book.id); + }; + const mine = await make('Own'); + created.push(mine.id); + await useLogin(second); + let theirs: ContactCard | undefined; + try { + theirs = await make('Second'); + // Destroying the second login's card through that login leaves the + // first login's card alone, whatever the ids are. + await deleteContacts([theirs.id]); + theirs = undefined; + } finally { + if (theirs) await deleteContacts([theirs.id]).catch(() => undefined); + await useLogin(client); + } + expect((await getContact(mine.id))?.id).toBe(mine.id); + }, 60_000); + }); + + describe('search, folders, Sieve, identities, Metadata', () => { + it('finds a quoted phrase as a phrase on 1.0 and by its words on 0.16', async () => { + const id = await importMessage(scratch, `search ${mailTag}`, 'the annual report for the board'); + const phrase = await eventually(() => searchEmails('"annual report"', scratch), (ids) => ids.includes(id)); + expect(phrase).toContain(id); + const reversed = await searchEmails('"report annual"', scratch); + if (is10()) expect(reversed).not.toContain(id); + else expect(reversed).toContain(id); + // A phrase next to a bare word: two conditions ANDed (the splitter). + expect(await searchEmails('"annual report" board', scratch)).toContain(id); + expect(await searchEmails('"annual report" nonexistentword', scratch)).not.toContain(id); + }, 60_000); + + it('reports a new folder through Mailbox/changes', async () => { + const { state } = await getMailboxesWithState(); + const id = await createMailbox({ name: `live-changes-${RUN}` }); + try { + const changes = await getMailboxChanges(state); + expect(changes?.created).toContain(id); + } finally { + await deleteMailbox(id); + } + }, 60_000); + + it('keeps Metadata out of the folder list', async () => { + const res = await client.request([ + ['Mailbox/get', { accountId: client.accountId, properties: MAILBOX_PROPERTIES }, '0'], + ]); + const [first] = res.methodResponses[0][1].list as Record[]; + expect(first).not.toHaveProperty('metadata'); + expect(first).toHaveProperty('myRights'); + }, 60_000); + + it('creates, reads, validates and deletes a Sieve script', async () => { + const content = `require ["fileinto"];\r\n# ${RUN} */\r\nif header :contains "subject" "${RUN}" { fileinto "INBOX"; }\r\n`; + expect((await validateSieveScript(content)).isValid).toBe(true); + expect((await validateSieveScript('if bogus {')).isValid).toBe(false); + const script = await createSieveScript(`live-${RUN}`, content, false); + try { + const listed = (await getSieveScripts()).find((s) => s.id === script.id); + expect(listed?.name).toBe(`live-${RUN}`); + expect(await getSieveScriptContent(listed!.blobId)).toContain(RUN); + } finally { + await deleteSieveScript(script.id); + } + }, 60_000); + + it('changes and restores an identity\'s name and signature', async () => { + const [identity] = await getIdentities(); + const before = { name: identity.name, textSignature: identity.textSignature ?? '' }; + try { + await updateIdentity(identity.id, { name: `Live ${RUN}`, textSignature: `sig ${RUN}` }); + const changed = (await getIdentities()).find((i) => i.id === identity.id); + expect(changed?.name).toBe(`Live ${RUN}`); + expect(changed?.textSignature).toBe(`sig ${RUN}`); + } finally { + await updateIdentity(identity.id, before); + } + }, 60_000); + }); +}); diff --git a/src/api/__tests__/live-stalwart.test.ts b/src/api/__tests__/live-stalwart.test.ts index f15d5999..253a48b8 100644 --- a/src/api/__tests__/live-stalwart.test.ts +++ b/src/api/__tests__/live-stalwart.test.ts @@ -267,9 +267,16 @@ live('live Stalwart', () => { draftId = saved.id; state = rebaseDraftPartBlobs(state, sent, saved.attachments); } - // Without the rebase the seeded part blob is gone with D0. - await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts)) - .rejects.toThrow(/does not exist|blobNotFound/); + // Each save re-pointed the image at the new version's part (on 1.0 + // the read-back finds it in the body lists only). + expect(liveBlobId(state, seeded)).not.toBe(seeded); + // Without the rebase the seeded part blob is gone with D0. 0.16 says + // "blobId … does not exist", 1.0 "One or more blobIds do not exist"; + // 1.0 sometimes still resolves it for a moment after the destroy. + const stale = await saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts) + .then((v) => v, (e: Error) => e); + if (stale instanceof Error) expect(stale.message).toMatch(/does not exist|do not exist|blobNotFound/); + else created.push(stale.id); const [last] = await getFullEmails([draftId]); expect(last.attachments?.[0]?.cid).toContain('img1@live.test'); } finally { diff --git a/src/api/blob.ts b/src/api/blob.ts index 9f87e2d1..2bff5f5f 100644 --- a/src/api/blob.ts +++ b/src/api/blob.ts @@ -2,6 +2,7 @@ import { File } from 'expo-file-system'; import type * as LegacyFileSystemTypes from 'expo-file-system/legacy'; import { jmapClient } from './jmap-client'; import { batched } from './jmap-result'; +import { downloadNameSegment } from './download-name'; import { CAPABILITIES } from './types'; import { getClientCertAlias, secureFetch } from '../lib/client-cert'; @@ -268,7 +269,7 @@ export function getDownloadUrl( return session.downloadUrl .replace('{accountId}', encodeURIComponent(accountId ?? jmapClient.accountId)) .replace('{blobId}', encodeURIComponent(blobId)) - .replace('{name}', encodeURIComponent(name ?? 'download')) + .replace('{name}', encodeURIComponent(downloadNameSegment(name))) .replace('{type}', encodeURIComponent(type ?? 'application/octet-stream')); } diff --git a/src/api/download-name.ts b/src/api/download-name.ts new file mode 100644 index 00000000..324258fe --- /dev/null +++ b/src/api/download-name.ts @@ -0,0 +1,15 @@ +/** + * The {name} segment of a JMAP download URL (RFC 8620 §6.2). Port of the + * webmail's lib/jmap/download-name.ts (#1371). + * + * The server only uses it for the Content-Disposition filename, but mailers + * sometimes put a path in a part's name ("/images/banner.jpg"). Percent- + * encoded, its slashes become %2F, which Apache (without AllowEncodedSlashes + * NoDecode), Traefik and nginx proxy_pass with a URI reject or decode, so the + * download failed and inline cid: images showed as blank. Keep the last path + * component instead. + */ +export function downloadNameSegment(name: string | undefined): string { + const base = (name ?? '').split(/[/\\]/).pop()?.trim(); + return base || 'download'; +} diff --git a/src/api/email.ts b/src/api/email.ts index 787a0fba..75aaabc4 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -16,6 +16,7 @@ import { sanitizeDisplayName } from '../lib/rfc5322-mailbox'; import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading'; import { buildMdnMessage, type MdnOptions } from '../lib/mdn'; import { hasTruncatedDisplayedBody } from '../lib/email-body'; +import { withInlineBodyParts } from '../lib/email-inline-parts'; import { stalwartStateWentBack } from './stalwart-state'; import { isTransientNetworkError } from '../lib/network-error'; @@ -34,11 +35,10 @@ export const EMAIL_FULL_PROPERTIES = [ 'messageId', 'inReplyTo', 'references', 'headers', ]; -// The Mailbox properties the folder list reads (RFC 8621 §2), which is also -// the default list on 0.16 and 1.0. Stalwart 1.0 advertises JMAP Metadata -// but its handlers are still `todo!()`: a get that selects `metadata` or -// `privateMetadata` panics on the server. An explicit list keeps them out -// even if a later release adds them to the default. +// The Mailbox properties the folder list reads (RFC 8621 §2), the default +// list on 0.16. Stalwart 1.0.0 adds JMAP Metadata's `metadata` and +// `privateMetadata` to the default (the pre-release's handlers panicked on +// them); the app reads neither, so an explicit list keeps them out. export const MAILBOX_PROPERTIES = [ 'id', 'name', 'parentId', 'role', 'sortOrder', 'totalEmails', 'unreadEmails', 'totalThreads', 'unreadThreads', 'myRights', 'isSubscribed', @@ -936,8 +936,10 @@ export async function getFullEmail(id: string, accountIdOverride?: string): Prom ['Email/get', { accountId, ids: [id], ...FULL_BODY_ARGS }, '0'], ]); const body = requireMethodResult(res, '0', 'Email/get'); - const email = (body.list as Email[] | undefined)?.[0]; - if (!email) throw new Error(`Email ${id} not found`); + const fetched = (body.list as Email[] | undefined)?.[0]; + if (!fetched) throw new Error(`Email ${id} not found`); + // Stalwart 1.0 lists an inline image in mixed only in the body lists. + const email = withInlineBodyParts(fetched); await refetchTruncatedBodyValues([email], accountId); return email; } @@ -972,7 +974,7 @@ export async function getFullEmailsWithState( ['Email/get', { accountId, ids: slice, ...FULL_BODY_ARGS }, '0'], ]); const body = requireMethodResult(res, '0', 'Email/get'); - out.list.push(...((body.list as Email[]) ?? [])); + out.list.push(...((body.list as Email[]) ?? []).map(withInlineBodyParts)); out.notFound.push(...((body.notFound as string[] | undefined) ?? [])); out.state = body.state as string | undefined; } @@ -1267,9 +1269,25 @@ export async function setEmailMailboxes( export async function destroyEmails(ids: string[], accountIdOverride?: string): Promise { if (ids.length === 0) return; const accountId = accountIdOverride ?? jmapClient.accountId; + // Every slice goes out before a refusal is thrown, and the error names the + // ids that are still there (failedIds): the server destroyed the rest, and + // the list must drop those rather than keep them until the next refresh + // (webmail ac99c68ef). + let refusal: JMAPSetError | null = null; + const failed: string[] = []; for (const slice of batched(ids, maxInSet())) { const res = await jmapClient.request([['Email/set', { accountId, destroy: slice }, '0']]); - assertEmailsDestroyed(requireMethodResult(res, '0', 'Email/set')); + try { + assertEmailsDestroyed(requireMethodResult(res, '0', 'Email/set')); + } catch (err) { + if (!(err instanceof JMAPSetError)) throw err; + refusal ??= err; + failed.push(...(err.failedIds ?? [])); + } + } + if (refusal) { + refusal.failedIds = failed; + throw refusal; } } @@ -1279,9 +1297,12 @@ function assertEmailsDestroyed(body: Record): void { const real = Object.entries(notDestroyed).filter(([, err]) => err?.type !== 'notFound'); if (real.length > 0) { const [id, err] = real[0]; - throw new Error( + const error = new JMAPSetError( + err.type || 'setError', `Failed to delete ${real.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`, ); + error.failedIds = real.map(([failedId]) => failedId); + throw error; } } @@ -1908,7 +1929,8 @@ export async function saveDraftVersion( ['Email/get', { accountId, ids: ['#draft'], - properties: ['id', 'attachments'], + // The body lists too: Stalwart 1.0 lists the inline images there only. + properties: ['id', 'attachments', 'textBody', 'htmlBody'], bodyProperties: DRAFT_PART_BODY_PROPERTIES, }, '1'], ]); @@ -1920,8 +1942,9 @@ export async function saveDraftVersion( let attachments: Attachment[] = []; try { const got = requireMethodResult(res, '1', 'Email/get'); - const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id); - attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId); + const saved = (got.list as Array> | undefined) + ?.find((e) => e.id === id); + attachments = (saved ? withInlineBodyParts(saved).attachments ?? [] : []).filter((a) => !!a.blobId); } catch (err) { console.warn('[email] could not read back the saved draft parts:', err); } diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 8998da4a..09457c31 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -15,6 +15,7 @@ import { type OAuthTokenSource, } from '../lib/oauth'; import { FirstTouchGate } from './first-touch-gate'; +import { downloadNameSegment } from './download-name'; import { writeSharedSecret, moveToSharedGroup } from '../lib/ios-push-shared'; import { beginOwnWrite, recordOwnEmailWrites } from './own-writes'; @@ -1352,7 +1353,7 @@ export class JMAPClient { return this.session.downloadUrl .replace('{accountId}', encodeURIComponent(accountId ?? this.accountId)) .replace('{blobId}', encodeURIComponent(blobId)) - .replace('{name}', encodeURIComponent(name || 'download')) + .replace('{name}', encodeURIComponent(downloadNameSegment(name))) .replace('{type}', encodeURIComponent(type || 'application/octet-stream')); } diff --git a/src/api/sieve.ts b/src/api/sieve.ts index 964a37b0..6b3b3522 100644 --- a/src/api/sieve.ts +++ b/src/api/sieve.ts @@ -9,8 +9,8 @@ import type { SieveScript, SieveCapabilities } from '../lib/sieve/types'; const SIEVE_USING = [CAPABILITIES.CORE, CAPABILITIES.SIEVE]; -// SieveScript properties (RFC 9661 §2), explicit so a default that grows to -// include Stalwart 1.0's unfinished JMAP Metadata can't reach the get. +// SieveScript properties (RFC 9661 §2), explicit so JMAP Metadata, which +// Stalwart 1.0 serves, stays out of a get that does not read it. const SIEVE_SCRIPT_PROPERTIES = ['id', 'name', 'blobId', 'isActive']; function requireSession() { diff --git a/src/lib/__tests__/draft-login.test.ts b/src/lib/__tests__/draft-login.test.ts new file mode 100644 index 00000000..ff95c5f2 --- /dev/null +++ b/src/lib/__tests__/draft-login.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { composerLoginMoved } from '../draft-login'; + +describe('composerLoginMoved', () => { + it('stays put while the login the composer opened in is active', () => { + expect(composerLoginMoved('acc-a', { activeAccountId: 'acc-a', connectedAccountId: 'acc-a' })).toBe(false); + // Offline cold start: the client is not connected yet. + expect(composerLoginMoved('acc-a', { activeAccountId: 'acc-a', connectedAccountId: null })).toBe(false); + }); + + it('moves once another login is active or the client serves another one', () => { + expect(composerLoginMoved('acc-a', { activeAccountId: 'acc-b', connectedAccountId: 'acc-b' })).toBe(true); + // Mid-switch: the registry still names A, the client already serves B. + expect(composerLoginMoved('acc-a', { activeAccountId: 'acc-a', connectedAccountId: 'acc-b' })).toBe(true); + }); + + it('never blocks a composer opened without a login', () => { + expect(composerLoginMoved(null, { activeAccountId: 'acc-b', connectedAccountId: 'acc-b' })).toBe(false); + }); +}); diff --git a/src/lib/__tests__/email-detail-cache.test.ts b/src/lib/__tests__/email-detail-cache.test.ts index 414cffbb..5248f445 100644 --- a/src/lib/__tests__/email-detail-cache.test.ts +++ b/src/lib/__tests__/email-detail-cache.test.ts @@ -184,6 +184,17 @@ describe('loading a message', () => { expect((await useOfflineCacheStore.getState().get('e1'))?.keywords).toEqual({ $flagged: true }); }); + it('takes the inline image of an offline copy saved from Stalwart 1.0 into its attachments', async () => { + const image = { partId: '4', blobId: 'b-img', type: 'image/png', disposition: 'inline', cid: 'logo@x' }; + const offline = { ...full('e1'), attachments: [], htmlBody: [{ partId: '1', type: 'text/html' }, image] } as Email; + await useOfflineCacheStore.getState().put(offline, 100); + flagsGet.mockReturnValue(new Promise(() => undefined)); + + void loadDetail('e1'); + await flush(); + expect(peekDetail('e1')?.attachments).toEqual([image]); + }); + it('batches what it holds and what it lacks into one request each', async () => { fullGet.mockResolvedValueOnce(fullResponse([full('a'), full('b')], 's1')); await loadDetails(['a', 'b']); diff --git a/src/lib/__tests__/email-inline-parts.test.ts b/src/lib/__tests__/email-inline-parts.test.ts new file mode 100644 index 00000000..22f39808 --- /dev/null +++ b/src/lib/__tests__/email-inline-parts.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { inlineBodyParts, withInlineBodyParts } from '../email-inline-parts'; + +const image = { partId: '4', blobId: 'b-img', type: 'image/png', disposition: 'inline', cid: 'x@y' }; +const text = { partId: '2', blobId: 'b-text', type: 'text/plain' }; +const html = { partId: '3', blobId: 'b-html', type: 'text/html' }; + +describe('inlineBodyParts', () => { + it('takes non-text body parts once, in body order', () => { + const audio = { partId: '5', blobId: 'b-audio', type: 'audio/ogg' }; + expect(inlineBodyParts({ attachments: [], textBody: [text, image, audio], htmlBody: [html, image] })) + .toEqual([image, audio]); + }); + + it('skips parts already in attachments and text parts in either list', () => { + expect(inlineBodyParts({ attachments: [image], textBody: [text, image], htmlBody: [html, image] })).toEqual([]); + expect(inlineBodyParts({ textBody: [text], htmlBody: [html, { partId: '6', type: 'TEXT/calendar', blobId: 'c' }] })).toEqual([]); + }); + + it('returns the same object when there is nothing to add', () => { + const email = { attachments: [image], textBody: [text], htmlBody: [html] }; + expect(withInlineBodyParts(email)).toBe(email); + const on10 = { attachments: [], textBody: [text, image], htmlBody: [html, image] }; + expect(withInlineBodyParts(on10).attachments).toEqual([image]); + }); +}); diff --git a/src/lib/__tests__/reply-compose.test.ts b/src/lib/__tests__/reply-compose.test.ts index 0a5f4f68..80abf7a3 100644 --- a/src/lib/__tests__/reply-compose.test.ts +++ b/src/lib/__tests__/reply-compose.test.ts @@ -27,3 +27,25 @@ describe('replyComposeParams', () => { expect(replyComposeParams('reply', source, undefined, ' ')?.prefillBody).toBeUndefined(); }); }); + +describe('replyComposeParams: quoted inline images', () => { + const quotedImage = { blobId: 'b-img', type: 'image/png', name: 'logo.png', disposition: 'inline', cid: '' }; + const unusedInline = { blobId: 'b-sig', type: 'image/png', name: 'sig.png', disposition: 'inline', cid: 'sig@x' }; + const file = { blobId: 'b-pdf', type: 'application/pdf', name: 'report.pdf', disposition: 'attachment' }; + const withImage = { + ...source, + htmlBody: [{ partId: '2', type: 'text/html' }], + textBody: [{ partId: '1', type: 'text/plain' }], + bodyValues: { 1: { value: 'Hi' }, 2: { value: '

Hi

' } }, + attachments: [quotedImage, unusedInline, file], + } as unknown as Email; + + it('hands a reply the image its quote embeds and nothing else', () => { + expect(replyComposeParams('reply', withImage)?.replyTo?.attachments).toEqual([quotedImage]); + expect(replyComposeParams('reply', source)?.replyTo?.attachments).toBeUndefined(); + }); + + it('hands a forward the quoted image and the files, not unused inline parts', () => { + expect(replyComposeParams('forward', withImage)?.replyTo?.attachments).toEqual([quotedImage, file]); + }); +}); diff --git a/src/lib/__tests__/scheduled-send-key.test.ts b/src/lib/__tests__/scheduled-send-key.test.ts new file mode 100644 index 00000000..9b0afb2b --- /dev/null +++ b/src/lib/__tests__/scheduled-send-key.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../scheduled-send-key'; + +// Stalwart numbers each account's submissions from the start, so the own +// account and a group account both hold an "a". +const own = { accountId: 'own', emailSubmissionId: 'a' }; +const group = { accountId: 'grp', emailSubmissionId: 'a' }; + +describe('scheduled send identity', () => { + it('tells apart records of two accounts with the same id', () => { + expect(scheduledSendKey(own)).not.toBe(scheduledSendKey(group)); + expect(sameScheduledSend(own, group)).toBe(false); + expect(sameScheduledSend(own, { ...own })).toBe(true); + }); + + it('matches the undo snackbar only to its own account\'s row', () => { + // A send from the composer names no account: it is the own one. + expect(isPendingSend({ emailSubmissionId: 'a' }, own, 'own')).toBe(true); + expect(isPendingSend({ emailSubmissionId: 'a' }, group, 'own')).toBe(false); + expect(isPendingSend({ emailSubmissionId: 'a', accountId: 'grp' }, group, 'own')).toBe(true); + expect(isPendingSend(null, own, 'own')).toBe(false); + }); +}); diff --git a/src/lib/draft-login.ts b/src/lib/draft-login.ts new file mode 100644 index 00000000..e498a4c7 --- /dev/null +++ b/src/lib/draft-login.ts @@ -0,0 +1,26 @@ +// The composer writes through the active login: autosave creates a version +// and destroys the previous one by id, Discard destroys the autosaved draft, +// Send flags the original `$answered`. A notification or permalink for +// another account can switch logins while the composer stays open under the +// message it opens. Back in the composer, those writes went to the other +// login, where the same ids name other messages: an autosave or a discard +// destroyed one of them (webmail 54b197080, d72bff95f). So the composer +// remembers the login it was opened in and writes nothing once that changed. + +export interface ComposerLogins { + /** The registry's active account now. */ + activeAccountId: string | null; + /** The account the JMAP client is connected to now; null while it is not connected. */ + connectedAccountId: string | null; +} + +/** + * Whether the composer opened in `owner` would now write to another login. + * A client that is not connected yet (a cold start offline) still counts as + * the owner's while the registry says so: its writes cannot go anywhere else. + */ +export function composerLoginMoved(owner: string | null | undefined, now: ComposerLogins): boolean { + if (!owner) return false; + if (now.activeAccountId !== owner) return true; + return now.connectedAccountId != null && now.connectedAccountId !== owner; +} diff --git a/src/lib/email-detail-cache.ts b/src/lib/email-detail-cache.ts index aea6259c..4e6f114e 100644 --- a/src/lib/email-detail-cache.ts +++ b/src/lib/email-detail-cache.ts @@ -18,6 +18,7 @@ import { getEmailFlags, getFullEmailsWithState, getThreadsHeaders } from '../api import { useOfflineCacheStore } from '../stores/offline-cache-store'; import { useSettingsStore } from '../stores/settings-store'; import { onStateChangeType } from './state-change-bus'; +import { withInlineBodyParts } from './email-inline-parts'; /** What the list row the viewer was opened from says about the message. */ export type FlagsHint = Partial>; @@ -378,7 +379,10 @@ function startLoads( const copies = await Promise.all(fromOffline.map((id) => offline.get(id, accountId).catch(() => null))); let shown = false; fromOffline.forEach((id, i) => { - const email = gen === generation ? copies[i] : null; + // A copy saved from Stalwart 1.0 before the inline parts were taken + // into `attachments` gets them here. + const copy = gen === generation ? copies[i] : null; + const email = copy ? withInlineBodyParts(copy) : null; if (!email) { missing.push(id); return; diff --git a/src/lib/email-inline-parts.ts b/src/lib/email-inline-parts.ts new file mode 100644 index 00000000..e9f0561b --- /dev/null +++ b/src/lib/email-inline-parts.ts @@ -0,0 +1,48 @@ +// Non-text parts that sit in a message's body lists but not in `attachments`. +// +// RFC 8621 §4.1.4 puts an inline image, audio or video part that sits +// directly in multipart/mixed into textBody and htmlBody only: it is meant to +// show at that place in the body, so it is not an attachment. Stalwart 1.0 +// follows that; 0.16 also listed such a part in `attachments`. The app saves +// a draft with an inline image exactly that way (an HTML body plus an inline +// part with a Content-ID), and everything that resolves `cid:` images or +// carries parts over (viewer, re-opened draft, forward, the draft's part +// blobs) reads `attachments`. So on 1.0 the image vanished from all of them. +// Taking those parts into the attachment list makes 1.0 read like 0.16. + +import type { Attachment, BodyPart, Email } from '../api/types'; + +const isTextPart = (part: BodyPart) => (part.type ?? '').toLowerCase().startsWith('text/'); + +// Body parts carry a partId; an attachment without one is matched by blob. +const partKey = (part: { partId?: string; blobId?: string }) => part.partId ?? `blob:${part.blobId ?? ''}`; + +/** + * The non-text parts of textBody/htmlBody that `attachments` lacks, in body + * order, deduped by partId. + */ +export function inlineBodyParts( + email: Pick, +): Attachment[] { + const seen = new Set((email.attachments ?? []).map((a) => partKey(a as Attachment & { partId?: string }))); + const out: Attachment[] = []; + for (const part of [...(email.htmlBody ?? []), ...(email.textBody ?? [])]) { + if (!part.blobId || isTextPart(part)) continue; + const key = partKey(part); + if (seen.has(key)) continue; + seen.add(key); + out.push({ ...part, blobId: part.blobId, type: part.type ?? 'application/octet-stream' }); + } + return out; +} + +/** + * The email with {@link inlineBodyParts} appended to its attachments. Returns + * the same object when there is nothing to add (0.16, or no such parts), so + * calling it twice is harmless. + */ +export function withInlineBodyParts>(email: T): T { + const extra = inlineBodyParts(email); + if (extra.length === 0) return email; + return { ...email, attachments: [...(email.attachments ?? []), ...extra] }; +} diff --git a/src/lib/reply-compose.ts b/src/lib/reply-compose.ts index 20fe3a3b..c328d619 100644 --- a/src/lib/reply-compose.ts +++ b/src/lib/reply-compose.ts @@ -1,6 +1,23 @@ -import type { Email } from '../api/types'; +import type { Attachment, Email } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; import { pickEmailBody, plainTextBody } from './email-body'; +import { extractCidRefs } from './email-html'; +import { stripMessageIdBrackets } from './email-threading'; + +/** + * The parts a reply or forward hands the composer. The inline images the + * quoted HTML embeds go along in both modes: the composer fetches them into + * the quote and attaches them again on send (it never shows them as chips). + * Without them a quoted `cid:` image went out pointing at a part the new + * message did not have. A forward also carries the real attachments. + */ +function carriedParts(mode: 'reply' | 'replyAll' | 'forward', source: Email, quoteHtml?: string): Attachment[] | undefined { + const quoted = new Set(quoteHtml ? extractCidRefs(quoteHtml) : []); + const isQuoted = (a: Attachment) => !!a.cid && quoted.has(stripMessageIdBrackets(a.cid)); + const parts = (source.attachments ?? []).filter((a) => + isQuoted(a) || (mode === 'forward' && !(a.disposition === 'inline' && a.cid))); + return parts.length > 0 || mode === 'forward' ? parts : undefined; +} /** * Composer params for a reply / reply-all / forward of `source`. Shared by @@ -42,11 +59,7 @@ export function replyComposeParams( // Threading needs the RFC Message-ID, never the JMAP object id (#234). messageId: source.messageId ?? undefined, references: source.references ?? undefined, - // Forward carries the original attachments as blob refs; cid-embedded - // inline images are already part of the quoted HTML. - attachments: mode === 'forward' - ? (source.attachments ?? []).filter((a) => !(a.disposition === 'inline' && a.cid)) - : undefined, + attachments: carriedParts(mode, source, quoteHtml), originalEmailId: source.id, jmapAccountId: ownerAccountId, }, diff --git a/src/lib/scheduled-send-key.ts b/src/lib/scheduled-send-key.ts new file mode 100644 index 00000000..f34b9359 --- /dev/null +++ b/src/lib/scheduled-send-key.ts @@ -0,0 +1,32 @@ +// Scheduled sends of the own and a group or shared account are listed +// together, and submission ids repeat across accounts (Stalwart numbers each +// account's records from the start). So a row is the pair of account and +// submission id: by the id alone, cancelling or rescheduling one account's +// message also took the other's row off the list or rewrote it, and the undo +// snackbar of one was cleared by an action on the other (webmail e314e5666). + +import type { ScheduledEmail } from '../api/email'; + +type Keyed = Pick; + +/** The list key of a scheduled send. */ +export function scheduledSendKey(item: Keyed): string { + return `${item.accountId}|${item.emailSubmissionId}`; +} + +/** Whether two scheduled sends are the same record. */ +export function sameScheduledSend(a: Keyed, b: Keyed): boolean { + return a.accountId === b.accountId && a.emailSubmissionId === b.emailSubmissionId; +} + +/** + * Whether the undo snackbar's pending send is this row. The pending send + * names no account for a send from the own account (`ownAccountId`). + */ +export function isPendingSend( + pending: { emailSubmissionId: string; accountId?: string } | null | undefined, + item: Keyed, + ownAccountId: string, +): boolean { + return !!pending && sameScheduledSend({ accountId: pending.accountId ?? ownAccountId, emailSubmissionId: pending.emailSubmissionId }, item); +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 9105cee5..fe4faadd 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -66,6 +66,7 @@ import { findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, } from '../lib/reply-identity'; import { shouldBlockEditorRemoteImages } from '../lib/editor-html'; +import { composerLoginMoved } from '../lib/draft-login'; import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody, } from '../lib/draft-part-blobs'; @@ -1122,6 +1123,12 @@ export default function ComposeScreen({ route, navigation }: Props) { const lastSaveAtRef = React.useRef(0); const allowLeaveRef = React.useRef(false); 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, + connectedAccountId: jmapClient.connectedAccountId, + }); const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle'); const [savingDraft, setSavingDraft] = React.useState(false); @@ -1336,6 +1343,8 @@ export default function ComposeScreen({ route, navigation }: Props) { const saveDraftOnce = async (opts: { live: boolean }): Promise => { const identity = primaryIdentity; if (!identity || !draftsMailbox || sendingRef.current) return draftIdRef.current; + // Another login is active: its Drafts and ids are not this draft's. + if (loginMoved()) return draftIdRef.current; let html = latestRef.current.bodyHtml; if (opts.live && !plainTextMode) { try { @@ -1460,7 +1469,8 @@ export default function ComposeScreen({ route, navigation }: Props) { // A draft this session created by autosave goes with the discard; a // re-opened draft keeps its last saved version. const autosaved = draftIdRef.current; - if (autosaved && !draft) { + // In another login that id names another message; the draft stays. + if (autosaved && !draft && !loginMoved()) { void (async () => { try { await inflightSaveRef.current; } catch { /* ignore */ } const id = draftIdRef.current ?? autosaved; @@ -2109,6 +2119,18 @@ 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; + 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 || '' }, + ), + ); + 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 diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index d1991ce5..9b74e6d2 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -29,6 +29,7 @@ import { formatQuoteDate } from '../lib/quote-header'; import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/scheduled-send-key'; type Props = NativeStackScreenProps; @@ -79,7 +80,7 @@ export default function ScheduledScreen({ navigation }: Props) { }, [load]); const runAction = async (item: ScheduledEmail, action: () => Promise, failTitle: string) => { - setBusyId(item.emailSubmissionId); + setBusyId(scheduledSendKey(item)); try { await action(); } catch (e) { @@ -124,7 +125,11 @@ export default function ScheduledScreen({ navigation }: Props) { toast.warning(title, { message, duration: 10_000 }); }; - const removeItem = (id: string) => setItems((prev) => prev.filter((i) => i.emailSubmissionId !== id)); + // Submission ids repeat across accounts: a row is its account and id. + const removeItem = (item: ScheduledEmail) => setItems((prev) => prev.filter((i) => !sameScheduledSend(i, item))); + const clearUndoFor = (item: ScheduledEmail) => { + if (isPendingSend(useSendUndoStore.getState().pending, item, jmapClient.accountId)) useSendUndoStore.getState().clear(); + }; const onCancel = (item: ScheduledEmail) => { Alert.alert( @@ -138,10 +143,8 @@ export default function ScheduledScreen({ navigation }: Props) { onPress: () => { void runAction(item, async () => { await cancelScheduledSend(item.emailSubmissionId, item.accountId); - if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { - useSendUndoStore.getState().clear(); - } - removeItem(item.emailSubmissionId); + clearUndoFor(item); + removeItem(item); }, t('scheduled.cancel_failed', 'Cancel failed')); }, }, @@ -152,10 +155,8 @@ export default function ScheduledScreen({ navigation }: Props) { const onSendNow = (item: ScheduledEmail) => { void runAction(item, async () => { const result = await rescheduleScheduledSend(item, 0); - if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { - useSendUndoStore.getState().clear(); - } - removeItem(item.emailSubmissionId); + clearUndoFor(item); + removeItem(item); warnRefused(result.refusedRecipients); }, t('scheduled.send_now_failed', 'Could not send now')); }; @@ -179,10 +180,9 @@ export default function ScheduledScreen({ navigation }: Props) { } void runAction(item, async () => { const result = await rescheduleScheduledSend(item, seconds); - const pending = useSendUndoStore.getState().pending; - if (pending?.emailSubmissionId === item.emailSubmissionId) useSendUndoStore.getState().clear(); + clearUndoFor(item); setItems((prev) => prev - .map((i) => (i.emailSubmissionId === item.emailSubmissionId + .map((i) => (sameScheduledSend(i, item) ? { ...i, emailSubmissionId: result.emailSubmissionId ?? i.emailSubmissionId, sendAt: result.sendAt ?? date.toISOString() } : i)) .sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime())); @@ -195,9 +195,7 @@ export default function ScheduledScreen({ navigation }: Props) { const onEdit = (item: ScheduledEmail) => { void runAction(item, async () => { await cancelScheduledSend(item.emailSubmissionId, item.accountId); - if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { - useSendUndoStore.getState().clear(); - } + clearUndoFor(item); // A message held in a shared account goes back to that account's Drafts. const shared = item.accountId !== jmapClient.accountId; const scope = shared @@ -214,7 +212,7 @@ export default function ScheduledScreen({ navigation }: Props) { ); } const email = await getFullEmail(item.emailId, item.accountId); - removeItem(item.emailSubmissionId); + removeItem(item); navigation.replace('Compose', { draft: draftContextFromEmail(email, shared ? item.accountId : undefined) }); }, t('scheduled.edit_failed', 'Could not open the message for editing')); }; @@ -273,7 +271,7 @@ export default function ScheduledScreen({ navigation }: Props) { }; const renderItem = ({ item }: { item: ScheduledEmail }) => { - const busy = busyId === item.emailSubmissionId; + const busy = busyId === scheduledSendKey(item); return ( setActionsFor(item)} disabled={busy}> @@ -347,7 +345,7 @@ export default function ScheduledScreen({ navigation }: Props) { ) : ( i.emailSubmissionId} + keyExtractor={scheduledSendKey} renderItem={renderItem} ItemSeparatorComponent={() => } // A stack screen has no tab bar: the list ends clear of the gesture bar. diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 0298fc12..0a2805a9 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -1032,6 +1032,27 @@ describe('email-store', () => { expect(mockPatchKeywords).toHaveBeenCalledWith(['e1'], { $seen: true }, undefined); useSettingsStore.getState().updateSetting('deleteAction', 'trash'); }); + + it('drops the rows the server deleted when it refused others of the selection', async () => { + const { JMAPSetError } = await import('../../api/jmap-result'); + const refusal = new JMAPSetError('forbidden', 'Failed to delete 1 email(s), first: e2 – forbidden'); + refusal.failedIds = ['e2']; + (emailApi.deleteEmails as ReturnType).mockRejectedValueOnce(refusal); + useEmailStore.setState({ + mailboxes: [ + { id: 'mb-1', name: 'Inbox', role: 'inbox', isShared: false } as any, + { id: 'mb-trash', name: 'Trash', role: 'trash', isShared: false } as any, + ], + currentMailboxId: 'mb-trash', + emails: [ + { id: 'e1', keywords: {}, mailboxIds: { 'mb-trash': true } } as any, + { id: 'e2', keywords: {}, mailboxIds: { 'mb-trash': true } } as any, + ], + }); + + await expect(useEmailStore.getState().deleteEmailsBatch(['e1', 'e2'], 'mb-trash', 'mb-trash')).rejects.toBe(refusal); + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e2']); + }); }); describe('bulk keyword actions', () => { diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 0968034b..8e7926af 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -1885,43 +1885,63 @@ export const useEmailStore = create()( patch: markReadPatch, })), ]; + // The server refuses messages one by one and applies the rest. The + // refused ones keep their rows, the others are reconciled as done, and + // the refusal is still reported (webmail ac99c68ef): before, one refused + // message kept every row of the selection, the deleted ones included. + let refusal: unknown = null; + const kept = new Set(); + const step = async (run: () => Promise, keepRefused: boolean) => { + try { + await run(); + } catch (err) { + const failed = failedIdsOf(err); + if (!failed) throw err; + refusal ??= err; + if (keepRefused) for (const id of failed) kept.add(id); + } + }; await applyOrQueueBatch(ops, async () => { if (toDestroy.length > 0) { - await apiDeleteEmails(toDestroy.map((e) => e.id), trash.id, trash.id, trash.accountId); + await step(() => apiDeleteEmails(toDestroy.map((e) => e.id), trash.id, trash.id, trash.accountId), true); } if (toTrash.length > 0) { - await apiMoveEmails(toTrash.map((e) => e.id), source.id, trash.id, source.accountId); + await step(() => apiMoveEmails(toTrash.map((e) => e.id), source.id, trash.id, source.accountId), true); } if (toMarkRead.length > 0) { - await patchKeywordsForEmails([...markReadIds], markReadPatch, source.accountId); + // Moved but left unread: the row has still left the folder. + await step(() => patchKeywordsForEmails([...markReadIds], markReadPatch, source.accountId), false); } }); + const destroyed = toDestroy.filter((e) => !kept.has(e.id)); + const trashed = toTrash.filter((e) => !kept.has(e.id)); - if (toDestroy.length > 0) dropFromCache(toDestroy.map((e) => e.id), trash.accountId); - for (const e of toTrash) { + if (destroyed.length > 0) dropFromCache(destroyed.map((e) => e.id), trash.accountId); + for (const e of trashed) { patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, trash.id), ...(markReadIds.has(e.id) ? { keywords: markReadPatch } : {}), }, source.accountId); } - const removed = new Set(targets.map(rowKeyOf)); + const removed = new Set([...destroyed, ...trashed].map(rowKeyOf)); set({ emails: get().emails.filter((e) => !removed.has(rowKeyOf(e))) }); // Only the moved-to-trash items are recoverable; destroyed ones are gone. - if (toTrash.length > 0) { + if (trashed.length > 0) { set({ pendingUndo: { kind: 'delete', - label: toTrash.length === 1 + label: trashed.length === 1 ? t('email_list.moved_to_trash', 'Email moved to Trash') - : t('email_list.moved_to_trash_count', `${toTrash.length} emails moved to Trash`, { count: toTrash.length }), + : t('email_list.moved_to_trash_count', `${trashed.length} emails moved to Trash`, { count: trashed.length }), createdAt: Date.now(), accountId: source.accountId, - items: toTrash.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } })), + items: trashed.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } })), }, }); } + if (refusal) throw refusal; }, setKeywordForEmails: async (emailIds, token, on, viewed) => {