mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
Merge branch 'fx/contacts' into feat/stalwart-1.0
This commit is contained in:
+7
-1
@@ -194,6 +194,10 @@
|
||||
"timezone_value": "Timezone: {zone}",
|
||||
"updated_on": "Updated {date}"
|
||||
},
|
||||
"directory": {
|
||||
"restricted_hint": "Your mail server lists only the accounts you already work with, such as your groups and the people who share with you.",
|
||||
"restricted_queries_off": "Your mail server lists only the accounts you already work with, such as your groups and the people who share with you. An administrator can turn on directory queries to show everyone."
|
||||
},
|
||||
"empty_state_hint_mobile": "Tap + to add one",
|
||||
"export": {
|
||||
"failed": "Export failed"
|
||||
@@ -256,7 +260,9 @@
|
||||
"into_address_book": "Import into address book",
|
||||
"into_named": "Import into {name}",
|
||||
"no_address_book": "Create an address book before importing contacts.",
|
||||
"picker_failed": "Could not open the file picker"
|
||||
"picker_failed": "Could not open the file picker",
|
||||
"unnamed": "Unnamed contact",
|
||||
"updated_count": "{count, plural, one {1 contact was already there and has been updated.} other {# contacts were already there and have been updated.}}"
|
||||
},
|
||||
"no_tags_hint": "No tags yet. Type one above.",
|
||||
"picker": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const TRANSLATED_FILES: Record<string, string[]> = {
|
||||
'components/contacts/TagAssignSheet.tsx': [],
|
||||
'components/contacts/ContactListRow.tsx': [],
|
||||
'components/contacts/FieldBlock.tsx': [],
|
||||
'components/contacts/DirectoryBrowser.tsx': [],
|
||||
};
|
||||
|
||||
describe('translated contacts screens', () => {
|
||||
|
||||
@@ -47,8 +47,9 @@ const TRANSLATED_PANES: Record<string, string[]> = {
|
||||
'settings/device-sync/DeviceSyncAccountRow.tsx': [],
|
||||
'settings/device-sync/DeviceSyncCollectionsSheet.tsx': [],
|
||||
'settings/device-sync/status.ts': [],
|
||||
'filters/FilterRuleModal.tsx': [],
|
||||
'filters/FilterRuleModal.tsx': ['YYYY-MM-DD HH:MM'],
|
||||
'filters/SieveEditorSheet.tsx': [],
|
||||
'filters/QuickRuleSheet.tsx': [],
|
||||
};
|
||||
|
||||
describe('translated settings panes', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Upload, X, Check, FileText, AlertTriangle, BookUser } from '../icons';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
import { parseVCard, detectDuplicates } from '../../lib/vcard';
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { useContactsStore, type ContactImportResult } from '../../stores/contacts-store';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -56,7 +56,7 @@ export default function ContactImportSheet({
|
||||
const [duplicates, setDuplicates] = React.useState<Map<number, string>>(new Map());
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null);
|
||||
const [result, setResult] = React.useState<ContactImportResult | null>(null);
|
||||
|
||||
const slideY = React.useRef(new Animated.Value(900)).current;
|
||||
// Centred on a wide window, clear of side insets in landscape.
|
||||
@@ -156,7 +156,7 @@ export default function ContactImportSheet({
|
||||
try {
|
||||
const outcome = await importContacts(toImport, targetBookId);
|
||||
setResult(outcome);
|
||||
onImported?.(outcome.imported);
|
||||
onImported?.(outcome.imported + outcome.updated);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('contacts.import.failed', 'Import failed'));
|
||||
} finally {
|
||||
@@ -192,6 +192,15 @@ export default function ContactImportSheet({
|
||||
<Text style={styles.successText}>
|
||||
{t('contacts.import.success', '{count, plural, one {1 contact imported} other {# contacts imported}}', { count: result.imported })}
|
||||
</Text>
|
||||
{result.updated > 0 && (
|
||||
<Text style={styles.successText}>
|
||||
{t(
|
||||
'contacts.import.updated_count',
|
||||
'{count, plural, one {1 contact was already there and has been updated.} other {# contacts were already there and have been updated.}}',
|
||||
{ count: result.updated },
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{result.failed > 0 && (
|
||||
<Text style={[styles.successText, { color: c.error }]}>
|
||||
{t(
|
||||
@@ -201,6 +210,18 @@ export default function ContactImportSheet({
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{result.failures.length > 0 && (
|
||||
<ScrollView style={styles.failureList} contentContainerStyle={{ gap: spacing.xs }}>
|
||||
{result.failures.map((f, i) => (
|
||||
<Text key={i} style={styles.failureText}>
|
||||
<Text style={styles.failureName}>
|
||||
{f.name || t('contacts.import.unnamed', 'Unnamed contact')}
|
||||
</Text>
|
||||
{': '}{f.reason}
|
||||
</Text>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onPress={onClose}>{t('common.done', 'Done')}</Button>
|
||||
</View>
|
||||
) : parsed.length === 0 ? (
|
||||
@@ -352,7 +373,10 @@ function makeStyles(c: ThemePalette) {
|
||||
width: 56, height: 56, borderRadius: radius.full,
|
||||
backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center',
|
||||
},
|
||||
successText: { ...typography.bodyMedium, color: c.text },
|
||||
successText: { ...typography.bodyMedium, color: c.text, textAlign: 'center' },
|
||||
failureList: { maxHeight: 200, alignSelf: 'stretch' },
|
||||
failureText: { ...typography.caption, color: c.mutedForeground },
|
||||
failureName: { ...typography.caption, color: c.text, fontWeight: '600' },
|
||||
targetRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, FlatList, ActivityIndicator, RefreshControl } from 'react-native';
|
||||
import { AlertCircle, BookUser, Lock, Users, MapPin } from '../icons';
|
||||
import SenderAvatar from '../SenderAvatar';
|
||||
import { AddressActionSheet } from '../email/AddressActionSheet';
|
||||
import type { EmailAddress } from '../../api/types';
|
||||
import { filterDirectory, type DirectoryEntry } from '../../lib/contact-directory';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
interface DirectoryBrowserProps {
|
||||
/** The Contacts search, applied to the directory while it shows. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The company directory (#1253, webmail components/contacts/directory-browser
|
||||
* .tsx): the people and groups the server lists for this account, read-only.
|
||||
* A tap offers to write to the entry or add it to the contacts.
|
||||
*/
|
||||
export function DirectoryBrowser({ query }: DirectoryBrowserProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const entries = useContactsStore((s) => s.directory);
|
||||
const status = useContactsStore((s) => s.directoryStatus);
|
||||
const queriesOff = useContactsStore((s) => s.directoryQueriesOff);
|
||||
const fetchDirectory = useContactsStore((s) => s.fetchDirectory);
|
||||
const [sheet, setSheet] = React.useState<EmailAddress | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status === 'idle') void fetchDirectory();
|
||||
}, [status, fetchDirectory]);
|
||||
|
||||
const filtered = React.useMemo(() => filterDirectory(entries, query, locale), [entries, query, locale]);
|
||||
const loading = status === 'loading' || status === 'idle';
|
||||
|
||||
const typeLabel = (entry: DirectoryEntry): string | null => {
|
||||
if (!entry.type || entry.type === 'individual') return null;
|
||||
return t(`contacts.directory.type.${entry.type}`, entry.type);
|
||||
};
|
||||
|
||||
let placeholder: React.ReactNode = null;
|
||||
if (entries.length === 0) {
|
||||
if (loading) {
|
||||
placeholder = <ActivityIndicator color={c.primary} />;
|
||||
} else if (status === 'unavailable') {
|
||||
placeholder = (
|
||||
<StatePanel styles={styles} icon={<BookUser size={40} color={c.surfaceActive} />}
|
||||
title={t('contacts.directory.unavailable_title', 'No directory')}
|
||||
hint={t('contacts.directory.unavailable_hint', "Your mail server doesn't offer a directory of accounts.")} />
|
||||
);
|
||||
} else if (status === 'restricted') {
|
||||
placeholder = (
|
||||
<StatePanel styles={styles} icon={<Lock size={40} color={c.surfaceActive} />}
|
||||
title={t('contacts.directory.forbidden_title', 'Directory not available')}
|
||||
hint={t('contacts.directory.forbidden_hint', "Your mail server doesn't allow this account to browse the directory. Ask your administrator.")} />
|
||||
);
|
||||
} else if (status === 'error') {
|
||||
placeholder = (
|
||||
<StatePanel styles={styles} icon={<AlertCircle size={40} color={c.surfaceActive} />}
|
||||
title={t('contacts.directory.error_title', "Couldn't load the directory")}
|
||||
hint={t('contacts.directory.error_hint', 'Check your connection and try again.')} />
|
||||
);
|
||||
} else {
|
||||
placeholder = (
|
||||
<StatePanel styles={styles} icon={<BookUser size={40} color={c.surfaceActive} />}
|
||||
title={t('contacts.directory.empty_title', 'The directory is empty')}
|
||||
hint={t('contacts.directory.empty_hint', 'Your mail server lists no accounts with an email address.')} />
|
||||
);
|
||||
}
|
||||
} else if (filtered.length === 0) {
|
||||
placeholder = (
|
||||
<StatePanel styles={styles} icon={<BookUser size={40} color={c.surfaceActive} />}
|
||||
title={t('contacts.directory.no_match', 'No one in the directory matches your search')} />
|
||||
);
|
||||
}
|
||||
|
||||
// Stalwart 1.0 keeps directory queries off unless the administrator turns
|
||||
// them on, and then lists only the principals the user already knows.
|
||||
const restrictedNote = status === 'restricted' && entries.length > 0
|
||||
? queriesOff
|
||||
? t('contacts.directory.restricted_queries_off', 'Your mail server lists only the accounts you already work with, such as your groups and the people who share with you. An administrator can turn on directory queries to show everyone.')
|
||||
: t('contacts.directory.restricted_hint', 'Your mail server lists only the accounts you already work with, such as your groups and the people who share with you.')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<FlatList
|
||||
data={filtered}
|
||||
keyExtractor={(e) => e.email.toLowerCase()}
|
||||
ListHeaderComponent={restrictedNote ? (
|
||||
<View style={styles.note}>
|
||||
<Lock size={14} color={c.mutedForeground} />
|
||||
<Text style={styles.noteText}>{restrictedNote}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
renderItem={({ item }) => {
|
||||
const kind = typeLabel(item);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => setSheet({ name: item.name !== item.email ? item.name : undefined, email: item.email })}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
{item.type === 'group' || item.type === 'location' ? (
|
||||
<View style={styles.iconAvatar}>
|
||||
{item.type === 'group'
|
||||
? <Users size={18} color={c.primary} />
|
||||
: <MapPin size={18} color={c.primary} />}
|
||||
</View>
|
||||
) : (
|
||||
<SenderAvatar name={item.name} email={item.email} size={componentSizes.avatarMd} photo={null} />
|
||||
)}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>{item.name}</Text>
|
||||
<Text style={styles.subtitle} numberOfLines={1}>
|
||||
{kind ? `${item.email} · ${kind}` : item.email}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
ListEmptyComponent={<View style={styles.empty}>{placeholder}</View>}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={status === 'loading' && entries.length > 0} onRefresh={() => void fetchDirectory()} tintColor={c.primary} />
|
||||
}
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
/>
|
||||
<AddressActionSheet address={sheet} onClose={() => setSheet(null)} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StatePanel({ styles, icon, title, hint }: {
|
||||
styles: ReturnType<typeof makeStyles>;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{icon}
|
||||
<Text style={styles.emptyTitle}>{title}</Text>
|
||||
{!!hint && <Text style={styles.emptyHint}>{hint}</Text>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.md,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
iconAvatar: {
|
||||
width: componentSizes.avatarMd,
|
||||
height: componentSizes.avatarMd,
|
||||
borderRadius: componentSizes.avatarMd / 2,
|
||||
backgroundColor: c.primaryBg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
info: { flex: 1, minWidth: 0 },
|
||||
name: { ...typography.bodyMedium, color: c.text },
|
||||
subtitle: { ...typography.caption, color: c.textSecondary, marginTop: 1 },
|
||||
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.border, marginLeft: spacing.lg },
|
||||
note: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
margin: spacing.lg,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.muted,
|
||||
},
|
||||
noteText: { ...typography.caption, color: c.mutedForeground, flex: 1 },
|
||||
empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.xl, gap: spacing.md },
|
||||
emptyTitle: { ...typography.bodyMedium, color: c.text, textAlign: 'center' },
|
||||
emptyHint: { ...typography.caption, color: c.mutedForeground, textAlign: 'center' },
|
||||
});
|
||||
}
|
||||
@@ -14,9 +14,10 @@ import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useKeywordsStore } from '../../stores/keywords-store';
|
||||
import { generateUUID } from '../../lib/uuid';
|
||||
import {
|
||||
comparatorsFor,
|
||||
inputStringToValue,
|
||||
isConditionValueEmpty,
|
||||
isHasAnyCondition,
|
||||
takesNoValue,
|
||||
valueToInputString,
|
||||
} from '../../lib/sieve/condition-value';
|
||||
import {
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
withMailboxTarget,
|
||||
} from '../../lib/sieve/rule-actions';
|
||||
import { isValidSieveHeaderName } from '../../lib/sieve/generator';
|
||||
import { hasPeriod } from '../../lib/sieve/period';
|
||||
import { periodError, periodFromInput, periodToInput } from '../../lib/sieve/rule-period';
|
||||
import type { Mailbox } from '../../api/types';
|
||||
import type {
|
||||
FilterRule,
|
||||
@@ -41,18 +44,9 @@ import type {
|
||||
import { SafeAreaModal } from '../SafeAreaModal';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment'];
|
||||
const TEXT_COMPARATORS: FilterComparator[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'];
|
||||
const SIZE_COMPARATORS: FilterComparator[] = ['greater_than', 'less_than'];
|
||||
const ATTACHMENT_COMPARATORS: FilterComparator[] = ['has_any', 'has_type'];
|
||||
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all'];
|
||||
|
||||
function comparatorsFor(field: FilterConditionField): FilterComparator[] {
|
||||
if (field === 'size') return SIZE_COMPARATORS;
|
||||
if (field === 'attachment') return ATTACHMENT_COMPARATORS;
|
||||
return TEXT_COMPARATORS;
|
||||
}
|
||||
|
||||
const isHasAny = isHasAnyCondition;
|
||||
const isHasAny = takesNoValue;
|
||||
|
||||
function seedConditions(rule?: FilterRule): FilterCondition[] {
|
||||
return rule?.conditions.length ? rule.conditions.map((cond) => ({ ...cond })) : [makeEmptyCondition()];
|
||||
@@ -71,18 +65,26 @@ function makeEmptyAction(): FilterAction {
|
||||
|
||||
interface FilterRuleModalProps {
|
||||
visible: boolean;
|
||||
/** The rule being edited. */
|
||||
rule?: FilterRule;
|
||||
/** A new rule that starts from these values ("Create rule..." on a message). */
|
||||
initialRule?: FilterRule;
|
||||
mailboxes: Mailbox[];
|
||||
/** The server can confine a rule to a period (Sieve "date" and "relational"). */
|
||||
periodsSupported?: boolean;
|
||||
onSave: (rule: FilterRule) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: FilterRuleModalProps) {
|
||||
export function FilterRuleModal({
|
||||
visible, rule: editedRule, initialRule, mailboxes, periodsSupported = false, onSave, onClose,
|
||||
}: FilterRuleModalProps) {
|
||||
const c = useColors();
|
||||
const styles = useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const keywords = useKeywordsStore((s) => s.keywords);
|
||||
const isEdit = !!rule;
|
||||
const isEdit = !!editedRule;
|
||||
const rule = editedRule ?? initialRule;
|
||||
|
||||
const [name, setName] = useState(rule?.name || '');
|
||||
const [matchType, setMatchType] = useState<'all' | 'any'>(rule?.matchType || 'all');
|
||||
@@ -90,6 +92,10 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
||||
const [actions, setActions] = useState<FilterAction[]>(() => seedActions(rule));
|
||||
const [stopProcessing, setStopProcessing] = useState(rule?.stopProcessing ?? false);
|
||||
const [includeSpam, setIncludeSpam] = useState(rule?.includeSpam ?? false);
|
||||
const hadPeriod = !!rule && hasPeriod(rule);
|
||||
const [periodOn, setPeriodOn] = useState(hadPeriod);
|
||||
const [periodStart, setPeriodStart] = useState(periodToInput(rule?.activeFrom));
|
||||
const [periodEnd, setPeriodEnd] = useState(periodToInput(rule?.activeUntil));
|
||||
|
||||
// The Modal stays mounted between opens, so re-seed every field whenever it
|
||||
// is (re)opened for a different rule - otherwise "Add Rule" after editing
|
||||
@@ -103,8 +109,15 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
||||
setActions(seedActions(rule));
|
||||
setStopProcessing(rule?.stopProcessing ?? false);
|
||||
setIncludeSpam(rule?.includeSpam ?? false);
|
||||
setPeriodOn(!!rule && hasPeriod(rule));
|
||||
setPeriodStart(periodToInput(rule?.activeFrom));
|
||||
setPeriodEnd(periodToInput(rule?.activeUntil));
|
||||
}, [visible, rule]);
|
||||
|
||||
const activeFrom = periodOn ? periodFromInput(periodStart, rule?.activeFrom) : undefined;
|
||||
const activeUntil = periodOn ? periodFromInput(periodEnd, rule?.activeUntil) : undefined;
|
||||
const periodProblem = periodOn ? periodError(activeFrom, activeUntil) : null;
|
||||
|
||||
const mailboxTargets = useMemo(() => buildMailboxTargets(mailboxes), [mailboxes]);
|
||||
|
||||
const fieldOptions = useMemo(
|
||||
@@ -225,6 +238,14 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
||||
Alert.alert(t('settings.filters.validation_empty_actions', 'At least one action is required'));
|
||||
return;
|
||||
}
|
||||
if (periodProblem) {
|
||||
Alert.alert(periodProblem === 'invalid'
|
||||
? t('settings.filters.validation_period_invalid', 'Enter a valid date with a time for the period, or leave the field empty')
|
||||
: periodProblem === 'empty'
|
||||
? t('settings.filters.validation_period_empty', 'Enter a start or end date for the period')
|
||||
: t('settings.filters.validation_period_order', 'End date must be after start date'));
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
id: rule?.id || generateUUID(),
|
||||
name: trimmedName,
|
||||
@@ -236,8 +257,15 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
||||
// Only folder moves are kept out of Junk, so the opt-in only means
|
||||
// something (and is only stored) while the rule has one.
|
||||
...(includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? { includeSpam: true } : {}),
|
||||
// Set to undefined when off: the settings page merges the result into
|
||||
// the stored rule, and a field left out would keep its old value there.
|
||||
activeFrom: activeFrom ?? undefined,
|
||||
activeUntil: activeUntil ?? undefined,
|
||||
});
|
||||
}, [name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets]);
|
||||
}, [
|
||||
name, conditions, actions, matchType, stopProcessing, includeSpam, rule, onSave, t, mailboxTargets,
|
||||
periodProblem, activeFrom, activeUntil,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
|
||||
@@ -332,14 +360,16 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={condition.comparator}
|
||||
onChange={(v) => updateCondition(index, { comparator: v as FilterComparator })}
|
||||
options={comparatorOptions(condition.field)}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
{condition.field !== 'all' && (
|
||||
<Select
|
||||
value={condition.comparator}
|
||||
onChange={(v) => updateCondition(index, { comparator: v as FilterComparator })}
|
||||
options={comparatorOptions(condition.field)}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* has_any takes no value ("an attachment is present"). */}
|
||||
{/* has_any and "all messages" take no value. */}
|
||||
{!isHasAny(condition) && (
|
||||
<Input
|
||||
value={valueToInputString(condition.value)}
|
||||
@@ -493,6 +523,48 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Offered where the server supports it; a rule that already has a
|
||||
period always shows it, so it can be removed. */}
|
||||
{(periodsSupported || hadPeriod) && (
|
||||
<View style={{ gap: spacing.sm }}>
|
||||
<View style={styles.stopRow}>
|
||||
<Text style={styles.stopLabel}>
|
||||
{t('settings.filters.period_toggle', 'Only active during a period')}
|
||||
</Text>
|
||||
<ToggleSwitch
|
||||
checked={periodOn}
|
||||
onChange={setPeriodOn}
|
||||
accessibilityLabel={t('settings.filters.period_toggle', 'Only active during a period')}
|
||||
/>
|
||||
</View>
|
||||
{periodOn && (
|
||||
<>
|
||||
<Text style={styles.optionLabel}>{t('settings.filters.period_start', 'Start date')}</Text>
|
||||
<Input
|
||||
value={periodStart}
|
||||
onChangeText={setPeriodStart}
|
||||
placeholder="YYYY-MM-DD HH:MM"
|
||||
accessibilityLabel={t('settings.filters.period_start', 'Start date')}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={styles.optionLabel}>{t('settings.filters.period_end', 'End date')}</Text>
|
||||
<Input
|
||||
value={periodEnd}
|
||||
onChangeText={setPeriodEnd}
|
||||
placeholder="YYYY-MM-DD HH:MM"
|
||||
accessibilityLabel={t('settings.filters.period_end', 'End date')}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={styles.hint}>
|
||||
{t('settings.filters.period_hint', 'Applies to messages that arrive during this period. Leave a field empty to keep that end open.')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
@@ -572,6 +644,7 @@ function makeStyles(c: ThemePalette) {
|
||||
gap: spacing.md,
|
||||
},
|
||||
optionLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
hint: { ...typography.caption, color: c.mutedForeground },
|
||||
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import React from 'react';
|
||||
import { ArrowRight, Ban, MailOpen, Plus, Tag } from '../icons';
|
||||
import { ActionSheet, type ActionSheetItem } from '../email/ActionSheet';
|
||||
import { MoveSheet } from '../MoveSheet';
|
||||
import { FilterRuleModal } from './FilterRuleModal';
|
||||
import type { Email, Mailbox } from '../../api/types';
|
||||
import { getSieveAccountId, getSieveCapabilities } from '../../api/sieve';
|
||||
import { OpaqueFiltersError, updateAccountFilters } from '../../api/sieve-rules';
|
||||
import { supportsPeriods } from '../../lib/sieve/period';
|
||||
import {
|
||||
applyQuickRule,
|
||||
buildPrefillRule,
|
||||
buildPresetRule,
|
||||
findJunkMailbox,
|
||||
insertRuleAtTop,
|
||||
normalizeAddress,
|
||||
quickRuleSubject,
|
||||
ruleTargetMailboxes,
|
||||
toRuleMailbox,
|
||||
type QuickRuleOutcome,
|
||||
type QuickRulePreset,
|
||||
} from '../../lib/sieve/quick-rules';
|
||||
import type { FilterRule } from '../../lib/sieve/types';
|
||||
import { generateUUID } from '../../lib/uuid';
|
||||
import { useFilterStore } from '../../stores/filter-store';
|
||||
import { useKeywordsStore } from '../../stores/keywords-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { toast } from '../../stores/toast-store';
|
||||
import { useColors } from '../../theme/colors';
|
||||
|
||||
type Step =
|
||||
| { kind: 'menu' }
|
||||
| { kind: 'folder'; preset: 'move_sender' | 'move_domain' | 'move_list' }
|
||||
| { kind: 'tag' }
|
||||
| { kind: 'editor'; rule: FilterRule };
|
||||
|
||||
interface QuickRuleSheetProps {
|
||||
visible: boolean;
|
||||
/** The message the rule is made from; it belongs to the user's own account. */
|
||||
email: Email | null;
|
||||
/** The own account's folders. */
|
||||
mailboxes: Mailbox[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Rules" on a message (webmail components/email/rules-menu.tsx): one tap
|
||||
* makes a rule from its sender, the sender's domain or its List-Id, or opens
|
||||
* the rule editor prefilled. The rule goes to the top of the account's
|
||||
* filters, or into an existing rule that does the same for other senders.
|
||||
*/
|
||||
export function QuickRuleSheet({ visible, email, mailboxes, onClose }: QuickRuleSheetProps) {
|
||||
const c = useColors();
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const identities = useSettingsStore((s) => s.identities);
|
||||
const keywords = useKeywordsStore((s) => s.keywords);
|
||||
const [step, setStep] = React.useState<Step>({ kind: 'menu' });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) setStep({ kind: 'menu' });
|
||||
}, [visible]);
|
||||
|
||||
const ownAddresses = React.useMemo(
|
||||
() => new Set(identities.map((i) => normalizeAddress(i.email))),
|
||||
[identities],
|
||||
);
|
||||
const subject = React.useMemo(
|
||||
() => quickRuleSubject(email ? [email] : [], ownAddresses),
|
||||
[email, ownAddresses],
|
||||
);
|
||||
const targets = React.useMemo(() => ruleTargetMailboxes(mailboxes), [mailboxes]);
|
||||
const junk = React.useMemo(() => findJunkMailbox(mailboxes), [mailboxes]);
|
||||
const periodsSupported = supportsPeriods(getSieveCapabilities()?.sieveExtensions);
|
||||
|
||||
const [sender] = subject.senders;
|
||||
const senderLabel = subject.senders.length === 1
|
||||
? (sender.name || sender.email)
|
||||
: t('context_menu.rules.senders_count', '{count, plural, one {# sender} other {# senders}}', { count: subject.senders.length });
|
||||
|
||||
const finish = () => {
|
||||
setStep({ kind: 'menu' });
|
||||
onClose();
|
||||
};
|
||||
|
||||
/** Write `modify`'s rules into the own account's script and say how it went. */
|
||||
const write = async (modify: (rules: FilterRule[]) => FilterRule[] | null) => {
|
||||
const accountId = getSieveAccountId();
|
||||
try {
|
||||
await updateAccountFilters(modify, accountId);
|
||||
} catch (err) {
|
||||
if (err instanceof OpaqueFiltersError) {
|
||||
toast.error(t('context_menu.rules.opaque_hint', 'Your filters were edited by hand. Open Filters settings'));
|
||||
} else {
|
||||
toast.error(t('notifications.filters_save_failed', 'Failed to save filters'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// The Filters settings show what was written when they show this account.
|
||||
const store = useFilterStore.getState();
|
||||
if (store.selectedAccountId === accountId) void store.fetchFilters(accountId);
|
||||
return true;
|
||||
};
|
||||
|
||||
const savePreset = async (preset: QuickRulePreset) => {
|
||||
finish();
|
||||
const candidate = buildPresetRule(preset, subject, t, generateUUID());
|
||||
let outcome: QuickRuleOutcome | null = null;
|
||||
const ok = await write((rules) => {
|
||||
outcome = applyQuickRule(rules, candidate);
|
||||
return outcome.kind === 'covered' ? null : outcome.rules;
|
||||
});
|
||||
if (!ok || !outcome) return;
|
||||
const result = outcome as QuickRuleOutcome;
|
||||
if (result.kind === 'covered') {
|
||||
toast.info(t('notifications.rule_already_covered', 'Already covered by rule “{name}”', { name: result.rule.name }));
|
||||
} else if (result.kind === 'merged') {
|
||||
toast.success(t('notifications.rule_merged', 'Added to rule “{name}”', { name: result.rule.name }));
|
||||
} else {
|
||||
toast.success(t('notifications.rule_created', 'Rule created'), result.rule.name);
|
||||
}
|
||||
};
|
||||
|
||||
const saveEditorRule = async (rule: FilterRule) => {
|
||||
finish();
|
||||
// As built in the editor: at the top, never merged into another rule.
|
||||
if (await write((rules) => insertRuleAtTop(rules, rule))) {
|
||||
toast.success(t('notifications.rule_created', 'Rule created'), rule.name);
|
||||
}
|
||||
};
|
||||
|
||||
const items: ActionSheetItem[] = [];
|
||||
if (subject.senders.length > 0) {
|
||||
items.push({
|
||||
key: 'move_sender',
|
||||
label: t('context_menu.rules.move_from', 'Always move messages from {sender}', { sender: senderLabel }),
|
||||
icon: <ArrowRight size={18} color={c.textSecondary} />,
|
||||
onPress: () => setStep({ kind: 'folder', preset: 'move_sender' }),
|
||||
});
|
||||
}
|
||||
if (subject.domain) {
|
||||
items.push({
|
||||
key: 'move_domain',
|
||||
label: t('context_menu.rules.move_from_domain', 'Always move messages from @{domain}', { domain: subject.domain }),
|
||||
icon: <ArrowRight size={18} color={c.textSecondary} />,
|
||||
onPress: () => setStep({ kind: 'folder', preset: 'move_domain' }),
|
||||
});
|
||||
}
|
||||
if (subject.listId) {
|
||||
items.push({
|
||||
key: 'move_list',
|
||||
label: t('context_menu.rules.move_from_list', 'Always move messages from this list'),
|
||||
icon: <ArrowRight size={18} color={c.textSecondary} />,
|
||||
onPress: () => setStep({ kind: 'folder', preset: 'move_list' }),
|
||||
});
|
||||
}
|
||||
if (subject.senders.length > 0) {
|
||||
items.push({
|
||||
key: 'mark_read',
|
||||
label: t('context_menu.rules.mark_read_from', 'Always mark as read from {sender}', { sender: senderLabel }),
|
||||
icon: <MailOpen size={18} color={c.textSecondary} />,
|
||||
onPress: () => void savePreset({ kind: 'mark_read' }),
|
||||
});
|
||||
if (keywords.length > 0) {
|
||||
items.push({
|
||||
key: 'tag',
|
||||
label: t('context_menu.rules.tag_with', 'Always tag with'),
|
||||
icon: <Tag size={18} color={c.textSecondary} />,
|
||||
onPress: () => setStep({ kind: 'tag' }),
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
key: 'block',
|
||||
label: junk
|
||||
? t('context_menu.rules.block', '{count, plural, one {Block sender} other {Block # senders}}', { count: subject.senders.length })
|
||||
: t('context_menu.rules.no_junk', 'This account has no Junk folder'),
|
||||
icon: <Ban size={18} color={junk ? c.error : c.textMuted} />,
|
||||
destructive: !!junk,
|
||||
disabled: !junk,
|
||||
onPress: () => { if (junk) void savePreset({ kind: 'block', junk: toRuleMailbox(mailboxes, junk) }); },
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
key: 'create',
|
||||
label: t('context_menu.rules.create', 'Create rule…'),
|
||||
icon: <Plus size={18} color={c.textSecondary} />,
|
||||
onPress: () => setStep({ kind: 'editor', rule: buildPrefillRule(subject, t, generateUUID()) }),
|
||||
});
|
||||
|
||||
const tagItems: ActionSheetItem[] = keywords.map((kw) => ({
|
||||
key: kw.id,
|
||||
label: kw.label,
|
||||
icon: <Tag size={18} color={c.textSecondary} />,
|
||||
onPress: () => void savePreset({ kind: 'tag', tagId: kw.id, tagName: kw.label }),
|
||||
}));
|
||||
|
||||
const pickFolder = (id: string) => {
|
||||
if (step.kind !== 'folder') return;
|
||||
const mailbox = mailboxes.find((m) => m.id === id);
|
||||
if (!mailbox) return;
|
||||
void savePreset({ kind: step.preset, mailbox: toRuleMailbox(mailboxes, mailbox) });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionSheet
|
||||
visible={visible && step.kind === 'menu'}
|
||||
title={t('context_menu.rules.title', 'Rules')}
|
||||
subtitle={subject.senders.map((s) => s.email).join(', ') || undefined}
|
||||
items={items}
|
||||
onClose={finish}
|
||||
/>
|
||||
<MoveSheet
|
||||
visible={visible && step.kind === 'folder'}
|
||||
onClose={() => setStep({ kind: 'menu' })}
|
||||
mailboxes={targets}
|
||||
currentMailboxId={null}
|
||||
onPick={pickFolder}
|
||||
/>
|
||||
<ActionSheet
|
||||
visible={visible && step.kind === 'tag'}
|
||||
title={t('context_menu.rules.tag_with', 'Always tag with')}
|
||||
items={tagItems}
|
||||
onClose={() => setStep({ kind: 'menu' })}
|
||||
/>
|
||||
<FilterRuleModal
|
||||
visible={visible && step.kind === 'editor'}
|
||||
initialRule={step.kind === 'editor' ? step.rule : undefined}
|
||||
mailboxes={mailboxes}
|
||||
periodsSupported={periodsSupported}
|
||||
onSave={(rule) => void saveEditorRule(rule)}
|
||||
onClose={finish}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { FilterRuleModal } from '../filters/FilterRuleModal';
|
||||
import { SieveEditorSheet } from '../filters/SieveEditorSheet';
|
||||
import type { FilterRule } from '../../lib/sieve/types';
|
||||
import { formatConditionValue, summarizeRule } from '../../lib/sieve/condition-value';
|
||||
import { periodStatus, supportsPeriods } from '../../lib/sieve/period';
|
||||
import Badge from '../Badge';
|
||||
|
||||
type Translate = (key: string, fallback?: string) => string;
|
||||
@@ -29,6 +30,18 @@ function isReadonlyRule(r: FilterRule): boolean {
|
||||
return r.origin === 'external' || r.origin === 'opaque';
|
||||
}
|
||||
|
||||
/** Whether a rule with a period is waiting for it, in it, or past it. */
|
||||
function PeriodBadge({ rule, t }: { rule: FilterRule; t: Translate }) {
|
||||
const status = periodStatus(rule);
|
||||
if (!status) return null;
|
||||
const label = status === 'scheduled'
|
||||
? t('settings.filters.period_status_scheduled', 'scheduled')
|
||||
: status === 'active'
|
||||
? t('settings.filters.period_status_active', 'active')
|
||||
: t('settings.filters.period_status_expired', 'expired');
|
||||
return <Badge>{label}</Badge>;
|
||||
}
|
||||
|
||||
// Expanded "IF ... / THEN ..." view (webmail VisualRuleSummary, 1.4.6): every
|
||||
// condition and action as a chip, with the match-type hint after the IF row.
|
||||
function VisualRuleSummary({ rule, t, c }: { rule: FilterRule; t: Translate; c: ThemePalette }) {
|
||||
@@ -147,6 +160,7 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
|
||||
selectAccount, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule,
|
||||
setOpaqueScript, resetToVisualBuilder, validateScript,
|
||||
} = useFilterStore();
|
||||
const periodsSupported = useFilterStore((s) => supportsPeriods(s.sieveCapabilities?.sieveExtensions));
|
||||
|
||||
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
|
||||
const [showRuleModal, setShowRuleModal] = useState(false);
|
||||
@@ -397,7 +411,10 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
onPress={() => { setEditingRule(rule); setShowRuleModal(true); }}
|
||||
>
|
||||
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
|
||||
<View style={styles.ruleNameRow}>
|
||||
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
|
||||
<PeriodBadge rule={rule} t={t} />
|
||||
</View>
|
||||
{expandedView ? (
|
||||
<VisualRuleSummary rule={rule} t={t} c={c} />
|
||||
) : (
|
||||
@@ -487,6 +504,7 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
|
||||
visible={showRuleModal}
|
||||
rule={editingRule}
|
||||
mailboxes={mailboxes}
|
||||
periodsSupported={periodsSupported}
|
||||
onSave={handleSaveRule}
|
||||
onClose={() => { setShowRuleModal(false); setEditingRule(undefined); }}
|
||||
/>
|
||||
|
||||
@@ -4,14 +4,18 @@ import { WebView } from 'react-native-webview';
|
||||
import {
|
||||
AlertTriangle, Eye, EyeOff, Bold, Italic, Underline, List as ListIcon,
|
||||
} from '../icons';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import RichTextEditor, { type RichTextEditorHandle } from '../RichTextEditor';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useVacationStore } from '../../stores/vacation-store';
|
||||
import { useVacationStore, VacationFiltersError, type VacationForwardSettings } from '../../stores/vacation-store';
|
||||
import { useManagedAccountStore } from '../../stores/managed-account-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { isValidEmail } from '../../lib/recipients';
|
||||
import { ownDomains } from '../../lib/sieve/vacation-audience';
|
||||
import type { VacationForward } from '../../lib/sieve/types';
|
||||
import { htmlToPlainText } from '../../lib/compose-html';
|
||||
import { stripDangerousTags, escapeHtml } from '../../lib/email-html';
|
||||
import {
|
||||
@@ -26,6 +30,16 @@ import {
|
||||
// are typed as local wall-clock time and sent as RFC 8621 UTCDate strings;
|
||||
// the optional HTML body is edited in the composer's rich-text editor and
|
||||
// sanitised on save, with the plain-text part derived from it when left blank.
|
||||
// Forwarding and "reply to internal/external senders only" (webmail 1.13,
|
||||
// #1152) live in the filters script, so they are offered only where that
|
||||
// script can carry them (see readVacationFilters).
|
||||
|
||||
type AudienceChoice = 'all' | 'internal' | 'external';
|
||||
|
||||
function sameForward(a: VacationForwardSettings | null, b: VacationForward | null): boolean {
|
||||
if (!a || !b) return a === b;
|
||||
return a.enabled === b.enabled && a.to === b.to && a.keepCopy === b.keepCopy;
|
||||
}
|
||||
|
||||
export function VacationSettings() {
|
||||
const c = useColors();
|
||||
@@ -46,6 +60,13 @@ export function VacationSettings() {
|
||||
const editorRef = useRef<RichTextEditorHandle>(null);
|
||||
// Seed the editor once per load; RichTextEditor only reads initialHtml on mount.
|
||||
const [editorSeed, setEditorSeed] = useState(store.htmlBody || '');
|
||||
const [forwardEnabled, setForwardEnabled] = useState(store.forward?.enabled ?? false);
|
||||
const [forwardTo, setForwardTo] = useState(store.forward?.to ?? '');
|
||||
const [forwardKeep, setForwardKeep] = useState(store.forward?.keepCopy ?? false);
|
||||
const [audienceOnly, setAudienceOnly] = useState<AudienceChoice>(store.audience?.only ?? 'all');
|
||||
// "Internal" are the account's own domains, as its identities give them.
|
||||
const identities = useSettingsStore((s) => s.identities);
|
||||
const domains = useMemo(() => ownDomains(identities.map((i) => i.email)), [identities]);
|
||||
|
||||
// The store reuses `error` for save failures too, so remember whether the
|
||||
// initial fetch itself failed (that's the only case that blanks the form).
|
||||
@@ -66,11 +87,41 @@ export function VacationSettings() {
|
||||
setHtmlEnabled(!!store.htmlBody);
|
||||
setHtmlBody(store.htmlBody || '');
|
||||
setEditorSeed(store.htmlBody || '');
|
||||
setForwardEnabled(store.forward?.enabled ?? false);
|
||||
setForwardTo(store.forward?.to ?? '');
|
||||
setForwardKeep(store.forward?.keepCopy ?? false);
|
||||
setAudienceOnly(store.audience?.only ?? 'all');
|
||||
}, [
|
||||
store.hasLoaded, store.isEnabled, store.fromDate, store.toDate,
|
||||
store.subject, store.textBody, store.htmlBody,
|
||||
store.subject, store.textBody, store.htmlBody, store.forward, store.audience,
|
||||
]);
|
||||
|
||||
// Who gets the auto-reply as a save stores it: everyone, or the senders
|
||||
// from (or not from) the account's domains as they are now. The domains
|
||||
// come from the signed-in user's identities, so a managed (shared) account
|
||||
// offers no narrowing rather than one built from the wrong domains.
|
||||
const canNarrow = store.audienceAvailable && !managedAccountId && domains.length > 0;
|
||||
const audienceSettings = !canNarrow
|
||||
? undefined
|
||||
: audienceOnly === 'all' ? null : { only: audienceOnly, domains };
|
||||
const audienceChanged = canNarrow && (
|
||||
audienceOnly !== (store.audience?.only ?? 'all') ||
|
||||
(audienceOnly !== 'all' && domains.join(',') !== (store.audience?.domains ?? []).join(','))
|
||||
);
|
||||
|
||||
// Forwarding as a save stores it: an address that is off is kept for next
|
||||
// time if it is usable, and dropped otherwise.
|
||||
const forwardAddress = forwardTo.trim();
|
||||
const forwardInvalid = store.forwardAvailable && forwardEnabled && !isValidEmail(forwardAddress);
|
||||
const forwardSettings: VacationForwardSettings | null | undefined = !store.forwardAvailable
|
||||
? undefined
|
||||
: forwardEnabled || isValidEmail(forwardAddress)
|
||||
? { enabled: forwardEnabled, to: forwardAddress, keepCopy: forwardKeep }
|
||||
: null;
|
||||
// Only what was changed here is sent: the rest stays as the server has it,
|
||||
// which another device may have changed since this one loaded.
|
||||
const forwardChanged = forwardSettings !== undefined && !sameForward(forwardSettings, store.forward);
|
||||
|
||||
const fromParsed = parseLocalInput(fromDate);
|
||||
const toParsed = parseLocalInput(toDate);
|
||||
const formatError = !isValidLocalInput(fromDate) || !isValidLocalInput(toDate);
|
||||
@@ -99,9 +150,11 @@ export function VacationSettings() {
|
||||
(formatError ? true : localInputToUtcIso(toDate) !== normalizeUtcIso(store.toDate)) ||
|
||||
subject !== store.subject ||
|
||||
body !== store.textBody ||
|
||||
(htmlEnabled ? htmlBody : '') !== (store.htmlBody || '');
|
||||
(htmlEnabled ? htmlBody : '') !== (store.htmlBody || '') ||
|
||||
forwardChanged ||
|
||||
audienceChanged;
|
||||
|
||||
const canSave = hasChanges && !endBeforeStart && !formatError && !store.isSaving;
|
||||
const canSave = hasChanges && !endBeforeStart && !formatError && !forwardInvalid && !store.isSaving;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Read the live editor DOM rather than trusting onChange state (issue #9).
|
||||
@@ -129,6 +182,8 @@ export function VacationSettings() {
|
||||
subject: subject.trim(),
|
||||
textBody,
|
||||
htmlBody: sanitizedHtml,
|
||||
forward: forwardChanged ? forwardSettings : undefined,
|
||||
audience: audienceChanged ? audienceSettings : undefined,
|
||||
});
|
||||
if (!sanitizedHtml) {
|
||||
setHtmlEnabled(false);
|
||||
@@ -137,11 +192,16 @@ export function VacationSettings() {
|
||||
Alert.alert(t('notifications.vacation_saved', 'Vacation responder settings saved'));
|
||||
} catch (err) {
|
||||
Alert.alert(
|
||||
t('notifications.vacation_save_failed', 'Failed to save vacation responder settings'),
|
||||
err instanceof VacationFiltersError
|
||||
? t('notifications.vacation_filters_save_failed', 'Out of office settings saved, but the forwarding and reply recipients could not be saved')
|
||||
: t('notifications.vacation_save_failed', 'Failed to save vacation responder settings'),
|
||||
err instanceof Error ? err.message : undefined,
|
||||
);
|
||||
}
|
||||
}, [htmlBody, htmlEnabled, body, enabled, fromDate, toDate, subject, store, t]);
|
||||
}, [
|
||||
htmlBody, htmlEnabled, body, enabled, fromDate, toDate, subject, store, t,
|
||||
forwardChanged, forwardSettings, audienceChanged, audienceSettings,
|
||||
]);
|
||||
|
||||
const title = t('settings.vacation.title', 'Vacation Responder');
|
||||
const description = t('settings.vacation.description', "Automatically reply to incoming emails while you're away");
|
||||
@@ -202,8 +262,34 @@ export function VacationSettings() {
|
||||
<ToggleSwitch checked={enabled} onChange={setEnabled} />
|
||||
</View>
|
||||
</SettingItem>
|
||||
{canNarrow && (
|
||||
<SettingItem
|
||||
label={t('settings.vacation.audience.label', 'Reply to')}
|
||||
description={t('settings.vacation.audience.description', 'Internal senders: {domains}', { domains: domains.join(', ') })}
|
||||
noBorder
|
||||
>
|
||||
<Select
|
||||
value={audienceOnly}
|
||||
onChange={(v) => setAudienceOnly(v as AudienceChoice)}
|
||||
options={[
|
||||
{ value: 'all', label: t('settings.vacation.audience.all', 'All senders') },
|
||||
{ value: 'internal', label: t('settings.vacation.audience.internal', 'Internal senders only') },
|
||||
{ value: 'external', label: t('settings.vacation.audience.external', 'External senders only') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{store.notRunning && (
|
||||
<View style={styles.warnRow}>
|
||||
<AlertTriangle size={14} color={c.warning} />
|
||||
<Text style={styles.warnText}>
|
||||
{t('settings.vacation.warnings.not_running', 'Forwarding or the reply recipients are saved but not active right now. Save to turn them back on.')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.vacation.date_range.title', 'Date Range')}
|
||||
description={t('settings.vacation.date_range.description', 'Optionally limit the auto-reply to a specific period')}
|
||||
@@ -333,6 +419,55 @@ export function VacationSettings() {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{store.forwardAvailable && (
|
||||
<SettingsSection
|
||||
title={t('settings.vacation.forward.title', 'Forwarding')}
|
||||
description={t('settings.vacation.forward.description', 'Passes incoming messages on to another address, with or without the auto-reply, within the period above (without an end date, until switched off). Spam is not forwarded.')}
|
||||
>
|
||||
<SettingItem label={t('settings.vacation.forward.enabled_label', 'Forward messages')} noBorder={!forwardEnabled}>
|
||||
<ToggleSwitch
|
||||
checked={forwardEnabled}
|
||||
onChange={setForwardEnabled}
|
||||
accessibilityLabel={t('settings.vacation.forward.enabled_label', 'Forward messages')}
|
||||
/>
|
||||
</SettingItem>
|
||||
{forwardEnabled && (
|
||||
<>
|
||||
<View style={styles.bodyBlock}>
|
||||
<Text style={styles.bodyLabel}>{t('settings.vacation.forward.to_label', 'Forward to')}</Text>
|
||||
<TextInput
|
||||
value={forwardTo}
|
||||
onChangeText={setForwardTo}
|
||||
placeholder={t('settings.vacation.forward.to_placeholder', 'email@example.com')}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
accessibilityLabel={t('settings.vacation.forward.to_label', 'Forward to')}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
style={[styles.subjectInput, forwardInvalid && !!forwardAddress && styles.inputInvalid]}
|
||||
/>
|
||||
{forwardInvalid && (
|
||||
<Text style={[styles.bodyDesc, { color: c.error }]}>
|
||||
{t('settings.vacation.warnings.forward_address', 'Enter a valid address to forward to')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<SettingItem
|
||||
label={t('settings.vacation.forward.keep_label', 'Keep in inbox')}
|
||||
description={t('settings.vacation.forward.keep_description', 'Otherwise the message is only forwarded: it is not kept in this mailbox, and no filter rule runs on it.')}
|
||||
noBorder
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={forwardKeep}
|
||||
onChange={setForwardKeep}
|
||||
accessibilityLabel={t('settings.vacation.forward.keep_label', 'Keep in inbox')}
|
||||
/>
|
||||
</SettingItem>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<View style={styles.warnings}>
|
||||
{warnings.map((w, i) => (
|
||||
|
||||
@@ -33,6 +33,14 @@ const CARDS: Array<[string, ContactCard]> = [
|
||||
];
|
||||
|
||||
describe('contact form save', () => {
|
||||
// RFC 9553 has "mobile"; "cell" is what older versions of the form wrote.
|
||||
it('reads a "cell" phone as mobile and saves it as the RFC 9553 feature', () => {
|
||||
const card: ContactCard = { ...base, phones: { p1: { number: '+1000', features: { cell: true } } } };
|
||||
const form = contactToForm(card, []);
|
||||
expect(form.phones[0].feature).toBe('mobile');
|
||||
expect(formToPatch(form, card, false, [card]).phones).toEqual({ p1: { number: '+1000', features: { mobile: true } } });
|
||||
});
|
||||
|
||||
it.each(CARDS)('a phone-only edit leaves the photo alone on Stalwart %s', (_version, card) => {
|
||||
const form = contactToForm(card, []);
|
||||
form.phones = [{ number: '+2000', context: '', feature: '' }];
|
||||
|
||||
@@ -372,8 +372,9 @@ describe('inlineContactMediaForExport', () => {
|
||||
expect(out[1].media).toEqual({ photo: { kind: 'photo', mediaType: 'image/png', uri: 'data:image/png;base64,iVBORw0KGgo=' } });
|
||||
|
||||
const vcf = contactsToVCard(out);
|
||||
expect(vcf).toContain(`PHOTO;ENCODING=b;TYPE=image/jpeg:${Buffer.from(JPEG_BYTES).toString('base64')}`);
|
||||
expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/png:iVBORw0KGgo=');
|
||||
const unfolded = vcf.replace(/\r\n /g, '');
|
||||
expect(unfolded).toContain(`PHOTO:data:image/jpeg;base64,${Buffer.from(JPEG_BYTES).toString('base64')}`);
|
||||
expect(unfolded).toContain('PHOTO:data:image/png;base64,iVBORw0KGgo=');
|
||||
expect(mockFetchBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { contactFromWire, contactToWire } from '../contact-wire';
|
||||
import { parseVCard } from '../vcard';
|
||||
import type { ContactCard } from '../../api/types';
|
||||
|
||||
describe('contactToWire', () => {
|
||||
// Stalwart refuses a card with calendarUri / schedulingUri / freeBusyUri
|
||||
// as properties ("Invalid property.").
|
||||
it('writes the calendar, scheduling and free/busy links as RFC 9553 entries', () => {
|
||||
const wire = contactToWire({
|
||||
name: { full: 'A' },
|
||||
calendarUri: 'https://cal.example/a',
|
||||
freeBusyUri: 'https://cal.example/a/fb',
|
||||
schedulingUri: 'mailto:a@example.com',
|
||||
}, 'create');
|
||||
expect(wire).not.toHaveProperty('calendarUri');
|
||||
expect(wire).not.toHaveProperty('freeBusyUri');
|
||||
expect(wire).not.toHaveProperty('schedulingUri');
|
||||
expect(wire.calendars).toEqual({
|
||||
cal: { '@type': 'Calendar', kind: 'calendar', uri: 'https://cal.example/a' },
|
||||
fb: { '@type': 'Calendar', kind: 'freeBusy', uri: 'https://cal.example/a/fb' },
|
||||
});
|
||||
expect(wire.schedulingAddresses).toEqual({ sched: { '@type': 'SchedulingAddress', uri: 'mailto:a@example.com' } });
|
||||
});
|
||||
|
||||
it('clears the links on an update that empties them', () => {
|
||||
const wire = contactToWire({ calendarUri: null as never, schedulingUri: null as never }, 'update');
|
||||
expect(wire.calendars).toBeNull();
|
||||
expect(wire.schedulingAddresses).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the links alone on an update that does not name them', () => {
|
||||
const wire = contactToWire({ name: { full: 'B' } }, 'update');
|
||||
expect(wire).not.toHaveProperty('calendars');
|
||||
expect(wire).not.toHaveProperty('schedulingAddresses');
|
||||
});
|
||||
|
||||
it('never clears id, uid or the address books', () => {
|
||||
const wire = contactToWire({ uid: undefined, addressBookIds: null as never, notes: undefined }, 'update');
|
||||
expect(wire).toEqual({ notes: null });
|
||||
});
|
||||
|
||||
// vCard import: flat ADR fields, LABEL, SOURCE and BIRTHPLACE.
|
||||
it('writes what a vCard import holds as RFC 9553 has it', () => {
|
||||
const [card] = parseVCard([
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'FN:Max Muster',
|
||||
'ADR;TYPE=HOME;LABEL="Hauptstr. 1, Berlin":;;Hauptstr. 1;Berlin;;10115;Germany',
|
||||
'SOURCE:https://dir.example/max.vcf',
|
||||
'BDAY:19800102',
|
||||
'BIRTHPLACE:Hamburg',
|
||||
'END:VCARD',
|
||||
].join('\r\n'));
|
||||
const { id: _id, addressBookIds: _b, ...data } = card;
|
||||
const wire = contactToWire(data, 'create');
|
||||
const address = Object.values(wire.addresses as Record<string, Record<string, unknown>>)[0];
|
||||
for (const flat of ['street', 'locality', 'region', 'postcode', 'country', 'fullAddress']) {
|
||||
expect(address).not.toHaveProperty(flat);
|
||||
}
|
||||
expect(address.components).toEqual([
|
||||
{ kind: 'name', value: 'Hauptstr. 1' },
|
||||
{ kind: 'locality', value: 'Berlin' },
|
||||
{ kind: 'postcode', value: '10115' },
|
||||
{ kind: 'country', value: 'Germany' },
|
||||
]);
|
||||
expect(address.full).toBe('Hauptstr. 1, Berlin');
|
||||
expect(wire).not.toHaveProperty('source');
|
||||
expect(wire.directories).toEqual({ source: { '@type': 'Directory', kind: 'entry', uri: 'https://dir.example/max.vcf' } });
|
||||
const birth = Object.values(wire.anniversaries as Record<string, { place?: Record<string, unknown> }>)[0];
|
||||
expect(birth.place).toEqual({ full: 'Hamburg' });
|
||||
});
|
||||
|
||||
it('drops the client-only account fields', () => {
|
||||
const wire = contactToWire({ originalId: 'x', accountId: 'a', accountName: 'n', isShared: true, name: { full: 'C' } }, 'create');
|
||||
expect(wire).toEqual({ name: { full: 'C' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('contactFromWire', () => {
|
||||
it('fills the flat links from what the server stores', () => {
|
||||
const card = contactFromWire({
|
||||
id: 'c',
|
||||
addressBookIds: {},
|
||||
calendars: { x: { kind: 'calendar', uri: 'https://cal' }, y: { kind: 'freeBusy', uri: 'https://fb' } },
|
||||
schedulingAddresses: { s: { uri: 'mailto:s@x' } },
|
||||
directories: { d: { kind: 'entry', uri: 'https://src' } },
|
||||
} as ContactCard);
|
||||
expect(card).toMatchObject({
|
||||
calendarUri: 'https://cal', freeBusyUri: 'https://fb', schedulingUri: 'mailto:s@x', source: 'https://src',
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips through the wire form', () => {
|
||||
const app = { calendarUri: 'https://cal', freeBusyUri: 'https://fb', schedulingUri: 'mailto:s@x' };
|
||||
const back = contactFromWire({ id: 'c', addressBookIds: {}, ...contactToWire(app, 'create') } as ContactCard);
|
||||
expect(back).toMatchObject(app);
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,8 @@ describe('parseVCard', () => {
|
||||
expect(emails[0].contexts).toEqual({ work: true });
|
||||
const phones = Object.values(card.phones ?? {});
|
||||
expect(phones[0].number).toBe('+1-555-0100');
|
||||
expect(phones[0].features).toEqual({ cell: true });
|
||||
// CELL is "mobile" in RFC 9553; "cell" is no JSContact feature.
|
||||
expect(phones[0].features).toEqual({ mobile: true });
|
||||
const given = card.name?.components?.find((c) => c.kind === 'given')?.value;
|
||||
const surname = card.name?.components?.find((c) => c.kind === 'surname')?.value;
|
||||
expect(given).toBe('Jane');
|
||||
@@ -115,6 +116,58 @@ describe('generateVCard', () => {
|
||||
expect(out.match(/BEGIN:VCARD/g)).toHaveLength(1);
|
||||
expect(out.match(/END:VCARD/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// #1407: what Apple Contacts, Google and Outlook expect of a vCard 4.0.
|
||||
describe('vCard 4.0 (#1407)', () => {
|
||||
const card: ContactCard = {
|
||||
id: 'c1',
|
||||
addressBookIds: {},
|
||||
uid: 'abc-123',
|
||||
updated: '2026-10-29T08:15:00Z',
|
||||
name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Doe' }] },
|
||||
phones: {
|
||||
p1: { number: '+1 555 0100', features: { mobile: true } },
|
||||
p2: { number: '+1 555 0101', features: { cell: true } },
|
||||
},
|
||||
notes: { n1: { note: 'x'.repeat(200) } },
|
||||
};
|
||||
const out = generateVCard([card]);
|
||||
const lines = out.split('\r\n');
|
||||
|
||||
it('says it is version 4.0 and ends every line with CRLF', () => {
|
||||
expect(lines.slice(0, 2)).toEqual(['BEGIN:VCARD', 'VERSION:4.0']);
|
||||
expect(out.endsWith('END:VCARD\r\n')).toBe(true);
|
||||
expect(out.replace(/\r\n/g, '')).not.toContain('\n');
|
||||
});
|
||||
|
||||
it('writes N before FN', () => {
|
||||
expect(lines.indexOf('N:Doe;Jane;;;')).toBeGreaterThan(-1);
|
||||
expect(lines.indexOf('N:Doe;Jane;;;')).toBeLessThan(lines.indexOf('FN:Jane Doe'));
|
||||
});
|
||||
|
||||
it('writes mobiles as CELL, never MOBILE', () => {
|
||||
expect(lines).toContain('TEL;TYPE=CELL:+1 555 0100');
|
||||
expect(lines).toContain('TEL;TYPE=CELL:+1 555 0101');
|
||||
expect(out).not.toContain('MOBILE');
|
||||
});
|
||||
|
||||
it('marks a UID without a scheme as text and writes REV in the basic format', () => {
|
||||
expect(lines).toContain('UID;VALUE=text:abc-123');
|
||||
expect(lines).toContain('REV:20261029T081500Z');
|
||||
});
|
||||
|
||||
it('folds lines longer than 75 octets', () => {
|
||||
expect(lines.every((line) => line.length <= 75)).toBe(true);
|
||||
expect(lines.some((line) => line.startsWith(' '))).toBe(true);
|
||||
});
|
||||
|
||||
it('reads its own export back', () => {
|
||||
const [back] = parseVCard(out);
|
||||
expect(back.uid).toBe('abc-123');
|
||||
expect(Object.values(back.phones ?? {}).map((p) => p.features)).toEqual([{ mobile: true }, { mobile: true }]);
|
||||
expect(Object.values(back.notes ?? {})[0]?.note).toBe('x'.repeat(200));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectDuplicates', () => {
|
||||
@@ -185,7 +238,7 @@ describe('anniversary dates (issue #224)', () => {
|
||||
|
||||
it('round-trips a birthday through generateVCard', () => {
|
||||
const exported = generateVCard([parseOne('BDAY:19850412')]);
|
||||
expect(exported).toContain('BDAY:1985-04-12');
|
||||
expect(exported).toContain('BDAY:19850412');
|
||||
const reparsed = parseVCard(exported)[0];
|
||||
expect(Object.values(reparsed.anniversaries || {})[0]?.date).toEqual({
|
||||
year: 1985, month: 4, day: 12,
|
||||
@@ -194,7 +247,7 @@ describe('anniversary dates (issue #224)', () => {
|
||||
|
||||
it('round-trips a day/month-only birthday', () => {
|
||||
const exported = generateVCard([parseOne('BDAY:--0412')]);
|
||||
expect(exported).toContain('BDAY:--04-12');
|
||||
expect(exported).toContain('BDAY:--0412');
|
||||
expect(Object.values(parseVCard(exported)[0].anniversaries || {})[0]?.date)
|
||||
.toEqual({ month: 4, day: 12 });
|
||||
});
|
||||
@@ -207,7 +260,7 @@ describe('anniversary dates (issue #224)', () => {
|
||||
anniversaries: { a0: { kind: 'other', date: { year: 2015, month: 9, day: 20 } } },
|
||||
};
|
||||
const exported = generateVCard([card]);
|
||||
expect(exported).toContain('X-ABDATE:2015-09-20');
|
||||
expect(exported).toContain('X-ABDATE:20150920');
|
||||
const reparsed = parseVCard(exported)[0];
|
||||
expect(Object.values(reparsed.anniversaries || {})).toEqual([
|
||||
{ kind: 'other', date: { year: 2015, month: 9, day: 20 } },
|
||||
@@ -399,10 +452,10 @@ describe('photo export', () => {
|
||||
d: { kind: 'photo', uri: 'data:;base64,DDDD' },
|
||||
})]);
|
||||
const lines = vcf.split('\r\n');
|
||||
expect(lines).toContain('PHOTO;ENCODING=b;TYPE=image/png:AAAA');
|
||||
expect(lines).toContain('LOGO;ENCODING=b;TYPE=image/jpeg:BBBB');
|
||||
expect(lines).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:CCCC');
|
||||
expect(lines).toContain('PHOTO;ENCODING=b:DDDD');
|
||||
expect(lines).toContain('PHOTO:data:image/png;base64,AAAA');
|
||||
expect(lines).toContain('LOGO:data:image/jpeg;base64,BBBB');
|
||||
expect(lines).toContain('PHOTO:data:image/jpeg;base64,CCCC');
|
||||
expect(lines).toContain('PHOTO:data:;base64,DDDD');
|
||||
});
|
||||
|
||||
it('skips a Stalwart 1.0 blob photo that was not downloaded into the card', () => {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Principal } from '../api/types';
|
||||
|
||||
// The company directory (#1253): the RFC 9670 principals the server lists for
|
||||
// this account, read-only, for the Contacts "Directory" view and recipient
|
||||
// lookups. Ported from the webmail's stores/contact-store.ts and
|
||||
// components/contacts/directory-browser.tsx.
|
||||
|
||||
export interface DirectoryEntry {
|
||||
id?: string;
|
||||
/** Display name: the principal's description, else its name, else the address. */
|
||||
name: string;
|
||||
email: string;
|
||||
description?: string;
|
||||
type?: Principal['type'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the directory load stands:
|
||||
* - `unavailable`: the server has no principals capability;
|
||||
* - `restricted`: the server refused to list its whole directory (Stalwart
|
||||
* 1.0 keeps directory queries off by default, or the account lacks the
|
||||
* right); the entries are the principals the user already knows: their
|
||||
* own account, their groups, the accounts that shared something with them;
|
||||
* - `error`: the request failed.
|
||||
*/
|
||||
export type DirectoryStatus = 'idle' | 'loading' | 'ready' | 'restricted' | 'unavailable' | 'error';
|
||||
|
||||
export function principalsToDirectory(principals: Principal[]): DirectoryEntry[] {
|
||||
const entries: DirectoryEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const p of principals) {
|
||||
// Only principals with an address can be written to.
|
||||
const email = p.email?.trim();
|
||||
if (!email || seen.has(email.toLowerCase())) continue;
|
||||
seen.add(email.toLowerCase());
|
||||
entries.push({
|
||||
id: p.id,
|
||||
name: p.description || p.name || email,
|
||||
email,
|
||||
description: p.description ?? undefined,
|
||||
type: p.type,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function matches(entry: DirectoryEntry, q: string): boolean {
|
||||
return entry.name.toLowerCase().includes(q)
|
||||
|| entry.email.toLowerCase().includes(q)
|
||||
|| (entry.description?.toLowerCase().includes(q) ?? false);
|
||||
}
|
||||
|
||||
/** The entries a search leaves, by name then address. */
|
||||
export function filterDirectory(entries: DirectoryEntry[], query: string, locale?: string): DirectoryEntry[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matched = q ? entries.filter((e) => matches(e, q)) : entries;
|
||||
const collator = new Intl.Collator(locale, { sensitivity: 'base' });
|
||||
return [...matched].sort((a, b) => collator.compare(a.name, b.name) || collator.compare(a.email, b.email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipient suggestions from the directory for what is typed, as the
|
||||
* composer's autocomplete shows them: a name only where it says more than the
|
||||
* address.
|
||||
*/
|
||||
export function directorySuggestions(
|
||||
entries: DirectoryEntry[],
|
||||
query: string,
|
||||
limit = 10,
|
||||
): Array<{ name: string; email: string }> {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const out: Array<{ name: string; email: string }> = [];
|
||||
for (const entry of entries) {
|
||||
if (out.length >= limit) break;
|
||||
if (!matches(entry, q)) continue;
|
||||
out.push({ name: entry.name !== entry.email ? entry.name : '', email: entry.email });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -138,8 +138,10 @@ export function contactToForm(contact: ContactCard, memberIds: string[]): FormSt
|
||||
const phones = contact.phones ? Object.values(contact.phones).map((p) => ({
|
||||
number: p.number,
|
||||
context: p.contexts?.work ? 'work' : p.contexts?.private ? 'private' : '',
|
||||
// RFC 9553 calls it "mobile"; cards saved by older versions of the app
|
||||
// (and of the webmail) say "cell", which a save turns into "mobile".
|
||||
feature:
|
||||
p.features?.cell ? 'cell'
|
||||
p.features?.mobile || p.features?.cell ? 'mobile'
|
||||
: p.features?.fax ? 'fax'
|
||||
: p.features?.pager ? 'pager'
|
||||
: p.features?.video ? 'video'
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { AddressComponent, ContactAddress, ContactAnniversary, ContactCard } from '../api/types';
|
||||
|
||||
/**
|
||||
* Translation between the ContactCard shape the app works with and the RFC
|
||||
* 9553 card the server stores (webmail lib/jmap/contact-wire.ts).
|
||||
*
|
||||
* ContactCard/set rejects a whole card over a single property it does not
|
||||
* know ("Invalid property."), so client-side fields must never reach it, and
|
||||
* the app's flat conveniences are written as their JSContact equivalents:
|
||||
* the vCard CALURI / FBURL / CALADRURI / SOURCE links, and the flat street,
|
||||
* locality... of a vCard ADR.
|
||||
*/
|
||||
|
||||
/** Fields the client adds to cards it lists; not part of RFC 9553. */
|
||||
const CLIENT_ONLY_KEYS = ['originalId', 'accountId', 'accountName', 'isShared'] as const;
|
||||
|
||||
/** vCard CALURI / FBURL / CALADRURI / SOURCE, kept flat in the app. */
|
||||
const URI_KEYS = ['calendarUri', 'freeBusyUri', 'schedulingUri', 'source'] as const;
|
||||
|
||||
/**
|
||||
* Properties an update never clears. A null `id` fails the whole update ("The
|
||||
* id property is immutable.") and so does a null `addressBookIds` ("Invalid
|
||||
* patch operation for addressBookIds."). A null `uid` is refused by Stalwart
|
||||
* 1.0 ("You cannot change the UID of a contact.") and on 0.16 silently
|
||||
* deletes the card's UID.
|
||||
*/
|
||||
const NEVER_CLEARED_KEYS = ['id', 'uid', 'addressBookIds'] as const;
|
||||
|
||||
const LEGACY_ADDRESS_FIELDS: Array<[keyof ContactAddress, AddressComponent['kind']]> = [
|
||||
['street', 'name'],
|
||||
['locality', 'locality'],
|
||||
['region', 'region'],
|
||||
['postcode', 'postcode'],
|
||||
['country', 'country'],
|
||||
];
|
||||
|
||||
/** The RFC 9553 calendar, scheduling and directory entries the app reads. */
|
||||
interface WireLinks {
|
||||
calendars?: Record<string, { kind?: string; uri?: string } | null>;
|
||||
schedulingAddresses?: Record<string, { uri?: string } | null>;
|
||||
}
|
||||
|
||||
/** vCard imports store flat fields; RFC 9553 wants `components` and `full`. */
|
||||
function addressToWire(address: ContactAddress): Record<string, unknown> {
|
||||
const { street: _s, locality: _l, region: _r, postcode: _p, country: _c, fullAddress, ...rest } = address;
|
||||
const out: Record<string, unknown> = { ...rest };
|
||||
if (!address.components?.length) {
|
||||
const components = LEGACY_ADDRESS_FIELDS
|
||||
.map(([field, kind]) => ({ kind, value: (address[field] as string | undefined)?.trim() ?? '' }))
|
||||
.filter((c) => c.value);
|
||||
if (components.length) {
|
||||
out.components = components;
|
||||
out.isOrdered = true;
|
||||
out.defaultSeparator = ', ';
|
||||
}
|
||||
}
|
||||
if (fullAddress && !address.full) out.full = fullAddress;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A BIRTHPLACE / DEATHPLACE read from a vCard is an address too. */
|
||||
function anniversaryToWire(anniversary: ContactAnniversary): Record<string, unknown> {
|
||||
if (!anniversary.place) return { ...anniversary };
|
||||
return { ...anniversary, place: addressToWire(anniversary.place) };
|
||||
}
|
||||
|
||||
type Mode = 'create' | 'update';
|
||||
|
||||
/**
|
||||
* The card as ContactCard/set expects it. On `update`, a key that is present
|
||||
* with the value `undefined` means "the user cleared it" and is sent as
|
||||
* `null`: JSON drops undefined, and an omitted property keeps its old value.
|
||||
* `id`, `uid` and `addressBookIds` without a value are left out instead.
|
||||
*/
|
||||
export function contactToWire(card: Partial<ContactCard>, mode: Mode): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(card)) {
|
||||
if ((CLIENT_ONLY_KEYS as readonly string[]).includes(key)) continue;
|
||||
if ((URI_KEYS as readonly string[]).includes(key)) continue;
|
||||
if (mode === 'update' && value == null && (NEVER_CLEARED_KEYS as readonly string[]).includes(key)) continue;
|
||||
if (value === undefined) {
|
||||
if (mode === 'update') out[key] = null;
|
||||
continue;
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
|
||||
if (card.addresses) {
|
||||
out.addresses = Object.fromEntries(
|
||||
Object.entries(card.addresses).map(([id, a]) => [id, addressToWire(a)]),
|
||||
);
|
||||
}
|
||||
if (card.anniversaries) {
|
||||
out.anniversaries = Object.fromEntries(
|
||||
Object.entries(card.anniversaries).map(([id, a]) => [id, anniversaryToWire(a)]),
|
||||
);
|
||||
}
|
||||
|
||||
// Rebuild the calendar links only when the caller manages them, so an
|
||||
// update that does not mention them leaves the server's entries alone.
|
||||
if ('calendarUri' in card || 'freeBusyUri' in card) {
|
||||
const calendars: Record<string, unknown> = {};
|
||||
if (card.calendarUri) calendars.cal = { '@type': 'Calendar', kind: 'calendar', uri: card.calendarUri };
|
||||
if (card.freeBusyUri) calendars.fb = { '@type': 'Calendar', kind: 'freeBusy', uri: card.freeBusyUri };
|
||||
if (Object.keys(calendars).length) out.calendars = calendars;
|
||||
else if (mode === 'update') out.calendars = null;
|
||||
}
|
||||
if ('schedulingUri' in card) {
|
||||
if (card.schedulingUri) out.schedulingAddresses = { sched: { '@type': 'SchedulingAddress', uri: card.schedulingUri } };
|
||||
else if (mode === 'update') out.schedulingAddresses = null;
|
||||
}
|
||||
if (card.source) {
|
||||
// vCard SOURCE is a JSContact directory entry (RFC 9553 §2.6.2).
|
||||
const directories = { ...(card.directories ?? {}) } as Record<string, unknown>;
|
||||
directories.source = { '@type': 'Directory', kind: 'entry', uri: card.source };
|
||||
out.directories = directories;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Fill the app's flat URI fields from the RFC 9553 properties. */
|
||||
export function contactFromWire<T extends ContactCard>(card: T): T {
|
||||
const links = card as T & WireLinks;
|
||||
const calendars = Object.values(links.calendars ?? {});
|
||||
const calendarUri = card.calendarUri ?? calendars.find((c) => c?.kind === 'calendar')?.uri;
|
||||
const freeBusyUri = card.freeBusyUri ?? calendars.find((c) => c?.kind === 'freeBusy')?.uri;
|
||||
const schedulingUri = card.schedulingUri ?? Object.values(links.schedulingAddresses ?? {})[0]?.uri;
|
||||
const source = card.source ?? Object.values(card.directories ?? {}).find((d) => d?.kind === 'entry')?.uri;
|
||||
if (!calendarUri && !freeBusyUri && !schedulingUri && !source) return card;
|
||||
return {
|
||||
...card,
|
||||
...(calendarUri ? { calendarUri } : {}),
|
||||
...(freeBusyUri ? { freeBusyUri } : {}),
|
||||
...(schedulingUri ? { schedulingUri } : {}),
|
||||
...(source ? { source } : {}),
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
isConditionValueEmpty,
|
||||
describeCondition,
|
||||
summarizeRule,
|
||||
comparatorsFor,
|
||||
takesNoValue,
|
||||
} from '../condition-value';
|
||||
import type { FilterRule } from '../types';
|
||||
|
||||
@@ -58,4 +60,24 @@ describe('summaries', () => {
|
||||
'from contains "@a.com" or "@b.com" or attachment has_any (+1) → move "Archive", stop',
|
||||
);
|
||||
});
|
||||
|
||||
it('describes "all messages" by its field alone', () => {
|
||||
expect(describeCondition({ field: 'all', comparator: 'any', value: '' }, t)).toBe('all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the comparators the editor offers', () => {
|
||||
it('offers exact address and domain on the address fields only', () => {
|
||||
for (const field of ['from', 'to', 'cc'] as const) {
|
||||
expect(comparatorsFor(field)).toEqual(expect.arrayContaining(['address_is', 'domain_is']));
|
||||
}
|
||||
expect(comparatorsFor('subject')).not.toContain('address_is');
|
||||
expect(comparatorsFor('header')).not.toContain('domain_is');
|
||||
});
|
||||
|
||||
it('offers "all messages" without a value', () => {
|
||||
expect(comparatorsFor('all')).toEqual(['any']);
|
||||
expect(takesNoValue({ field: 'all', comparator: 'any', value: '' })).toBe(true);
|
||||
expect(takesNoValue({ field: 'subject', comparator: 'contains', value: '' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/* @metadata:begin
|
||||
{"version":1,"rules":[{"id":"anna","name":"Anna exactly","enabled":true,"matchType":"any","conditions":[{"field":"from","comparator":"address_is","value":"anna@acme.com"},{"field":"cc","comparator":"address_is","value":["anna@acme.com","a.smith@acme.com"]}],"actions":[{"type":"star"}],"stopProcessing":false},{"id":"acme","name":"Acme domain","enabled":true,"matchType":"all","conditions":[{"field":"to","comparator":"domain_is","value":"acme.com"}],"actions":[{"type":"add_label","value":"acme"}],"stopProcessing":false},{"id":"drop","name":"Drop spam sender","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"domain_is","value":"spam.example"}],"actions":[{"type":"discard"}],"stopProcessing":true},{"id":"bounce","name":"Bounce","enabled":true,"matchType":"all","conditions":[{"field":"subject","comparator":"is","value":"win money"}],"actions":[{"type":"reject","value":"No thanks"}],"stopProcessing":true},{"id":"every","name":"Everything else","enabled":true,"matchType":"all","conditions":[{"field":"all","comparator":"any","value":""}],"actions":[{"type":"add_label","value":"seen-by-filter"}],"stopProcessing":false}]}
|
||||
@metadata:end */
|
||||
|
||||
require ["imap4flags", "reject"];
|
||||
|
||||
# Rule: Anna exactly
|
||||
if anyof(address :is "From" "anna@acme.com", address :is "Cc" ["anna@acme.com", "a.smith@acme.com"]) {
|
||||
addflag "\\Flagged";
|
||||
}
|
||||
|
||||
# Rule: Acme domain
|
||||
if address :domain :is "To" "acme.com" {
|
||||
addflag "$label:acme";
|
||||
}
|
||||
|
||||
# Rule: Drop spam sender
|
||||
if address :domain :is "From" "spam.example" {
|
||||
discard;
|
||||
stop;
|
||||
}
|
||||
|
||||
# Rule: Bounce
|
||||
if header :is "Subject" "win money" {
|
||||
reject "No thanks";
|
||||
stop;
|
||||
}
|
||||
|
||||
# Rule: Everything else
|
||||
if true {
|
||||
addflag "$label:seen-by-filter";
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* @metadata:begin
|
||||
{"version":1,"rules":[{"id":"news","name":"News","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"contains","value":"news@"}],"actions":[{"type":"mark_read"}],"stopProcessing":false}],"includeVacation":true,"vacationForward":{"enabled":false,"to":"backup@example.org","keepCopy":true}}
|
||||
@metadata:end */
|
||||
|
||||
require ["imap4flags", "include"];
|
||||
|
||||
# Vacation auto-reply
|
||||
include :personal :optional "vacation";
|
||||
|
||||
# Rule: News
|
||||
if header :contains "From" "news@" {
|
||||
addflag "\\Seen";
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// Sieve scripts written by the webmail's own generator (jmap-webmail
|
||||
// lib/sieve/generator.ts at e33ab899), used to check that a save on the
|
||||
// phone gives back the same script. Each one was generated from the rules in
|
||||
// its metadata for a server listing `extensions`.
|
||||
// lib/sieve/generator.ts at e33ab899; the version 2, address comparator and
|
||||
// forwarding ones at 4aea0976), used to check that a save on the phone gives
|
||||
// back the same script. Each one was generated from the rules in its metadata
|
||||
// for a server listing `extensions`.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -28,6 +29,14 @@ export const WEBMAIL_FIXTURES: WebmailFixture[] = [
|
||||
},
|
||||
// Everything at once, plus a hand-written rule the webmail kept.
|
||||
{ file: 'stalwart-full.sieve', extensions: STALWART_EXTENSIONS },
|
||||
// Version 2 (webmail 1.13+): rule periods, forwarding from the vacation
|
||||
// card in the vacation's period, the auto-reply for colleagues only.
|
||||
{ file: 'period-forward-audience.sieve', extensions: STALWART_EXTENSIONS },
|
||||
// Version 1 with the vacation forwarding switched off but remembered.
|
||||
{ file: 'forward-off.sieve', extensions: STALWART_EXTENSIONS },
|
||||
// "is the address" / "has the domain", "all messages", stop after discard
|
||||
// and reject, names with runs of spaces.
|
||||
{ file: 'address-comparators.sieve', extensions: STALWART_EXTENSIONS },
|
||||
];
|
||||
|
||||
export function readWebmailFixture(file: string): string {
|
||||
@@ -37,8 +46,9 @@ export function readWebmailFixture(file: string): string {
|
||||
|
||||
/**
|
||||
* What the webmail itself writes when it saves the fixture again. That is the
|
||||
* fixture unchanged, except that both clients add two blank lines in front of
|
||||
* a script's external rules on every save (`<name>.resaved.sieve`).
|
||||
* fixture unchanged, except that a script written before 4aea0976 had extra
|
||||
* blank lines in front of its external rules, which a save now trims
|
||||
* (`<name>.resaved.sieve`).
|
||||
*/
|
||||
export function readWebmailResave(file: string): string {
|
||||
const resaved = file.replace(/\.sieve$/, '.resaved.sieve');
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* @metadata:begin
|
||||
{"version":2,"rules":[{"id":"trip","name":"While travelling","enabled":true,"matchType":"all","conditions":[{"field":"from","comparator":"domain_is","value":"client.example"}],"actions":[{"type":"forward","value":"deputy@example.org","keepCopy":true}],"stopProcessing":false,"activeFrom":"2026-11-02T07:00:00.000Z","activeUntil":"2026-11-13T17:30:00.000Z"},{"id":"later","name":"From December","enabled":true,"matchType":"all","conditions":[{"field":"subject","comparator":"contains","value":"report"}],"actions":[{"type":"move","value":"Reports","mailboxId":"m7"}],"stopProcessing":true,"activeFrom":"2026-12-01T00:00:00.000Z"}],"includeVacation":true,"vacationForward":{"enabled":true,"to":"backup@example.org","keepCopy":false,"activeFrom":"2026-11-02T00:00:00.000Z","activeUntil":"2026-11-14T00:00:00.000Z"},"vacationAudience":{"only":"internal","domains":["example.org","example.com"]}}
|
||||
@metadata:end */
|
||||
|
||||
require ["comparator-i;ascii-numeric", "copy", "date", "envelope", "fileinto", "include", "mailbox", "mailboxid", "relational", "spamtestplus"];
|
||||
|
||||
# Vacation auto-reply
|
||||
if envelope :domain :is "from" ["example.org", "example.com"] {
|
||||
include :personal :optional "vacation";
|
||||
}
|
||||
|
||||
# Vacation forwarding
|
||||
if allof(anyof(currentdate :zone "+0000" :value "gt" "date" "2026-11-02", allof(currentdate :zone "+0000" :is "date" "2026-11-02", currentdate :zone "+0000" :value "ge" "time" "00:00:00")), anyof(currentdate :zone "+0000" :value "lt" "date" "2026-11-14", allof(currentdate :zone "+0000" :is "date" "2026-11-14", currentdate :zone "+0000" :value "le" "time" "00:00:00")), not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50") {
|
||||
redirect "backup@example.org";
|
||||
stop;
|
||||
}
|
||||
|
||||
# Rule: While travelling
|
||||
if allof(anyof(currentdate :zone "+0000" :value "gt" "date" "2026-11-02", allof(currentdate :zone "+0000" :is "date" "2026-11-02", currentdate :zone "+0000" :value "ge" "time" "07:00:00")), anyof(currentdate :zone "+0000" :value "lt" "date" "2026-11-13", allof(currentdate :zone "+0000" :is "date" "2026-11-13", currentdate :zone "+0000" :value "le" "time" "17:30:00")), address :domain :is "From" "client.example") {
|
||||
redirect :copy "deputy@example.org";
|
||||
}
|
||||
|
||||
# Rule: From December
|
||||
if allof(anyof(currentdate :zone "+0000" :value "gt" "date" "2026-12-01", allof(currentdate :zone "+0000" :is "date" "2026-12-01", currentdate :zone "+0000" :value "ge" "time" "00:00:00")), allof(header :contains "Subject" "report", not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50")) {
|
||||
fileinto :mailboxid "m7" "Reports";
|
||||
stop;
|
||||
}
|
||||
@@ -36,10 +36,6 @@ if size :over 10000000 {
|
||||
}
|
||||
|
||||
# --- External rules (managed outside Bulwark) ---
|
||||
|
||||
|
||||
|
||||
|
||||
# Hand-written rule
|
||||
if header :contains "X-Spam-Flag" "YES" {
|
||||
fileinto "Junk";
|
||||
|
||||
@@ -241,25 +241,99 @@ describe('generateScript', () => {
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not append stop after discard', () => {
|
||||
// discard and reject only cancel the implicit keep; the rules below
|
||||
// would still file, flag or forward the message (#1153).
|
||||
it('appends stop after discard', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'discard' }],
|
||||
stopProcessing: true,
|
||||
})]);
|
||||
const matches = script.match(/stop;/g);
|
||||
expect(matches).toBeNull();
|
||||
expect(script).toContain(' discard;\n stop;\n}');
|
||||
});
|
||||
|
||||
it('does not append stop after reject', () => {
|
||||
it('appends stop after reject', () => {
|
||||
const script = generateScript([makeRule({
|
||||
actions: [{ type: 'reject', value: 'No' }],
|
||||
stopProcessing: true,
|
||||
})]);
|
||||
const matches = script.match(/stop;/g);
|
||||
expect(matches).toBeNull();
|
||||
expect(script).toContain(' reject "No";\n stop;\n}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('address comparators', () => {
|
||||
it('writes "is the address" as an address test', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'from', comparator: 'address_is', value: 'anna@acme.com' }],
|
||||
})]);
|
||||
expect(script).toContain('if address :is "From" "anna@acme.com" {');
|
||||
expect(script).not.toContain('header :contains');
|
||||
});
|
||||
|
||||
it('writes "has the domain" as an address :domain test', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'cc', comparator: 'domain_is', value: ['acme.com', 'acme.org'] }],
|
||||
})]);
|
||||
expect(script).toContain('if address :domain :is "Cc" ["acme.com", "acme.org"] {');
|
||||
});
|
||||
|
||||
it('reads both back as the same comparators', () => {
|
||||
const rules = [
|
||||
makeRule({ id: 'a', name: 'A', conditions: [{ field: 'to', comparator: 'address_is', value: 'a@b.c' }] }),
|
||||
makeRule({ id: 'b', name: 'B', conditions: [{ field: 'from', comparator: 'domain_is', value: 'b.c' }] }),
|
||||
];
|
||||
expect(parseScript(generateScript(rules)).rules).toEqual(rules);
|
||||
});
|
||||
|
||||
it('refuses a comparator it does not know instead of broadening it', () => {
|
||||
const rule = makeRule({
|
||||
conditions: [{ field: 'from', comparator: 'sounds_like' as never, value: 'anna' }],
|
||||
});
|
||||
expect(() => generateScript([rule])).toThrow(/sounds_like/);
|
||||
});
|
||||
|
||||
it('refuses an address comparator on the subject', () => {
|
||||
const rule = makeRule({
|
||||
conditions: [{ field: 'subject', comparator: 'address_is', value: 'x' }],
|
||||
});
|
||||
expect(() => generateScript([rule])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// #1154: a rule for every message is Sieve `true`.
|
||||
it('writes the "all messages" condition as true', () => {
|
||||
const script = generateScript([makeRule({
|
||||
conditions: [{ field: 'all', comparator: 'any', value: '' }],
|
||||
actions: [{ type: 'add_label', value: 'x' }],
|
||||
})]);
|
||||
expect(script).toContain('if true {');
|
||||
});
|
||||
|
||||
it('confines a rule with a period to it', () => {
|
||||
const script = generateScript([makeRule({ activeFrom: '2026-11-02T07:00:00.000Z' })]);
|
||||
expect(script).toContain('{"version":2,');
|
||||
expect(script).toMatch(/require \[[^\]]*"date"[^\]]*"relational"/);
|
||||
expect(script).toContain('if allof(anyof(currentdate :zone "+0000" :value "gt" "date" "2026-11-02"');
|
||||
});
|
||||
|
||||
it('runs the vacation forwarding ahead of the rules', () => {
|
||||
const script = generateScript([makeRule()], undefined, {
|
||||
includeVacation: true,
|
||||
vacationForward: { enabled: true, to: 'backup@example.org', keepCopy: false },
|
||||
});
|
||||
const forward = script.indexOf('# Vacation forwarding\nif true {\n redirect "backup@example.org";\n stop;\n}');
|
||||
expect(forward).toBeGreaterThan(0);
|
||||
expect(forward).toBeLessThan(script.indexOf('# Rule: Test Rule'));
|
||||
});
|
||||
|
||||
it('includes the auto-reply for colleagues only', () => {
|
||||
const script = generateScript([], undefined, {
|
||||
includeVacation: true,
|
||||
vacationAudience: { only: 'internal', domains: ['example.org'] },
|
||||
});
|
||||
expect(script).toContain('if envelope :domain :is "from" ["example.org"] {\n include :personal :optional "vacation";\n}');
|
||||
expect(script).toMatch(/require \[[^\]]*"envelope"/);
|
||||
});
|
||||
|
||||
describe('action order and targets', () => {
|
||||
it('sets flags before the move so the moved message keeps them', () => {
|
||||
const script = generateScript([makeRule({
|
||||
|
||||
@@ -42,13 +42,55 @@ describe('parseScript', () => {
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns isOpaque for version mismatch', () => {
|
||||
const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
|
||||
it('returns isOpaque for a metadata version it does not know', () => {
|
||||
const script = '/* @metadata:begin\n{"version":3,"rules":[]}\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(true);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
// Webmail 1.13+ writes version 2 once a rule has a period or the vacation
|
||||
// card forwards or narrows its recipients (#1152).
|
||||
it('reads version 2 metadata as its own', () => {
|
||||
const script = '/* @metadata:begin\n{"version":2,"rules":[]}\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
expect(result.isOpaque).toBe(false);
|
||||
expect(result.rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a rule period and the vacation forwarding and audience through a save', () => {
|
||||
const rule = makeRule({ activeFrom: '2026-11-02T07:00:00.000Z', activeUntil: '2026-11-13T17:30:00.000Z' });
|
||||
const vacationForward = { enabled: true, to: 'backup@example.org', keepCopy: true, activeUntil: '2026-11-14T00:00:00.000Z' };
|
||||
const vacationAudience = { only: 'external' as const, domains: ['example.org'] };
|
||||
const script = generateScript([rule], undefined, { includeVacation: true, vacationForward, vacationAudience });
|
||||
expect(script).toContain('{"version":2,');
|
||||
const parsed = parseScript(script);
|
||||
expect(parsed.isOpaque).toBe(false);
|
||||
expect(parsed.rules[0]).toMatchObject({ activeFrom: rule.activeFrom, activeUntil: rule.activeUntil });
|
||||
expect(parsed.vacationForward).toEqual(vacationForward);
|
||||
expect(parsed.vacationAudience).toEqual(vacationAudience);
|
||||
expect(generateScript(parsed.rules, parsed.vacation, {
|
||||
includeVacation: parsed.includeVacation,
|
||||
vacationForward: parsed.vacationForward,
|
||||
vacationAudience: parsed.vacationAudience,
|
||||
})).toBe(script);
|
||||
});
|
||||
|
||||
it('treats a script with an unreadable rule period as hand-edited', () => {
|
||||
const script = '/* @metadata:begin\n' + JSON.stringify({ version: 2, rules: [makeRule({ activeFrom: 'next monday' })] }) + '\n@metadata:end */';
|
||||
expect(parseScript(script).isOpaque).toBe(true);
|
||||
});
|
||||
|
||||
// #1153: the comment collapses the run of spaces; compared as written the
|
||||
// rule came back as someone else's, one more copy on every save.
|
||||
it('matches a rule whose name has a run of spaces to its comment', () => {
|
||||
const script = generateScript([makeRule({ name: 'Foo Bar\tBaz' })]);
|
||||
expect(script).toContain('# Rule: Foo Bar Baz');
|
||||
const parsed = parseScript(script);
|
||||
expect(parsed.rules).toHaveLength(1);
|
||||
expect(parsed.rules[0].origin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns isOpaque for empty metadata block', () => {
|
||||
const script = '/* @metadata:begin\n\n@metadata:end */';
|
||||
const result = parseScript(script);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
applyQuickRule,
|
||||
buildPrefillRule,
|
||||
buildPresetRule,
|
||||
extractListId,
|
||||
quickRuleSubject,
|
||||
ruleTargetMailboxes,
|
||||
toRuleMailbox,
|
||||
} from '../quick-rules';
|
||||
import { generateScript } from '../generator';
|
||||
import type { Email, Mailbox } from '../../../api/types';
|
||||
import type { FilterRule } from '../types';
|
||||
|
||||
const t = (_key: string, fallback: string, params?: Record<string, string | number>) =>
|
||||
fallback.replace(/\{(\w+)\}/g, (_m, k) => String(params?.[k] ?? ''));
|
||||
|
||||
const mailbox = (id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox =>
|
||||
({ id, name, role: null, parentId: null, myRights: { mayAddItems: true }, ...extra }) as unknown as Mailbox;
|
||||
|
||||
const MAILBOXES = [
|
||||
mailbox('inbox', 'Inbox', { role: 'inbox' }),
|
||||
mailbox('news', 'Newsletters', { parentId: 'inbox' }),
|
||||
mailbox('junk', 'Junk', { role: 'junk' }),
|
||||
mailbox('drafts', 'Drafts', { role: 'drafts' }),
|
||||
];
|
||||
|
||||
const email = {
|
||||
from: [{ name: 'Anna', email: 'Anna@Acme.com' }],
|
||||
headers: [{ name: 'List-Id', value: 'Weekly news\r\n <news.acme.com>' }],
|
||||
} as Pick<Email, 'from' | 'headers'>;
|
||||
|
||||
describe('quick rules from a message', () => {
|
||||
const subject = quickRuleSubject([email], new Set(['me@example.org']));
|
||||
|
||||
it('takes the sender, its domain and the List-Id from the message', () => {
|
||||
expect(subject).toEqual({
|
||||
senders: [{ email: 'anna@acme.com', name: 'Anna' }],
|
||||
domain: 'acme.com',
|
||||
listId: 'news.acme.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the user\'s own address out', () => {
|
||||
expect(quickRuleSubject([{ from: [{ email: 'me@example.org' }] } as Email], new Set(['me@example.org'])).senders).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads a List-Id without brackets whole and refuses one with spaces', () => {
|
||||
expect(extractListId('list.example.org')).toBe('list.example.org');
|
||||
expect(extractListId('two words')).toBeNull();
|
||||
});
|
||||
|
||||
// The rules match the exact address or domain, so a save keeps them exact.
|
||||
it('moves from the exact sender into the folder by id and path', () => {
|
||||
const rule = buildPresetRule({ kind: 'move_sender', mailbox: toRuleMailbox(MAILBOXES, MAILBOXES[1]) }, subject, t, 'r1');
|
||||
expect(rule).toMatchObject({
|
||||
name: 'Move from anna@acme.com to Newsletters',
|
||||
conditions: [{ field: 'from', comparator: 'address_is', value: 'anna@acme.com' }],
|
||||
actions: [{ type: 'move', value: 'INBOX/Newsletters', mailboxId: 'news' }],
|
||||
stopProcessing: true,
|
||||
});
|
||||
expect(generateScript([rule])).toContain('if address :is "From" "anna@acme.com" {');
|
||||
});
|
||||
|
||||
it('moves a whole domain or list', () => {
|
||||
const target = toRuleMailbox(MAILBOXES, MAILBOXES[1]);
|
||||
expect(buildPresetRule({ kind: 'move_domain', mailbox: target }, subject, t, 'r').conditions)
|
||||
.toEqual([{ field: 'from', comparator: 'domain_is', value: 'acme.com' }]);
|
||||
expect(buildPresetRule({ kind: 'move_list', mailbox: target }, subject, t, 'r').conditions)
|
||||
.toEqual([{ field: 'header', headerName: 'List-Id', comparator: 'contains', value: 'news.acme.com' }]);
|
||||
});
|
||||
|
||||
it('blocks into Junk without the spam guard', () => {
|
||||
const rule = buildPresetRule({ kind: 'block', junk: toRuleMailbox(MAILBOXES, MAILBOXES[2]) }, subject, t, 'r');
|
||||
expect(rule.includeSpam).toBe(true);
|
||||
expect(rule.actions).toEqual([{ type: 'move', value: 'Junk', mailboxId: 'junk' }]);
|
||||
});
|
||||
|
||||
it('offers every folder that takes mail but Drafts', () => {
|
||||
expect(ruleTargetMailboxes(MAILBOXES).map((m) => m.id)).toEqual(['inbox', 'news', 'junk']);
|
||||
});
|
||||
|
||||
it('opens the editor on the senders with a move to pick', () => {
|
||||
expect(buildPrefillRule(subject, t, 'p')).toMatchObject({
|
||||
name: 'Mail from anna@acme.com',
|
||||
conditions: [{ field: 'from', comparator: 'address_is', value: 'anna@acme.com' }],
|
||||
actions: [{ type: 'move', value: '' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyQuickRule', () => {
|
||||
const target = toRuleMailbox(MAILBOXES, MAILBOXES[1]);
|
||||
const make = (address: string, id: string): FilterRule =>
|
||||
buildPresetRule({ kind: 'move_sender', mailbox: target }, { senders: [{ email: address }], domain: null, listId: null }, t, id);
|
||||
|
||||
it('puts a new rule at the top', () => {
|
||||
const other = make('x@y.z', 'old');
|
||||
const outcome = applyQuickRule([{ ...other, actions: [{ type: 'mark_read' }] }], make('a@b.c', 'new'));
|
||||
expect(outcome.kind).toBe('created');
|
||||
if (outcome.kind !== 'covered') expect(outcome.rules[0].id).toBe('new');
|
||||
});
|
||||
|
||||
it('adds the sender to a rule that already does the same', () => {
|
||||
const outcome = applyQuickRule([make('x@y.z', 'old')], make('a@b.c', 'new'));
|
||||
expect(outcome.kind).toBe('merged');
|
||||
if (outcome.kind === 'merged') {
|
||||
expect(outcome.rules).toHaveLength(1);
|
||||
expect(outcome.rule.conditions[0].value).toEqual(['x@y.z', 'a@b.c']);
|
||||
}
|
||||
});
|
||||
|
||||
it('writes nothing when a rule covers the sender already', () => {
|
||||
expect(applyQuickRule([make('A@b.c', 'old')], make('a@b.c', 'new')).kind).toBe('covered');
|
||||
});
|
||||
|
||||
it('never merges into a rule with a period', () => {
|
||||
const timed = { ...make('x@y.z', 'old'), activeUntil: '2030-01-01T00:00:00.000Z' };
|
||||
expect(applyQuickRule([timed], make('a@b.c', 'new')).kind).toBe('created');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { periodError, periodFromInput, periodToInput } from '../rule-period';
|
||||
import { utcIsoToLocalInput } from '../../vacation-dates';
|
||||
|
||||
describe('rule period fields', () => {
|
||||
it('shows a stored boundary in local time and an unreadable one as empty', () => {
|
||||
expect(periodToInput('2026-11-02T07:00:00.000Z')).toBe(utcIsoToLocalInput('2026-11-02T07:00:00.000Z'));
|
||||
expect(periodToInput('next monday')).toBe('');
|
||||
expect(periodToInput(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('stores the typed local time as a UTC moment', () => {
|
||||
const local = new Date(2026, 10, 2, 9, 30);
|
||||
expect(periodFromInput('2026-11-02 09:30', undefined)).toBe(local.toISOString());
|
||||
});
|
||||
|
||||
it('keeps a stored boundary exactly while its field is left as shown', () => {
|
||||
const saved = '2026-11-02T07:00:42.000Z';
|
||||
expect(periodFromInput(periodToInput(saved), saved)).toBe(saved);
|
||||
});
|
||||
|
||||
it('reads an empty field as an open end and garbage as unusable', () => {
|
||||
expect(periodFromInput(' ', undefined)).toBeUndefined();
|
||||
expect(periodFromInput('2026-13-45', undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('says what is wrong with a period', () => {
|
||||
expect(periodError(undefined, undefined)).toBe('empty');
|
||||
expect(periodError(null, undefined)).toBe('invalid');
|
||||
expect(periodError('2026-11-02T10:00:00.000Z', '2026-11-02T09:00:00.000Z')).toBe('order');
|
||||
expect(periodError('2026-11-02T10:00:00.000Z', undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,13 @@ import { generateScript } from '../generator';
|
||||
import { parseScript } from '../parser';
|
||||
import { readWebmailFixture, readWebmailResave, WEBMAIL_FIXTURES } from './fixtures/webmail';
|
||||
|
||||
function metadataRules(script: string): unknown[] {
|
||||
function metadata(script: string): Record<string, unknown> {
|
||||
const match = script.match(/@metadata:begin\n(.*)\n@metadata:end/);
|
||||
return JSON.parse(match![1]).rules;
|
||||
return JSON.parse(match![1]);
|
||||
}
|
||||
|
||||
function metadataRules(script: string): unknown[] {
|
||||
return metadata(script).rules as unknown[];
|
||||
}
|
||||
|
||||
// Blank lines carry no meaning in Sieve.
|
||||
@@ -27,6 +31,8 @@ describe('scripts written by the webmail', () => {
|
||||
const regenerated = generateScript(parsed.rules, parsed.vacation, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
includeVacation: parsed.includeVacation,
|
||||
vacationForward: parsed.vacationForward,
|
||||
vacationAudience: parsed.vacationAudience,
|
||||
extensions,
|
||||
});
|
||||
|
||||
@@ -36,6 +42,13 @@ describe('scripts written by the webmail', () => {
|
||||
expect(bulwark).toEqual(metadataRules(script));
|
||||
});
|
||||
|
||||
it('keeps the vacation forwarding and audience the webmail stored', () => {
|
||||
const stored = metadata(script);
|
||||
expect(parsed.vacationForward).toEqual(stored.vacationForward);
|
||||
expect(parsed.vacationAudience).toEqual(stored.vacationAudience);
|
||||
expect(metadata(regenerated)).toEqual(stored);
|
||||
});
|
||||
|
||||
it('saves back what the webmail would save', () => {
|
||||
expect(regenerated).toBe(readWebmailResave(file));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FilterCondition, FilterRule } from './types';
|
||||
import type { FilterComparator, FilterCondition, FilterConditionField, FilterRule } from './types';
|
||||
|
||||
// Helpers for the string | string[] condition value (1.7.3 "extended rules").
|
||||
// Mirrors the webmail's filter-rule-modal.tsx: conditions are stored as
|
||||
@@ -27,12 +27,35 @@ export function isHasAnyCondition(cond: FilterCondition): boolean {
|
||||
return cond.field === 'attachment' && cond.comparator === 'has_any';
|
||||
}
|
||||
|
||||
/** Conditions that test without a value: "has an attachment" and "all messages". */
|
||||
export function takesNoValue(cond: FilterCondition): boolean {
|
||||
return cond.field === 'all' || isHasAnyCondition(cond);
|
||||
}
|
||||
|
||||
const TEXT_COMPARATORS: FilterComparator[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'];
|
||||
// The `address` test (webmail 1.13): exact address or exact domain, where
|
||||
// "contains" would also match joanna@ for anna@, or acme.com.evil for acme.com.
|
||||
const ADDRESS_COMPARATORS: FilterComparator[] = [...TEXT_COMPARATORS, 'address_is', 'domain_is'];
|
||||
const SIZE_COMPARATORS: FilterComparator[] = ['greater_than', 'less_than'];
|
||||
const ATTACHMENT_COMPARATORS: FilterComparator[] = ['has_any', 'has_type'];
|
||||
// "All messages" (#1154): Sieve `true`, nothing to compare.
|
||||
const ALL_MESSAGES_COMPARATORS: FilterComparator[] = ['any'];
|
||||
|
||||
/** The comparators the rule editor offers for a field (webmail filter-rule-modal.tsx). */
|
||||
export function comparatorsFor(field: FilterConditionField): FilterComparator[] {
|
||||
if (field === 'size') return SIZE_COMPARATORS;
|
||||
if (field === 'attachment') return ATTACHMENT_COMPARATORS;
|
||||
if (field === 'all') return ALL_MESSAGES_COMPARATORS;
|
||||
if (field === 'from' || field === 'to' || field === 'cc') return ADDRESS_COMPARATORS;
|
||||
return TEXT_COMPARATORS;
|
||||
}
|
||||
|
||||
type Translate = (key: string, fallback?: string) => string;
|
||||
|
||||
// Human-readable value part of a condition: `"a"`, or `"a" or "b"` for lists
|
||||
// using the locale's OR glue, or nothing for the value-less has_any test.
|
||||
export function formatConditionValue(cond: FilterCondition, t: Translate): string {
|
||||
if (isHasAnyCondition(cond)) return '';
|
||||
if (isHasAnyCondition(cond) || cond.field === 'all') return '';
|
||||
if (Array.isArray(cond.value)) {
|
||||
return cond.value.map((v) => `"${v}"`).join(` ${t('settings.filters.or', 'or')} `);
|
||||
}
|
||||
@@ -41,6 +64,8 @@ export function formatConditionValue(cond: FilterCondition, t: Translate): strin
|
||||
|
||||
export function describeCondition(cond: FilterCondition, t: Translate): string {
|
||||
const field = t(`settings.filters.condition_fields.${cond.field}`, cond.field);
|
||||
// "All messages" says it all; it has no comparator to show.
|
||||
if (cond.field === 'all') return field;
|
||||
const comparator = t(`settings.filters.comparators.${cond.comparator}`, cond.comparator);
|
||||
const value = formatConditionValue(cond, t);
|
||||
return value ? `${field} ${comparator} ${value}` : `${field} ${comparator}`;
|
||||
|
||||
+146
-21
@@ -3,8 +3,13 @@ import type {
|
||||
FilterCondition,
|
||||
FilterAction,
|
||||
FilterMetadata,
|
||||
VacationAudience,
|
||||
VacationForward,
|
||||
VacationSieveConfig,
|
||||
} from './types';
|
||||
import { PERIOD_REQUIRES, hasPeriod, periodTests } from './period';
|
||||
import { VACATION_FORWARD_MARKER, isValidVacationForward, normalizeVacationForward } from './vacation-forward';
|
||||
import { isValidVacationAudience, normalizeVacationAudience } from './vacation-audience';
|
||||
|
||||
// Ported from the webmail's lib/sieve/generator.ts. Emits an RFC 5228 Sieve
|
||||
// script with a leading `@metadata` JSON comment that lets the parser round-trip
|
||||
@@ -18,6 +23,9 @@ const HEADER_MAP: Record<string, string> = {
|
||||
subject: 'Subject',
|
||||
};
|
||||
|
||||
/** Fields whose header holds addresses, so the `address` test applies. */
|
||||
const ADDRESS_FIELDS = new Set<FilterCondition['field']>(['from', 'to', 'cc']);
|
||||
|
||||
function escapeString(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
@@ -34,10 +42,11 @@ export function isValidSieveHeaderName(name: string): boolean {
|
||||
/**
|
||||
* A rule's name as it appears in its `# Rule:` comment, which ends at the
|
||||
* line break: a CR or LF in the name would have turned the rest of it into
|
||||
* script. The parser matches the comment back to the rule by this form.
|
||||
* script. Every whitespace run becomes one space, as the webmail writes it;
|
||||
* the parser matches the comment back to the rule by this form.
|
||||
*/
|
||||
export function ruleCommentName(name: string): string {
|
||||
return name.replace(/[\r\n]+/g, ' ').trim();
|
||||
return name.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/** A `header` condition whose name the server would refuse. */
|
||||
@@ -66,9 +75,15 @@ function formatStringArg(values: string[], transform: (s: string) => string = (s
|
||||
function generateCondition(condition: FilterCondition): string {
|
||||
const { field, comparator, value } = condition;
|
||||
|
||||
// Every message: there is nothing to compare.
|
||||
if (field === 'all') return 'true';
|
||||
|
||||
if (field === 'size') {
|
||||
// Size is numeric, single value only.
|
||||
const sizeValue = Array.isArray(value) ? value[0] : value;
|
||||
// Size is numeric, single value only. It is written unquoted, so
|
||||
// anything but a number (with an optional K/M/G quantifier) would be
|
||||
// Sieve source; fall back to 0.
|
||||
const raw = String((Array.isArray(value) ? value[0] : value) ?? '').trim();
|
||||
const sizeValue = /^\d+[KMG]?$/i.test(raw) ? raw : '0';
|
||||
const op = comparator === 'greater_than' ? ':over' : ':under';
|
||||
return `size ${op} ${sizeValue}`;
|
||||
}
|
||||
@@ -100,7 +115,7 @@ function generateCondition(condition: FilterCondition): string {
|
||||
return `header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ${formatStringArg(normalised, (ext) => `*.${ext}*`)}`;
|
||||
}
|
||||
|
||||
const headerName = field === 'header'
|
||||
const rawHeaderName = field === 'header'
|
||||
? (condition.headerName?.trim() || 'X-Unknown')
|
||||
: HEADER_MAP[field];
|
||||
|
||||
@@ -108,9 +123,22 @@ function generateCondition(condition: FilterCondition): string {
|
||||
// must never be flattened into `header :contains "undefined"`: that would
|
||||
// silently rewrite the user's rule server-side on the next save. Fail the
|
||||
// save instead so the script stays untouched.
|
||||
if (!headerName) {
|
||||
if (!rawHeaderName) {
|
||||
throw new Error(`Unsupported filter condition field: ${String(field)}`);
|
||||
}
|
||||
const headerName = escapeString(rawHeaderName);
|
||||
|
||||
// RFC 5228 §5.1: `address` compares the parsed address, so the display
|
||||
// name and the angle brackets play no part. `header :contains` would let
|
||||
// "anna@acme.com" match joanna@acme.com, and `header :matches "*@acme.com"`
|
||||
// never matches "Anna <anna@acme.com>" because of the closing ">".
|
||||
if (comparator === 'address_is' || comparator === 'domain_is') {
|
||||
if (!ADDRESS_FIELDS.has(field)) {
|
||||
throw new Error(`Unsupported filter comparator for ${String(field)}: ${comparator}`);
|
||||
}
|
||||
const part = comparator === 'domain_is' ? ':domain ' : '';
|
||||
return `address ${part}:is "${headerName}" ${formatStringArg(values)}`;
|
||||
}
|
||||
|
||||
switch (comparator) {
|
||||
case 'contains':
|
||||
@@ -128,7 +156,10 @@ function generateCondition(condition: FilterCondition): string {
|
||||
case 'matches':
|
||||
return `header :matches "${headerName}" ${formatStringArg(values)}`;
|
||||
default:
|
||||
return `header :contains "${headerName}" ${formatStringArg(values)}`;
|
||||
// As with an unknown field: a comparator from a newer client written
|
||||
// as `header :contains` would quietly broaden the rule ("is the
|
||||
// address" catching every address that merely contains it).
|
||||
throw new Error(`Unsupported filter comparator: ${String(comparator)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +207,8 @@ function generateActions(actions: FilterAction[], useMailboxId: boolean): string
|
||||
return 'fileinto "INBOX";';
|
||||
case 'stop':
|
||||
return 'stop;';
|
||||
default:
|
||||
throw new Error(`Unsupported filter action: ${String((action as FilterAction).type)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -185,7 +218,7 @@ function generateActions(actions: FilterAction[], useMailboxId: boolean): string
|
||||
const SPAM_GUARD = 'not spamtest :percent :value "ge" :comparator "i;ascii-numeric" "50"';
|
||||
const SPAM_GUARD_REQUIRES = ['spamtestplus', 'relational', 'comparator-i;ascii-numeric'];
|
||||
|
||||
function supportsSpamGuard(extensions: string[] | undefined): boolean {
|
||||
export function supportsSpamGuard(extensions: string[] | undefined): boolean {
|
||||
return ['spamtestplus', 'relational'].every(e => extensions?.includes(e));
|
||||
}
|
||||
|
||||
@@ -217,6 +250,9 @@ function computeRequires(
|
||||
if (needsSpamGuard(rule, serverExtensions)) {
|
||||
for (const e of SPAM_GUARD_REQUIRES) extensions.add(e);
|
||||
}
|
||||
if (periodTests(rule)?.length) {
|
||||
for (const e of PERIOD_REQUIRES) extensions.add(e);
|
||||
}
|
||||
for (const condition of rule.conditions) {
|
||||
if (condition.field === 'body') extensions.add('body');
|
||||
if (condition.field === 'attachment') extensions.add('mime');
|
||||
@@ -264,6 +300,8 @@ function stripRuleForMetadata(r: FilterRule): Omit<FilterRule, 'origin' | 'origi
|
||||
actions: r.actions,
|
||||
stopProcessing: r.stopProcessing,
|
||||
...(r.includeSpam ? { includeSpam: true } : {}),
|
||||
...(r.activeFrom !== undefined ? { activeFrom: r.activeFrom } : {}),
|
||||
...(r.activeUntil !== undefined ? { activeUntil: r.activeUntil } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -280,6 +318,16 @@ export interface GenerateOptions {
|
||||
* while the filters stay active.
|
||||
*/
|
||||
includeVacation?: boolean;
|
||||
/**
|
||||
* Forwarding from the vacation card. Always kept in the metadata; it runs
|
||||
* while it is switched on, with or without the auto-reply.
|
||||
*/
|
||||
vacationForward?: VacationForward | null;
|
||||
/**
|
||||
* Who gets the auto-reply when not everyone should. Always kept in the
|
||||
* metadata; it narrows the vacation include (`includeVacation`).
|
||||
*/
|
||||
vacationAudience?: VacationAudience | null;
|
||||
/**
|
||||
* The server's `sieveExtensions`. Folder moves use `:mailboxid` when it
|
||||
* lists "mailboxid"; without it they target the folder path only.
|
||||
@@ -313,6 +361,28 @@ export function generateScript(
|
||||
if (options.includeVacation) {
|
||||
metadata.includeVacation = true;
|
||||
}
|
||||
if (options.vacationForward) {
|
||||
if (isValidVacationForward(options.vacationForward)) {
|
||||
metadata.vacationForward = normalizeVacationForward(options.vacationForward);
|
||||
} else {
|
||||
console.warn('[filters] Dropping unusable vacation forwarding');
|
||||
}
|
||||
}
|
||||
if (options.vacationAudience) {
|
||||
if (isValidVacationAudience(options.vacationAudience)) {
|
||||
metadata.vacationAudience = normalizeVacationAudience(options.vacationAudience);
|
||||
} else {
|
||||
console.warn('[filters] Dropping unusable vacation audience');
|
||||
}
|
||||
}
|
||||
// A build that only knows version 1 would take such a script as its own and
|
||||
// write it back without what it does not know: a rule without its period
|
||||
// forwards for good, the auto-reply goes to everyone. It leaves version 2
|
||||
// alone. A forward that is off only loses its remembered address that way,
|
||||
// so it stays version 1, as the webmail writes it.
|
||||
if (metadata.rules.some(hasPeriod) || metadata.vacationForward?.enabled || metadata.vacationAudience) {
|
||||
metadata.version = 2;
|
||||
}
|
||||
// The rules ride in a `/* */` comment, which the first `*/` ends: one in a
|
||||
// rule name or value cut the metadata short and turned the rest into
|
||||
// script. JSON reads `\/` back as `/`, so every client parses it as before.
|
||||
@@ -327,6 +397,19 @@ export function generateScript(
|
||||
const useMailboxId = options.extensions?.includes('mailboxid') ?? false;
|
||||
const bulwarkRequires = computeRequires(bulwarkRules, vacation, useMailboxId, options.extensions);
|
||||
if (options.includeVacation) bulwarkRequires.push('include');
|
||||
if (options.includeVacation && metadata.vacationAudience) bulwarkRequires.push('envelope');
|
||||
|
||||
// Forwarding from the vacation card runs whenever it is switched on, with
|
||||
// or without the auto-reply, ahead of every rule. A period it cannot read
|
||||
// would let it forward for good, so then it does not run.
|
||||
const forward = metadata.vacationForward?.enabled ? metadata.vacationForward : null;
|
||||
const forwardPeriod = forward ? periodTests(forward) : null;
|
||||
const forwardSpamGuard = supportsSpamGuard(options.extensions);
|
||||
if (forward && forwardPeriod) {
|
||||
if (forward.keepCopy) bulwarkRequires.push('copy');
|
||||
if (forwardPeriod.length > 0) bulwarkRequires.push(...PERIOD_REQUIRES);
|
||||
if (forwardSpamGuard) bulwarkRequires.push(...SPAM_GUARD_REQUIRES);
|
||||
}
|
||||
const externalRequires = options.externalRequires ?? [];
|
||||
const allRequires = [...new Set([...bulwarkRequires, ...externalRequires])].sort();
|
||||
|
||||
@@ -339,7 +422,20 @@ export function generateScript(
|
||||
// the parser does not re-import it as an external rule.
|
||||
lines.push('');
|
||||
lines.push('# Vacation auto-reply');
|
||||
lines.push(`include :personal :optional "${VACATION_SCRIPT_NAME}";`);
|
||||
const include = `include :personal :optional "${VACATION_SCRIPT_NAME}";`;
|
||||
const audience = metadata.vacationAudience;
|
||||
if (audience) {
|
||||
// Only senders from (or not from) the account's own domains. The reply
|
||||
// goes to the envelope sender, so that is the address to judge: a From
|
||||
// header can name a colleague on mail from anywhere, and the reply
|
||||
// meant for colleagues would go to whoever sent it.
|
||||
const domains = audience.domains.map(d => `"${escapeString(d)}"`).join(', ');
|
||||
lines.push(`if ${audience.only === 'external' ? 'not ' : ''}envelope :domain :is "from" [${domains}] {`);
|
||||
lines.push(` ${include}`);
|
||||
lines.push('}');
|
||||
} else {
|
||||
lines.push(include);
|
||||
}
|
||||
}
|
||||
|
||||
if (vacation?.isEnabled) {
|
||||
@@ -353,6 +449,19 @@ export function generateScript(
|
||||
lines.push(`vacation ${vacationParts.join(' ')};`);
|
||||
}
|
||||
|
||||
if (forward && forwardPeriod) {
|
||||
// Spam is not passed on: it stays here for the server's own handling.
|
||||
const tests = [...forwardPeriod, ...(forwardSpamGuard ? [SPAM_GUARD] : [])];
|
||||
const condition = tests.length === 0 ? 'true' : tests.length === 1 ? tests[0] : `allof(${tests.join(', ')})`;
|
||||
lines.push('');
|
||||
lines.push(VACATION_FORWARD_MARKER);
|
||||
lines.push(`if ${condition} {`);
|
||||
lines.push(` redirect ${forward.keepCopy ? ':copy ' : ''}"${escapeString(forward.to)}";`);
|
||||
// Without a copy kept here, no rule below may file it either.
|
||||
if (!forward.keepCopy) lines.push(' stop;');
|
||||
lines.push('}');
|
||||
}
|
||||
|
||||
const enabledBulwarkRules = bulwarkRules.filter(r => r.enabled);
|
||||
|
||||
for (const rule of enabledBulwarkRules) {
|
||||
@@ -367,6 +476,13 @@ export function generateScript(
|
||||
console.warn(`[filters] Skipping rule "${rule.name}": a header name the server would refuse`);
|
||||
continue;
|
||||
}
|
||||
// Without its period a rule would act all the time - a forwarding rule
|
||||
// would forward for good - so one whose period is unusable is dropped.
|
||||
const period = periodTests(rule);
|
||||
if (period === null) {
|
||||
console.warn(`[filters] Skipping rule "${rule.name}": unusable period`);
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(`# Rule: ${ruleCommentName(rule.name)}`);
|
||||
@@ -389,13 +505,20 @@ export function generateScript(
|
||||
: `allof(${conditionStr}, ${SPAM_GUARD})`;
|
||||
}
|
||||
|
||||
// The period comes first: outside it, nothing else is evaluated.
|
||||
if (period.length > 0) {
|
||||
conditionStr = `allof(${[...period, conditionStr].join(', ')})`;
|
||||
}
|
||||
|
||||
const actionLines = generateActions(rule.actions, useMailboxId);
|
||||
|
||||
if (rule.stopProcessing) {
|
||||
const lastAction = rule.actions[rule.actions.length - 1];
|
||||
if (!lastAction || !['stop', 'discard', 'reject'].includes(lastAction.type)) {
|
||||
actionLines.push('stop;');
|
||||
}
|
||||
// discard and reject only cancel the implicit keep (RFC 5228 4.4,
|
||||
// RFC 5429); the script goes on, and the rules below would still act on
|
||||
// the message. So "stop processing" always writes a stop, unless the
|
||||
// block has one already: it runs straight through, so any stop in it ends
|
||||
// the script.
|
||||
if (rule.stopProcessing && !actionLines.includes('stop;')) {
|
||||
actionLines.push('stop;');
|
||||
}
|
||||
|
||||
lines.push(`if ${conditionStr} {`);
|
||||
@@ -405,15 +528,17 @@ export function generateScript(
|
||||
lines.push('}');
|
||||
}
|
||||
|
||||
// Append preserved external rules verbatim. Each rawBlock already carries its
|
||||
// own leading comments and trailing whitespace from the source script.
|
||||
if (externalRules.length > 0) {
|
||||
// Append preserved external rules verbatim. Each rawBlock carries its own
|
||||
// leading comments, but also the blank lines around it in the source
|
||||
// script; trim those and put exactly one blank line between blocks, so a
|
||||
// save writes the same script as the one before it.
|
||||
const externalBlocks = externalRules
|
||||
.map((ext) => (ext.rawBlock ?? '').trim())
|
||||
.filter((block) => block.length > 0);
|
||||
if (externalBlocks.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('# --- External rules (managed outside Bulwark) ---');
|
||||
for (const ext of externalRules) {
|
||||
if (!ext.rawBlock) continue;
|
||||
lines.push(ext.rawBlock.replace(/\s+$/, ''));
|
||||
}
|
||||
lines.push(externalBlocks.join('\n\n'));
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
+86
-14
@@ -5,15 +5,19 @@ import type {
|
||||
FilterConditionField,
|
||||
FilterMetadata,
|
||||
FilterRule,
|
||||
VacationAudience,
|
||||
VacationForward,
|
||||
VacationSieveConfig,
|
||||
} from './types';
|
||||
import { ruleCommentName } from './generator';
|
||||
|
||||
import { isPeriodBoundary } from './period';
|
||||
import { VACATION_FORWARD_MARKER_RE, isValidVacationForward } from './vacation-forward';
|
||||
import { isValidVacationAudience } from './vacation-audience';
|
||||
// Ported from the webmail's lib/sieve/parser.ts. Parses a Sieve script back
|
||||
// into structured rules. Bulwark-authored scripts carry an `@metadata` JSON
|
||||
// block we trust directly; foreign scripts (Roundcube, Nextcloud, hand-edited)
|
||||
// are best-effort parsed into read-only "external" rules or preserved opaquely.
|
||||
|
||||
|
||||
export interface ParseResult {
|
||||
rules: FilterRule[];
|
||||
isOpaque: boolean;
|
||||
@@ -21,6 +25,10 @@ export interface ParseResult {
|
||||
externalRequires: string[];
|
||||
/** The script runs the server's "vacation" script via `include`. */
|
||||
includeVacation?: boolean;
|
||||
/** Forwarding from the vacation card, on or off. */
|
||||
vacationForward?: VacationForward;
|
||||
/** Who gets the auto-reply, when not everyone. */
|
||||
vacationAudience?: VacationAudience;
|
||||
}
|
||||
|
||||
const OPAQUE: ParseResult = { rules: [], isOpaque: true, externalRequires: [] };
|
||||
@@ -69,6 +77,12 @@ function isValidRule(rule: unknown): rule is FilterRule {
|
||||
typeof r.stopProcessing !== 'boolean'
|
||||
) return false;
|
||||
|
||||
// A period that cannot be read would be dropped on the next save and the
|
||||
// rule would act all the time; treating the script as hand-edited leaves
|
||||
// it untouched instead.
|
||||
if (r.activeFrom !== undefined && !isPeriodBoundary(r.activeFrom)) return false;
|
||||
if (r.activeUntil !== undefined && !isPeriodBoundary(r.activeUntil)) return false;
|
||||
|
||||
return r.conditions.every(isValidCondition) && r.actions.every(isValidAction);
|
||||
}
|
||||
|
||||
@@ -134,6 +148,19 @@ function skipBlockComment(s: string, i: number): number {
|
||||
return end === -1 ? s.length : end + 2;
|
||||
}
|
||||
|
||||
// Past a `text:` string: every line up to and including the lone ".".
|
||||
function skipMultiLineString(s: string, i: number): number {
|
||||
let line = s.indexOf('\n', i) + 1;
|
||||
if (line === 0) return s.length;
|
||||
while (line < s.length) {
|
||||
const nl = s.indexOf('\n', line);
|
||||
const next = nl === -1 ? s.length : nl + 1;
|
||||
if (s.slice(line, next).replace(/\r?\n$/, '') === '.') return next;
|
||||
line = next;
|
||||
}
|
||||
return s.length;
|
||||
}
|
||||
|
||||
function skipStatement(s: string, i: number): number {
|
||||
while (i < s.length) {
|
||||
const c = s[i];
|
||||
@@ -458,6 +485,22 @@ function parseAtom(raw: string): FilterCondition | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `address [:all|:domain] :is` on From/To/Cc, as the generator writes the
|
||||
// address_is / domain_is comparators. Sieve takes tagged arguments in any
|
||||
// order, so the address part may also follow `:is`. Anything else about an
|
||||
// address test (another part, another match type, a negation) has no
|
||||
// builder equivalent and is left for the opaque fallback.
|
||||
m = /^address\s+(?:(:all|:domain)\s+)?:is\s+(?:(:all|:domain)\s+)?"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
const [, partBefore, partAfter, headerName, rawTail] = m;
|
||||
if (negated || (partBefore && partAfter)) return null;
|
||||
const field = FIELD_FROM_HEADER[unescapeSieveString(headerName).toLowerCase()];
|
||||
const value = parseValueTail(rawTail);
|
||||
if (!field || field === 'subject' || value === null) return null;
|
||||
const part = partBefore ?? partAfter;
|
||||
return { field, comparator: part === ':domain' ? 'domain_is' : 'address_is', value };
|
||||
}
|
||||
|
||||
m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
|
||||
if (m) {
|
||||
const [, tag, headerName, rawTail] = m;
|
||||
@@ -674,14 +717,25 @@ function extractNextcloudRegions(content: string): {
|
||||
requires: string[];
|
||||
} {
|
||||
const marker = NEXTCLOUD_BLOCK_MARKER;
|
||||
// Only a comment line counts. The same text inside a string (a rule's value,
|
||||
// an auto-reply) is data, and taken for a region it would be written back
|
||||
// as live Sieve.
|
||||
const positions: number[] = [];
|
||||
let searchFrom = 0;
|
||||
while (true) {
|
||||
const idx = content.indexOf(marker, searchFrom);
|
||||
if (idx === -1) break;
|
||||
const atLineStart = idx === 0 || content[idx - 1] === '\n';
|
||||
if (atLineStart) positions.push(idx);
|
||||
searchFrom = idx + marker.length;
|
||||
let i = 0;
|
||||
while (i < content.length) {
|
||||
const c = content[i];
|
||||
if (c === '"') { i = skipStringLit(content, i); continue; }
|
||||
if (c === '/' && content[i + 1] === '*') { i = skipBlockComment(content, i); continue; }
|
||||
if (c === '#') {
|
||||
if ((i === 0 || content[i - 1] === '\n') && content.startsWith(marker, i)) positions.push(i);
|
||||
i = skipHashComment(content, i);
|
||||
continue;
|
||||
}
|
||||
if (content.slice(i, i + 5).toLowerCase() === 'text:' && !/\w/.test(content[i - 1] ?? '')) {
|
||||
i = skipMultiLineString(content, i);
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (positions.length < 2) {
|
||||
@@ -801,12 +855,21 @@ export function parseScript(content: string): ParseResult {
|
||||
return OPAQUE;
|
||||
}
|
||||
|
||||
if (!metadata || metadata.version !== 1) return OPAQUE;
|
||||
// Version 2 only marks fields older builds do not know (see FilterMetadata).
|
||||
if (!metadata || (metadata.version !== 1 && metadata.version !== 2)) return OPAQUE;
|
||||
if (!Array.isArray(metadata.rules)) return OPAQUE;
|
||||
|
||||
for (const rule of metadata.rules) {
|
||||
if (!isValidRule(rule)) return OPAQUE;
|
||||
}
|
||||
// Forwarding that cannot be read could be written back without its
|
||||
// period; as with a rule, the script then counts as edited by hand.
|
||||
if (metadata.vacationForward !== undefined && !isValidVacationForward(metadata.vacationForward)) {
|
||||
return OPAQUE;
|
||||
}
|
||||
if (metadata.vacationAudience !== undefined && !isValidVacationAudience(metadata.vacationAudience)) {
|
||||
return OPAQUE;
|
||||
}
|
||||
|
||||
// Scan the portion AFTER the metadata block for external rules. A prior
|
||||
// Bulwark save may have emitted its "External rules" header here; strip
|
||||
@@ -836,13 +899,20 @@ export function parseScript(content: string): ParseResult {
|
||||
// identifies it as ours.
|
||||
const filteredExternal = external.rules.filter(r => {
|
||||
const raw = r.rawBlock || '';
|
||||
const match = raw.match(/#\s*Rule:\s*(.+?)\s*$/m);
|
||||
// The name ends with its line, and may be empty.
|
||||
const match = raw.match(/#\s*Rule:[ \t]*(.*?)[ \t]*$/m);
|
||||
if (match) {
|
||||
const name = match[1].trim();
|
||||
// The comment carries the name without line breaks (ruleCommentName).
|
||||
if (bulwarkRules.some(b => ruleCommentName(b.name) === name)) return false;
|
||||
// The generator writes the name on one line, its whitespace runs
|
||||
// collapsed; compared as written, "Foo Bar" would come back as
|
||||
// someone else's rule, and as one more copy on every save.
|
||||
const oneLine = (name: string) => name.replace(/\s+/g, ' ').trim();
|
||||
const name = oneLine(match[1]);
|
||||
if (bulwarkRules.some(b => oneLine(b.name) === name)) return false;
|
||||
}
|
||||
if (/#\s*Vacation auto-reply/i.test(raw)) return false;
|
||||
// The generator writes the forwarding block from the metadata again.
|
||||
// Only written while switched on, so only then is such a block Bulwark's.
|
||||
if (metadata.vacationForward?.enabled && VACATION_FORWARD_MARKER_RE.test(raw)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -860,6 +930,8 @@ export function parseScript(content: string): ParseResult {
|
||||
vacation: metadata.vacation,
|
||||
externalRequires,
|
||||
...(metadata.includeVacation === true ? { includeVacation: true } : {}),
|
||||
...(metadata.vacationForward ? { vacationForward: metadata.vacationForward } : {}),
|
||||
...(metadata.vacationAudience ? { vacationAudience: metadata.vacationAudience } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { FilterRule } from './types';
|
||||
|
||||
/**
|
||||
* Time periods for filter rules: "only act on mail that arrives from ...
|
||||
* until ...". Sieve has no rule scheduling of its own; the date extension
|
||||
* (RFC 5260) lets a rule test the moment the message is being filtered.
|
||||
*/
|
||||
|
||||
/** Sieve extensions a period needs: `currentdate` and `:value` comparisons. */
|
||||
export const PERIOD_REQUIRES = ['date', 'relational'] as const;
|
||||
|
||||
export function supportsPeriods(extensions: readonly string[] | undefined | null): boolean {
|
||||
return PERIOD_REQUIRES.every((e) => extensions?.includes(e));
|
||||
}
|
||||
|
||||
// A moment with its zone spelled out. Without one, Date.parse reads the value
|
||||
// as local time, which would make the script depend on the browser saving it.
|
||||
const ISO_WITH_ZONE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})$/;
|
||||
|
||||
/** Whether `value` is a usable period boundary (see FilterRule.activeFrom). */
|
||||
export function isPeriodBoundary(value: unknown): value is string {
|
||||
if (typeof value !== 'string' || !ISO_WITH_ZONE.test(value)) return false;
|
||||
const time = Date.parse(value);
|
||||
// In UTC the zone can push it past year 9999 ("9999-12-31T23:59-01:00"),
|
||||
// which the four-digit year of Sieve's "date" part cannot hold.
|
||||
return !Number.isNaN(time) && /^\d{4}-/.test(new Date(time).toISOString());
|
||||
}
|
||||
|
||||
export function hasPeriod(rule: Pick<FilterRule, 'activeFrom' | 'activeUntil'>): boolean {
|
||||
return rule.activeFrom !== undefined || rule.activeUntil !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The boundary as RFC 5260 "date" (yyyy-mm-dd) and "time" (hh:mm:ss) parts in
|
||||
* UTC. The RFC fixes both formats, so comparing them as strings works on any
|
||||
* server - unlike "iso8601", whose zone suffix is up to the implementation.
|
||||
*/
|
||||
function utcParts(boundary: string): { date: string; time: string } {
|
||||
const iso = new Date(Date.parse(boundary)).toISOString();
|
||||
return { date: iso.slice(0, 10), time: iso.slice(11, 19) };
|
||||
}
|
||||
|
||||
const NOW = 'currentdate :zone "+0000"';
|
||||
|
||||
/** now >= boundary: a later day, or the same day at or after the time. */
|
||||
function atOrAfter(boundary: string): string {
|
||||
const { date, time } = utcParts(boundary);
|
||||
return `anyof(${NOW} :value "gt" "date" "${date}", allof(${NOW} :is "date" "${date}", ${NOW} :value "ge" "time" "${time}"))`;
|
||||
}
|
||||
|
||||
/** now <= boundary: an earlier day, or the same day at or before the time. */
|
||||
function atOrBefore(boundary: string): string {
|
||||
const { date, time } = utcParts(boundary);
|
||||
return `anyof(${NOW} :value "lt" "date" "${date}", allof(${NOW} :is "date" "${date}", ${NOW} :value "le" "time" "${time}"))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Sieve tests that confine `rule` to its period: none without a period,
|
||||
* null when a boundary is unusable. A caller must then drop the rule rather
|
||||
* than run it without its period - a forwarding rule would otherwise forward
|
||||
* for good.
|
||||
*/
|
||||
export function periodTests(rule: Pick<FilterRule, 'activeFrom' | 'activeUntil'>): string[] | null {
|
||||
const tests: string[] = [];
|
||||
if (rule.activeFrom !== undefined) {
|
||||
if (!isPeriodBoundary(rule.activeFrom)) return null;
|
||||
tests.push(atOrAfter(rule.activeFrom));
|
||||
}
|
||||
if (rule.activeUntil !== undefined) {
|
||||
if (!isPeriodBoundary(rule.activeUntil)) return null;
|
||||
tests.push(atOrBefore(rule.activeUntil));
|
||||
}
|
||||
return tests;
|
||||
}
|
||||
|
||||
export type PeriodStatus = 'scheduled' | 'active' | 'expired';
|
||||
|
||||
/** Where `now` falls in the rule's period; null for a rule without one. */
|
||||
export function periodStatus(
|
||||
rule: Pick<FilterRule, 'activeFrom' | 'activeUntil'>,
|
||||
now: number = Date.now(),
|
||||
): PeriodStatus | null {
|
||||
if (!hasPeriod(rule)) return null;
|
||||
if (isPeriodBoundary(rule.activeFrom) && now < Date.parse(rule.activeFrom)) return 'scheduled';
|
||||
if (isPeriodBoundary(rule.activeUntil) && now > Date.parse(rule.activeUntil)) return 'expired';
|
||||
return 'active';
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import type { Email, Mailbox } from '../../api/types';
|
||||
import type { FilterAction, FilterCondition, FilterRule } from './types';
|
||||
import { hasPeriod } from './period';
|
||||
|
||||
// Rules made from a message (webmail lib/filters/quick-rules.ts): "Always move
|
||||
// messages from Anna to...", "Block sender", and the rule "Create rule..."
|
||||
// opens with. Pure functions; the account plumbing is in api/sieve-rules.ts.
|
||||
|
||||
export interface RuleSender {
|
||||
/** Lower-cased address. */
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** A folder a rule files into: the JMAP id plus the Sieve path fallback. */
|
||||
export interface RuleMailbox {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type QuickRulePreset =
|
||||
| { kind: 'move_sender'; mailbox: RuleMailbox }
|
||||
| { kind: 'move_domain'; mailbox: RuleMailbox }
|
||||
| { kind: 'move_list'; mailbox: RuleMailbox }
|
||||
| { kind: 'mark_read' }
|
||||
| { kind: 'tag'; tagId: string; tagName: string }
|
||||
| { kind: 'block'; junk: RuleMailbox };
|
||||
|
||||
/** What the presets are made from. */
|
||||
export interface QuickRuleSubject {
|
||||
senders: RuleSender[];
|
||||
/** The one domain every sender shares. */
|
||||
domain: string | null;
|
||||
/** The one List-Id every message carries. */
|
||||
listId: string | null;
|
||||
}
|
||||
|
||||
/** The settings.filters translator, as the rule names use it. */
|
||||
export type Translate = (key: string, fallback: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export function normalizeAddress(address: string | null | undefined): string {
|
||||
return (address ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function addressDomain(address: string): string | null {
|
||||
const at = address.lastIndexOf('@');
|
||||
if (at <= 0 || at === address.length - 1) return null;
|
||||
return address.slice(at + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct sender of `emails`, compared case-insensitively, with the
|
||||
* first display name seen for each. The user's own addresses are dropped: a
|
||||
* rule against yourself would file your own replies away.
|
||||
*/
|
||||
export function collectSenders(emails: Array<Pick<Email, 'from'>>, ownAddresses: ReadonlySet<string>): RuleSender[] {
|
||||
const byAddress = new Map<string, RuleSender>();
|
||||
for (const email of emails) {
|
||||
for (const from of email.from ?? []) {
|
||||
const address = normalizeAddress(from.email);
|
||||
if (!address || !address.includes('@') || ownAddresses.has(address)) continue;
|
||||
const existing = byAddress.get(address);
|
||||
const name = from.name?.trim() || undefined;
|
||||
if (!existing) byAddress.set(address, { email: address, ...(name ? { name } : {}) });
|
||||
else if (!existing.name && name) existing.name = name;
|
||||
}
|
||||
}
|
||||
return [...byAddress.values()];
|
||||
}
|
||||
|
||||
/** The domain every sender shares, or null when they differ (or there are none). */
|
||||
export function sharedDomain(senders: RuleSender[]): string | null {
|
||||
let domain: string | null = null;
|
||||
for (const sender of senders) {
|
||||
const d = addressDomain(sender.email);
|
||||
if (!d) return null;
|
||||
if (domain === null) domain = d;
|
||||
else if (d !== domain) return null;
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* The list identifier of a List-Id header (RFC 2919): the part inside the
|
||||
* angle brackets, so "Weekly news <news.example.org>" gives
|
||||
* "news.example.org". A header without brackets is taken whole.
|
||||
*/
|
||||
export function extractListId(raw: unknown): string | null {
|
||||
const value = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof value !== 'string') return null;
|
||||
const unfolded = value.replace(/\r?\n[ \t]+/g, ' ').trim();
|
||||
const bracketed = unfolded.match(/<([^<>]+)>\s*$/);
|
||||
const id = (bracketed ? bracketed[1] : unfolded).trim();
|
||||
if (!id || /\s/.test(id)) return null;
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The List-Id of a message, from its raw header list. */
|
||||
export function emailListId(email: Pick<Email, 'headers'>): string | null {
|
||||
const header = email.headers?.find((h) => h.name.toLowerCase() === 'list-id');
|
||||
return header ? extractListId(header.value) : null;
|
||||
}
|
||||
|
||||
/** What the presets for `emails` are made from. */
|
||||
export function quickRuleSubject(
|
||||
emails: Array<Pick<Email, 'from' | 'headers'>>,
|
||||
ownAddresses: ReadonlySet<string>,
|
||||
): QuickRuleSubject {
|
||||
const senders = collectSenders(emails, ownAddresses);
|
||||
return { senders, domain: sharedDomain(senders), listId: sharedListId(emails.map(emailListId)) };
|
||||
}
|
||||
|
||||
/** The List-Id every message has, or null when one lacks it or they differ. */
|
||||
export function sharedListId(listIds: Array<string | null | undefined>): string | null {
|
||||
let shared: string | null = null;
|
||||
for (const id of listIds) {
|
||||
if (!id) return null;
|
||||
if (shared === null) shared = id;
|
||||
else if (shared.toLowerCase() !== id.toLowerCase()) return null;
|
||||
}
|
||||
return shared;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Sieve path of a folder, as the rule editor writes it: parent names
|
||||
* joined with "/", with the inbox as the IMAP-canonical "INBOX".
|
||||
*/
|
||||
export function mailboxSievePath(mailboxes: Mailbox[], mailboxId: string): string {
|
||||
const byId = new Map(mailboxes.map((m) => [m.id, m]));
|
||||
const segments: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = byId.get(mailboxId);
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
segments.unshift(current.role === 'inbox' ? 'INBOX' : current.name);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
export function toRuleMailbox(mailboxes: Mailbox[], mailbox: Mailbox): RuleMailbox {
|
||||
return { id: mailbox.originalId || mailbox.id, path: mailboxSievePath(mailboxes, mailbox.id), name: mailbox.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Folders an "Always move" rule can file into: the account's own folders
|
||||
* that accept mail, without Drafts. Unlike "Move to", the current folder and
|
||||
* the Inbox stay in.
|
||||
*/
|
||||
export function ruleTargetMailboxes(mailboxes: Mailbox[]): Mailbox[] {
|
||||
return mailboxes.filter((m) =>
|
||||
!m.isShared && !m.id.startsWith('shared-') && m.role !== 'drafts' && m.myRights?.mayAddItems !== false);
|
||||
}
|
||||
|
||||
export function findJunkMailbox(mailboxes: Mailbox[]): Mailbox | undefined {
|
||||
return mailboxes.find((m) => !m.isShared && m.role === 'junk');
|
||||
}
|
||||
|
||||
function senderValue(senders: RuleSender[]): string | string[] {
|
||||
const addresses = senders.map((s) => s.email);
|
||||
return addresses.length === 1 ? addresses[0] : addresses;
|
||||
}
|
||||
|
||||
/** The condition "from one of these senders", matched on the exact address. */
|
||||
export function senderCondition(senders: RuleSender[]): FilterCondition {
|
||||
return { field: 'from', comparator: 'address_is', value: senderValue(senders) };
|
||||
}
|
||||
|
||||
export function domainCondition(domain: string): FilterCondition {
|
||||
return { field: 'from', comparator: 'domain_is', value: domain };
|
||||
}
|
||||
|
||||
export function listIdCondition(listId: string): FilterCondition {
|
||||
return { field: 'header', headerName: 'List-Id', comparator: 'contains', value: listId };
|
||||
}
|
||||
|
||||
/** How rule names refer to the senders: the address, or "3 senders". */
|
||||
export function senderNameFor(senders: RuleSender[], t: Translate): string {
|
||||
return senders.length === 1
|
||||
? senders[0].email
|
||||
: t('settings.filters.quick_rule_names.senders', '{count, plural, one {# sender} other {# senders}}', { count: senders.length });
|
||||
}
|
||||
|
||||
function moveAction(mailbox: RuleMailbox): FilterAction {
|
||||
return { type: 'move', value: mailbox.path, mailboxId: mailbox.id };
|
||||
}
|
||||
|
||||
/** The rule a one-click preset saves, named for what it does. */
|
||||
export function buildPresetRule(
|
||||
preset: QuickRulePreset,
|
||||
subject: QuickRuleSubject,
|
||||
t: Translate,
|
||||
id: string,
|
||||
): FilterRule {
|
||||
const sender = senderNameFor(subject.senders, t);
|
||||
const base = { id, enabled: true, matchType: 'all' as const, stopProcessing: true };
|
||||
switch (preset.kind) {
|
||||
case 'move_sender':
|
||||
return {
|
||||
...base,
|
||||
name: t('settings.filters.quick_rule_names.move_sender', 'Move from {sender} to {folder}', { sender, folder: preset.mailbox.name }),
|
||||
conditions: [senderCondition(subject.senders)],
|
||||
actions: [moveAction(preset.mailbox)],
|
||||
};
|
||||
case 'move_domain':
|
||||
return {
|
||||
...base,
|
||||
name: t('settings.filters.quick_rule_names.move_domain', 'Move from @{domain} to {folder}', { domain: subject.domain ?? '', folder: preset.mailbox.name }),
|
||||
conditions: [domainCondition(subject.domain ?? '')],
|
||||
actions: [moveAction(preset.mailbox)],
|
||||
};
|
||||
case 'move_list':
|
||||
return {
|
||||
...base,
|
||||
name: t('settings.filters.quick_rule_names.move_list', 'Move list {list} to {folder}', { list: subject.listId ?? '', folder: preset.mailbox.name }),
|
||||
conditions: [listIdCondition(subject.listId ?? '')],
|
||||
actions: [moveAction(preset.mailbox)],
|
||||
};
|
||||
case 'mark_read':
|
||||
return {
|
||||
...base,
|
||||
name: t('settings.filters.quick_rule_names.mark_read', 'Mark as read from {sender}', { sender }),
|
||||
conditions: [senderCondition(subject.senders)],
|
||||
actions: [{ type: 'mark_read' }],
|
||||
};
|
||||
case 'tag':
|
||||
return {
|
||||
...base,
|
||||
name: t('settings.filters.quick_rule_names.tag', 'Tag as {tag} from {sender}', { sender, tag: preset.tagName }),
|
||||
conditions: [senderCondition(subject.senders)],
|
||||
actions: [{ type: 'add_label', value: preset.tagId }],
|
||||
};
|
||||
case 'block':
|
||||
// The spam guard would skip exactly the mail this rule is for.
|
||||
return {
|
||||
...base,
|
||||
name: t('settings.filters.quick_rule_names.block', 'Block {sender}', { sender }),
|
||||
conditions: [senderCondition(subject.senders)],
|
||||
actions: [moveAction(preset.junk)],
|
||||
includeSpam: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule "Create rule..." opens with: From = the senders, one Move with no
|
||||
* folder chosen yet, stop after it.
|
||||
*/
|
||||
export function buildPrefillRule(subject: QuickRuleSubject, t: Translate, id: string): FilterRule {
|
||||
const hasSenders = subject.senders.length > 0;
|
||||
return {
|
||||
id,
|
||||
name: hasSenders
|
||||
? t('settings.filters.quick_rule_names.custom', 'Mail from {sender}', { sender: senderNameFor(subject.senders, t) })
|
||||
: t('settings.filters.quick_rule_names.custom_plain', 'New rule'),
|
||||
enabled: true,
|
||||
matchType: 'all',
|
||||
conditions: hasSenders ? [senderCondition(subject.senders)] : [],
|
||||
actions: [{ type: 'move', value: '' }],
|
||||
stopProcessing: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isBulwarkRule(rule: FilterRule): boolean {
|
||||
return !rule.origin || rule.origin === 'bulwark';
|
||||
}
|
||||
|
||||
/**
|
||||
* New rules made from a message go first, so an older, broader rule does not
|
||||
* catch the mail before them. The generator writes the vacation block and the
|
||||
* external/opaque blocks where they always go, so the front of the list is
|
||||
* the top of Bulwark's own section.
|
||||
*/
|
||||
export function insertRuleAtTop(rules: FilterRule[], rule: FilterRule): FilterRule[] {
|
||||
return [rule, ...rules];
|
||||
}
|
||||
|
||||
/** Replace the rule with the same id in place, or add it at the top. */
|
||||
export function replaceOrInsertRule(rules: FilterRule[], rule: FilterRule): FilterRule[] {
|
||||
return rules.some((r) => r.id === rule.id)
|
||||
? rules.map((r) => (r.id === rule.id ? rule : r))
|
||||
: insertRuleAtTop(rules, rule);
|
||||
}
|
||||
|
||||
function valueList(value: string | string[]): string[] {
|
||||
return (Array.isArray(value) ? value : [value]).filter((v) => typeof v === 'string' && v.length > 0);
|
||||
}
|
||||
|
||||
function sameConditionKind(a: FilterCondition, b: FilterCondition): boolean {
|
||||
return a.field === b.field
|
||||
&& a.comparator === b.comparator
|
||||
&& (a.headerName ?? '').toLowerCase() === (b.headerName ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function sameAction(a: FilterAction, b: FilterAction): boolean {
|
||||
if (a.type !== b.type) return false;
|
||||
switch (a.type) {
|
||||
case 'move':
|
||||
case 'copy':
|
||||
return a.mailboxId && b.mailboxId ? a.mailboxId === b.mailboxId : (a.value ?? '') === (b.value ?? '');
|
||||
case 'add_label':
|
||||
case 'forward':
|
||||
case 'reject':
|
||||
return (a.value ?? '') === (b.value ?? '');
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function sameActions(a: FilterAction[], b: FilterAction[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const unmatched = [...b];
|
||||
for (const action of a) {
|
||||
const index = unmatched.findIndex((other) => sameAction(action, other));
|
||||
if (index === -1) return false;
|
||||
unmatched.splice(index, 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export type QuickRuleOutcome =
|
||||
/** No rule did this yet: `rule` is new, at the top. */
|
||||
| { kind: 'created'; rules: FilterRule[]; rule: FilterRule }
|
||||
/** `rule` already did this for other values and now covers these too. */
|
||||
| { kind: 'merged'; rules: FilterRule[]; rule: FilterRule }
|
||||
/** `rule` already covers every value; nothing to write. */
|
||||
| { kind: 'covered'; rule: FilterRule };
|
||||
|
||||
/**
|
||||
* Save a one-click rule without piling up near-duplicates: an enabled
|
||||
* Bulwark rule with a single condition of the same kind and the same actions
|
||||
* takes the new values into its value list and keeps its name and place. A
|
||||
* rule with a period only acts within it, and the new values would too, so
|
||||
* it is never the one.
|
||||
*/
|
||||
export function applyQuickRule(rules: FilterRule[], candidate: FilterRule): QuickRuleOutcome {
|
||||
const condition = candidate.conditions[0];
|
||||
const target = candidate.conditions.length === 1
|
||||
? rules.find((r) =>
|
||||
isBulwarkRule(r)
|
||||
&& r.enabled
|
||||
&& !hasPeriod(r)
|
||||
&& r.conditions.length === 1
|
||||
&& sameConditionKind(r.conditions[0], condition)
|
||||
&& sameActions(r.actions, candidate.actions))
|
||||
: undefined;
|
||||
|
||||
if (!target) {
|
||||
return { kind: 'created', rules: insertRuleAtTop(rules, candidate), rule: candidate };
|
||||
}
|
||||
|
||||
const existing = valueList(target.conditions[0].value);
|
||||
const known = new Set(existing.map((v) => v.toLowerCase()));
|
||||
const added: string[] = [];
|
||||
for (const value of valueList(condition.value)) {
|
||||
const key = value.toLowerCase();
|
||||
if (known.has(key)) continue;
|
||||
known.add(key);
|
||||
added.push(value);
|
||||
}
|
||||
if (added.length === 0) return { kind: 'covered', rule: target };
|
||||
|
||||
const merged: FilterRule = {
|
||||
...target,
|
||||
conditions: [{ ...target.conditions[0], value: [...existing, ...added] }],
|
||||
};
|
||||
return { kind: 'merged', rules: rules.map((r) => (r.id === target.id ? merged : r)), rule: merged };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { isPeriodBoundary } from './period';
|
||||
import { isValidLocalInput, localInputToUtcIso, utcIsoToLocalInput } from '../vacation-dates';
|
||||
|
||||
// The rule editor's period fields (webmail filter-rule-modal.tsx). The user
|
||||
// types local wall-clock time as for the vacation dates ("YYYY-MM-DD HH:MM");
|
||||
// the rule stores the UTC moment, so the script does not depend on the time
|
||||
// zone of whoever saves it next.
|
||||
|
||||
/** A stored boundary as the field shows it; '' for none or one it cannot read. */
|
||||
export function periodToInput(boundary: string | undefined): string {
|
||||
return isPeriodBoundary(boundary) ? utcIsoToLocalInput(boundary) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The field's text as the boundary the rule stores: undefined for an empty
|
||||
* field, null for one that holds no usable moment. A boundary left as the
|
||||
* dialog showed it is kept exactly: the field shows whole minutes, so
|
||||
* reading it back would drop a saved second.
|
||||
*/
|
||||
export function periodFromInput(value: string, saved: string | undefined): string | null | undefined {
|
||||
const text = value.trim();
|
||||
if (!text) return undefined;
|
||||
if (saved !== undefined && text === periodToInput(saved)) return saved;
|
||||
if (!isValidLocalInput(text)) return null;
|
||||
const boundary = localInputToUtcIso(text);
|
||||
return boundary && isPeriodBoundary(boundary) ? boundary : null;
|
||||
}
|
||||
|
||||
export type PeriodError = 'invalid' | 'empty' | 'order';
|
||||
|
||||
/** What is wrong with a period, read from its two fields (see periodFromInput). */
|
||||
export function periodError(from: string | null | undefined, until: string | null | undefined): PeriodError | null {
|
||||
if (from === null || until === null) return 'invalid';
|
||||
if (!from && !until) return 'empty';
|
||||
if (from && until && Date.parse(until) <= Date.parse(from)) return 'order';
|
||||
return null;
|
||||
}
|
||||
+63
-3
@@ -22,7 +22,9 @@ export interface SieveCapabilities {
|
||||
|
||||
export type FilterConditionField =
|
||||
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
|
||||
| 'attachment';
|
||||
| 'attachment'
|
||||
// Every message (Sieve `true`): no value, and always the comparator 'any'.
|
||||
| 'all';
|
||||
|
||||
export type FilterComparator =
|
||||
| 'contains' | 'not_contains'
|
||||
@@ -34,7 +36,16 @@ export type FilterComparator =
|
||||
// has_any → message has any attachment (Content-Disposition: attachment)
|
||||
// has_type → message has an attachment whose Content-Type matches `value`
|
||||
// (substring match, e.g. "application/pdf" or "image/")
|
||||
| 'has_any' | 'has_type';
|
||||
| 'has_any' | 'has_type'
|
||||
// For field === 'from' | 'to' | 'cc', compared against the parsed address
|
||||
// with the Sieve `address` test rather than the raw header text:
|
||||
// address_is → the whole address equals `value` (anna@acme.com, but not
|
||||
// joanna@acme.com)
|
||||
// domain_is → the domain part equals `value` (acme.com, but neither
|
||||
// sub.acme.com nor acme.com.evil)
|
||||
| 'address_is' | 'domain_is'
|
||||
// For field === 'all': any message at all.
|
||||
| 'any';
|
||||
|
||||
export type FilterActionType =
|
||||
| 'move' | 'copy' | 'forward'
|
||||
@@ -84,6 +95,15 @@ export interface FilterRule {
|
||||
* folder rule does not pull spam out of Junk.
|
||||
*/
|
||||
includeSpam?: boolean;
|
||||
/**
|
||||
* The rule only acts on mail that arrives from `activeFrom` until
|
||||
* `activeUntil`, both inclusive, either one open-ended. Absolute moments
|
||||
* as ISO 8601 UTC ("2026-10-05T06:00:00.000Z"), so the script does not
|
||||
* depend on the time zone of whoever saves it next. Compiled to Sieve
|
||||
* `currentdate` tests (RFC 5260), which need "date" and "relational".
|
||||
*/
|
||||
activeFrom?: string;
|
||||
activeUntil?: string;
|
||||
origin?: FilterOrigin;
|
||||
originLabel?: string;
|
||||
rawBlock?: string;
|
||||
@@ -95,10 +115,50 @@ export interface VacationSieveConfig {
|
||||
textBody: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forwarding set up in the vacation card: mail goes on to one address, with
|
||||
* or without the auto-reply, in the vacation's period. It is not a filter
|
||||
* rule and the filter list does not show it; it runs ahead of the rules.
|
||||
*/
|
||||
export interface VacationForward {
|
||||
/** The forwarding switch, independent of the auto-reply's. */
|
||||
enabled: boolean;
|
||||
/** The one address mail is forwarded to. */
|
||||
to: string;
|
||||
/**
|
||||
* Keep the message in this mailbox too (`redirect :copy`). Otherwise it
|
||||
* is only forwarded, and no filter rule runs on it.
|
||||
*/
|
||||
keepCopy: boolean;
|
||||
/** The auto-reply's period when it was saved, as for FilterRule.activeFrom. */
|
||||
activeFrom?: string;
|
||||
activeUntil?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who gets the auto-reply when not everyone should: only senders from the
|
||||
* account's own domains, or only senders from elsewhere. It is decided on
|
||||
* the envelope sender while the auto-reply runs from the filters script.
|
||||
*/
|
||||
export interface VacationAudience {
|
||||
only: 'internal' | 'external';
|
||||
/** The account's own domains (from its identities) when it was saved. */
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
export interface FilterMetadata {
|
||||
version: 1;
|
||||
/**
|
||||
* 2 once a rule has a period or the vacation's forwarding or recipients are
|
||||
* stored. Builds that only know version 1 would write such a script back
|
||||
* without these fields, so they leave anything else alone.
|
||||
*/
|
||||
version: 1 | 2;
|
||||
rules: FilterRule[];
|
||||
vacation?: VacationSieveConfig;
|
||||
/** See VacationAudience; absent when everyone gets the auto-reply. */
|
||||
vacationAudience?: VacationAudience;
|
||||
/** See VacationForward; kept while switched off, so the card remembers it. */
|
||||
vacationForward?: VacationForward;
|
||||
/**
|
||||
* The script runs the server-managed "vacation" script via `include`.
|
||||
* Servers like Stalwart keep one active script, so a VacationResponse
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { VacationAudience } from './types';
|
||||
|
||||
// A host name in the form the identities give it: labels of letters, digits
|
||||
// and inner hyphens, at least two of them.
|
||||
const DOMAIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/;
|
||||
|
||||
/** Whether `value` is an audience Bulwark can write back as it reads. */
|
||||
export function isValidVacationAudience(value: unknown): value is VacationAudience {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const a = value as Record<string, unknown>;
|
||||
if (a.only !== 'internal' && a.only !== 'external') return false;
|
||||
return Array.isArray(a.domains) && a.domains.length > 0
|
||||
&& a.domains.every((d) => typeof d === 'string' && DOMAIN.test(d));
|
||||
}
|
||||
|
||||
/** The fields Bulwark stores, in a fixed order. */
|
||||
export function normalizeVacationAudience(audience: VacationAudience): VacationAudience {
|
||||
return { only: audience.only, domains: audience.domains };
|
||||
}
|
||||
|
||||
/** The domains of the given addresses, lower-cased, each once, in order. */
|
||||
export function ownDomains(addresses: string[]): string[] {
|
||||
const domains: string[] = [];
|
||||
for (const address of addresses) {
|
||||
const at = address.lastIndexOf('@');
|
||||
const domain = at === -1 ? '' : address.slice(at + 1).trim().toLowerCase();
|
||||
if (DOMAIN.test(domain) && !domains.includes(domain)) domains.push(domain);
|
||||
}
|
||||
return domains;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { VacationForward } from './types';
|
||||
import { isValidEmail } from '../recipients';
|
||||
import { isPeriodBoundary } from './period';
|
||||
|
||||
/**
|
||||
* Forwarding from the vacation card (see VacationForward). The generator
|
||||
* writes it as its own block after the vacation include, marked by this
|
||||
* comment; the parser knows the block by it and leaves it to the generator.
|
||||
*/
|
||||
export const VACATION_FORWARD_MARKER = '# Vacation forwarding';
|
||||
|
||||
/**
|
||||
* The marker as the parser looks for it in a block: the whole line, so a
|
||||
* comment of someone's own that only begins like it is not taken for it.
|
||||
*/
|
||||
export const VACATION_FORWARD_MARKER_RE = /^[ \t]*# Vacation forwarding[ \t]*$/m;
|
||||
|
||||
/** Whether `value` is forwarding Bulwark can write back as it reads. */
|
||||
export function isValidVacationForward(value: unknown): value is VacationForward {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const f = value as Record<string, unknown>;
|
||||
if (typeof f.enabled !== 'boolean' || typeof f.keepCopy !== 'boolean') return false;
|
||||
if (typeof f.to !== 'string' || !isValidEmail(f.to)) return false;
|
||||
if (f.activeFrom !== undefined && !isPeriodBoundary(f.activeFrom)) return false;
|
||||
if (f.activeUntil !== undefined && !isPeriodBoundary(f.activeUntil)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The forwarding with the vacation's period, whatever period it had: it runs
|
||||
* while the vacation does (an empty date leaves that end open).
|
||||
*/
|
||||
export function withVacationPeriod(
|
||||
forward: Pick<VacationForward, 'enabled' | 'to' | 'keepCopy'>,
|
||||
period: { from: string | null; until: string | null },
|
||||
): VacationForward {
|
||||
return {
|
||||
enabled: forward.enabled,
|
||||
to: forward.to,
|
||||
keepCopy: forward.keepCopy,
|
||||
...(period.from ? { activeFrom: period.from } : {}),
|
||||
...(period.until ? { activeUntil: period.until } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The fields Bulwark stores, in a fixed order. */
|
||||
export function normalizeVacationForward(forward: VacationForward): VacationForward {
|
||||
return {
|
||||
enabled: forward.enabled,
|
||||
to: forward.to,
|
||||
keepCopy: forward.keepCopy,
|
||||
...(forward.activeFrom !== undefined ? { activeFrom: forward.activeFrom } : {}),
|
||||
...(forward.activeUntil !== undefined ? { activeUntil: forward.activeUntil } : {}),
|
||||
};
|
||||
}
|
||||
+129
-36
@@ -9,19 +9,21 @@ import { generateUUID } from './uuid';
|
||||
import { deriveFullName } from './contact-utils';
|
||||
import { parseBase64DataUri } from './data-uri';
|
||||
|
||||
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string
|
||||
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to a vCard
|
||||
// 4.0 date, which uses the ISO 8601 basic format (RFC 6350 §4.3.1): 19850412,
|
||||
// --0412, 1985-04, 1985.
|
||||
function anniversaryDateToVcardString(date: AnniversaryDate): string {
|
||||
if (typeof date === 'string') return date;
|
||||
if (date && typeof date === 'object') {
|
||||
if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) {
|
||||
return (date as { utc: string }).utc.split('T')[0];
|
||||
return (date as { utc: string }).utc.split('T')[0].replace(/-/g, '');
|
||||
}
|
||||
const pd = date as PartialDate;
|
||||
if (pd.year && pd.month && pd.day) {
|
||||
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
||||
return `${String(pd.year).padStart(4, '0')}${String(pd.month).padStart(2, '0')}${String(pd.day).padStart(2, '0')}`;
|
||||
}
|
||||
if (pd.month && pd.day) {
|
||||
return `--${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
||||
return `--${String(pd.month).padStart(2, '0')}${String(pd.day).padStart(2, '0')}`;
|
||||
}
|
||||
if (pd.year && pd.month) {
|
||||
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`;
|
||||
@@ -367,20 +369,58 @@ function anniversaryKindFromLabel(label: string | undefined): 'birth' | 'wedding
|
||||
return 'other';
|
||||
}
|
||||
|
||||
const PHONE_FEATURE_TYPES = new Set(['CELL', 'FAX', 'VOICE', 'PAGER', 'VIDEO', 'TEXT', 'TEXTPHONE']);
|
||||
// vCard TEL types and the RFC 9553 phone features they stand for. CELL is
|
||||
// "mobile" in JSContact; a "cell" feature is no RFC 9553 one. MOBILE is no
|
||||
// vCard type, but Bulwark's own exports used to write it.
|
||||
const TEL_TYPE_TO_PHONE_FEATURE: Record<string, string> = {
|
||||
CELL: 'mobile',
|
||||
MOBILE: 'mobile',
|
||||
VOICE: 'voice',
|
||||
FAX: 'fax',
|
||||
PAGER: 'pager',
|
||||
VIDEO: 'video',
|
||||
TEXT: 'text',
|
||||
TEXTPHONE: 'textphone',
|
||||
'MAIN-NUMBER': 'main-number',
|
||||
};
|
||||
|
||||
const PHONE_FEATURE_TO_TEL_TYPE: Record<string, string> = {
|
||||
mobile: 'CELL',
|
||||
cell: 'CELL',
|
||||
voice: 'VOICE',
|
||||
fax: 'FAX',
|
||||
pager: 'PAGER',
|
||||
video: 'VIDEO',
|
||||
text: 'TEXT',
|
||||
textphone: 'TEXTPHONE',
|
||||
'main-number': 'MAIN-NUMBER',
|
||||
};
|
||||
|
||||
function typeToPhoneFeatures(typeStr: string | undefined): Record<string, boolean> | undefined {
|
||||
if (!typeStr) return undefined;
|
||||
const types = typeStr.toUpperCase().split(',');
|
||||
const features: Record<string, boolean> = {};
|
||||
for (const t of types) {
|
||||
if (PHONE_FEATURE_TYPES.has(t)) {
|
||||
features[t.toLowerCase()] = true;
|
||||
}
|
||||
const feature = TEL_TYPE_TO_PHONE_FEATURE[t];
|
||||
if (feature) features[feature] = true;
|
||||
}
|
||||
return Object.keys(features).length > 0 ? features : undefined;
|
||||
}
|
||||
|
||||
function phoneFeaturesToTypes(features: Record<string, boolean> | undefined): string[] {
|
||||
const types = new Set<string>();
|
||||
for (const [feature, on] of Object.entries(features || {})) {
|
||||
if (!on) continue;
|
||||
const type = PHONE_FEATURE_TO_TEL_TYPE[feature.toLowerCase()];
|
||||
if (type) {
|
||||
types.add(type);
|
||||
} else if (/^x-[a-z0-9-]+$/i.test(feature)) {
|
||||
types.add(feature.toUpperCase());
|
||||
}
|
||||
}
|
||||
return [...types];
|
||||
}
|
||||
|
||||
function typeToContext(typeStr: string | undefined): Record<string, boolean> | undefined {
|
||||
if (!typeStr) return undefined;
|
||||
const types = typeStr.toUpperCase().split(',');
|
||||
@@ -1185,19 +1225,68 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
return card;
|
||||
}
|
||||
|
||||
const VCARD_PRODID = '-//JMAP-Webmail//EN';
|
||||
|
||||
function hasUriScheme(value: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:/i.test(value);
|
||||
}
|
||||
|
||||
function toVcardTimestamp(value: string): string {
|
||||
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/i);
|
||||
if (!m) return value;
|
||||
const zone = (m[7] || '').replace(':', '').toUpperCase();
|
||||
return `${m[1]}${m[2]}${m[3]}T${m[4]}${m[5]}${m[6]}${zone}`;
|
||||
}
|
||||
|
||||
function toGeoUri(coordinates: string): string {
|
||||
if (/^geo:/i.test(coordinates)) return coordinates;
|
||||
const m = coordinates.match(/^\s*(-?[\d.]+)\s*[;,]\s*(-?[\d.]+)\s*$/);
|
||||
return m ? `geo:${m[1]},${m[2]}` : coordinates;
|
||||
}
|
||||
|
||||
function utf8Length(char: string): number {
|
||||
const code = char.codePointAt(0) ?? 0;
|
||||
if (code < 0x80) return 1;
|
||||
if (code < 0x800) return 2;
|
||||
if (code < 0x10000) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
// RFC 6350 §3.2: lines longer than 75 octets are folded, the continuation
|
||||
// starting with a space.
|
||||
function foldLine(line: string): string {
|
||||
const out: string[] = [];
|
||||
let current = '';
|
||||
let octets = 0;
|
||||
for (const char of line) {
|
||||
const size = utf8Length(char);
|
||||
const limit = out.length === 0 ? 75 : 74;
|
||||
if (octets + size > limit) {
|
||||
out.push(current);
|
||||
current = '';
|
||||
octets = 0;
|
||||
}
|
||||
current += char;
|
||||
octets += size;
|
||||
}
|
||||
out.push(current);
|
||||
return out.join('\r\n ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Contacts as vCard 4.0 (RFC 6350), each line folded and ended by CRLF
|
||||
* (webmail #1407): dates in the basic format, CELL for mobiles, N before FN.
|
||||
*/
|
||||
export function generateVCard(contacts: ContactCard[]): string {
|
||||
return contacts.map(generateSingleVCard).join('\r\n');
|
||||
return contacts.map(generateSingleVCard).join('');
|
||||
}
|
||||
|
||||
function generateSingleVCard(contact: ContactCard): string {
|
||||
const lines: string[] = ['BEGIN:VCARD', 'VERSION:3.0'];
|
||||
const lines: string[] = ['BEGIN:VCARD', 'VERSION:4.0', `PRODID:${VCARD_PRODID}`];
|
||||
|
||||
if (contact.uid) {
|
||||
lines.push(`UID:${contact.uid}`);
|
||||
}
|
||||
|
||||
if (contact.prodId) {
|
||||
lines.push(`PRODID:${contact.prodId}`);
|
||||
// UID is a URI in vCard 4.0; anything else is marked as text.
|
||||
lines.push(hasUriScheme(contact.uid) ? `UID:${contact.uid}` : `UID;VALUE=text:${contact.uid}`);
|
||||
}
|
||||
|
||||
if (contact.kind) {
|
||||
@@ -1205,7 +1294,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
}
|
||||
|
||||
if (contact.updated) {
|
||||
lines.push(`REV:${contact.updated}`);
|
||||
lines.push(`REV:${toVcardTimestamp(contact.updated)}`);
|
||||
}
|
||||
|
||||
const components = contact.name?.components || [];
|
||||
@@ -1224,8 +1313,10 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
|| Object.values(contact.organizations || {})[0]?.name
|
||||
|| '';
|
||||
if (fn) {
|
||||
lines.push(`FN:${encodeValue(fn)}`);
|
||||
// vCard does not order properties, but some importers drop the first
|
||||
// and last name unless N comes before FN (#1407).
|
||||
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
|
||||
lines.push(`FN:${encodeValue(fn)}`);
|
||||
}
|
||||
|
||||
// vCard has no second-surname slot; X-MAIDENNAME is the de-facto extension
|
||||
@@ -1259,11 +1350,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
const typeParts: string[] = [];
|
||||
const ctxType = contextToType(phone.contexts);
|
||||
if (ctxType) typeParts.push(ctxType);
|
||||
if (phone.features) {
|
||||
for (const feat of Object.keys(phone.features)) {
|
||||
if (phone.features[feat]) typeParts.push(feat.toUpperCase());
|
||||
}
|
||||
}
|
||||
typeParts.push(...phoneFeaturesToTypes(phone.features));
|
||||
const params: string[] = [];
|
||||
if (typeParts.length > 0) params.push(`TYPE=${typeParts.join(',')}`);
|
||||
if (phone.pref) params.push(`PREF=${phone.pref}`);
|
||||
@@ -1326,16 +1413,16 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
const dateStr = anniversaryDateToVcardString(ann.date);
|
||||
if (ann.kind === 'birth') {
|
||||
if (dateStr) lines.push(`BDAY:${dateStr}`);
|
||||
if (ann.place?.fullAddress) {
|
||||
lines.push(`BIRTHPLACE:${encodeValue(ann.place.fullAddress)}`);
|
||||
}
|
||||
// `full` as the server stores it (contactToWire), fullAddress as an
|
||||
// import holds it before the save.
|
||||
const place = ann.place?.full || ann.place?.fullAddress;
|
||||
if (place) lines.push(`BIRTHPLACE:${encodeValue(place)}`);
|
||||
} else if (ann.kind === 'wedding') {
|
||||
if (dateStr) lines.push(`ANNIVERSARY:${dateStr}`);
|
||||
} else if (ann.kind === 'death') {
|
||||
if (dateStr) lines.push(`DEATHDATE:${dateStr}`);
|
||||
if (ann.place?.fullAddress) {
|
||||
lines.push(`DEATHPLACE:${encodeValue(ann.place.fullAddress)}`);
|
||||
}
|
||||
const place = ann.place?.full || ann.place?.fullAddress;
|
||||
if (place) lines.push(`DEATHPLACE:${encodeValue(place)}`);
|
||||
} else if (dateStr) {
|
||||
// No standard property for an unclassified date; X-ABDATE is the one
|
||||
// the common exporters (and our own importer) understand.
|
||||
@@ -1389,8 +1476,13 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
if (contact.relatedTo) {
|
||||
for (const [uri, rel] of Object.entries(contact.relatedTo)) {
|
||||
const relType = rel.relation ? Object.keys(rel.relation).find((k) => rel.relation![k]) : undefined;
|
||||
const typeParam = relType ? `;TYPE=${relType}` : '';
|
||||
lines.push(`RELATED${typeParam}:${uri}`);
|
||||
const params: string[] = [];
|
||||
// RELATED is a URI by default (RFC 6350 §6.6.6); a bare name, as
|
||||
// imported from Apple or Android, is text.
|
||||
if (!hasUriScheme(uri)) params.push('VALUE=text');
|
||||
if (relType) params.push(`TYPE=${relType}`);
|
||||
const paramStr = params.length > 0 ? `;${params.join(';')}` : '';
|
||||
lines.push(`RELATED${paramStr}:${hasUriScheme(uri) ? uri : encodeValue(uri)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1475,8 +1567,9 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
// (0.16.x) and `data:base64,` (0.16.0); the card's mediaType fills in.
|
||||
const parsed = parseBase64DataUri(media.uri);
|
||||
if (parsed?.data) {
|
||||
const type = parsed.type || media.mediaType;
|
||||
lines.push(`${prop};ENCODING=b${type ? `;TYPE=${type}` : ''}:${parsed.data}`);
|
||||
// vCard 4.0 carries inline media as a data: URI (no ENCODING=b).
|
||||
const type = parsed.type || media.mediaType || '';
|
||||
lines.push(`${prop}:data:${type};base64,${parsed.data}`);
|
||||
}
|
||||
} else {
|
||||
const mt = media.mediaType ? `;MEDIATYPE=${media.mediaType}` : '';
|
||||
@@ -1490,7 +1583,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
if (contact.addresses) {
|
||||
for (const addr of Object.values(contact.addresses)) {
|
||||
if (addr.coordinates) {
|
||||
lines.push(`GEO:${addr.coordinates}`);
|
||||
lines.push(`GEO:${toGeoUri(addr.coordinates)}`);
|
||||
}
|
||||
if (addr.timeZone) {
|
||||
lines.push(`TZ:${addr.timeZone}`);
|
||||
@@ -1526,12 +1619,12 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
}
|
||||
|
||||
if (contact.created) {
|
||||
// RFC 9554 §3.1 - CREATED is a timestamp; emit as-is for round-trip.
|
||||
lines.push(`CREATED:${contact.created}`);
|
||||
// RFC 9554 §3.1 - CREATED is a timestamp.
|
||||
lines.push(`CREATED:${toVcardTimestamp(contact.created)}`);
|
||||
}
|
||||
|
||||
lines.push('END:VCARD');
|
||||
return lines.join('\r\n');
|
||||
return lines.map((line) => `${foldLine(line)}\r\n`).join('');
|
||||
}
|
||||
|
||||
export function detectDuplicates(
|
||||
|
||||
@@ -62,6 +62,7 @@ function contextLabel(key: string, t: TranslateFn): string {
|
||||
function phoneFeatureLabel(key: string, t: TranslateFn): string {
|
||||
switch (key) {
|
||||
case 'voice': return t('contacts.form.phone_voice', 'Voice');
|
||||
case 'mobile':
|
||||
case 'cell': return t('contacts.form.phone_cell', 'Mobile');
|
||||
case 'fax': return t('contacts.form.phone_fax', 'Fax');
|
||||
case 'pager': return t('contacts.form.phone_pager', 'Pager');
|
||||
|
||||
@@ -45,7 +45,7 @@ function formOptions(t: TranslateFn) {
|
||||
const phoneFeatures: Option[] = [
|
||||
{ value: '', label: t('contacts.form.phone', 'Phone') },
|
||||
{ value: 'voice', label: t('contacts.form.phone_voice', 'Voice') },
|
||||
{ value: 'cell', label: t('contacts.form.phone_cell', 'Mobile') },
|
||||
{ value: 'mobile', label: t('contacts.form.phone_cell', 'Mobile') },
|
||||
{ value: 'fax', label: t('contacts.form.phone_fax', 'Fax') },
|
||||
{ value: 'pager', label: t('contacts.form.phone_pager', 'Pager') },
|
||||
{ value: 'video', label: t('contacts.form.phone_video', 'Video') },
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
} from '../components/contacts';
|
||||
import Dialog from '../components/Dialog';
|
||||
import ContactsSidebarDrawer from '../components/contacts/ContactsSidebarDrawer';
|
||||
import { DirectoryBrowser } from '../components/contacts/DirectoryBrowser';
|
||||
import { supportsSharing } from '../api/files';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useLocaleStore, type TranslateFn } from '../stores/locale-store';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
@@ -135,6 +137,10 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
|
||||
const sortByLastName = useSettingsStore((s) => s.sortContactsByLastName);
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
// The company directory shows in place of the list (#1253). Not a stored
|
||||
// category: it is the server's list, not the user's contacts.
|
||||
const [showDirectory, setShowDirectory] = React.useState(false);
|
||||
const directoryAvailable = supportsSharing();
|
||||
const [searchActive, setSearchActive] = React.useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = React.useState(false);
|
||||
const [selection, setSelection] = React.useState<Set<string>>(new Set());
|
||||
@@ -353,7 +359,9 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
|
||||
[addressBooks, importTargetBookId],
|
||||
);
|
||||
|
||||
const title = categoryTitle(selectedCategory, addressBooks, groups, t);
|
||||
const title = showDirectory
|
||||
? t('contacts.directory.title', 'Directory')
|
||||
: categoryTitle(selectedCategory, addressBooks, groups, t);
|
||||
|
||||
// Top chip row: All / per-address-book / Groups
|
||||
const chips = React.useMemo(() => {
|
||||
@@ -546,18 +554,34 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
|
||||
) : (
|
||||
<Pressable
|
||||
key={chip.key}
|
||||
onPress={() => setSelectedCategory(chip.category)}
|
||||
style={[styles.chip, chip.active && styles.chipActive]}
|
||||
onPress={() => { setShowDirectory(false); setSelectedCategory(chip.category); }}
|
||||
style={[styles.chip, chip.active && !showDirectory && styles.chipActive]}
|
||||
hitSlop={{ top: 6, bottom: 6 }}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: chip.active }}
|
||||
accessibilityState={{ selected: chip.active && !showDirectory }}
|
||||
>
|
||||
<Text style={[styles.chipText, chip.active && styles.chipTextActive]} numberOfLines={1}>
|
||||
<Text style={[styles.chipText, chip.active && !showDirectory && styles.chipTextActive]} numberOfLines={1}>
|
||||
{chip.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
)}
|
||||
{directoryAvailable && (
|
||||
<>
|
||||
<View style={styles.chipDivider} />
|
||||
<Pressable
|
||||
onPress={() => setShowDirectory(true)}
|
||||
style={[styles.chip, showDirectory && styles.chipActive]}
|
||||
hitSlop={{ top: 6, bottom: 6 }}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: showDirectory }}
|
||||
>
|
||||
<Text style={[styles.chipText, showDirectory && styles.chipTextActive]} numberOfLines={1}>
|
||||
{t('contacts.directory.title', 'Directory')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
@@ -567,7 +591,7 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SectionList
|
||||
{showDirectory && !selectionMode ? <DirectoryBrowser query={searchQuery} /> : <SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
@@ -621,7 +645,7 @@ export default function ContactsScreen({ onOpenContact, activeContactId = null }
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
/>}
|
||||
|
||||
<Dialog
|
||||
visible={confirmBulkDelete}
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
|
||||
ChevronLeft, ChevronRight, Archive, Mail, MailOpen,
|
||||
FolderInput, ShieldAlert, ShieldCheck, X, Check,
|
||||
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus,
|
||||
Code, Download, Tag, Sun, Moon, FileInput, UserRoundPlus, Filter,
|
||||
} from '../components/icons';
|
||||
import { QuickRuleSheet } from '../components/filters/QuickRuleSheet';
|
||||
import { isSieveSupported } from '../api/sieve';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import { MoveSheet } from '../components/MoveSheet';
|
||||
@@ -171,6 +173,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
const [moreMenuOpen, setMoreMenuOpen] = React.useState(false);
|
||||
const [moveMenuOpen, setMoveMenuOpen] = React.useState(false);
|
||||
const [tagMenuOpen, setTagMenuOpen] = React.useState(false);
|
||||
const [rulesOpen, setRulesOpen] = React.useState(false);
|
||||
const [addressSheet, setAddressSheet] = React.useState<EmailAddress | null>(null);
|
||||
// Per-message override of the light/dark rendering (More sheet toggle).
|
||||
const [themeOverrides, setThemeOverrides] = React.useState<Record<string, 'light' | 'dark'>>({});
|
||||
@@ -893,6 +896,8 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
if (from) setAddressSheet(from);
|
||||
}}
|
||||
onForwardAsAttachment={onForwardAsAttachment}
|
||||
// Rules go into the own account's filters; a shared account's are not the user's to set.
|
||||
onRules={!ownerAccountId && email && isSieveSupported() ? () => { setMoreMenuOpen(false); setRulesOpen(true); } : undefined}
|
||||
onViewSource={() => {
|
||||
setMoreMenuOpen(false);
|
||||
if (email?.blobId) {
|
||||
@@ -937,6 +942,7 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
/>
|
||||
|
||||
<AddressActionSheet address={addressSheet} onClose={() => setAddressSheet(null)} />
|
||||
<QuickRuleSheet visible={rulesOpen} email={email ?? null} mailboxes={scopedMailboxes} onClose={() => setRulesOpen(false)} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1309,6 +1315,8 @@ interface MoreMenuSheetProps {
|
||||
onToggleTheme: () => void;
|
||||
onSenderActions: () => void;
|
||||
onForwardAsAttachment: () => void;
|
||||
/** Make a filter rule from the message; absent where none can be made. */
|
||||
onRules?: () => void;
|
||||
onViewSource: () => void;
|
||||
onExport: () => void;
|
||||
}
|
||||
@@ -1317,7 +1325,7 @@ function MoreMenuSheet({
|
||||
visible, onClose, unread, canArchive, canMarkUnread, canMove, canTag,
|
||||
showSpam, isInJunk, canViewSource, canExport, renderDark, hasSender,
|
||||
onArchive, onToggleUnread, onMove, onTag, onToggleSpam, onToggleTheme, onSenderActions,
|
||||
onForwardAsAttachment, onViewSource, onExport,
|
||||
onForwardAsAttachment, onRules, onViewSource, onExport,
|
||||
}: MoreMenuSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -1434,6 +1442,14 @@ function MoreMenuSheet({
|
||||
trailing={<ChevronRight size={16} color={c.textMuted} />}
|
||||
/>
|
||||
)}
|
||||
{onRules && (
|
||||
<MoreMenuItem
|
||||
icon={<Filter size={18} color={c.textSecondary} />}
|
||||
label={t('context_menu.rules.title', 'Rules')}
|
||||
onPress={onRules}
|
||||
trailing={<ChevronRight size={16} color={c.textMuted} />}
|
||||
/>
|
||||
)}
|
||||
{canExport && (
|
||||
<MoreMenuItem
|
||||
icon={<FileInput size={18} color={c.textSecondary} />}
|
||||
|
||||
@@ -47,6 +47,11 @@ vi.mock('../../api/jmap-client', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api/files', () => ({
|
||||
listPrincipals: vi.fn(),
|
||||
supportsSharing: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/blob', () => ({
|
||||
uploadBytes: vi.fn(),
|
||||
getBlobsAsBase64: vi.fn(),
|
||||
@@ -54,6 +59,7 @@ vi.mock('../../api/blob', () => ({
|
||||
|
||||
import * as contactsApi from '../../api/contacts';
|
||||
import * as blobApi from '../../api/blob';
|
||||
import * as filesApi from '../../api/files';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { resetContactBlobMediaRefusals } from '../../lib/contact-media';
|
||||
import * as recentApi from '../../api/recent-recipients';
|
||||
@@ -407,9 +413,42 @@ describe('contacts-store', () => {
|
||||
[{ id: 'import-1', name: { full: 'A' } }, { id: 'import-2', name: { full: 'B' } }],
|
||||
'ab-1',
|
||||
);
|
||||
expect(result).toEqual({ imported: 1, failed: 1 });
|
||||
expect(result).toEqual({
|
||||
imported: 1, updated: 0, failed: 1, failures: [{ name: 'B', reason: 'invalid' }],
|
||||
});
|
||||
expect(useContactsStore.getState().contacts).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Re-importing the app's own export: Stalwart refuses a second card with
|
||||
// a UID the account has ("Contact with UID ... already exists").
|
||||
it('updates a card whose UID the account already has instead of creating it', async () => {
|
||||
useContactsStore.setState({
|
||||
addressBooks: [{ id: 'ab-1', name: 'P' }],
|
||||
contacts: [card('c1', { uid: 'urn:uuid:aaa', name: { full: 'Old' } })],
|
||||
});
|
||||
const result = await useContactsStore.getState().importContacts(
|
||||
[{ id: 'import-1', uid: 'urn:uuid:aaa', addressBookIds: { x: true }, name: { full: 'New' } }],
|
||||
'ab-1',
|
||||
);
|
||||
expect(mockCreateContact).not.toHaveBeenCalled();
|
||||
expect(mockUpdateContact).toHaveBeenCalledWith('c1', { name: { full: 'New' } }, undefined);
|
||||
expect(result).toMatchObject({ imported: 0, updated: 1, failed: 0 });
|
||||
expect(useContactsStore.getState().contacts[0].name?.full).toBe('New');
|
||||
});
|
||||
|
||||
it('updates the card the server names when the list does not hold it', async () => {
|
||||
useContactsStore.setState({ addressBooks: [{ id: 'ab-1', name: 'P' }] });
|
||||
mockCreateContact.mockRejectedValueOnce(
|
||||
new Error('Contact with UID urn:uuid:bbb already exists with id m. (uid)'),
|
||||
);
|
||||
mockGetAllContacts.mockResolvedValue([]);
|
||||
const result = await useContactsStore.getState().importContacts(
|
||||
[{ id: 'import-1', uid: 'urn:uuid:bbb', name: { full: 'B' } }],
|
||||
'ab-1',
|
||||
);
|
||||
expect(mockUpdateContact).toHaveBeenCalledWith('m', { name: { full: 'B' } }, undefined);
|
||||
expect(result).toMatchObject({ imported: 0, updated: 1, failed: 0, failures: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('address books', () => {
|
||||
@@ -420,6 +459,8 @@ describe('contacts-store', () => {
|
||||
});
|
||||
mockDeleteAddressBook.mockResolvedValue(undefined);
|
||||
await useContactsStore.getState().deleteAddressBook('ab-2');
|
||||
// A book that still holds cards is refused without it (Stalwart 1.0).
|
||||
expect(mockDeleteAddressBook).toHaveBeenCalledWith('ab-2', undefined, { removeContents: true });
|
||||
expect(useContactsStore.getState().addressBooks.map((b) => b.id)).toEqual(['ab-1']);
|
||||
expect(useContactsStore.getState().contacts.map((c) => c.id)).toEqual(['c1']);
|
||||
});
|
||||
@@ -597,6 +638,111 @@ describe('contacts-store', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('company directory (#1253)', () => {
|
||||
const mockListPrincipals = filesApi.listPrincipals as ReturnType<typeof vi.fn>;
|
||||
const principal = (id: string, name: string, email?: string) =>
|
||||
({ id, type: 'individual' as const, name, email });
|
||||
|
||||
it('lists the principals with an address', async () => {
|
||||
mockListPrincipals.mockResolvedValue({
|
||||
status: 'ok',
|
||||
principals: [principal('a', 'anna', 'anna@example.org'), principal('g', 'admins'), principal('b', 'bob', 'bob@example.org')],
|
||||
});
|
||||
await useContactsStore.getState().fetchDirectory();
|
||||
const s = useContactsStore.getState();
|
||||
expect(s.directoryStatus).toBe('ready');
|
||||
expect(s.directory.map((e) => e.email)).toEqual(['anna@example.org', 'bob@example.org']);
|
||||
});
|
||||
|
||||
// Stalwart 1.0 answers forbidden while directory queries are off (its
|
||||
// default) and hands back the principals the user knows.
|
||||
it('keeps the known principals of a restricted listing and says why', async () => {
|
||||
mockListPrincipals.mockResolvedValue({
|
||||
status: 'restricted', directoryQueriesOff: true, principals: [principal('me', 'me', 'me@example.org')],
|
||||
});
|
||||
await useContactsStore.getState().fetchDirectory();
|
||||
const s = useContactsStore.getState();
|
||||
expect(s.directoryStatus).toBe('restricted');
|
||||
expect(s.directoryQueriesOff).toBe(true);
|
||||
expect(s.directory).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('looks up recipients in the loaded directory', async () => {
|
||||
mockListPrincipals.mockResolvedValue({
|
||||
status: 'ok', principals: [{ ...principal('a', 'anna', 'anna@example.org'), description: 'Anna Berg' }],
|
||||
});
|
||||
await useContactsStore.getState().fetchDirectory();
|
||||
expect(useContactsStore.getState().lookupDirectory('berg')).toEqual([{ name: 'Anna Berg', email: 'anna@example.org' }]);
|
||||
expect(useContactsStore.getState().lookupDirectory('zzz')).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops a listing that arrives after an account switch', async () => {
|
||||
let resolve: (v: unknown) => void = () => {};
|
||||
mockListPrincipals.mockReturnValue(new Promise((r) => { resolve = r; }));
|
||||
const load = useContactsStore.getState().fetchDirectory();
|
||||
useContactsStore.getState().reset();
|
||||
resolve({ status: 'ok', principals: [principal('a', 'anna', 'anna@example.org')] });
|
||||
await load;
|
||||
expect(useContactsStore.getState().directory).toEqual([]);
|
||||
expect(useContactsStore.getState().directoryStatus).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('an account switch while contacts load or save', () => {
|
||||
function deferred<T>() {
|
||||
let resolve: (v: T) => void = () => {};
|
||||
const promise = new Promise<T>((r) => { resolve = r; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
it('drops the previous account\'s cards that arrive after the switch', async () => {
|
||||
const old = deferred<ContactCard[]>();
|
||||
mockGetAllContacts.mockReturnValueOnce(old.promise);
|
||||
const oldLoad = useContactsStore.getState().fetchContacts();
|
||||
|
||||
useContactsStore.getState().reset();
|
||||
mockGetAllContacts.mockResolvedValueOnce([card('new-1')]);
|
||||
// Not handed the old account's load still in flight.
|
||||
await useContactsStore.getState().fetchContacts();
|
||||
expect(mockGetAllContacts).toHaveBeenCalledTimes(2);
|
||||
|
||||
old.resolve([card('old-1')]);
|
||||
await oldLoad;
|
||||
expect(useContactsStore.getState().contacts.map((c) => c.id)).toEqual(['new-1']);
|
||||
});
|
||||
|
||||
it('drops the previous account\'s address books', async () => {
|
||||
const old = deferred<AddressBook[]>();
|
||||
mockGetAllAddressBooks.mockReturnValueOnce(old.promise);
|
||||
const oldLoad = useContactsStore.getState().fetchAddressBooks();
|
||||
useContactsStore.getState().reset();
|
||||
old.resolve([{ id: 'ab-old', name: 'Old' } as AddressBook]);
|
||||
await oldLoad;
|
||||
expect(useContactsStore.getState().addressBooks).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a card created for the previous account out of the next one', async () => {
|
||||
const write = deferred<ContactCard>();
|
||||
mockCreateContact.mockReturnValueOnce(write.promise);
|
||||
const create = useContactsStore.getState().createContact({}, 'ab-1');
|
||||
useContactsStore.getState().reset();
|
||||
write.resolve(card('created-before'));
|
||||
await expect(create).rejects.toThrow(/account changed/);
|
||||
expect(useContactsStore.getState().contacts).toEqual([]);
|
||||
});
|
||||
|
||||
it('stops an import at the switch instead of writing into the next account', async () => {
|
||||
const first = deferred<ContactCard>();
|
||||
mockCreateContact.mockReturnValueOnce(first.promise);
|
||||
const run = useContactsStore.getState().importContacts([{ id: 'a' }, { id: 'b' }, { id: 'c' }], 'ab-1');
|
||||
useContactsStore.getState().reset();
|
||||
first.resolve(card('imported-a'));
|
||||
await expect(run).rejects.toThrow(/account changed/);
|
||||
expect(mockCreateContact).toHaveBeenCalledTimes(1);
|
||||
expect(useContactsStore.getState().contacts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('hands the storage the same slice while the cards are unchanged', () => {
|
||||
const partialize = useContactsStore.persist.getOptions().partialize!;
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
readWebmailResave as webmailResave,
|
||||
STALWART_EXTENSIONS,
|
||||
} from '../../lib/sieve/__tests__/fixtures/webmail';
|
||||
import { isVacationIncludedInFilters, syncVacationWithFilters, useFilterStore } from '../filter-store';
|
||||
import {
|
||||
isVacationIncludedInFilters, readVacationFilters, syncVacationWithFilters, useFilterStore,
|
||||
} from '../filter-store';
|
||||
|
||||
const api = vi.mocked(sieve);
|
||||
|
||||
@@ -258,4 +260,92 @@ describe('saving scripts the webmail wrote', () => {
|
||||
await useFilterStore.getState().saveFilters();
|
||||
expect(api.updateSieveScript.mock.calls[0][1]).toBe(webmailResave('stalwart-full.sieve'));
|
||||
});
|
||||
|
||||
// #1152: webmail 1.13+ writes version 2 for rule periods and the vacation
|
||||
// card's forwarding and audience. Read as raw Sieve, "Reset to visual
|
||||
// builder" wiped them; a save must keep them byte for byte.
|
||||
it('edits a version 2 script and keeps periods, forwarding and audience', async () => {
|
||||
const script = webmailFixture('period-forward-audience.sieve');
|
||||
api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
|
||||
api.getSieveScripts.mockResolvedValue([
|
||||
{ id: 's1', name: 'filters', blobId: 'b1', isActive: true },
|
||||
{ id: 'v1', name: 'vacation', blobId: 'bv', isActive: false },
|
||||
]);
|
||||
api.getSieveScriptContent.mockResolvedValue(script);
|
||||
|
||||
await useFilterStore.getState().selectAccount(null);
|
||||
const s = useFilterStore.getState();
|
||||
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();
|
||||
expect(api.updateSieveScript).toHaveBeenCalledWith('s1', script, true, 'own');
|
||||
});
|
||||
|
||||
it('keeps the forwarding and audience when the auto-reply is switched off on the phone', async () => {
|
||||
const script = webmailFixture('period-forward-audience.sieve');
|
||||
api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
|
||||
api.getSieveScripts.mockResolvedValue([
|
||||
{ id: 's1', name: 'filters', blobId: 'b1', isActive: true },
|
||||
{ id: 'v1', name: 'vacation', blobId: 'bv', isActive: false },
|
||||
]);
|
||||
api.getSieveScriptContent.mockResolvedValue(script);
|
||||
|
||||
await syncVacationWithFilters(false);
|
||||
const written = api.updateSieveScript.mock.calls[0][1] as string;
|
||||
expect(written).not.toContain('include :personal');
|
||||
expect(written).toContain('# Vacation forwarding');
|
||||
expect(written).toContain('"vacationAudience":{"only":"internal","domains":["example.org","example.com"]}');
|
||||
expect(written).toContain('"activeUntil":"2026-11-13T17:30:00.000Z"');
|
||||
});
|
||||
|
||||
it('keeps the remembered forwarding of a version 1 script', async () => {
|
||||
const script = webmailFixture('forward-off.sieve');
|
||||
api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
|
||||
api.getSieveScripts.mockResolvedValue([{ id: 's1', name: 'filters', blobId: 'b1', isActive: true }]);
|
||||
api.getSieveScriptContent.mockResolvedValue(script);
|
||||
|
||||
await useFilterStore.getState().selectAccount(null);
|
||||
await useFilterStore.getState().saveFilters();
|
||||
expect(api.updateSieveScript).toHaveBeenCalledWith('s1', script, true, 'own');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readVacationFilters', () => {
|
||||
it('reports the stored forwarding and audience and what the server can run', async () => {
|
||||
api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
|
||||
api.getSieveScripts.mockResolvedValue([
|
||||
{ id: 's1', name: 'filters', blobId: 'b1', isActive: true },
|
||||
{ id: 'v1', name: 'vacation', blobId: 'bv', isActive: false },
|
||||
]);
|
||||
api.getSieveScriptContent.mockResolvedValue(webmailFixture('period-forward-audience.sieve'));
|
||||
|
||||
const filters = await readVacationFilters();
|
||||
expect(filters).toMatchObject({
|
||||
includesVacation: true,
|
||||
forward: { enabled: true, to: 'backup@example.org', keepCopy: false },
|
||||
forwardAvailable: true,
|
||||
audience: { only: 'internal', domains: ['example.org', 'example.com'] },
|
||||
audienceAvailable: true,
|
||||
notRunning: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets up forwarding in the vacation period, creating the script when there is none', async () => {
|
||||
api.getSieveCapabilities.mockReturnValue({ ...WITH_INCLUDE, sieveExtensions: STALWART_EXTENSIONS });
|
||||
api.getSieveScripts.mockResolvedValue([{ id: 'v1', name: 'vacation', blobId: 'bv', isActive: true }]);
|
||||
api.createSieveScript.mockResolvedValue({ id: 'new', name: 'filters', blobId: 'b', isActive: true });
|
||||
|
||||
await syncVacationWithFilters(
|
||||
true, undefined, { enabled: true, to: 'backup@example.org', keepCopy: true },
|
||||
undefined, { from: '2026-11-02T00:00:00Z', until: null },
|
||||
);
|
||||
const [name, content, activate] = api.createSieveScript.mock.calls[0];
|
||||
expect(name).toBe('filters');
|
||||
expect(activate).toBe(true);
|
||||
expect(content).toContain('"vacationForward":{"enabled":true,"to":"backup@example.org","keepCopy":true,"activeFrom":"2026-11-02T00:00:00Z"}');
|
||||
expect(content).toContain(' redirect :copy "backup@example.org";');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,13 @@ vi.mock('../../api/vacation', () => ({
|
||||
|
||||
vi.mock('../../api/sieve', () => ({ isSieveSupported: vi.fn(() => false) }));
|
||||
|
||||
const NO_FILTERS = {
|
||||
includesVacation: false, forward: null, forwardAvailable: false,
|
||||
audience: null, audienceAvailable: false, notRunning: false,
|
||||
};
|
||||
|
||||
vi.mock('../filter-store', () => ({
|
||||
isVacationIncludedInFilters: vi.fn(async () => false),
|
||||
readVacationFilters: vi.fn(async () => NO_FILTERS),
|
||||
syncVacationWithFilters: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
@@ -39,7 +44,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.isVacationSupported.mockReturnValue(true);
|
||||
sieveApi.isSieveSupported.mockReturnValue(false);
|
||||
filterStore.isVacationIncludedInFilters.mockResolvedValue(false);
|
||||
filterStore.readVacationFilters.mockResolvedValue(NO_FILTERS);
|
||||
useVacationStore.getState().reset();
|
||||
});
|
||||
|
||||
@@ -96,12 +101,12 @@ describe('vacation-store account scoping', () => {
|
||||
describe('vacation-store and the filters script (webmail 198a3c0d)', () => {
|
||||
it('reports the auto-reply as on while the filters script includes it', async () => {
|
||||
sieveApi.isSieveSupported.mockReturnValue(true);
|
||||
filterStore.isVacationIncludedInFilters.mockResolvedValue(true);
|
||||
filterStore.readVacationFilters.mockResolvedValue({ ...NO_FILTERS, includesVacation: true });
|
||||
api.getVacationResponse.mockResolvedValue(responder({ isEnabled: false, subject: 'Away' }));
|
||||
|
||||
await useVacationStore.getState().fetch('team');
|
||||
|
||||
expect(filterStore.isVacationIncludedInFilters).toHaveBeenCalledWith('team');
|
||||
expect(filterStore.readVacationFilters).toHaveBeenCalledWith('team');
|
||||
expect(useVacationStore.getState()).toMatchObject({ isEnabled: true, subject: 'Away' });
|
||||
});
|
||||
|
||||
@@ -111,7 +116,9 @@ describe('vacation-store and the filters script (webmail 198a3c0d)', () => {
|
||||
await useVacationStore.getState().fetch();
|
||||
|
||||
await useVacationStore.getState().save({ isEnabled: true });
|
||||
expect(filterStore.syncVacationWithFilters).toHaveBeenCalledWith(true, undefined);
|
||||
expect(filterStore.syncVacationWithFilters).toHaveBeenCalledWith(
|
||||
true, undefined, undefined, undefined, { from: null, until: null },
|
||||
);
|
||||
|
||||
await useVacationStore.getState().save({ subject: 'Only the subject' });
|
||||
expect(filterStore.syncVacationWithFilters).toHaveBeenCalledTimes(1);
|
||||
@@ -127,6 +134,48 @@ describe('vacation-store and the filters script (webmail 198a3c0d)', () => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
// #1152: forwarding and who gets the reply live in the filters script.
|
||||
it('loads the stored forwarding and audience', async () => {
|
||||
sieveApi.isSieveSupported.mockReturnValue(true);
|
||||
const forward = { enabled: true, to: 'backup@example.org', keepCopy: false };
|
||||
const audience = { only: 'internal' as const, domains: ['example.org'] };
|
||||
filterStore.readVacationFilters.mockResolvedValue({
|
||||
...NO_FILTERS, forward, forwardAvailable: true, audience, audienceAvailable: true,
|
||||
});
|
||||
api.getVacationResponse.mockResolvedValue(responder());
|
||||
|
||||
await useVacationStore.getState().fetch();
|
||||
expect(useVacationStore.getState()).toMatchObject({
|
||||
forward, forwardAvailable: true, audience, audienceAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('saves forwarding and audience in the vacation period, not to the responder', async () => {
|
||||
sieveApi.isSieveSupported.mockReturnValue(true);
|
||||
api.getVacationResponse.mockResolvedValue(responder({ fromDate: '2026-11-02T08:00:00Z' }));
|
||||
await useVacationStore.getState().fetch();
|
||||
|
||||
const forward = { enabled: true, to: 'backup@example.org', keepCopy: true };
|
||||
const audience = { only: 'external' as const, domains: ['example.org'] };
|
||||
await useVacationStore.getState().save({ toDate: '2026-11-14T00:00:00Z', forward, audience });
|
||||
expect(api.setVacationResponse).toHaveBeenCalledWith({ toDate: '2026-11-14T00:00:00Z' }, undefined);
|
||||
expect(filterStore.syncVacationWithFilters).toHaveBeenCalledWith(
|
||||
false, undefined, forward, audience, { from: '2026-11-02T08:00:00Z', until: '2026-11-14T00:00:00Z' },
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a forwarding that could not be saved', async () => {
|
||||
sieveApi.isSieveSupported.mockReturnValue(true);
|
||||
filterStore.syncVacationWithFilters.mockRejectedValueOnce(new Error('boom'));
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await expect(useVacationStore.getState().save({
|
||||
forward: { enabled: true, to: 'backup@example.org', keepCopy: false },
|
||||
})).rejects.toThrow('boom');
|
||||
expect(useVacationStore.getState().isSaving).toBe(false);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('leaves the filters alone on servers without Sieve', async () => {
|
||||
await useVacationStore.getState().save({ isEnabled: true });
|
||||
expect(filterStore.syncVacationWithFilters).not.toHaveBeenCalled();
|
||||
|
||||
+260
-38
@@ -35,6 +35,13 @@ import {
|
||||
matchesContactSearch,
|
||||
} from '../lib/contact-utils';
|
||||
import { sanitizeDisplayName, splitMailbox } from '../lib/rfc5322-mailbox';
|
||||
import { listPrincipals, supportsSharing } from '../api/files';
|
||||
import {
|
||||
directorySuggestions,
|
||||
principalsToDirectory,
|
||||
type DirectoryEntry,
|
||||
type DirectoryStatus,
|
||||
} from '../lib/contact-directory';
|
||||
import { requestDeviceSync } from '../device-sync/app/request';
|
||||
import { CONTACTS_AUTHORITY } from '../device-sync/types';
|
||||
|
||||
@@ -81,6 +88,15 @@ export interface ContactsState {
|
||||
recentRecipientsLoaded: boolean;
|
||||
sentMailboxId: string | null;
|
||||
|
||||
// The company directory (#1253): principals the server lists, read-only.
|
||||
directory: DirectoryEntry[];
|
||||
directoryStatus: DirectoryStatus;
|
||||
/**
|
||||
* The server refused the listing because its administrator keeps directory
|
||||
* queries off (Stalwart 1.0's default), not over this account's rights.
|
||||
*/
|
||||
directoryQueriesOff: boolean;
|
||||
|
||||
hydrate: () => Promise<void>;
|
||||
fetchAddressBooks: () => Promise<void>;
|
||||
fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise<void>;
|
||||
@@ -100,10 +116,14 @@ export interface ContactsState {
|
||||
fetchContactMedia: (id: string) => Promise<Pick<ContactCard, 'media'> | null>;
|
||||
deleteContact: (id: string) => Promise<void>;
|
||||
bulkDelete: (ids: string[]) => Promise<void>;
|
||||
/**
|
||||
* Creates the cards in the book; one whose UID the account already has is
|
||||
* updated in place instead (a re-import of an export).
|
||||
*/
|
||||
importContacts: (
|
||||
contacts: Partial<ContactCard>[],
|
||||
addressBookId: string,
|
||||
) => Promise<{ imported: number; failed: number }>;
|
||||
) => Promise<ContactImportResult>;
|
||||
|
||||
addContactKeyword: (id: string, keyword: string) => Promise<void>;
|
||||
removeContactKeyword: (id: string, keyword: string) => Promise<void>;
|
||||
@@ -130,6 +150,13 @@ export interface ContactsState {
|
||||
searchRecipients: (query: string) => Promise<RecipientSuggestion[]>;
|
||||
getAutocomplete: (query: string, limit?: number) => RecipientSuggestion[];
|
||||
findContactByEmail: (email: string) => ContactCard | undefined;
|
||||
/** Load the company directory (the server's principals). */
|
||||
fetchDirectory: () => Promise<void>;
|
||||
/**
|
||||
* Directory people matching what is typed, for recipient fields. Reads what
|
||||
* fetchDirectory loaded; empty until then.
|
||||
*/
|
||||
lookupDirectory: (query: string, limit?: number) => RecipientSuggestion[];
|
||||
|
||||
// Trusted senders address book. Passive loads (createIfMissing=false) only
|
||||
// read an existing book; the book is created lazily on the first add.
|
||||
@@ -142,6 +169,31 @@ export interface ContactsState {
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/** A card an import could not save, and the server's reason. */
|
||||
export interface ContactImportFailure {
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ContactImportResult {
|
||||
imported: number;
|
||||
/** Cards whose UID the account already had, updated in place. */
|
||||
updated: number;
|
||||
failed: number;
|
||||
failures: ContactImportFailure[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The card a create was refused for because its UID is taken, as Stalwart
|
||||
* names it (0.16 and 1.0): "Contact with UID urn:uuid:... already exists
|
||||
* with id b."
|
||||
*/
|
||||
export function existingUidCardId(err: unknown): string | null {
|
||||
if (!(err instanceof Error)) return null;
|
||||
const m = /already exists with id ([^\s.]+)/i.exec(err.message);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function persistCategory(category: ContactCategory): void {
|
||||
void AsyncStorage.setItem(SELECTED_CATEGORY_STORAGE_KEY, JSON.stringify(category)).catch(
|
||||
(err) => console.warn('[contacts-store] persist category failed', err),
|
||||
@@ -239,6 +291,35 @@ export function normalizeSuggestions(results: RecipientSuggestion[]): RecipientS
|
||||
let addressBooksPromise: Promise<void> | null = null;
|
||||
let contactsPromise: Promise<void> | null = null;
|
||||
let trustedSendersPromise: Promise<void> | null = null;
|
||||
let directoryPromise: Promise<void> | null = null;
|
||||
|
||||
// One up with every reset (an account switch, a sign-out), as in the calendar
|
||||
// store. The client is one singleton that serves whichever account is active,
|
||||
// so a load or a write still under way from before must keep what it read to
|
||||
// itself: landing after the switch, it put the previous account's cards and
|
||||
// books under the next one, and a loop of writes went on in the new account.
|
||||
let storeGeneration = 0;
|
||||
|
||||
/** The store was reset (another account) while a contacts write was under way. */
|
||||
export class ContactsAccountChangedError extends Error {
|
||||
constructor() {
|
||||
super('The account changed while the contacts were being saved');
|
||||
this.name = 'ContactsAccountChangedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** For one load or write: whether a reset has overtaken it since it started. */
|
||||
function generationGuard() {
|
||||
const generation = storeGeneration;
|
||||
const stale = () => generation !== storeGeneration;
|
||||
return {
|
||||
stale,
|
||||
/** Throws ContactsAccountChangedError once stale: nothing more may be sent or kept. */
|
||||
check: () => {
|
||||
if (stale()) throw new ContactsAccountChangedError();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Startup fetches every card and live `ContactCard` state changes refetch
|
||||
// them, so opening Contacts only downloads the cards again once the last full
|
||||
@@ -298,10 +379,13 @@ export const useContactsStore = create<ContactsState>()(
|
||||
};
|
||||
|
||||
const loadAddressBooks = async (): Promise<void> => {
|
||||
const { stale } = generationGuard();
|
||||
try {
|
||||
const addressBooks = (await fetchAllAddressBooks()) ?? [];
|
||||
if (stale()) return;
|
||||
set({ addressBooks });
|
||||
} catch (err) {
|
||||
if (stale()) return;
|
||||
set({ error: err instanceof Error ? err.message : 'Failed to load address books' });
|
||||
}
|
||||
};
|
||||
@@ -324,6 +408,10 @@ export const useContactsStore = create<ContactsState>()(
|
||||
recentRecipientsLoaded: false,
|
||||
sentMailboxId: null,
|
||||
|
||||
directory: [],
|
||||
directoryStatus: 'idle',
|
||||
directoryQueriesOff: false,
|
||||
|
||||
hydrate: async () => {
|
||||
if (get().hydrated) return;
|
||||
try {
|
||||
@@ -346,19 +434,23 @@ export const useContactsStore = create<ContactsState>()(
|
||||
// has set up the client.
|
||||
if (!jmapClient.isConnected) return;
|
||||
if (addressBooksPromise) return addressBooksPromise;
|
||||
addressBooksPromise = loadAddressBooks().finally(() => {
|
||||
addressBooksPromise = null;
|
||||
const promise = loadAddressBooks().finally(() => {
|
||||
// A reset may have handed the slot to the next account's load.
|
||||
if (addressBooksPromise === promise) addressBooksPromise = null;
|
||||
});
|
||||
return addressBooksPromise;
|
||||
addressBooksPromise = promise;
|
||||
return promise;
|
||||
},
|
||||
|
||||
fetchContacts: async (filter) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
if (contactsPromise) return contactsPromise;
|
||||
contactsPromise = (async () => {
|
||||
const { stale } = generationGuard();
|
||||
const promise: Promise<void> = (async () => {
|
||||
// Books first: AddressBook/get is the call that makes Stalwart mint
|
||||
// the default book, so ContactCard/query must never run alongside it.
|
||||
if (addressBooksPromise) await addressBooksPromise;
|
||||
if (stale()) return;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
let contacts: ContactCard[];
|
||||
@@ -369,15 +461,18 @@ export const useContactsStore = create<ContactsState>()(
|
||||
} else {
|
||||
contacts = (await fetchAllContacts()) ?? [];
|
||||
}
|
||||
if (stale()) return;
|
||||
// A filtered load leaves only some of the cards in state.
|
||||
set({ contacts, loading: false, contactsFetchedAt: filtered ? 0 : Date.now() });
|
||||
} catch (err) {
|
||||
if (stale()) return;
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load contacts' });
|
||||
}
|
||||
})().finally(() => {
|
||||
contactsPromise = null;
|
||||
if (contactsPromise === promise) contactsPromise = null;
|
||||
});
|
||||
return contactsPromise;
|
||||
contactsPromise = promise;
|
||||
return promise;
|
||||
},
|
||||
|
||||
fetchContactsIfStale: async () => {
|
||||
@@ -411,12 +506,15 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
createContact: async (contact, addressBookId, options) => {
|
||||
const { check } = generationGuard();
|
||||
const { originalId, accountId, book } = bookTarget(addressBookId);
|
||||
const write = (card: Partial<ContactCard>) => apiCreateContact(card, originalId, accountId);
|
||||
// The photo goes up first; the card must not follow it into the next account.
|
||||
const write = (card: Partial<ContactCard>) => { check(); return apiCreateContact(card, originalId, accountId); };
|
||||
const saved = options?.photoKey
|
||||
? await writeContactWithNewPhoto(accountId, contact, options.photoKey, write)
|
||||
: await write(contact);
|
||||
const created = tagCreated(saved, book);
|
||||
check();
|
||||
set({ contacts: [...get().contacts, created] });
|
||||
requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
return created;
|
||||
@@ -426,12 +524,14 @@ export const useContactsStore = create<ContactsState>()(
|
||||
const contact = get().contacts.find((c) => c.id === id);
|
||||
const { originalId, accountId } = contactTarget(id);
|
||||
const patch = cleanPatch(contact, changes);
|
||||
const write = (card: Partial<ContactCard>) => apiUpdateContact(originalId, card, accountId);
|
||||
const { check } = generationGuard();
|
||||
const write = (card: Partial<ContactCard>) => { check(); return apiUpdateContact(originalId, card, accountId); };
|
||||
if (options?.photoKey) {
|
||||
await writeContactWithNewPhoto(accountId, patch, options.photoKey, write);
|
||||
} else {
|
||||
await write(patch);
|
||||
}
|
||||
check();
|
||||
set({
|
||||
contacts: get().contacts.map((c) => {
|
||||
if (c.id !== id) return c;
|
||||
@@ -459,8 +559,10 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
deleteContact: async (id) => {
|
||||
const { check } = generationGuard();
|
||||
const { originalId, accountId } = contactTarget(id);
|
||||
await apiDeleteContacts([originalId], accountId);
|
||||
check();
|
||||
set({ contacts: cleanGroupMembers(get().contacts, new Set([id])).filter((c) => c.id !== id) });
|
||||
requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
},
|
||||
@@ -477,7 +579,9 @@ export const useContactsStore = create<ContactsState>()(
|
||||
}
|
||||
const removed = new Set<string>();
|
||||
let failed = 0;
|
||||
const { check } = generationGuard();
|
||||
for (const [accountId, entries] of byAccount) {
|
||||
check();
|
||||
try {
|
||||
const destroyed = new Set(
|
||||
await apiDeleteContacts(entries.map((e) => e.originalId), accountId),
|
||||
@@ -491,6 +595,7 @@ export const useContactsStore = create<ContactsState>()(
|
||||
failed += entries.length;
|
||||
}
|
||||
}
|
||||
check();
|
||||
set({
|
||||
contacts: cleanGroupMembers(get().contacts, removed).filter((c) => !removed.has(c.id)),
|
||||
...(failed > 0 ? { error: `Failed to delete ${failed} contact${failed === 1 ? '' : 's'}` } : {}),
|
||||
@@ -500,22 +605,69 @@ export const useContactsStore = create<ContactsState>()(
|
||||
|
||||
importContacts: async (incoming, addressBookId) => {
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
let updated = 0;
|
||||
let refetch = false;
|
||||
const failures: ContactImportFailure[] = [];
|
||||
const { originalId, accountId, book } = bookTarget(addressBookId);
|
||||
const { check } = generationGuard();
|
||||
// Stalwart keeps one card per UID in an account and refuses a second
|
||||
// ("Contact with UID ... already exists"), so re-importing an export
|
||||
// failed card by card. A card the account already has is updated.
|
||||
const byUid = new Map<string, ContactCard>();
|
||||
for (const c of get().contacts) {
|
||||
// As the server compares them: "x" and "urn:uuid:x" are two UIDs.
|
||||
if (c.uid && (c.isShared ? c.accountId : undefined) === accountId) byUid.set(c.uid, c);
|
||||
}
|
||||
const updateExisting = async (serverId: string, data: Partial<ContactCard>) => {
|
||||
// The card stays in its own book and keeps its UID.
|
||||
const { uid: _uid, ...patch } = data;
|
||||
await apiUpdateContact(serverId, patch, accountId);
|
||||
check();
|
||||
const storeId = accountId ? namespaceId(accountId, serverId) : serverId;
|
||||
if (!get().contacts.some((c) => c.id === storeId)) {
|
||||
refetch = true;
|
||||
return;
|
||||
}
|
||||
set({ contacts: get().contacts.map((c) => (c.id === storeId ? { ...c, ...patch } : c)) });
|
||||
};
|
||||
for (const contact of incoming) {
|
||||
check();
|
||||
// Strip the temporary client-side id; the server assigns a real one.
|
||||
const { id: _id, addressBookIds: _abIds, ...data } = contact;
|
||||
const known = data.uid ? byUid.get(data.uid) : undefined;
|
||||
try {
|
||||
// Strip the temporary client-side id; the server assigns a real one.
|
||||
const { id: _id, addressBookIds: _abIds, ...data } = contact;
|
||||
const created = tagCreated(await apiCreateContact(data, originalId, accountId), book);
|
||||
if (known) {
|
||||
await updateExisting(known.originalId || known.id, data);
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
let created: ContactCard;
|
||||
try {
|
||||
created = tagCreated(await apiCreateContact(data, originalId, accountId), book);
|
||||
} catch (err) {
|
||||
// A card with this UID the list does not hold (not loaded yet,
|
||||
// or in another book): the server names it.
|
||||
const existingId = existingUidCardId(err);
|
||||
if (!existingId) throw err;
|
||||
await updateExisting(existingId, data);
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
check();
|
||||
set({ contacts: [...get().contacts, created] });
|
||||
imported++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
if (err instanceof ContactsAccountChangedError) throw err;
|
||||
console.warn('[contacts-store] import failed for one contact', err);
|
||||
failures.push({
|
||||
name: getContactDisplayName(contact as ContactCard) || getContactPrimaryEmail(contact as ContactCard),
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (imported > 0) requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
return { imported, failed };
|
||||
if (imported > 0 || updated > 0) requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
if (refetch) void get().fetchContacts();
|
||||
return { imported, updated, failed: failures.length, failures };
|
||||
},
|
||||
|
||||
addContactKeyword: async (id, keyword) => {
|
||||
@@ -561,13 +713,16 @@ export const useContactsStore = create<ContactsState>()(
|
||||
const target = bookTarget(addressBookId);
|
||||
const primaryAccountId = getContactsAccountId();
|
||||
const targetAccount = target.accountId || primaryAccountId;
|
||||
const { check } = generationGuard();
|
||||
for (const id of ids) {
|
||||
check();
|
||||
const contact = get().contacts.find((c) => c.id === id);
|
||||
if (!contact) continue;
|
||||
const source = contactTarget(id);
|
||||
const sourceAccount = source.accountId || primaryAccountId;
|
||||
if (sourceAccount === targetAccount) {
|
||||
await apiUpdateContact(source.originalId, { addressBookIds: { [target.originalId]: true } }, source.accountId);
|
||||
check();
|
||||
set({
|
||||
contacts: get().contacts.map((c) =>
|
||||
c.id === id ? { ...c, addressBookIds: { [addressBookId]: true } } : c,
|
||||
@@ -583,7 +738,9 @@ export const useContactsStore = create<ContactsState>()(
|
||||
// successful create.
|
||||
const media = await inlineContactMedia(contact, 'keep');
|
||||
if (media) data.media = media;
|
||||
check();
|
||||
const created = tagCreated(await apiCreateContact(data, target.originalId, target.accountId), target.book);
|
||||
check();
|
||||
await apiDeleteContacts([source.originalId], source.accountId);
|
||||
set({ contacts: get().contacts.map((c) => (c.id === id ? created : c)) });
|
||||
}
|
||||
@@ -645,7 +802,9 @@ export const useContactsStore = create<ContactsState>()(
|
||||
createAddressBook: async (name) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error('Address book name is required');
|
||||
const { check } = generationGuard();
|
||||
const book = await apiCreateAddressBook(trimmed);
|
||||
check();
|
||||
set({ addressBooks: [...get().addressBooks, book] });
|
||||
requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
return book;
|
||||
@@ -654,8 +813,10 @@ export const useContactsStore = create<ContactsState>()(
|
||||
renameAddressBook: async (id, name) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
const { check } = generationGuard();
|
||||
const { originalId, accountId } = bookTarget(id);
|
||||
await apiUpdateAddressBook(originalId, { name: trimmed }, accountId);
|
||||
check();
|
||||
set({
|
||||
addressBooks: get().addressBooks.map((b) => (b.id === id ? { ...b, name: trimmed } : b)),
|
||||
});
|
||||
@@ -663,9 +824,12 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
deleteAddressBook: async (id) => {
|
||||
const { check } = generationGuard();
|
||||
const { originalId, accountId } = bookTarget(id);
|
||||
await apiDeleteAddressBook(originalId, accountId);
|
||||
// Stalwart destroys the cards filed in the book along with it, so
|
||||
// The confirmation names the cards that go with it.
|
||||
await apiDeleteAddressBook(originalId, accountId, { removeContents: true });
|
||||
check();
|
||||
// The server destroys the cards filed in the book along with it, so
|
||||
// drop them from the cache instead of leaving orphans under "All".
|
||||
set({
|
||||
addressBooks: get().addressBooks.filter((b) => b.id !== id),
|
||||
@@ -679,8 +843,10 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
setDefaultAddressBook: async (id) => {
|
||||
const { check } = generationGuard();
|
||||
const { originalId, accountId, book } = bookTarget(id);
|
||||
await apiSetDefaultAddressBook(originalId, accountId);
|
||||
check();
|
||||
set({
|
||||
addressBooks: get().addressBooks.map((b) => {
|
||||
if (b.id === id) return { ...b, isDefault: true };
|
||||
@@ -704,12 +870,14 @@ export const useContactsStore = create<ContactsState>()(
|
||||
if (sentMailboxId) set({ sentMailboxId });
|
||||
const mailboxId = get().sentMailboxId;
|
||||
if (get().recentRecipientsLoaded || !mailboxId || !jmapClient.isConnected) return;
|
||||
const { stale } = generationGuard();
|
||||
try {
|
||||
const recentRecipients = await queryRecentRecipients(mailboxId, 300);
|
||||
if (stale()) return;
|
||||
set({ recentRecipients, recentRecipientsLoaded: true });
|
||||
} catch (err) {
|
||||
console.warn('[contacts-store] load recent recipients failed', err);
|
||||
set({ recentRecipientsLoaded: true });
|
||||
if (!stale()) set({ recentRecipientsLoaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -781,6 +949,38 @@ export const useContactsStore = create<ContactsState>()(
|
||||
&& Object.values(c.emails).some((e) => e.address?.toLowerCase().trim() === needle));
|
||||
},
|
||||
|
||||
fetchDirectory: async () => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
if (!supportsSharing()) {
|
||||
set({ directoryStatus: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
if (directoryPromise) return directoryPromise;
|
||||
const { stale } = generationGuard();
|
||||
set({ directoryStatus: 'loading' });
|
||||
const promise: Promise<void> = (async () => {
|
||||
const listing = await listPrincipals();
|
||||
if (stale()) return;
|
||||
if (listing.status === 'error' && listing.principals.length === 0) {
|
||||
// Keep what an earlier load returned.
|
||||
set({ directoryStatus: 'error' });
|
||||
return;
|
||||
}
|
||||
set({
|
||||
directory: principalsToDirectory(listing.principals),
|
||||
directoryStatus: listing.status === 'restricted' ? 'restricted' : listing.status === 'error' ? 'error' : 'ready',
|
||||
directoryQueriesOff: !!listing.directoryQueriesOff,
|
||||
});
|
||||
})().finally(() => {
|
||||
if (directoryPromise === promise) directoryPromise = null;
|
||||
});
|
||||
directoryPromise = promise;
|
||||
return promise;
|
||||
},
|
||||
|
||||
lookupDirectory: (query, limit = 10) =>
|
||||
normalizeSuggestions(directorySuggestions(get().directory, query, limit)),
|
||||
|
||||
loadTrustedSendersBook: async (createIfMissing = false) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
// Share the in-flight load so parallel callers (mail view, settings,
|
||||
@@ -791,7 +991,8 @@ export const useContactsStore = create<ContactsState>()(
|
||||
if (get().trustedSendersLoaded && !createIfMissing) return;
|
||||
|
||||
set({ trustedSendersLoading: true });
|
||||
trustedSendersPromise = (async () => {
|
||||
const { stale } = generationGuard();
|
||||
const promise: Promise<void> = (async () => {
|
||||
try {
|
||||
// Must throw rather than yield [] on failure: treating a failed
|
||||
// fetch as "no book yet" minted a duplicate on every hiccup (#730).
|
||||
@@ -801,15 +1002,18 @@ export const useContactsStore = create<ContactsState>()(
|
||||
.filter((b) => b.name === TRUSTED_SENDERS_BOOK_NAME)
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
let book = matches[0];
|
||||
if (stale()) return;
|
||||
if (!book) {
|
||||
if (!createIfMissing) {
|
||||
set({ trustedSendersLoaded: true, trustedSendersLoading: false });
|
||||
return;
|
||||
}
|
||||
book = await apiCreateAddressBook(TRUSTED_SENDERS_BOOK_NAME);
|
||||
if (stale()) return;
|
||||
}
|
||||
const bookId = book.id;
|
||||
const cards = await getContactsInBook(bookId);
|
||||
if (stale()) return;
|
||||
set({
|
||||
trustedSendersBookId: bookId,
|
||||
trustedSenderEmails: collectContactEmails(cards),
|
||||
@@ -818,12 +1022,13 @@ export const useContactsStore = create<ContactsState>()(
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[contacts-store] load trusted senders failed', err);
|
||||
set({ trustedSendersLoaded: true, trustedSendersLoading: false });
|
||||
if (!stale()) set({ trustedSendersLoaded: true, trustedSendersLoading: false });
|
||||
}
|
||||
})().finally(() => {
|
||||
trustedSendersPromise = null;
|
||||
if (trustedSendersPromise === promise) trustedSendersPromise = null;
|
||||
});
|
||||
return trustedSendersPromise;
|
||||
trustedSendersPromise = promise;
|
||||
return promise;
|
||||
},
|
||||
|
||||
addToTrustedSendersBook: async (input) => {
|
||||
@@ -839,6 +1044,7 @@ export const useContactsStore = create<ContactsState>()(
|
||||
// The load may have found the address already filed in the book.
|
||||
if (get().trustedSenderEmails.includes(email)) return;
|
||||
|
||||
const { check } = generationGuard();
|
||||
await apiCreateContact(
|
||||
{
|
||||
...(name ? { name: { full: name } } : {}),
|
||||
@@ -846,6 +1052,7 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
bookId,
|
||||
);
|
||||
check();
|
||||
set({ trustedSenderEmails: [...get().trustedSenderEmails, email] });
|
||||
requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
},
|
||||
@@ -854,10 +1061,13 @@ export const useContactsStore = create<ContactsState>()(
|
||||
const { email } = parseTrustedSenderInput(input);
|
||||
const bookId = get().trustedSendersBookId;
|
||||
if (!bookId || !email) return;
|
||||
const { check } = generationGuard();
|
||||
const cards = await getContactsInBook(bookId);
|
||||
check();
|
||||
const matches = cards.filter((c) =>
|
||||
c.emails && Object.values(c.emails).some((e) => e.address?.toLowerCase().trim() === email));
|
||||
if (matches.length > 0) await apiDeleteContacts(matches.map((m) => m.id));
|
||||
check();
|
||||
set({ trustedSenderEmails: get().trustedSenderEmails.filter((e) => e !== email) });
|
||||
if (matches.length > 0) requestDeviceSync(CONTACTS_AUTHORITY);
|
||||
},
|
||||
@@ -870,21 +1080,33 @@ export const useContactsStore = create<ContactsState>()(
|
||||
persistCategory(category);
|
||||
},
|
||||
|
||||
reset: () => set({
|
||||
addressBooks: [],
|
||||
contacts: [],
|
||||
selectedCategory: { type: 'all' },
|
||||
loading: false,
|
||||
error: null,
|
||||
contactsFetchedAt: 0,
|
||||
trustedSendersBookId: null,
|
||||
trustedSenderEmails: [],
|
||||
trustedSendersLoaded: false,
|
||||
trustedSendersLoading: false,
|
||||
recentRecipients: [],
|
||||
recentRecipientsLoaded: false,
|
||||
sentMailboxId: null,
|
||||
}),
|
||||
reset: () => {
|
||||
storeGeneration += 1;
|
||||
// The next account's first load must not join one still under way
|
||||
// for this one.
|
||||
addressBooksPromise = null;
|
||||
contactsPromise = null;
|
||||
trustedSendersPromise = null;
|
||||
directoryPromise = null;
|
||||
set({
|
||||
addressBooks: [],
|
||||
contacts: [],
|
||||
selectedCategory: { type: 'all' },
|
||||
loading: false,
|
||||
error: null,
|
||||
contactsFetchedAt: 0,
|
||||
trustedSendersBookId: null,
|
||||
trustedSenderEmails: [],
|
||||
trustedSendersLoaded: false,
|
||||
trustedSendersLoading: false,
|
||||
recentRecipients: [],
|
||||
recentRecipientsLoaded: false,
|
||||
sentMailboxId: null,
|
||||
directory: [],
|
||||
directoryStatus: 'idle',
|
||||
directoryQueriesOff: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
+179
-15
@@ -1,7 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FilterRule, SieveCapabilities, VacationSieveConfig } from '../lib/sieve/types';
|
||||
import type {
|
||||
FilterRule, SieveCapabilities, VacationAudience, VacationForward, VacationSieveConfig,
|
||||
} from '../lib/sieve/types';
|
||||
import { parseScript } from '../lib/sieve/parser';
|
||||
import { generateScript, VACATION_SCRIPT_NAME } from '../lib/sieve/generator';
|
||||
import { generateScript, supportsSpamGuard, VACATION_SCRIPT_NAME } from '../lib/sieve/generator';
|
||||
import { supportsPeriods } from '../lib/sieve/period';
|
||||
import { isValidVacationForward, withVacationPeriod } from '../lib/sieve/vacation-forward';
|
||||
import { isValidVacationAudience, normalizeVacationAudience } from '../lib/sieve/vacation-audience';
|
||||
import {
|
||||
createSieveScript,
|
||||
getSieveAccountId,
|
||||
@@ -12,6 +17,9 @@ import {
|
||||
updateSieveScript,
|
||||
validateSieveScript,
|
||||
} from '../api/sieve';
|
||||
import { OpaqueFiltersError } from '../api/sieve-rules';
|
||||
|
||||
export { OpaqueFiltersError };
|
||||
|
||||
// Ported from the webmail's stores/filter-store.ts. The mobile client is a
|
||||
// singleton (api/sieve drives `jmapClient` directly), so the actions drop the
|
||||
@@ -32,6 +40,10 @@ interface FilterStore {
|
||||
externalRequires: string[];
|
||||
/** The script runs the server's vacation script via `include`. */
|
||||
includeVacation: boolean;
|
||||
/** Forwarding from the vacation card; carried through every save. */
|
||||
vacationForward: VacationForward | null;
|
||||
/** Who gets the auto-reply, when not everyone; carried through every save. */
|
||||
vacationAudience: VacationAudience | null;
|
||||
selectedAccountId: string | null;
|
||||
|
||||
fetchFilters: (accountId?: string) => Promise<void>;
|
||||
@@ -63,6 +75,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
vacationSettings: null,
|
||||
externalRequires: [],
|
||||
includeVacation: false,
|
||||
vacationForward: null,
|
||||
vacationAudience: null,
|
||||
selectedAccountId: null,
|
||||
|
||||
fetchFilters: async (accountId) => {
|
||||
@@ -102,6 +116,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
rawScript: '',
|
||||
isOpaque: false,
|
||||
includeVacation: vacationActive,
|
||||
vacationForward: null,
|
||||
vacationAudience: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -121,6 +137,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
vacationSettings: result.vacation || null,
|
||||
externalRequires: result.externalRequires,
|
||||
includeVacation: !result.isOpaque && (!!result.includeVacation || vacationActive),
|
||||
vacationForward: result.isOpaque ? null : result.vacationForward ?? null,
|
||||
vacationAudience: result.isOpaque ? null : result.vacationAudience ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (stale()) return;
|
||||
@@ -143,6 +161,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
vacationSettings: null,
|
||||
externalRequires: [],
|
||||
includeVacation: false,
|
||||
vacationForward: null,
|
||||
vacationAudience: null,
|
||||
});
|
||||
await get().fetchFilters(accountId ?? undefined);
|
||||
},
|
||||
@@ -152,7 +172,7 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
try {
|
||||
const {
|
||||
isOpaque, rawScript, rules, activeScriptId, vacationSettings, externalRequires, includeVacation,
|
||||
selectedAccountId, sieveCapabilities,
|
||||
vacationForward, vacationAudience, selectedAccountId, sieveCapabilities,
|
||||
} = get();
|
||||
const accountId = selectedAccountId ?? undefined;
|
||||
|
||||
@@ -161,6 +181,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
: generateScript(rules, vacationSettings || undefined, {
|
||||
externalRequires,
|
||||
includeVacation,
|
||||
vacationForward,
|
||||
vacationAudience,
|
||||
extensions: sieveCapabilities?.sieveExtensions,
|
||||
});
|
||||
|
||||
@@ -257,6 +279,8 @@ export const useFilterStore = create<FilterStore>()((set, get) => ({
|
||||
vacationSettings: null,
|
||||
externalRequires: [],
|
||||
includeVacation: false,
|
||||
vacationForward: null,
|
||||
vacationAudience: null,
|
||||
selectedAccountId: null,
|
||||
}),
|
||||
}));
|
||||
@@ -285,36 +309,176 @@ export async function isVacationIncludedInFilters(accountId?: string): Promise<b
|
||||
return !!(vacationScript && target?.isActive && parsed?.includeVacation);
|
||||
}
|
||||
|
||||
/** What the vacation card needs from the account's filters script (webmail readVacationFilters). */
|
||||
export interface VacationFilters {
|
||||
/**
|
||||
* The filters script runs the server's vacation script. VacationResponse
|
||||
* .isEnabled reads false in that case, because the vacation script itself
|
||||
* is not the active one.
|
||||
*/
|
||||
includesVacation: boolean;
|
||||
/** The forwarding as stored, on or off; null when none is set up. */
|
||||
forward: VacationForward | null;
|
||||
/**
|
||||
* Forwarding can be set up: it runs from the filters script next to the
|
||||
* included vacation script, so it needs `include`, a script Bulwark can
|
||||
* read, a server that allows a redirect and what the block uses besides
|
||||
* (its period, its spam check, the copy). Forwarding that is on is offered
|
||||
* all the same, so that it can be switched off.
|
||||
*/
|
||||
forwardAvailable: boolean;
|
||||
/** Who gets the auto-reply as stored; null when everyone does. */
|
||||
audience: VacationAudience | null;
|
||||
/**
|
||||
* The auto-reply can be narrowed to some senders: it then runs from the
|
||||
* filters script, so this needs `include`, `envelope` and a script Bulwark
|
||||
* can read.
|
||||
*/
|
||||
audienceAvailable: boolean;
|
||||
/**
|
||||
* Forwarding that is on, or an auto-reply for some senders only, is stored
|
||||
* but does not run: both run only from the active filters script, and
|
||||
* Stalwart's own vacation script took over (a save cut short, another
|
||||
* client), or no script runs at all. Saving the card sets it right.
|
||||
*/
|
||||
notRunning: boolean;
|
||||
}
|
||||
|
||||
/** See VacationFilters.notRunning. */
|
||||
function storedButIdle(
|
||||
filtersActive: boolean,
|
||||
vacationActive: boolean,
|
||||
forward: VacationForward | null,
|
||||
audience: VacationAudience | null,
|
||||
): boolean {
|
||||
if (filtersActive) return false;
|
||||
return !!forward?.enabled || (!!audience && vacationActive);
|
||||
}
|
||||
|
||||
/** The server runs the forwarding block: a redirect, its period, its spam check and the copy. */
|
||||
function canForward(capabilities: SieveCapabilities | null | undefined): boolean {
|
||||
const extensions = capabilities?.sieveExtensions;
|
||||
return capabilities?.maxNumberRedirects !== 0 &&
|
||||
supportsPeriods(extensions) &&
|
||||
supportsSpamGuard(extensions) &&
|
||||
!!extensions?.includes('copy');
|
||||
}
|
||||
|
||||
export async function readVacationFilters(accountId?: string): Promise<VacationFilters> {
|
||||
const sieveAccountId = accountId ?? getSieveAccountId();
|
||||
const capabilities = getSieveCapabilities(sieveAccountId);
|
||||
const { vacationScript, target, parsed } = await loadManagedScript(sieveAccountId);
|
||||
const filtersUsable = supportsInclude(capabilities) && !(target && !parsed);
|
||||
const forward = parsed?.vacationForward ?? null;
|
||||
const audience = parsed?.vacationAudience ?? null;
|
||||
return {
|
||||
includesVacation: !!(vacationScript && target?.isActive && parsed?.includeVacation),
|
||||
forward,
|
||||
forwardAvailable: filtersUsable && (canForward(capabilities) || !!forward?.enabled),
|
||||
audience,
|
||||
// Told apart by the envelope sender, which the auto-reply goes to.
|
||||
audienceAvailable: filtersUsable && !!capabilities?.sieveExtensions?.includes('envelope'),
|
||||
notRunning: storedButIdle(!!target?.isActive, !!vacationScript?.isActive, forward, audience),
|
||||
};
|
||||
}
|
||||
|
||||
function same<T>(a: T | null, b: T | null, normalize: (value: T) => T): boolean {
|
||||
if (!a || !b) return a === b;
|
||||
return JSON.stringify(normalize(a)) === JSON.stringify(normalize(b));
|
||||
}
|
||||
|
||||
// The same moment, however it is written: the server hands the vacation's
|
||||
// dates back in its own form.
|
||||
function sameMoment(a: string | undefined, b: string | undefined): boolean {
|
||||
if (a === undefined || b === undefined) return a === b;
|
||||
return a === b || Date.parse(a) === Date.parse(b);
|
||||
}
|
||||
|
||||
function sameForward(a: VacationForward | null, b: VacationForward | null): boolean {
|
||||
if (!a || !b) return a === b;
|
||||
return a.enabled === b.enabled && a.to === b.to && a.keepCopy === b.keepCopy &&
|
||||
sameMoment(a.activeFrom, b.activeFrom) && sameMoment(a.activeUntil, b.activeUntil);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the filters and the auto-reply both running after VacationResponse/set
|
||||
* (webmail 198a3c0d).
|
||||
* Keep the filters, the auto-reply and its forwarding running after
|
||||
* VacationResponse/set (webmail 198a3c0d, 1.13 forwarding).
|
||||
*
|
||||
* Stalwart allows one active Sieve script and turns the auto-reply on by
|
||||
* activating its own "vacation" script, which switches every filter off.
|
||||
* When that happened, re-activate the filters script with an `include` of
|
||||
* the vacation script. When the auto-reply is turned off, drop the include.
|
||||
*
|
||||
* `forward` replaces the stored forwarding (null removes it, undefined keeps
|
||||
* it). Forwarding runs with or without the auto-reply, but only from an
|
||||
* active filters script, so for it the script is activated even without
|
||||
* rules, and created when there is none. `audience` likewise replaces who
|
||||
* gets the auto-reply (null: everyone). `period` is the vacation's, which
|
||||
* whatever forwarding there is keeps to.
|
||||
*/
|
||||
export async function syncVacationWithFilters(enabled: boolean, accountId?: string): Promise<void> {
|
||||
export async function syncVacationWithFilters(
|
||||
enabled: boolean,
|
||||
accountId?: string,
|
||||
forward?: VacationForward | null,
|
||||
audience?: VacationAudience | null,
|
||||
period?: { from: string | null; until: string | null },
|
||||
): Promise<void> {
|
||||
const sieveAccountId = accountId ?? getSieveAccountId();
|
||||
const capabilities = getSieveCapabilities(sieveAccountId);
|
||||
if (enabled && !supportsInclude(capabilities)) return;
|
||||
const canInclude = supportsInclude(capabilities);
|
||||
if (enabled && !canInclude && forward === undefined && audience === undefined) return;
|
||||
|
||||
const { vacationScript, target, parsed } = await loadManagedScript(sieveAccountId);
|
||||
if (!target || !parsed) return;
|
||||
const opaque = !!target && !parsed;
|
||||
const storedForward = parsed?.vacationForward ?? null;
|
||||
const storedAudience = parsed?.vacationAudience ?? null;
|
||||
const requestedForward = forward === undefined ? storedForward : forward;
|
||||
const nextForward = requestedForward && period ? withVacationPeriod(requestedForward, period) : requestedForward;
|
||||
const nextAudience = audience === undefined ? storedAudience : audience;
|
||||
const changed =
|
||||
!sameForward(storedForward, nextForward) ||
|
||||
!same(storedAudience, nextAudience, normalizeVacationAudience);
|
||||
|
||||
if (changed) {
|
||||
// The card only offers these where they can be stored and run.
|
||||
if (opaque) throw new OpaqueFiltersError();
|
||||
if (!canInclude) throw new Error('Forwarding and reply recipients need the Sieve "include" extension');
|
||||
// The generator leaves out what it could not read back, and the save
|
||||
// would look done while nothing forwards, or everyone gets the reply.
|
||||
if (nextForward && !isValidVacationForward(nextForward)) throw new Error('Unusable vacation forwarding');
|
||||
if (nextAudience && !isValidVacationAudience(nextAudience)) throw new Error('Unusable vacation reply recipients');
|
||||
}
|
||||
if ((enabled && !canInclude) || opaque) return;
|
||||
|
||||
const rules = parsed?.rules ?? [];
|
||||
const forwarding = !!nextForward?.enabled;
|
||||
if (enabled) {
|
||||
// Only act when the vacation script took over from existing filters.
|
||||
if (!vacationScript?.isActive || target.isActive || parsed.rules.length === 0) return;
|
||||
} else if (!parsed.includeVacation) {
|
||||
return;
|
||||
// Act when the vacation script took over from filters that must keep
|
||||
// running, or when something changed. An auto-reply for some senders
|
||||
// only must run from the filters script: on its own it answers everyone.
|
||||
const tookOver = !!vacationScript?.isActive && !target?.isActive;
|
||||
const needsFilters = rules.length > 0 || forwarding || !!nextAudience;
|
||||
if (!changed && !(tookOver && needsFilters)) return;
|
||||
} else {
|
||||
// Forwarding also runs without the auto-reply, from an active filters
|
||||
// script; turning the auto-reply off can leave no script active.
|
||||
const forwardingIdle = forwarding && !target?.isActive;
|
||||
if (!changed && !parsed?.includeVacation && !forwardingIdle) return;
|
||||
}
|
||||
|
||||
const content = generateScript(parsed.rules, parsed.vacation, {
|
||||
externalRequires: parsed.externalRequires,
|
||||
const content = generateScript(rules, parsed?.vacation, {
|
||||
externalRequires: parsed?.externalRequires,
|
||||
includeVacation: enabled,
|
||||
vacationForward: nextForward,
|
||||
vacationAudience: nextAudience,
|
||||
extensions: capabilities?.sieveExtensions,
|
||||
});
|
||||
await updateSieveScript(target.id, content, enabled || target.isActive, sieveAccountId);
|
||||
const activate = enabled || forwarding || !!target?.isActive;
|
||||
if (target) {
|
||||
await updateSieveScript(target.id, content, activate, sieveAccountId);
|
||||
} else {
|
||||
await createSieveScript('filters', content, activate, sieveAccountId);
|
||||
}
|
||||
|
||||
const store = useFilterStore.getState();
|
||||
if (store.selectedAccountId === sieveAccountId) {
|
||||
|
||||
+117
-19
@@ -6,7 +6,24 @@ import {
|
||||
type VacationResponse,
|
||||
} from '../api/vacation';
|
||||
import { isSieveSupported } from '../api/sieve';
|
||||
import { isVacationIncludedInFilters, syncVacationWithFilters } from './filter-store';
|
||||
import type { VacationAudience, VacationForward } from '../lib/sieve/types';
|
||||
import { withVacationPeriod } from '../lib/sieve/vacation-forward';
|
||||
import { readVacationFilters, syncVacationWithFilters, type VacationFilters } from './filter-store';
|
||||
|
||||
/** What the vacation card sets for forwarding; the period comes from the vacation. */
|
||||
export type VacationForwardSettings = Pick<VacationForward, 'enabled' | 'to' | 'keepCopy'>;
|
||||
|
||||
/**
|
||||
* The vacation response was saved, but what the filters script carries for
|
||||
* it (forwarding, who gets the auto-reply) was not: forwarding may not run
|
||||
* or still run, and the auto-reply may go to the wrong senders.
|
||||
*/
|
||||
export class VacationFiltersError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(cause instanceof Error ? cause.message : 'Failed to save the forwarding');
|
||||
this.name = 'VacationFiltersError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface VacationState {
|
||||
isEnabled: boolean;
|
||||
@@ -15,6 +32,16 @@ export interface VacationState {
|
||||
subject: string;
|
||||
textBody: string;
|
||||
htmlBody: string | null;
|
||||
/** Forwarding as stored in the filters script; null when none is set up. */
|
||||
forward: VacationForward | null;
|
||||
/** The account's filters script can carry forwarding (see readVacationFilters). */
|
||||
forwardAvailable: boolean;
|
||||
/** Who gets the auto-reply as stored; null when everyone does. */
|
||||
audience: VacationAudience | null;
|
||||
/** The auto-reply can be narrowed to some senders (see readVacationFilters). */
|
||||
audienceAvailable: boolean;
|
||||
/** Stored forwarding or recipients do not run until the card is saved (see readVacationFilters). */
|
||||
notRunning: boolean;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
@@ -24,10 +51,27 @@ export interface VacationState {
|
||||
accountId: string | null;
|
||||
|
||||
fetch: (accountId?: string) => Promise<void>;
|
||||
save: (updates: Partial<Omit<VacationResponse, 'id'>>) => Promise<void>;
|
||||
/**
|
||||
* Saves the out-of-office reply. Throws VacationFiltersError when only the
|
||||
* filters script part (forwarding, recipients) could not be saved.
|
||||
*/
|
||||
save: (updates: Partial<Omit<VacationResponse, 'id'>> & {
|
||||
/** null removes the forwarding, leaving it out keeps it as it is. */
|
||||
forward?: VacationForwardSettings | null;
|
||||
/** null lets everyone have the auto-reply, leaving it out keeps it as it is. */
|
||||
audience?: VacationAudience | null;
|
||||
}) => Promise<void>;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const NO_FILTERS = {
|
||||
forward: null,
|
||||
forwardAvailable: false,
|
||||
audience: null,
|
||||
audienceAvailable: false,
|
||||
notRunning: false,
|
||||
};
|
||||
|
||||
const INITIAL = {
|
||||
accountId: null,
|
||||
isEnabled: false,
|
||||
@@ -36,6 +80,7 @@ const INITIAL = {
|
||||
subject: '',
|
||||
textBody: '',
|
||||
htmlBody: null,
|
||||
...NO_FILTERS,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
error: null,
|
||||
@@ -43,6 +88,17 @@ const INITIAL = {
|
||||
hasLoaded: false,
|
||||
};
|
||||
|
||||
/** The card's part of what the filters script holds. */
|
||||
function fromFilters(filters: VacationFilters) {
|
||||
return {
|
||||
forward: filters.forward,
|
||||
forwardAvailable: filters.forwardAvailable,
|
||||
audience: filters.audience,
|
||||
audienceAvailable: filters.audienceAvailable,
|
||||
notRunning: filters.notRunning,
|
||||
};
|
||||
}
|
||||
|
||||
export const useVacationStore = create<VacationState>((set, get) => ({
|
||||
...INITIAL,
|
||||
|
||||
@@ -58,11 +114,13 @@ export const useVacationStore = create<VacationState>((set, get) => ({
|
||||
const vacation = await getVacationResponse(accountId);
|
||||
if (stale()) return;
|
||||
let isEnabled = vacation.isEnabled;
|
||||
if (!isEnabled && isSieveSupported(accountId)) {
|
||||
let filters: VacationFilters | null = null;
|
||||
if (isSieveSupported(accountId)) {
|
||||
filters = await readVacationFilters(accountId).catch(() => null);
|
||||
if (stale()) return;
|
||||
// Running from the filters script leaves the vacation script itself
|
||||
// inactive, so VacationResponse reports it as off.
|
||||
isEnabled = await isVacationIncludedInFilters(accountId).catch(() => false);
|
||||
if (stale()) return;
|
||||
if (filters) isEnabled = isEnabled || filters.includesVacation;
|
||||
}
|
||||
set({
|
||||
isEnabled,
|
||||
@@ -71,6 +129,8 @@ export const useVacationStore = create<VacationState>((set, get) => ({
|
||||
subject: vacation.subject ?? '',
|
||||
textBody: vacation.textBody ?? '',
|
||||
htmlBody: vacation.htmlBody,
|
||||
...NO_FILTERS,
|
||||
...(filters ? fromFilters(filters) : {}),
|
||||
isLoading: false,
|
||||
hasLoaded: true,
|
||||
});
|
||||
@@ -85,22 +145,13 @@ export const useVacationStore = create<VacationState>((set, get) => ({
|
||||
},
|
||||
|
||||
save: async (updates) => {
|
||||
const { forward: forwardSettings, audience, ...vacationUpdates } = updates;
|
||||
const accountId = get().accountId ?? undefined;
|
||||
// What the save leaves in the store is dropped once another account took over.
|
||||
const superseded = () => (get().accountId ?? undefined) !== accountId;
|
||||
set({ isSaving: true, error: null });
|
||||
try {
|
||||
const accountId = get().accountId ?? undefined;
|
||||
await setVacationResponse(updates, accountId);
|
||||
if (updates.isEnabled !== undefined && isSieveSupported(accountId)) {
|
||||
try {
|
||||
await syncVacationWithFilters(updates.isEnabled, accountId);
|
||||
} catch (err) {
|
||||
console.warn('[vacation] Failed to keep filters active next to the vacation response:', err);
|
||||
}
|
||||
}
|
||||
set((s) => ({
|
||||
...s,
|
||||
...updates,
|
||||
isSaving: false,
|
||||
}));
|
||||
await setVacationResponse(vacationUpdates, accountId);
|
||||
} catch (err) {
|
||||
set({
|
||||
isSaving: false,
|
||||
@@ -108,6 +159,53 @@ export const useVacationStore = create<VacationState>((set, get) => ({
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Forwarding runs in the vacation's period, so it takes the dates along.
|
||||
const state = get();
|
||||
const fromDate = vacationUpdates.fromDate !== undefined ? vacationUpdates.fromDate : state.fromDate;
|
||||
const toDate = vacationUpdates.toDate !== undefined ? vacationUpdates.toDate : state.toDate;
|
||||
const period = { from: fromDate, until: toDate };
|
||||
|
||||
let filtersError: VacationFiltersError | null = null;
|
||||
let filtersState: Partial<VacationState> = {};
|
||||
const needsSync = vacationUpdates.isEnabled !== undefined || forwardSettings !== undefined || audience !== undefined;
|
||||
if (needsSync && isSieveSupported(accountId)) {
|
||||
let synced = false;
|
||||
try {
|
||||
await syncVacationWithFilters(
|
||||
vacationUpdates.isEnabled ?? state.isEnabled, accountId, forwardSettings, audience, period,
|
||||
);
|
||||
synced = true;
|
||||
} catch (err) {
|
||||
console.warn('[vacation] Failed to keep filters active next to the vacation response:', err);
|
||||
// Forwarding that is or was on may now not run, or still run; an
|
||||
// auto-reply meant for some senders may go to everyone, or the other
|
||||
// way round.
|
||||
if (forwardSettings?.enabled || state.forward?.enabled || audience || state.audience) {
|
||||
filtersError = new VacationFiltersError(err);
|
||||
}
|
||||
}
|
||||
// What the filters script holds now, and whether it runs. After a
|
||||
// failed save the card keeps what the user set, to save it again.
|
||||
const filters = await readVacationFilters(accountId).catch(() => null);
|
||||
if (filters) {
|
||||
filtersState = synced ? fromFilters(filters) : { notRunning: filters.notRunning };
|
||||
} else if (synced) {
|
||||
filtersState = {
|
||||
...(forwardSettings !== undefined
|
||||
? { forward: forwardSettings && withVacationPeriod(forwardSettings, period) }
|
||||
: {}),
|
||||
...(audience !== undefined ? { audience } : {}),
|
||||
notRunning: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
set((s) => (superseded()
|
||||
? { ...s, isSaving: false }
|
||||
: { ...s, ...vacationUpdates, ...filtersState, isSaving: false }));
|
||||
// The reply itself is saved even when its filters part failed.
|
||||
if (filtersError) throw filtersError;
|
||||
},
|
||||
|
||||
reset: () => set({ ...INITIAL }),
|
||||
|
||||
Reference in New Issue
Block a user