mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Merge branch 'fx/contacts' into feat/stalwart-1.0
This commit is contained in:
@@ -174,6 +174,41 @@ describe('contacts operations', () => {
|
||||
const result = await getContacts(['c1']);
|
||||
expect(result).toEqual(contacts);
|
||||
});
|
||||
|
||||
it('reads the calendar and scheduling links into the form fields', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['ContactCard/get', { list: [{
|
||||
id: 'c1',
|
||||
calendars: { cal: { kind: 'calendar', uri: 'https://cal' } },
|
||||
schedulingAddresses: { s: { uri: 'mailto:s@x' } },
|
||||
}] }, '0']],
|
||||
});
|
||||
const [card] = await getContacts(['c1']);
|
||||
expect(card).toMatchObject({ calendarUri: 'https://cal', schedulingUri: 'mailto:s@x' });
|
||||
});
|
||||
});
|
||||
|
||||
// Stalwart refuses the whole card over calendarUri ("Invalid property.").
|
||||
describe('calendar, scheduling and free/busy links', () => {
|
||||
it('creates them as RFC 9553 calendars and scheduling addresses', async () => {
|
||||
mockRequest
|
||||
.mockResolvedValueOnce({ methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'c-new' } } }, '0']] })
|
||||
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [] }, '0']] });
|
||||
await createContact({ name: { full: 'A' }, calendarUri: 'https://cal', freeBusyUri: 'https://fb', schedulingUri: 'mailto:a@x' }, 'ab-1');
|
||||
const create = mockRequest.mock.calls[0][0][0][1].create['new-contact'];
|
||||
expect(create).not.toHaveProperty('calendarUri');
|
||||
expect(create).not.toHaveProperty('freeBusyUri');
|
||||
expect(create).not.toHaveProperty('schedulingUri');
|
||||
expect(Object.keys(create.calendars)).toEqual(['cal', 'fb']);
|
||||
expect(create.schedulingAddresses.sched.uri).toBe('mailto:a@x');
|
||||
});
|
||||
|
||||
it('clears them on an update', async () => {
|
||||
mockRequest.mockResolvedValue({ methodResponses: [['ContactCard/set', { updated: { c1: null } }, '0']] });
|
||||
await updateContact('c1', { calendarUri: null, schedulingUri: null } as never);
|
||||
const patch = mockRequest.mock.calls[0][0][0][1].update.c1;
|
||||
expect(patch).toEqual({ calendars: null, schedulingAddresses: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllContacts', () => {
|
||||
@@ -445,6 +480,15 @@ describe('contacts operations', () => {
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].destroy).toEqual(['ab-1']);
|
||||
expect(call[1]).not.toHaveProperty('onDestroyRemoveContents');
|
||||
});
|
||||
|
||||
it('takes the cards along when asked (RFC 9610 addressBookHasContents)', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['AddressBook/set', { destroyed: ['ab-1'] }, '0']],
|
||||
});
|
||||
await deleteAddressBook('ab-1', undefined, { removeContents: true });
|
||||
expect(mockRequest.mock.calls[0][0][0][1].onDestroyRemoveContents).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when destroy fails', async () => {
|
||||
|
||||
@@ -318,6 +318,40 @@ describe('JMAPClient hardening', () => {
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// A write the server may already have applied must not run twice.
|
||||
it.each([
|
||||
[[['Email/set', { accountId: 'acc-1', destroy: ['e1'] }, '0']]],
|
||||
[[['ContactCard/set', { accountId: 'acc-1', create: { c: {} } }, '0']]],
|
||||
[[['Mailbox/get', { accountId: 'acc-1' }, '0'], ['Email/copy', { accountId: 'acc-1' }, '1']]],
|
||||
[[['SieveScript/set', { accountId: 'acc-1' }, '0']]],
|
||||
])('does not replay a batch with a write after a network error (%j)', async (calls) => {
|
||||
const client = await connected();
|
||||
const fetch = mockFetch([
|
||||
{ status: 200, throw: new TypeError('Network request failed') },
|
||||
{ status: 200, json: { methodResponses: [] } },
|
||||
]);
|
||||
const err = await client.request(calls as never).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(NetworkError);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('replays a batch made only of reads', async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = await connected();
|
||||
const fetch = mockFetch([
|
||||
{ status: 200, throw: new TypeError('Network request failed') },
|
||||
{ status: 200, json: { methodResponses: [['Email/query', { ids: [] }, '0'], ['Email/get', { list: [] }, '1']] } },
|
||||
]);
|
||||
const p = client.request([
|
||||
['Email/query', { accountId: 'acc-1' }, '0'],
|
||||
['Email/get', { accountId: 'acc-1', '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' } }, '1'],
|
||||
['Email/changes', { accountId: 'acc-1', sinceState: 's' }, '2'],
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1100);
|
||||
await p;
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not retry a submission after a network error and wraps it as NetworkError', async () => {
|
||||
const client = await connected();
|
||||
const fetch = mockFetch([{ status: 200, throw: new TypeError('Network request failed') }]);
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Live checks of contacts and Sieve filters against a real Stalwart server,
|
||||
* for what the phone writes: contacts with calendar links, vCard imports and
|
||||
* re-imports, address books with cards, the company directory, webmail 1.13+
|
||||
* filter scripts (rule periods, vacation forwarding and audience, exact
|
||||
* address rules) and rules made from a message.
|
||||
*
|
||||
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
|
||||
* `BULWARK_LIVE_PASS` are set.
|
||||
*
|
||||
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
|
||||
* BULWARK_LIVE_USER=e2e-tablet-dark@example.org BULWARK_LIVE_PASS=… \
|
||||
* npx vitest run src/api/__tests__/live-stalwart-contacts.test.ts
|
||||
*
|
||||
* Everything it creates is removed again; the account's Sieve scripts are
|
||||
* put back as they were, the active one included.
|
||||
*
|
||||
* 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 {
|
||||
createAddressBook,
|
||||
deleteAddressBook,
|
||||
deleteContacts,
|
||||
getAddressBooks,
|
||||
getContact,
|
||||
createContact,
|
||||
updateContact,
|
||||
} from '../contacts';
|
||||
import {
|
||||
createSieveScript,
|
||||
deleteSieveScript,
|
||||
getSieveAccountId,
|
||||
getSieveCapabilities,
|
||||
getSieveScriptContent,
|
||||
getSieveScripts,
|
||||
updateSieveScript,
|
||||
validateSieveScript,
|
||||
} from '../sieve';
|
||||
import { updateAccountFilters } from '../sieve-rules';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { readVacationFilters, useFilterStore } from '../../stores/filter-store';
|
||||
import { generateVCard, parseVCard } from '../../lib/vcard';
|
||||
import { generateScript } from '../../lib/sieve/generator';
|
||||
import { parseScript } from '../../lib/sieve/parser';
|
||||
import { applyQuickRule, buildPresetRule, toRuleMailbox } from '../../lib/sieve/quick-rules';
|
||||
import { readWebmailFixture, WEBMAIL_FIXTURES } from '../../lib/sieve/__tests__/fixtures/webmail';
|
||||
import { getMailboxes } from '../email';
|
||||
import type { ContactCard } from '../types';
|
||||
import type { SieveScript } from '../../lib/sieve/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 live = SERVER && PASS ? describe : describe.skip;
|
||||
|
||||
const RUN = Date.now().toString(36);
|
||||
const SIEVE_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:sieve'];
|
||||
|
||||
let client: JMAPClient;
|
||||
let singleton: JMAPClient;
|
||||
|
||||
/** Stalwart 1.0 advertises JMAP Metadata, 0.16 does not. */
|
||||
const is10 = () => !!client.currentSession?.capabilities?.['urn:ietf:params:jmap:metadata'];
|
||||
|
||||
const t = (_key: string, fallback?: string, params?: Record<string, string | number>) =>
|
||||
(fallback ?? '').replace(/\{(\w+)\}/g, (_m, k) => String(params?.[k] ?? ''));
|
||||
|
||||
live('live Stalwart: contacts and filters (0.16 and 1.0)', () => {
|
||||
let bookId = '';
|
||||
const createdCards: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../jmap-client');
|
||||
singleton = mod.jmapClient as unknown as JMAPClient;
|
||||
client = new JMAPClient();
|
||||
await client.connect(SERVER!, USER, PASS);
|
||||
singleton.restoreSnapshot(client.snapshot());
|
||||
useContactsStore.getState().reset();
|
||||
bookId = (await createAddressBook(`live-contacts-${RUN}`)).id;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdCards.length) await deleteContacts(createdCards).catch(() => undefined);
|
||||
const books = await getAddressBooks().catch(() => []);
|
||||
for (const book of books.filter((b) => b.name.startsWith(`live-contacts-${RUN}`))) {
|
||||
await deleteAddressBook(book.id, undefined, { removeContents: true }).catch(() => undefined);
|
||||
}
|
||||
useContactsStore.getState().reset();
|
||||
}, 60_000);
|
||||
|
||||
describe('contacts', () => {
|
||||
// Stalwart refuses calendarUri as a property ("Invalid property.").
|
||||
it('saves a contact with calendar, free/busy and scheduling links and reads them back', async () => {
|
||||
const card = await createContact({
|
||||
'@type': 'Card', version: '1.0', kind: 'individual',
|
||||
name: { full: `Live Links ${RUN}` },
|
||||
calendarUri: 'https://cal.example.org/anna',
|
||||
freeBusyUri: 'https://cal.example.org/anna/fb',
|
||||
schedulingUri: 'mailto:anna@example.org',
|
||||
} as Partial<ContactCard>, bookId);
|
||||
createdCards.push(card.id);
|
||||
const back = await getContact(card.id);
|
||||
expect(back).toMatchObject({
|
||||
calendarUri: 'https://cal.example.org/anna',
|
||||
freeBusyUri: 'https://cal.example.org/anna/fb',
|
||||
schedulingUri: 'mailto:anna@example.org',
|
||||
});
|
||||
|
||||
await updateContact(card.id, { calendarUri: null, freeBusyUri: null, schedulingUri: null } as never);
|
||||
const cleared = await getContact(card.id);
|
||||
expect(cleared?.calendarUri).toBeUndefined();
|
||||
expect(cleared?.schedulingUri).toBeUndefined();
|
||||
}, 60_000);
|
||||
|
||||
it('imports a vCard with flat address, label, source and birthplace', async () => {
|
||||
const [parsed] = parseVCard([
|
||||
'BEGIN:VCARD', 'VERSION:3.0', `FN:Live Import ${RUN}`, `N:Import ${RUN};Live;;;`,
|
||||
'ADR;TYPE=HOME;LABEL="Hauptstr. 1, Berlin":;;Hauptstr. 1;Berlin;;10115;Germany',
|
||||
'TEL;TYPE=CELL:+49 30 1234',
|
||||
'SOURCE:https://dir.example.org/live.vcf',
|
||||
'CALURI:https://cal.example.org/live',
|
||||
'BDAY:19800102', 'BIRTHPLACE:Hamburg',
|
||||
'END:VCARD',
|
||||
].join('\r\n'));
|
||||
const result = await useContactsStore.getState().importContacts([parsed], bookId);
|
||||
expect(result.failures).toEqual([]);
|
||||
expect(result.imported).toBe(1);
|
||||
const card = useContactsStore.getState().contacts.find((c) => c.name?.full === `Live Import ${RUN}`);
|
||||
createdCards.push(card!.id);
|
||||
const back = await getContact(card!.id);
|
||||
const address = Object.values(back?.addresses ?? {})[0];
|
||||
expect(address?.components?.find((c) => c.kind === 'locality')?.value).toBe('Berlin');
|
||||
expect(Object.values(back?.phones ?? {})[0]?.features).toEqual({ mobile: true });
|
||||
expect(back?.calendarUri).toBe('https://cal.example.org/live');
|
||||
expect(back?.source).toBe('https://dir.example.org/live.vcf');
|
||||
}, 60_000);
|
||||
|
||||
// Both servers refuse a second card with the same UID; the import used
|
||||
// to count every card of a re-imported export as failed.
|
||||
it('re-imports its own vCard 4.0 export by updating the cards', async () => {
|
||||
const card = await createContact({
|
||||
'@type': 'Card', version: '1.0', kind: 'individual',
|
||||
name: { components: [{ kind: 'given', value: 'Live' }, { kind: 'surname', value: `Reimport${RUN}` }], full: `Live Reimport${RUN}` },
|
||||
emails: { e: { address: `reimport-${RUN}@example.org` } },
|
||||
phones: { p: { number: '+1 555 0100', features: { mobile: true } } },
|
||||
} as Partial<ContactCard>, bookId);
|
||||
createdCards.push(card.id);
|
||||
const vcf = generateVCard([{ ...card, notes: { n: { note: 'from the export' } } }]);
|
||||
expect(vcf.startsWith('BEGIN:VCARD\r\nVERSION:4.0\r\n')).toBe(true);
|
||||
expect(vcf).toContain('TEL;TYPE=CELL:');
|
||||
|
||||
// A fresh store: the card is not in the list, the server names it.
|
||||
useContactsStore.getState().reset();
|
||||
const result = await useContactsStore.getState().importContacts(parseVCard(vcf), bookId);
|
||||
expect(result).toMatchObject({ imported: 0, updated: 1, failed: 0 });
|
||||
const back = await getContact(card.id);
|
||||
expect(Object.values(back?.notes ?? {})[0]?.note).toBe('from the export');
|
||||
}, 60_000);
|
||||
|
||||
// 1.0 refuses to destroy a book that holds cards without
|
||||
// onDestroyRemoveContents (addressBookHasContents).
|
||||
it('deletes an address book that still holds contacts', async () => {
|
||||
const book = await createAddressBook(`live-contacts-${RUN}-full`);
|
||||
const card = await createContact({ '@type': 'Card', version: '1.0', name: { full: `Live Book ${RUN}` } } as Partial<ContactCard>, book.id);
|
||||
useContactsStore.setState({ addressBooks: [book], contacts: [card] });
|
||||
await useContactsStore.getState().deleteAddressBook(book.id);
|
||||
expect((await getAddressBooks()).some((b) => b.id === book.id)).toBe(false);
|
||||
expect(await getContact(card.id)).toBeNull();
|
||||
}, 60_000);
|
||||
|
||||
it('lists the directory, saying so when the server keeps it restricted', async () => {
|
||||
useContactsStore.getState().reset();
|
||||
await useContactsStore.getState().fetchDirectory();
|
||||
const s = useContactsStore.getState();
|
||||
if (is10()) {
|
||||
// Directory queries are off by default on 1.0.
|
||||
expect(s.directoryStatus).toBe('restricted');
|
||||
expect(s.directoryQueriesOff).toBe(true);
|
||||
} else {
|
||||
expect(['ready', 'restricted']).toContain(s.directoryStatus);
|
||||
}
|
||||
expect(s.directory.some((e) => e.email.toLowerCase() === USER.toLowerCase())).toBe(true);
|
||||
expect(s.lookupDirectory(USER.split('@')[0]).map((r) => r.email.toLowerCase())).toContain(USER.toLowerCase());
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
describe('filters', () => {
|
||||
let before: Array<SieveScript & { content: string }> = [];
|
||||
let created: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
// What the account has, to put back afterwards.
|
||||
const scripts = await getSieveScripts();
|
||||
before = [];
|
||||
for (const s of scripts) before.push({ ...s, content: await getSieveScriptContent(s.blobId) });
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
const accountId = getSieveAccountId();
|
||||
const now = await getSieveScripts().catch(() => [] as SieveScript[]);
|
||||
// An active script cannot be destroyed (RFC 9661): switch them all off first.
|
||||
await client.request([['SieveScript/set', { accountId, onSuccessDeactivateScript: true }, '0']], SIEVE_USING)
|
||||
.catch(() => undefined);
|
||||
for (const s of now) {
|
||||
const old = before.find((b) => b.id === s.id);
|
||||
if (!old) await deleteSieveScript(s.id).catch(() => undefined);
|
||||
else if ((await getSieveScriptContent(s.blobId).catch(() => old.content)) !== old.content) {
|
||||
await updateSieveScript(s.id, old.content, false).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
const active = before.find((b) => b.isActive);
|
||||
if (active) {
|
||||
await client.request([['SieveScript/set', { accountId, onSuccessActivateScript: active.id }, '0']], SIEVE_USING)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
const after = await getSieveScripts();
|
||||
expect(after.map((s) => [s.id, s.isActive]).sort()).toEqual(before.map((s) => [s.id, s.isActive]).sort());
|
||||
useFilterStore.getState().clearState();
|
||||
}, 60_000);
|
||||
|
||||
it('compiles every webmail fixture', async () => {
|
||||
for (const { file } of WEBMAIL_FIXTURES) {
|
||||
const result = await validateSieveScript(readWebmailFixture(file));
|
||||
expect({ file, ...result }).toEqual({ file, isValid: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// #1152: version 2 opened as raw Sieve, and a save dropped the periods,
|
||||
// the forwarding and the audience.
|
||||
it('edits a webmail 1.13 script as rules and saves it back whole', async () => {
|
||||
const fixture = readWebmailFixture('period-forward-audience.sieve');
|
||||
const script = await createSieveScript(`live-filters-${RUN}`, fixture, true);
|
||||
created = script.id;
|
||||
|
||||
await useFilterStore.getState().selectAccount(null);
|
||||
const s = useFilterStore.getState();
|
||||
expect(s.activeScriptId).toBe(script.id);
|
||||
expect(s.isOpaque).toBe(false);
|
||||
expect(s.rules.map((r) => r.activeFrom)).toEqual(['2026-11-02T07:00:00.000Z', '2026-12-01T00:00:00.000Z']);
|
||||
expect(s.vacationForward?.to).toBe('backup@example.org');
|
||||
expect(s.vacationAudience?.only).toBe('internal');
|
||||
|
||||
await useFilterStore.getState().saveFilters();
|
||||
const saved = (await getSieveScripts()).find((x) => x.id === script.id)!;
|
||||
const content = await getSieveScriptContent(saved.blobId);
|
||||
const parsed = parseScript(fixture);
|
||||
expect(content).toBe(generateScript(parsed.rules, parsed.vacation, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
includeVacation: parsed.includeVacation,
|
||||
vacationForward: parsed.vacationForward,
|
||||
vacationAudience: parsed.vacationAudience,
|
||||
extensions: getSieveCapabilities()?.sieveExtensions,
|
||||
}));
|
||||
expect(parseScript(content).vacationForward).toEqual(parsed.vacationForward);
|
||||
|
||||
const vacation = await readVacationFilters();
|
||||
expect(vacation.forward?.to).toBe('backup@example.org');
|
||||
expect(vacation.audience?.domains).toEqual(['example.org', 'example.com']);
|
||||
expect(vacation.forwardAvailable).toBe(true);
|
||||
expect(vacation.audienceAvailable).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
it('adds a rule made from a message to the top, as an exact address test', async () => {
|
||||
const mailboxes = await getMailboxes();
|
||||
const inbox = mailboxes.find((m) => m.role === 'inbox')!;
|
||||
const subject = { senders: [{ email: `sender-${RUN}@example.net` }], domain: null, listId: null };
|
||||
const rule = buildPresetRule({ kind: 'move_sender', mailbox: toRuleMailbox(mailboxes, inbox) }, subject, t, `quick-${RUN}`);
|
||||
expect(await updateAccountFilters((rules) => {
|
||||
const outcome = applyQuickRule(rules, rule);
|
||||
return outcome.kind === 'covered' ? null : outcome.rules;
|
||||
})).toBe(true);
|
||||
|
||||
const active = (await getSieveScripts()).find((x) => x.isActive)!;
|
||||
expect(active.id).toBe(created);
|
||||
const content = await getSieveScriptContent(active.blobId);
|
||||
expect(content).toContain(`if allof(address :is "From" "sender-${RUN}@example.net", not spamtest`);
|
||||
const back = parseScript(content);
|
||||
expect(back.rules[0].id).toBe(`quick-${RUN}`);
|
||||
// The rule went in without losing what the vacation card set.
|
||||
expect(back.vacationForward?.to).toBe('backup@example.org');
|
||||
}, 60_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const client = vi.hoisted(() => ({
|
||||
isConnected: true,
|
||||
username: 'me@example.org',
|
||||
serverUrl: 'https://mail.example.org',
|
||||
}));
|
||||
vi.mock('../jmap-client', () => ({ jmapClient: client }));
|
||||
|
||||
vi.mock('../sieve', () => ({
|
||||
createSieveScript: vi.fn(async () => ({ id: 'new', name: 'filters', blobId: 'b', isActive: true })),
|
||||
getSieveAccountId: vi.fn(() => 'own'),
|
||||
getSieveCapabilities: vi.fn(() => ({
|
||||
implementation: 't', maxSizeScript: 1e6, notificationMethods: [], externalLists: [],
|
||||
sieveExtensions: ['fileinto', 'include', 'envelope', 'copy', 'date', 'relational', 'spamtestplus', 'mailbox', 'mailboxid'],
|
||||
})),
|
||||
getSieveScriptContent: vi.fn(),
|
||||
getSieveScripts: vi.fn(),
|
||||
updateSieveScript: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import * as sieve from '../sieve';
|
||||
import { FiltersLoginChangedError, OpaqueFiltersError, updateAccountFilters } from '../sieve-rules';
|
||||
import { generateScript } from '../../lib/sieve/generator';
|
||||
import { parseScript } from '../../lib/sieve/parser';
|
||||
import type { FilterRule } from '../../lib/sieve/types';
|
||||
|
||||
const api = vi.mocked(sieve);
|
||||
|
||||
const rule = (id: string): FilterRule => ({
|
||||
id, name: id, enabled: true, matchType: 'all', stopProcessing: true,
|
||||
conditions: [{ field: 'from', comparator: 'address_is', value: `${id}@example.com` }],
|
||||
actions: [{ type: 'mark_read' }],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
client.username = 'me@example.org';
|
||||
});
|
||||
|
||||
describe('updateAccountFilters', () => {
|
||||
it('adds the rule to the script the server has and keeps its forwarding and audience', async () => {
|
||||
const stored = generateScript([rule('old')], undefined, {
|
||||
includeVacation: true,
|
||||
vacationForward: { enabled: true, to: 'backup@example.org', keepCopy: true },
|
||||
vacationAudience: { only: 'internal', domains: ['example.org'] },
|
||||
});
|
||||
api.getSieveScripts.mockResolvedValue([
|
||||
{ id: 's1', name: 'filters', blobId: 'b1', isActive: true },
|
||||
{ id: 'v', name: 'vacation', blobId: 'bv', isActive: false },
|
||||
]);
|
||||
api.getSieveScriptContent.mockResolvedValue(stored);
|
||||
|
||||
expect(await updateAccountFilters((rules) => [rule('new'), ...rules], 'own')).toBe(true);
|
||||
const [id, content, activate, accountId] = api.updateSieveScript.mock.calls[0];
|
||||
expect([id, activate, accountId]).toEqual(['s1', true, 'own']);
|
||||
const written = parseScript(content as string);
|
||||
expect(written.rules.map((r) => r.id)).toEqual(['new', 'old']);
|
||||
expect(written.includeVacation).toBe(true);
|
||||
expect(written.vacationForward?.to).toBe('backup@example.org');
|
||||
expect(written.vacationAudience?.only).toBe('internal');
|
||||
});
|
||||
|
||||
it('creates the filters script when there is none', async () => {
|
||||
api.getSieveScripts.mockResolvedValue([]);
|
||||
await updateAccountFilters(() => [rule('new')], 'own');
|
||||
expect(api.createSieveScript).toHaveBeenCalledWith('filters', expect.stringContaining('# Rule: new'), true, 'own');
|
||||
});
|
||||
|
||||
it('refuses a hand-edited script', async () => {
|
||||
api.getSieveScripts.mockResolvedValue([{ id: 's1', name: 'filters', blobId: 'b1', isActive: true }]);
|
||||
// Metadata from a version this build does not know reads as opaque.
|
||||
api.getSieveScriptContent.mockResolvedValue('/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */\nkeep;\n');
|
||||
await expect(updateAccountFilters(() => [rule('new')], 'own')).rejects.toBeInstanceOf(OpaqueFiltersError);
|
||||
expect(api.updateSieveScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes nothing when the rule changes nothing', async () => {
|
||||
api.getSieveScripts.mockResolvedValue([]);
|
||||
expect(await updateAccountFilters(() => null, 'own')).toBe(false);
|
||||
expect(api.createSieveScript).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The client serves one login; ids repeat across servers.
|
||||
it('writes nothing once another login took over', async () => {
|
||||
api.getSieveScripts.mockImplementation(async () => {
|
||||
client.username = 'other@example.net';
|
||||
return [];
|
||||
});
|
||||
await expect(updateAccountFilters(() => [rule('new')], 'own')).rejects.toBeInstanceOf(FiltersLoginChangedError);
|
||||
expect(api.createSieveScript).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+19
-16
@@ -3,6 +3,7 @@ import { JMAPSetError } from './jmap-result';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { ContactCard, ContactMedia, AddressBook } from './types';
|
||||
import { generateUUID } from '../lib/uuid';
|
||||
import { contactFromWire, contactToWire } from '../lib/contact-wire';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS];
|
||||
|
||||
@@ -42,14 +43,6 @@ function setError(err: SetError | undefined, fallback: string): JMAPSetError {
|
||||
return new JMAPSetError(err?.type ?? 'setError', setErrorMessage(err, fallback), err?.properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties an update never clears (webmail contactToWire). A null `id` fails
|
||||
* the whole update ("The id property is immutable.") and so does a null
|
||||
* `addressBookIds`; a null `uid` is refused by Stalwart 1.0 and on 0.16
|
||||
* silently deletes the card's UID.
|
||||
*/
|
||||
const NEVER_CLEARED_KEYS = ['id', 'uid', 'addressBookIds'] as const;
|
||||
|
||||
/**
|
||||
* Primary account for the contacts capability (RFC 8620 `primaryAccounts`),
|
||||
* falling back to the mail primary. Contacts can live in a different account
|
||||
@@ -180,7 +173,7 @@ export async function getContacts(ids: string[], accountId?: string): Promise<Co
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: ContactCard[] }>(res).list ?? [];
|
||||
all.push(...list);
|
||||
all.push(...list.map(contactFromWire));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
@@ -238,12 +231,15 @@ export async function createContact(
|
||||
): Promise<ContactCard> {
|
||||
const account = accountId || getContactsAccountId();
|
||||
const data = stripClientFields(contact);
|
||||
// The calendar, scheduling and SOURCE links and a vCard's flat address
|
||||
// fields as RFC 9553 has them: Stalwart refuses the whole card otherwise.
|
||||
const wire = contactToWire(data, 'create');
|
||||
const res = await jmapClient.request(
|
||||
[['ContactCard/set', {
|
||||
accountId: account,
|
||||
create: {
|
||||
'new-contact': {
|
||||
...data,
|
||||
...wire,
|
||||
// Stalwart stores the card without one if omitted (#644), which
|
||||
// breaks group membership and CardDAV round-trips.
|
||||
uid: data.uid || `urn:uuid:${generateUUID()}`,
|
||||
@@ -285,10 +281,7 @@ export async function updateContact(
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const account = accountId || getContactsAccountId();
|
||||
const patch = { ...stripClientFields(changes) } as Record<string, unknown>;
|
||||
for (const key of NEVER_CLEARED_KEYS) {
|
||||
if (patch[key] == null) delete patch[key];
|
||||
}
|
||||
const patch = contactToWire(stripClientFields(changes), 'update');
|
||||
const res = await jmapClient.request(
|
||||
[['ContactCard/set', { accountId: account, update: { [id]: patch } }, '0']],
|
||||
USING,
|
||||
@@ -398,10 +391,20 @@ export async function setDefaultAddressBook(id: string, accountId?: string): Pro
|
||||
methodResult(res);
|
||||
}
|
||||
|
||||
export async function deleteAddressBook(id: string, accountId?: string): Promise<void> {
|
||||
export async function deleteAddressBook(
|
||||
id: string,
|
||||
accountId?: string,
|
||||
options?: { removeContents?: boolean },
|
||||
): Promise<void> {
|
||||
const account = accountId || getContactsAccountId();
|
||||
// Without onDestroyRemoveContents a book that still holds cards is
|
||||
// refused with `addressBookHasContents` (RFC 9610 §2.3; Stalwart 1.0).
|
||||
const res = await jmapClient.request(
|
||||
[['AddressBook/set', { accountId: account, destroy: [id] }, '0']],
|
||||
[['AddressBook/set', {
|
||||
accountId: account,
|
||||
destroy: [id],
|
||||
...(options?.removeContents ? { onDestroyRemoveContents: true } : {}),
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
const result = methodResult<{ notDestroyed?: Record<string, SetError> }>(res);
|
||||
|
||||
@@ -98,15 +98,15 @@ export function parseRetryAfter(header: string | null): number {
|
||||
return 60_000;
|
||||
}
|
||||
|
||||
// Methods whose bodies must never be replayed automatically: a retried
|
||||
// EmailSubmission/set would send the mail twice.
|
||||
const READ_ONLY_METHOD = /\/(?:get|query|changes|queryChanges|parse)$|^Core\/echo$/;
|
||||
|
||||
// Whether a batch must not be sent again after the connection failed without
|
||||
// an answer. The server may already have acted on it, so only batches made of
|
||||
// read methods are replayed (webmail isReplaySafeRequest): a retried /set,
|
||||
// /copy, /import or submission would be done twice - one Send delivering two
|
||||
// messages, one move or delete applied on top of its own result.
|
||||
function hasNonIdempotentMethod(methodCalls: ReadonlyArray<JMAPMethodCall>): boolean {
|
||||
return methodCalls.some(
|
||||
([name]) =>
|
||||
name === 'EmailSubmission/set' ||
|
||||
name === 'Email/import' ||
|
||||
name === 'Blob/upload',
|
||||
);
|
||||
return methodCalls.length === 0 || methodCalls.some(([name]) => !READ_ONLY_METHOD.test(name));
|
||||
}
|
||||
|
||||
function fetchSessionDescribes(session: unknown): session is JMAPSession {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import {
|
||||
createSieveScript,
|
||||
getSieveAccountId,
|
||||
getSieveCapabilities,
|
||||
getSieveScriptContent,
|
||||
getSieveScripts,
|
||||
updateSieveScript,
|
||||
} from './sieve';
|
||||
import { parseScript, type ParseResult } from '../lib/sieve/parser';
|
||||
import { generateScript, VACATION_SCRIPT_NAME } from '../lib/sieve/generator';
|
||||
import type { FilterRule } from '../lib/sieve/types';
|
||||
import { servedLoginOf } from '../lib/draft-login';
|
||||
|
||||
// One account's filters script, read and written on its own (webmail
|
||||
// lib/filters/account-filters.ts). A rule made from a message goes through
|
||||
// here, not through the filter store: the store holds whichever account the
|
||||
// Filters settings show, and its copy may be stale.
|
||||
|
||||
/** The script was edited by hand and the builder cannot read it back. */
|
||||
export class OpaqueFiltersError extends Error {
|
||||
constructor() {
|
||||
super('The filters script was edited by hand');
|
||||
this.name = 'OpaqueFiltersError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Another login took over while the rule was being saved; nothing was written. */
|
||||
export class FiltersLoginChangedError extends Error {
|
||||
constructor() {
|
||||
super('The account changed while the rule was being saved');
|
||||
this.name = 'FiltersLoginChangedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the account's script from the server, let `modify` change its rules,
|
||||
* and write the result back as the active script, keeping the vacation
|
||||
* include, its forwarding and audience and the external rules. `modify`
|
||||
* returns null when there is nothing to write. A hand-edited script is
|
||||
* refused: it is never rewritten from a rule. Resolves to whether it wrote.
|
||||
*/
|
||||
export async function updateAccountFilters(
|
||||
modify: (rules: FilterRule[]) => FilterRule[] | null,
|
||||
accountId: string = getSieveAccountId(),
|
||||
): Promise<boolean> {
|
||||
// The client serves one login at a time; a switch while the script is read
|
||||
// would write these rules into the other login's account.
|
||||
const login = servedLoginOf(jmapClient);
|
||||
const capabilities = getSieveCapabilities(accountId);
|
||||
const all = await getSieveScripts(accountId);
|
||||
const scripts = all.filter((s) => s.name !== VACATION_SCRIPT_NAME);
|
||||
const vacationActive =
|
||||
all.some((s) => s.name === VACATION_SCRIPT_NAME && s.isActive) &&
|
||||
!!capabilities?.sieveExtensions?.includes('include');
|
||||
const script = scripts.find((s) => s.isActive) || scripts[0] || null;
|
||||
const parsed: ParseResult = script
|
||||
? parseScript(await getSieveScriptContent(script.blobId, accountId))
|
||||
: { rules: [], isOpaque: false, externalRequires: [] };
|
||||
if (parsed.isOpaque) throw new OpaqueFiltersError();
|
||||
|
||||
const rules = modify(parsed.rules);
|
||||
if (!rules) return false;
|
||||
const content = generateScript(rules, parsed.vacation, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
includeVacation: !!parsed.includeVacation || vacationActive,
|
||||
vacationForward: parsed.vacationForward,
|
||||
vacationAudience: parsed.vacationAudience,
|
||||
extensions: capabilities?.sieveExtensions,
|
||||
});
|
||||
if (servedLoginOf(jmapClient) !== login) throw new FiltersLoginChangedError();
|
||||
if (script) await updateSieveScript(script.id, content, true, accountId);
|
||||
else await createSieveScript('filters', content, true, accountId);
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user