Merge branch 's10/mail' into feat/stalwart-1.0

This commit is contained in:
Linus Rath
2026-10-29 02:34:22 +01:00
26 changed files with 896 additions and 57 deletions
+2
View File
@@ -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.",
+9
View File
@@ -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?');
});
});
});
@@ -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<typeof vi.fn>;
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']);
});
});
@@ -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<typeof vi.fn>;
// 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, unknown>, 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: '<p><img src="cid:img1@live.test"></p>',
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']);
});
});
@@ -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 () => {
@@ -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<void> {
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<T>(read: () => Promise<T>, check: (v: T) => boolean, ms = 15_000): Promise<T> {
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<string, boolean> = {}) {
const raw = `From: Partner <partner@example.net>\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<string[]> {
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<boolean> {
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<ContactCard> = {
'@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<ContactCard>;
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<ContactCard>, 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<string, unknown>[];
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);
});
});
+10 -3
View File
@@ -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 {
+2 -1
View File
@@ -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'));
}
+15
View File
@@ -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';
}
+36 -13
View File
@@ -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<void> {
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<string, any>): 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<Pick<Email, 'id' | 'attachments' | 'textBody' | 'htmlBody'>> | 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);
}
+2 -1
View File
@@ -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'));
}
+2 -2
View File
@@ -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() {
+20
View File
@@ -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);
});
});
@@ -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']);
@@ -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]);
});
});
+22
View File
@@ -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: '<logo@x>' };
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: '<p>Hi <img src="cid:logo@x"></p>' } },
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]);
});
});
@@ -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);
});
});
+26
View File
@@ -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;
}
+5 -1
View File
@@ -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<Pick<Email, 'keywords' | 'mailboxIds'>>;
@@ -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;
+48
View File
@@ -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<Email, 'attachments' | 'textBody' | 'htmlBody'>,
): 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<T extends Pick<Email, 'attachments' | 'textBody' | 'htmlBody'>>(email: T): T {
const extra = inlineBodyParts(email);
if (extra.length === 0) return email;
return { ...email, attachments: [...(email.attachments ?? []), ...extra] };
}
+19 -6
View File
@@ -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,
},
+32
View File
@@ -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<ScheduledEmail, 'accountId' | 'emailSubmissionId'>;
/** 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);
}
+23 -1
View File
@@ -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<string | null> => {
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
+17 -19
View File
@@ -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<RootStackParamList, 'Scheduled'>;
@@ -79,7 +80,7 @@ export default function ScheduledScreen({ navigation }: Props) {
}, [load]);
const runAction = async (item: ScheduledEmail, action: () => Promise<void>, 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 (
<Pressable style={styles.row} onPress={() => setActionsFor(item)} disabled={busy}>
<View style={styles.rowMain}>
@@ -347,7 +345,7 @@ export default function ScheduledScreen({ navigation }: Props) {
) : (
<FlatList
data={items}
keyExtractor={(i) => i.emailSubmissionId}
keyExtractor={scheduledSendKey}
renderItem={renderItem}
ItemSeparatorComponent={() => <View style={styles.separator} />}
// A stack screen has no tab bar: the list ends clear of the gesture bar.
+21
View File
@@ -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<typeof vi.fn>).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', () => {
+30 -10
View File
@@ -1885,43 +1885,63 @@ export const useEmailStore = create<EmailState>()(
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<string>();
const step = async (run: () => Promise<void>, 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) => {