From 81061c0d0ef2d545e7a09370c41c5d3da7332e5e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:44:42 +0100 Subject: [PATCH] test: check contacts and filters live on Stalwart 0.16 and 1.0 --- .../__tests__/live-stalwart-contacts.test.ts | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 src/api/__tests__/live-stalwart-contacts.test.ts diff --git a/src/api/__tests__/live-stalwart-contacts.test.ts b/src/api/__tests__/live-stalwart-contacts.test.ts new file mode 100644 index 00000000..792275f1 --- /dev/null +++ b/src/api/__tests__/live-stalwart-contacts.test.ts @@ -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) => + (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, 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, 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, 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 = []; + 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); + }); +});