diff --git a/locales/rn/en.json b/locales/rn/en.json index 82f749e3..84ffef24 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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": { diff --git a/src/api/__tests__/contacts.test.ts b/src/api/__tests__/contacts.test.ts index 35ceacdc..c919cf4a 100644 --- a/src/api/__tests__/contacts.test.ts +++ b/src/api/__tests__/contacts.test.ts @@ -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 () => { diff --git a/src/api/__tests__/jmap-client-hardening.test.ts b/src/api/__tests__/jmap-client-hardening.test.ts index a3c732b9..cda342eb 100644 --- a/src/api/__tests__/jmap-client-hardening.test.ts +++ b/src/api/__tests__/jmap-client-hardening.test.ts @@ -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') }]); diff --git a/src/api/__tests__/live-stalwart-contacts.test.ts b/src/api/__tests__/live-stalwart-contacts.test.ts new file mode 100644 index 00000000..792275f1 --- /dev/null +++ b/src/api/__tests__/live-stalwart-contacts.test.ts @@ -0,0 +1,286 @@ +/** + * Live checks of contacts and Sieve filters against a real Stalwart server, + * for what the phone writes: contacts with calendar links, vCard imports and + * re-imports, address books with cards, the company directory, webmail 1.13+ + * filter scripts (rule periods, vacation forwarding and audience, exact + * address rules) and rules made from a message. + * + * Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and + * `BULWARK_LIVE_PASS` are set. + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \ + * BULWARK_LIVE_USER=e2e-tablet-dark@example.org BULWARK_LIVE_PASS=… \ + * npx vitest run src/api/__tests__/live-stalwart-contacts.test.ts + * + * Everything it creates is removed again; the account's Sieve scripts are + * put back as they were, the active one included. + * + * Verified against stalwartlabs/stalwart:v0.16.27 and v1.0.0 (2026-10-29). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { + createAddressBook, + deleteAddressBook, + deleteContacts, + getAddressBooks, + getContact, + createContact, + updateContact, +} from '../contacts'; +import { + createSieveScript, + deleteSieveScript, + getSieveAccountId, + getSieveCapabilities, + getSieveScriptContent, + getSieveScripts, + updateSieveScript, + validateSieveScript, +} from '../sieve'; +import { updateAccountFilters } from '../sieve-rules'; +import { useContactsStore } from '../../stores/contacts-store'; +import { readVacationFilters, useFilterStore } from '../../stores/filter-store'; +import { generateVCard, parseVCard } from '../../lib/vcard'; +import { generateScript } from '../../lib/sieve/generator'; +import { parseScript } from '../../lib/sieve/parser'; +import { applyQuickRule, buildPresetRule, toRuleMailbox } from '../../lib/sieve/quick-rules'; +import { readWebmailFixture, WEBMAIL_FIXTURES } from '../../lib/sieve/__tests__/fixtures/webmail'; +import { getMailboxes } from '../email'; +import type { ContactCard } from '../types'; +import type { SieveScript } from '../../lib/sieve/types'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org'; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const live = SERVER && PASS ? describe : describe.skip; + +const RUN = Date.now().toString(36); +const SIEVE_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:sieve']; + +let client: JMAPClient; +let singleton: JMAPClient; + +/** Stalwart 1.0 advertises JMAP Metadata, 0.16 does not. */ +const is10 = () => !!client.currentSession?.capabilities?.['urn:ietf:params:jmap:metadata']; + +const t = (_key: string, fallback?: string, params?: Record) => + (fallback ?? '').replace(/\{(\w+)\}/g, (_m, k) => String(params?.[k] ?? '')); + +live('live Stalwart: contacts and filters (0.16 and 1.0)', () => { + let bookId = ''; + const createdCards: string[] = []; + + beforeAll(async () => { + const mod = await import('../jmap-client'); + singleton = mod.jmapClient as unknown as JMAPClient; + client = new JMAPClient(); + await client.connect(SERVER!, USER, PASS); + singleton.restoreSnapshot(client.snapshot()); + useContactsStore.getState().reset(); + bookId = (await createAddressBook(`live-contacts-${RUN}`)).id; + }, 60_000); + + afterAll(async () => { + if (createdCards.length) await deleteContacts(createdCards).catch(() => undefined); + const books = await getAddressBooks().catch(() => []); + for (const book of books.filter((b) => b.name.startsWith(`live-contacts-${RUN}`))) { + await deleteAddressBook(book.id, undefined, { removeContents: true }).catch(() => undefined); + } + useContactsStore.getState().reset(); + }, 60_000); + + describe('contacts', () => { + // Stalwart refuses calendarUri as a property ("Invalid property."). + it('saves a contact with calendar, free/busy and scheduling links and reads them back', async () => { + const card = await createContact({ + '@type': 'Card', version: '1.0', kind: 'individual', + name: { full: `Live Links ${RUN}` }, + calendarUri: 'https://cal.example.org/anna', + freeBusyUri: 'https://cal.example.org/anna/fb', + schedulingUri: 'mailto:anna@example.org', + } as Partial, bookId); + createdCards.push(card.id); + const back = await getContact(card.id); + expect(back).toMatchObject({ + calendarUri: 'https://cal.example.org/anna', + freeBusyUri: 'https://cal.example.org/anna/fb', + schedulingUri: 'mailto:anna@example.org', + }); + + await updateContact(card.id, { calendarUri: null, freeBusyUri: null, schedulingUri: null } as never); + const cleared = await getContact(card.id); + expect(cleared?.calendarUri).toBeUndefined(); + expect(cleared?.schedulingUri).toBeUndefined(); + }, 60_000); + + it('imports a vCard with flat address, label, source and birthplace', async () => { + const [parsed] = parseVCard([ + 'BEGIN:VCARD', 'VERSION:3.0', `FN:Live Import ${RUN}`, `N:Import ${RUN};Live;;;`, + 'ADR;TYPE=HOME;LABEL="Hauptstr. 1, Berlin":;;Hauptstr. 1;Berlin;;10115;Germany', + 'TEL;TYPE=CELL:+49 30 1234', + 'SOURCE:https://dir.example.org/live.vcf', + 'CALURI:https://cal.example.org/live', + 'BDAY:19800102', 'BIRTHPLACE:Hamburg', + 'END:VCARD', + ].join('\r\n')); + const result = await useContactsStore.getState().importContacts([parsed], bookId); + expect(result.failures).toEqual([]); + expect(result.imported).toBe(1); + const card = useContactsStore.getState().contacts.find((c) => c.name?.full === `Live Import ${RUN}`); + createdCards.push(card!.id); + const back = await getContact(card!.id); + const address = Object.values(back?.addresses ?? {})[0]; + expect(address?.components?.find((c) => c.kind === 'locality')?.value).toBe('Berlin'); + expect(Object.values(back?.phones ?? {})[0]?.features).toEqual({ mobile: true }); + expect(back?.calendarUri).toBe('https://cal.example.org/live'); + expect(back?.source).toBe('https://dir.example.org/live.vcf'); + }, 60_000); + + // Both servers refuse a second card with the same UID; the import used + // to count every card of a re-imported export as failed. + it('re-imports its own vCard 4.0 export by updating the cards', async () => { + const card = await createContact({ + '@type': 'Card', version: '1.0', kind: 'individual', + name: { components: [{ kind: 'given', value: 'Live' }, { kind: 'surname', value: `Reimport${RUN}` }], full: `Live Reimport${RUN}` }, + emails: { e: { address: `reimport-${RUN}@example.org` } }, + phones: { p: { number: '+1 555 0100', features: { mobile: true } } }, + } as Partial, bookId); + createdCards.push(card.id); + const vcf = generateVCard([{ ...card, notes: { n: { note: 'from the export' } } }]); + expect(vcf.startsWith('BEGIN:VCARD\r\nVERSION:4.0\r\n')).toBe(true); + expect(vcf).toContain('TEL;TYPE=CELL:'); + + // A fresh store: the card is not in the list, the server names it. + useContactsStore.getState().reset(); + const result = await useContactsStore.getState().importContacts(parseVCard(vcf), bookId); + expect(result).toMatchObject({ imported: 0, updated: 1, failed: 0 }); + const back = await getContact(card.id); + expect(Object.values(back?.notes ?? {})[0]?.note).toBe('from the export'); + }, 60_000); + + // 1.0 refuses to destroy a book that holds cards without + // onDestroyRemoveContents (addressBookHasContents). + it('deletes an address book that still holds contacts', async () => { + const book = await createAddressBook(`live-contacts-${RUN}-full`); + const card = await createContact({ '@type': 'Card', version: '1.0', name: { full: `Live Book ${RUN}` } } as Partial, book.id); + useContactsStore.setState({ addressBooks: [book], contacts: [card] }); + await useContactsStore.getState().deleteAddressBook(book.id); + expect((await getAddressBooks()).some((b) => b.id === book.id)).toBe(false); + expect(await getContact(card.id)).toBeNull(); + }, 60_000); + + it('lists the directory, saying so when the server keeps it restricted', async () => { + useContactsStore.getState().reset(); + await useContactsStore.getState().fetchDirectory(); + const s = useContactsStore.getState(); + if (is10()) { + // Directory queries are off by default on 1.0. + expect(s.directoryStatus).toBe('restricted'); + expect(s.directoryQueriesOff).toBe(true); + } else { + expect(['ready', 'restricted']).toContain(s.directoryStatus); + } + expect(s.directory.some((e) => e.email.toLowerCase() === USER.toLowerCase())).toBe(true); + expect(s.lookupDirectory(USER.split('@')[0]).map((r) => r.email.toLowerCase())).toContain(USER.toLowerCase()); + }, 60_000); + }); + + describe('filters', () => { + let before: Array = []; + let created: string | null = null; + + beforeAll(async () => { + // What the account has, to put back afterwards. + const scripts = await getSieveScripts(); + before = []; + for (const s of scripts) before.push({ ...s, content: await getSieveScriptContent(s.blobId) }); + }, 60_000); + + afterAll(async () => { + const accountId = getSieveAccountId(); + const now = await getSieveScripts().catch(() => [] as SieveScript[]); + // An active script cannot be destroyed (RFC 9661): switch them all off first. + await client.request([['SieveScript/set', { accountId, onSuccessDeactivateScript: true }, '0']], SIEVE_USING) + .catch(() => undefined); + for (const s of now) { + const old = before.find((b) => b.id === s.id); + if (!old) await deleteSieveScript(s.id).catch(() => undefined); + else if ((await getSieveScriptContent(s.blobId).catch(() => old.content)) !== old.content) { + await updateSieveScript(s.id, old.content, false).catch(() => undefined); + } + } + const active = before.find((b) => b.isActive); + if (active) { + await client.request([['SieveScript/set', { accountId, onSuccessActivateScript: active.id }, '0']], SIEVE_USING) + .catch(() => undefined); + } + const after = await getSieveScripts(); + expect(after.map((s) => [s.id, s.isActive]).sort()).toEqual(before.map((s) => [s.id, s.isActive]).sort()); + useFilterStore.getState().clearState(); + }, 60_000); + + it('compiles every webmail fixture', async () => { + for (const { file } of WEBMAIL_FIXTURES) { + const result = await validateSieveScript(readWebmailFixture(file)); + expect({ file, ...result }).toEqual({ file, isValid: true }); + } + }, 60_000); + + // #1152: version 2 opened as raw Sieve, and a save dropped the periods, + // the forwarding and the audience. + it('edits a webmail 1.13 script as rules and saves it back whole', async () => { + const fixture = readWebmailFixture('period-forward-audience.sieve'); + const script = await createSieveScript(`live-filters-${RUN}`, fixture, true); + created = script.id; + + await useFilterStore.getState().selectAccount(null); + const s = useFilterStore.getState(); + expect(s.activeScriptId).toBe(script.id); + expect(s.isOpaque).toBe(false); + expect(s.rules.map((r) => r.activeFrom)).toEqual(['2026-11-02T07:00:00.000Z', '2026-12-01T00:00:00.000Z']); + expect(s.vacationForward?.to).toBe('backup@example.org'); + expect(s.vacationAudience?.only).toBe('internal'); + + await useFilterStore.getState().saveFilters(); + const saved = (await getSieveScripts()).find((x) => x.id === script.id)!; + const content = await getSieveScriptContent(saved.blobId); + const parsed = parseScript(fixture); + expect(content).toBe(generateScript(parsed.rules, parsed.vacation, { + externalRequires: parsed.externalRequires, + includeVacation: parsed.includeVacation, + vacationForward: parsed.vacationForward, + vacationAudience: parsed.vacationAudience, + extensions: getSieveCapabilities()?.sieveExtensions, + })); + expect(parseScript(content).vacationForward).toEqual(parsed.vacationForward); + + const vacation = await readVacationFilters(); + expect(vacation.forward?.to).toBe('backup@example.org'); + expect(vacation.audience?.domains).toEqual(['example.org', 'example.com']); + expect(vacation.forwardAvailable).toBe(true); + expect(vacation.audienceAvailable).toBe(true); + }, 60_000); + + it('adds a rule made from a message to the top, as an exact address test', async () => { + const mailboxes = await getMailboxes(); + const inbox = mailboxes.find((m) => m.role === 'inbox')!; + const subject = { senders: [{ email: `sender-${RUN}@example.net` }], domain: null, listId: null }; + const rule = buildPresetRule({ kind: 'move_sender', mailbox: toRuleMailbox(mailboxes, inbox) }, subject, t, `quick-${RUN}`); + expect(await updateAccountFilters((rules) => { + const outcome = applyQuickRule(rules, rule); + return outcome.kind === 'covered' ? null : outcome.rules; + })).toBe(true); + + const active = (await getSieveScripts()).find((x) => x.isActive)!; + expect(active.id).toBe(created); + const content = await getSieveScriptContent(active.blobId); + expect(content).toContain(`if allof(address :is "From" "sender-${RUN}@example.net", not spamtest`); + const back = parseScript(content); + expect(back.rules[0].id).toBe(`quick-${RUN}`); + // The rule went in without losing what the vacation card set. + expect(back.vacationForward?.to).toBe('backup@example.org'); + }, 60_000); + }); +}); diff --git a/src/api/__tests__/sieve-rules.test.ts b/src/api/__tests__/sieve-rules.test.ts new file mode 100644 index 00000000..c9f3f3de --- /dev/null +++ b/src/api/__tests__/sieve-rules.test.ts @@ -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(); + }); +}); diff --git a/src/api/contacts.ts b/src/api/contacts.ts index cdd9afb8..5330c0ec 100644 --- a/src/api/contacts.ts +++ b/src/api/contacts.ts @@ -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(res).list ?? []; - all.push(...list); + all.push(...list.map(contactFromWire)); } return all; } @@ -238,12 +231,15 @@ export async function createContact( ): Promise { 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 { const account = accountId || getContactsAccountId(); - const patch = { ...stripClientFields(changes) } as Record; - 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 { +export async function deleteAddressBook( + id: string, + accountId?: string, + options?: { removeContents?: boolean }, +): Promise { 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 }>(res); diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 09457c31..bdf27a16 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -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): 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 { diff --git a/src/api/sieve-rules.ts b/src/api/sieve-rules.ts new file mode 100644 index 00000000..454799b9 --- /dev/null +++ b/src/api/sieve-rules.ts @@ -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 { + // 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; +} diff --git a/src/components/__tests__/contacts-i18n.test.ts b/src/components/__tests__/contacts-i18n.test.ts index 833e27f6..87754386 100644 --- a/src/components/__tests__/contacts-i18n.test.ts +++ b/src/components/__tests__/contacts-i18n.test.ts @@ -26,6 +26,7 @@ const TRANSLATED_FILES: Record = { 'components/contacts/TagAssignSheet.tsx': [], 'components/contacts/ContactListRow.tsx': [], 'components/contacts/FieldBlock.tsx': [], + 'components/contacts/DirectoryBrowser.tsx': [], }; describe('translated contacts screens', () => { diff --git a/src/components/__tests__/settings-i18n.test.ts b/src/components/__tests__/settings-i18n.test.ts index e22520e3..85344896 100644 --- a/src/components/__tests__/settings-i18n.test.ts +++ b/src/components/__tests__/settings-i18n.test.ts @@ -47,8 +47,9 @@ const TRANSLATED_PANES: Record = { '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', () => { diff --git a/src/components/contacts/ContactImportSheet.tsx b/src/components/contacts/ContactImportSheet.tsx index 0a86f1f1..f87a409a 100644 --- a/src/components/contacts/ContactImportSheet.tsx +++ b/src/components/contacts/ContactImportSheet.tsx @@ -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>(new Map()); const [error, setError] = React.useState(null); const [importing, setImporting] = React.useState(false); - const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null); + const [result, setResult] = React.useState(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({ {t('contacts.import.success', '{count, plural, one {1 contact imported} other {# contacts imported}}', { count: result.imported })} + {result.updated > 0 && ( + + {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 }, + )} + + )} {result.failed > 0 && ( {t( @@ -201,6 +210,18 @@ export default function ContactImportSheet({ )} )} + {result.failures.length > 0 && ( + + {result.failures.map((f, i) => ( + + + {f.name || t('contacts.import.unnamed', 'Unnamed contact')} + + {': '}{f.reason} + + ))} + + )} ) : 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', diff --git a/src/components/contacts/DirectoryBrowser.tsx b/src/components/contacts/DirectoryBrowser.tsx new file mode 100644 index 00000000..2616ca2c --- /dev/null +++ b/src/components/contacts/DirectoryBrowser.tsx @@ -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(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 = ; + } else if (status === 'unavailable') { + placeholder = ( + } + 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 = ( + } + 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 = ( + } + 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 = ( + } + 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 = ( + } + 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 ( + + e.email.toLowerCase()} + ListHeaderComponent={restrictedNote ? ( + + + {restrictedNote} + + ) : null} + renderItem={({ item }) => { + const kind = typeLabel(item); + return ( + 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' ? ( + + {item.type === 'group' + ? + : } + + ) : ( + + )} + + {item.name} + + {kind ? `${item.email} · ${kind}` : item.email} + + + + ); + }} + ItemSeparatorComponent={() => } + ListEmptyComponent={{placeholder}} + refreshControl={ + 0} onRefresh={() => void fetchDirectory()} tintColor={c.primary} /> + } + contentContainerStyle={{ flexGrow: 1 }} + /> + setSheet(null)} /> + + ); +} + +function StatePanel({ styles, icon, title, hint }: { + styles: ReturnType; + icon: React.ReactNode; + title: string; + hint?: string; +}) { + return ( + <> + {icon} + {title} + {!!hint && {hint}} + + ); +} + +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' }, + }); +} diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index 5564cccd..503cf800 100644 --- a/src/components/filters/FilterRuleModal.tsx +++ b/src/components/filters/FilterRuleModal.tsx @@ -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(() => 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 ( @@ -332,14 +360,16 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F /> )} - 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) && ( )} + + {/* Offered where the server supports it; a rule that already has a + period always shows it, so it can be removed. */} + {(periodsSupported || hadPeriod) && ( + + + + {t('settings.filters.period_toggle', 'Only active during a period')} + + + + {periodOn && ( + <> + {t('settings.filters.period_start', 'Start date')} + + {t('settings.filters.period_end', 'End date')} + + + {t('settings.filters.period_hint', 'Applies to messages that arrive during this period. Leave a field empty to keep that end open.')} + + + )} + + )} @@ -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', diff --git a/src/components/filters/QuickRuleSheet.tsx b/src/components/filters/QuickRuleSheet.tsx new file mode 100644 index 00000000..1bb0f924 --- /dev/null +++ b/src/components/filters/QuickRuleSheet.tsx @@ -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({ 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + onPress: () => setStep({ kind: 'editor', rule: buildPrefillRule(subject, t, generateUUID()) }), + }); + + const tagItems: ActionSheetItem[] = keywords.map((kw) => ({ + key: kw.id, + label: kw.label, + icon: , + 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 ( + <> + s.email).join(', ') || undefined} + items={items} + onClose={finish} + /> + setStep({ kind: 'menu' })} + mailboxes={targets} + currentMailboxId={null} + onPick={pickFolder} + /> + setStep({ kind: 'menu' })} + /> + void saveEditorRule(rule)} + onClose={finish} + /> + + ); +} + diff --git a/src/components/settings/FilterSettings.tsx b/src/components/settings/FilterSettings.tsx index beeb4461..83d21b7d 100644 --- a/src/components/settings/FilterSettings.tsx +++ b/src/components/settings/FilterSettings.tsx @@ -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 {label}; +} + // 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(); const [showRuleModal, setShowRuleModal] = useState(false); @@ -397,7 +411,10 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) { style={{ flex: 1, minWidth: 0 }} onPress={() => { setEditingRule(rule); setShowRuleModal(true); }} > - {rule.name} + + {rule.name} + + {expandedView ? ( ) : ( @@ -487,6 +504,7 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) { visible={showRuleModal} rule={editingRule} mailboxes={mailboxes} + periodsSupported={periodsSupported} onSave={handleSaveRule} onClose={() => { setShowRuleModal(false); setEditingRule(undefined); }} /> diff --git a/src/components/settings/VacationSettings.tsx b/src/components/settings/VacationSettings.tsx index 83801284..60aef49e 100644 --- a/src/components/settings/VacationSettings.tsx +++ b/src/components/settings/VacationSettings.tsx @@ -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(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(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() { + {canNarrow && ( + +