mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
Merge branch 's10/mail' into feat/stalwart-1.0
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user