diff --git a/app/api/calendar-agenda/route.ts b/app/api/calendar-agenda/route.ts index 4415a0ed6..7c2f199ca 100644 --- a/app/api/calendar-agenda/route.ts +++ b/app/api/calendar-agenda/route.ts @@ -12,10 +12,15 @@ import type { CalendarEvent } from '@/lib/jmap/types'; * POST /api/calendar-agenda * * Sidecar for the "Calendar Agenda" plugin. Resolves the caller's calendar - * account from the stored Stalwart auth context, queries upcoming + * accounts from the stored Stalwart auth context, queries upcoming * CalendarEvents over JMAP, expands recurring series server-side, and returns * a slim, structured-cloneable agenda the plugin can render directly. * + * Events come from every account that has calendars: the primary calendar + * account plus shared and group accounts (a family or team calendar), the + * same set the calendar app shows. Ids are scoped by account, since event + * and calendar ids repeat across accounts. + * * Credentials never leave the server — the sandboxed plugin only ever sees * the resulting agenda DTOs. * @@ -35,7 +40,9 @@ const EVENT_PROPERTIES = [ ] as const; interface AgendaEvent { + /** Unique across accounts: `:`. */ id: string; + accountId: string; uid: string | null; title: string; start: string; @@ -131,8 +138,8 @@ export async function POST(request: NextRequest) { if (!session) { return NextResponse.json({ error: 'JMAP session fetch failed' }, { status: 502 }); } - const accountId = session.primaryAccounts?.[CALENDAR_CAP]; - if (!accountId) { + const accountIds = calendarAccountIds(session); + if (accountIds.length === 0) { // No calendar account for this user — return an empty agenda, not an error. return NextResponse.json({ events: [], generatedAt: new Date().toISOString() }); } @@ -153,85 +160,94 @@ export async function POST(request: NextRequest) { const windowStart = new Date(now); windowStart.setHours(0, 0, 0, 0); - // ── 1) Query event IDs in range + load calendars (colours) ── - const queryReq = { - using, - methodCalls: [ - [ - 'CalendarEvent/query', - { - accountId, - // Mirror the app's calendar store: an { after, before } window lets - // Stalwart evaluate recurrence so masters with occurrences in range - // are returned (a `before`-only filter can drop unbounded series). - filter: { after: windowStart.toISOString(), before: horizon.toISOString() }, - limit: 1000, - }, - '0', - ], - [ - 'Calendar/get', - { accountId, ids: null, properties: ['id', 'name', 'color'] }, - 'c', - ], - ], - }; - - const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq, fetchOptions); - const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0'); - if (!queryResp) { - const err = findResponse(queryRes, 'error', '0'); - return NextResponse.json( - { error: (err?.description as string) || 'CalendarEvent/query failed' }, - { status: 502 }, - ); - } - const ids = (queryResp.ids as string[]) || []; - + // ── 1–3) Per account: query ids in range, load calendars (colours), + // fetch the events, normalize and expand recurrences server-side ── const calColors = new Map(); - const calResp = findResponse(queryRes, 'Calendar/get', 'c'); - for (const cal of ((calResp?.list as Array>) || [])) { - if (typeof cal.id === 'string') { - calColors.set(cal.id, { - name: typeof cal.name === 'string' ? cal.name : '', - color: typeof cal.color === 'string' ? cal.color : null, - }); - } - } + const expanded: Array = []; - if (ids.length === 0) { - return NextResponse.json({ events: [], generatedAt: now.toISOString() }); - } - - // ── 2) Fetch full event objects (batched) ── - const raw: Array> = []; - const BATCH = 100; - for (let i = 0; i < ids.length; i += BATCH) { - const batch = ids.slice(i, i + BATCH); - const getRes = await jmapPost(apiUrl, creds.authHeader, { + for (const accountId of accountIds) { + const queryReq = { using, methodCalls: [ - ['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'], + [ + 'CalendarEvent/query', + { + accountId, + // Mirror the app's calendar store: an { after, before } window lets + // Stalwart evaluate recurrence so masters with occurrences in range + // are returned (a `before`-only filter can drop unbounded series). + filter: { after: windowStart.toISOString(), before: horizon.toISOString() }, + limit: 1000, + }, + '0', + ], + [ + 'Calendar/get', + { accountId, ids: null, properties: ['id', 'name', 'color'] }, + 'c', + ], ], - }, fetchOptions); - const getResp = findResponse(getRes, 'CalendarEvent/get', '0'); - if (getResp?.list) raw.push(...(getResp.list as Array>)); + }; + + const queryRes = await jmapPost(apiUrl, creds.authHeader, queryReq, fetchOptions); + const queryResp = findResponse(queryRes, 'CalendarEvent/query', '0'); + if (!queryResp) { + const err = findResponse(queryRes, 'error', '0'); + if (accountId !== accountIds[0]) { + // A shared account the caller can see but not read calendars from + // (Stalwart answers forbidden/accountNotSupported) must not empty + // the agenda for the accounts that do work. + logger.debug('Calendar agenda: skipping account', { + accountId, + error: (err?.type as string) || 'CalendarEvent/query failed', + }); + continue; + } + return NextResponse.json( + { error: (err?.description as string) || 'CalendarEvent/query failed' }, + { status: 502 }, + ); + } + const ids = (queryResp.ids as string[]) || []; + + const calResp = findResponse(queryRes, 'Calendar/get', 'c'); + for (const cal of ((calResp?.list as Array>) || [])) { + if (typeof cal.id === 'string') { + calColors.set(scopedId(accountId, cal.id), { + name: typeof cal.name === 'string' ? cal.name : '', + color: typeof cal.color === 'string' ? cal.color : null, + }); + } + } + + if (ids.length === 0) continue; + + const raw: Array> = []; + const BATCH = 100; + for (let i = 0; i < ids.length; i += BATCH) { + const batch = ids.slice(i, i + BATCH); + const getRes = await jmapPost(apiUrl, creds.authHeader, { + using, + methodCalls: [ + ['CalendarEvent/get', { accountId, ids: batch, properties: EVENT_PROPERTIES }, '0'], + ], + }, fetchOptions); + const getResp = findResponse(getRes, 'CalendarEvent/get', '0'); + if (getResp?.list) raw.push(...(getResp.list as Array>)); + } + + const normalized = raw + .map((e) => normalizeCalendarEventLike(e as Partial)) + .filter((e) => (e['@type'] ?? 'Event') === 'Event') + // Drop malformed events without a parseable start (would crash format()/ + // parseISO downstream) — mirrors the calendar store guard (#316). + .filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[]; + + for (const e of expandRecurringEvents(normalized, windowStart.toISOString(), horizon.toISOString())) { + expanded.push({ ...e, accountId }); + } } - // ── 3) Normalize + expand recurrences server-side ── - const normalized = raw - .map((e) => normalizeCalendarEventLike(e as Partial)) - .filter((e) => (e['@type'] ?? 'Event') === 'Event') - // Drop malformed events without a parseable start (would crash format()/ - // parseISO downstream) — mirrors the calendar store guard (#316). - .filter((e) => typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())) as CalendarEvent[]; - - const expanded = expandRecurringEvents( - normalized, - windowStart.toISOString(), - horizon.toISOString(), - ); - // ── 4) Keep ongoing/upcoming, sort, slice, map to DTOs ── const agenda: AgendaEvent[] = expanded .filter((e) => eventEnd(e).getTime() >= now.getTime()) @@ -239,9 +255,10 @@ export async function POST(request: NextRequest) { .slice(0, limit) .map((e) => { const calId = firstCalendarId(e); - const cal = calId ? calColors.get(calId) : undefined; + const cal = calId ? calColors.get(scopedId(e.accountId, calId)) : undefined; return { - id: String(e.id ?? ''), + id: scopedId(e.accountId, String(e.id ?? '')), + accountId: e.accountId, uid: e.uid ?? null, title: (e.title ?? '').trim() || '(no title)', start: eventStart(e).toISOString(), @@ -268,6 +285,31 @@ export async function POST(request: NextRequest) { } } +/** + * The accounts to read calendars from, primary first. Mirrors the client's + * getCalendarCapableAccountIds(): shared and group accounts count even when + * Stalwart does not advertise the calendar capability on them. + */ +function calendarAccountIds(session: { + primaryAccounts?: Record; + accounts?: Record; +}): string[] { + const primary = session.primaryAccounts?.[CALENDAR_CAP]; + const ids: string[] = primary ? [primary] : []; + for (const [id, value] of Object.entries(session.accounts ?? {})) { + if (id === primary || !value || typeof value !== 'object') continue; + const account = value as { isPersonal?: unknown; accountCapabilities?: Record }; + if (account.accountCapabilities?.[CALENDAR_CAP] || account.isPersonal === false) { + ids.push(id); + } + } + return ids; +} + +function scopedId(accountId: string, id: string): string { + return `${accountId}:${id}`; +} + function clampInt(value: unknown, min: number, max: number, fallback: number): number { const n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n)) return fallback; diff --git a/app/globals.css b/app/globals.css index 0817bf6b0..1e6aeb12a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -212,6 +212,44 @@ optgroup { color-scheme: dark; } +/* Tailwind v4's preflight no longer gives buttons a pointer cursor, so most + controls showed the arrow. Give everything clickable the pointer again, and + disabled controls not-allowed, as the components that style it themselves + already do. This sits in the base layer so a cursor utility on a component + (cursor-grab, cursor-text, disabled:cursor-default) still wins. */ +@layer base { + button:not(:disabled), + select:not(:disabled), + input:is([type="checkbox"], [type="radio"], [type="color"]):not(:disabled), + summary, + [role="button"]:not([aria-disabled="true"]), + [role="tab"]:not([aria-disabled="true"]), + [role="switch"]:not([aria-disabled="true"]), + [role="checkbox"]:not([aria-disabled="true"]), + [role="radio"]:not([aria-disabled="true"]), + [role="option"]:not([aria-disabled="true"]), + [role="menuitem"]:not([aria-disabled="true"]), + [role="menuitemcheckbox"]:not([aria-disabled="true"]), + [role="menuitemradio"]:not([aria-disabled="true"]) { + cursor: pointer; + } + + button:disabled, + select:disabled, + input:is([type="checkbox"], [type="radio"], [type="color"]):disabled, + [role="button"][aria-disabled="true"], + [role="tab"][aria-disabled="true"], + [role="switch"][aria-disabled="true"], + [role="checkbox"][aria-disabled="true"], + [role="radio"][aria-disabled="true"], + [role="option"][aria-disabled="true"], + [role="menuitem"][aria-disabled="true"], + [role="menuitemcheckbox"][aria-disabled="true"], + [role="menuitemradio"][aria-disabled="true"] { + cursor: not-allowed; + } +} + /* Minimalist scrollbar */ ::-webkit-scrollbar { width: 8px; diff --git a/components/filters/__tests__/filter-rule-modal-dst.test.tsx b/components/filters/__tests__/filter-rule-modal-dst.test.tsx new file mode 100644 index 000000000..0924bf93c --- /dev/null +++ b/components/filters/__tests__/filter-rule-modal-dst.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { FilterRule } from '@/lib/jmap/sieve-types'; +import { getBrowserTimeZone } from '@/lib/timezone'; +import { useSettingsStore } from '@/stores/settings-store'; +import { FilterRuleModal } from '../filter-rule-modal'; + +vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })); + +// The browser runs on Berlin time, the user's setting says New York. Set +// before the imports run: the app reads the browser zone once and keeps it. +const ORIGINAL_TZ = vi.hoisted(() => { + const original = process.env.TZ; + process.env.TZ = 'Europe/Berlin'; + return original; +}); +beforeAll(() => { + expect(getBrowserTimeZone()).toBe('Europe/Berlin'); +}); +afterAll(() => { + if (ORIGINAL_TZ === undefined) delete process.env.TZ; + else process.env.TZ = ORIGINAL_TZ; +}); + +const initialSettings = useSettingsStore.getState(); +beforeEach(() => { + useSettingsStore.setState({ timeZone: 'America/New_York' }); +}); +afterEach(() => { + useSettingsStore.setState(initialSettings, true); +}); + +const rule: FilterRule = { + id: 'r1', + name: 'Urlaub', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: '@' }], + actions: [{ type: 'forward', value: 'kollege@example.com' }], + stopProcessing: false, +}; + +it('reads a time the browser zone skips at its DST change on the clock of the user zone', () => { + const onSave = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText('period_toggle')); + // 02:30 on 29 March does not exist in Berlin; in New York it is 02:30 EDT. + fireEvent.change(screen.getByLabelText('period_end'), { target: { value: '2026-03-29T02:30' } }); + fireEvent.click(screen.getByText('save')); + expect(onSave).toHaveBeenCalledTimes(1); + expect((onSave.mock.calls[0][0] as FilterRule).activeUntil).toBe('2026-03-29T06:30:00.000Z'); +}); diff --git a/components/filters/__tests__/filter-rule-modal-period.test.tsx b/components/filters/__tests__/filter-rule-modal-period.test.tsx new file mode 100644 index 000000000..0109630bb --- /dev/null +++ b/components/filters/__tests__/filter-rule-modal-period.test.tsx @@ -0,0 +1,263 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { FilterRule } from '@/lib/jmap/sieve-types'; +import { getBrowserTimeZone } from '@/lib/timezone'; +import { useSettingsStore } from '@/stores/settings-store'; +import { FilterRuleModal } from '../filter-rule-modal'; +import { RuleSummary, VisualRuleSummary } from '@/components/settings/filter-settings'; + +const toastError = vi.hoisted(() => vi.fn()); +vi.mock('@/stores/toast-store', () => ({ toast: { error: toastError, success: vi.fn(), info: vi.fn() } })); + +const rule = (extra: Partial = {}): FilterRule => ({ + id: 'r1', + name: 'Urlaub', + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: '@' }], + actions: [{ type: 'forward', value: 'kollege@example.com' }, { type: 'discard' }], + stopProcessing: false, + ...extra, +}); + +// The browser reports UTC while the user's time zone setting says Berlin, so +// the times show which of the two the dialog and the list follow. Set before +// the imports run: the app reads the browser zone once and keeps it. +const ORIGINAL_TZ = vi.hoisted(() => { + const original = process.env.TZ; + process.env.TZ = 'UTC'; + return original; +}); +beforeAll(() => { + expect(getBrowserTimeZone()).toBe('UTC'); +}); +afterAll(() => { + if (ORIGINAL_TZ === undefined) delete process.env.TZ; + else process.env.TZ = ORIGINAL_TZ; +}); + +const initialSettings = useSettingsStore.getState(); +beforeEach(() => { + toastError.mockClear(); + useSettingsStore.setState({ timeZone: 'Europe/Berlin', dateLocale: 'en-GB', timeFormat: '24h' }); +}); +afterEach(() => { + useSettingsStore.setState(initialSettings, true); + vi.useRealTimers(); +}); + +function openModal(props: Partial> = {}) { + const onSave = vi.fn(); + render(); + return { onSave, saved: () => onSave.mock.calls[0][0] as FilterRule }; +} + +const toggle = () => screen.getByLabelText('period_toggle') as HTMLInputElement; +const startInput = () => screen.getByLabelText('period_start') as HTMLInputElement; +const endInput = () => screen.getByLabelText('period_end') as HTMLInputElement; +const save = () => fireEvent.click(screen.getByText('save')); + +describe('filter rule period (dialog)', () => { + it('is offered only where the server supports it', () => { + openModal({ rule: rule() }); + expect(screen.queryByLabelText('period_toggle')).toBeNull(); + }); + + it('saves the period as the UTC moments of the times entered in the user time zone', () => { + const { onSave, saved } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + expect(screen.getByText('period_hint')).toBeInTheDocument(); + // Berlin: CEST (+02:00) on 5 Oct, CET (+01:00) after the switch on 25 Oct. + fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } }); + fireEvent.change(endInput(), { target: { value: '2026-10-26T18:00' } }); + save(); + expect(onSave).toHaveBeenCalledTimes(1); + expect(saved()).toMatchObject({ + activeFrom: '2026-10-05T06:00:00.000Z', + activeUntil: '2026-10-26T17:00:00.000Z', + }); + }); + + it('keeps the start open', () => { + const { saved } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + fireEvent.change(endInput(), { target: { value: '2026-10-16T18:00' } }); + save(); + expect(saved().activeUntil).toBe('2026-10-16T16:00:00.000Z'); + expect(saved().activeFrom).toBeUndefined(); + }); + + it('keeps the end open', () => { + const { saved } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } }); + save(); + expect(saved().activeFrom).toBe('2026-10-05T06:00:00.000Z'); + expect(saved().activeUntil).toBeUndefined(); + }); + + it('asks for a start or an end when the period is on but empty', () => { + const { onSave } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + save(); + expect(onSave).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('validation_period_empty'); + }); + + it('will not take a date without its time for an open end', () => { + const { onSave } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } }); + // A browser reads a field it holds half filled in as empty, often without + // an event; jsdom never holds one, so the field says so here. + Object.defineProperty(endInput(), 'validity', { value: { badInput: true }, configurable: true }); + save(); + expect(onSave).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('validation_period_invalid'); + }); + + it('refuses a year it cannot store, rather than leave that end open', () => { + const { onSave } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + fireEvent.change(startInput(), { target: { value: '2026-10-05T08:00' } }); + fireEvent.change(endInput(), { target: { value: '20266-10-16T18:00' } }); + save(); + expect(onSave).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('validation_period_invalid'); + + // The last minute of 9999 in New York is year 10000 in UTC already. + toastError.mockClear(); + useSettingsStore.setState({ timeZone: 'America/New_York' }); + fireEvent.change(endInput(), { target: { value: '9999-12-31T23:59' } }); + save(); + expect(onSave).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('validation_period_invalid'); + }); + + it('offers no year past 9999', () => { + openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + expect(startInput().max).toBe('9999-12-31T23:59'); + expect(endInput().max).toBe('9999-12-31T23:59'); + }); + + it('refuses an end that is not after the start', () => { + const { onSave } = openModal({ rule: rule(), periodsSupported: true }); + fireEvent.click(toggle()); + fireEvent.change(startInput(), { target: { value: '2026-10-16T18:00' } }); + fireEvent.change(endInput(), { target: { value: '2026-10-16T18:00' } }); + save(); + expect(onSave).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('validation_period_order'); + }); + + it('shows a saved period in the user time zone', () => { + openModal({ + rule: rule({ activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-26T17:00:00.000Z' }), + periodsSupported: true, + }); + expect(toggle().checked).toBe(true); + expect(startInput().value).toBe('2026-10-05T08:00'); + expect(endInput().value).toBe('2026-10-26T18:00'); + }); + + it('keeps a saved boundary exactly while it is left as shown', () => { + // 08:00:30, shown as 08:00; and 02:30 CEST on the night the clocks go + // back, when 02:30 comes twice. + const period = { activeFrom: '2026-10-05T06:00:30.000Z', activeUntil: '2026-10-25T00:30:00.000Z' }; + const { saved } = openModal({ rule: rule(period), periodsSupported: true }); + expect(startInput().value).toBe('2026-10-05T08:00'); + expect(endInput().value).toBe('2026-10-25T02:30'); + save(); + expect(saved()).toMatchObject(period); + }); + + it('reads a changed boundary from the input and keeps the other one', () => { + const { saved } = openModal({ + rule: rule({ activeFrom: '2026-10-05T06:00:30.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }), + periodsSupported: true, + }); + fireEvent.change(endInput(), { target: { value: '2026-10-18T12:00' } }); + save(); + expect(saved()).toMatchObject({ + activeFrom: '2026-10-05T06:00:30.000Z', + activeUntil: '2026-10-18T10:00:00.000Z', + }); + }); + + it('drops the period when it is switched off', () => { + const { saved } = openModal({ rule: rule({ activeUntil: '2026-10-16T16:00:00.000Z' }), periodsSupported: true }); + fireEvent.click(toggle()); + save(); + expect(saved().activeFrom).toBeUndefined(); + expect(saved().activeUntil).toBeUndefined(); + }); + + it('shows a saved period where the server does not offer periods, so it can be removed', () => { + const { saved } = openModal({ rule: rule({ activeUntil: '2026-10-16T16:00:00.000Z' }) }); + expect(toggle().checked).toBe(true); + fireEvent.click(toggle()); + // The switch stays where it was, so a slip can be undone. + expect(toggle().checked).toBe(false); + save(); + expect(saved().activeUntil).toBeUndefined(); + }); + + it('does not run a rule with a period on existing messages', () => { + openModal({ + initialRule: rule({ actions: [{ type: 'mark_read' }] }), + offerApplyToExisting: true, + periodsSupported: true, + }); + const applyExisting = screen.getByLabelText('apply_existing') as HTMLInputElement; + expect(applyExisting.disabled).toBe(false); + fireEvent.click(toggle()); + expect(applyExisting.disabled).toBe(true); + expect(screen.getByText('apply_existing_period')).toBeInTheDocument(); + }); +}); + +describe('filter rule period (list)', () => { + // 14:00 in Berlin + beforeEach(() => vi.useFakeTimers({ now: new Date('2026-10-10T12:00:00.000Z'), toFake: ['Date'] })); + + const periods = [ + ['scheduled', { activeFrom: '2026-10-12T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }, '12 Oct 2026, 08:00 – 16 Oct 2026, 18:00'], + ['active', { activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }, '5 Oct 2026, 08:00 – 16 Oct 2026, 18:00'], + ['expired', { activeFrom: '2026-10-01T06:00:00.000Z', activeUntil: '2026-10-09T16:00:00.000Z' }, '1 Oct 2026, 08:00 – 9 Oct 2026, 18:00'], + ['active', { activeUntil: '2026-10-16T16:00:00.000Z' }, '… – 16 Oct 2026, 18:00'], + ['scheduled', { activeFrom: '2026-10-12T06:00:00.000Z' }, '12 Oct 2026, 08:00 – …'], + ] as const; + + it.each(periods)('shows a %s period in the detailed view', (status, period, range) => { + render(); + expect(screen.getByText('period_title')).toBeInTheDocument(); + expect(screen.getByText(range)).toBeInTheDocument(); + expect(screen.getByText(`(period_status_${status})`)).toBeInTheDocument(); + }); + + it.each(periods)('shows a %s period in the compact view', (status, period, range) => { + render(); + expect(screen.getByText(`· ${range} (period_status_${status})`)).toBeInTheDocument(); + }); + + it('shows the period of a rule that is off without saying it is active', () => { + const off = rule({ enabled: false, activeFrom: '2026-10-05T06:00:00.000Z', activeUntil: '2026-10-16T16:00:00.000Z' }); + const { unmount } = render(); + expect(screen.getByText('5 Oct 2026, 08:00 – 16 Oct 2026, 18:00')).toBeInTheDocument(); + expect(screen.queryByText(/period_status_/)).toBeNull(); + unmount(); + render(); + expect(screen.getByText('· 5 Oct 2026, 08:00 – 16 Oct 2026, 18:00')).toBeInTheDocument(); + expect(screen.queryByText(/period_status_/)).toBeNull(); + }); + + it('shows no period for a rule without one', () => { + const { unmount } = render(); + expect(screen.queryByText('period_title')).toBeNull(); + unmount(); + const { container } = render(); + expect(container.textContent).not.toContain('·'); + }); +}); diff --git a/components/filters/__tests__/quick-rule-host.test.tsx b/components/filters/__tests__/quick-rule-host.test.tsx new file mode 100644 index 000000000..1d6d55acf --- /dev/null +++ b/components/filters/__tests__/quick-rule-host.test.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import type { FilterRule, VacationForward } from '@/lib/jmap/sieve-types'; +import { generateScript } from '@/lib/sieve/generator'; +import { mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock'; +import { useQuickRuleStore } from '@/stores/quick-rule-store'; +import { QuickRuleHost } from '../quick-rule-host'; + +vi.mock('@/stores/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })); + +const EXTENSIONS = ['fileinto', 'imap4flags', 'include', 'envelope', 'copy', 'date', 'relational', 'spamtestplus', 'comparator-i;ascii-numeric']; + +const forwardRule = (id: string): FilterRule => ({ + id, + name: `Rule ${id}`, + enabled: true, + matchType: 'all', + conditions: [{ field: 'from', comparator: 'contains', value: 'chef@example.com' }], + actions: [{ type: 'forward', value: 'kollege@example.com' }], + stopProcessing: false, +}); + +/** An account that allows one redirect per message, as Stalwart does. */ +const account = (rules: FilterRule[], vacationForward?: VacationForward) => mockStalwartAccount('b', [ + { name: 'filters', content: generateScript(rules, undefined, { extensions: EXTENSIONS, vacationForward }), isActive: true }, +], EXTENSIONS); + +function openEditor(server: ReturnType, mode: 'prefill' | 'edit', rule: FilterRule) { + useQuickRuleStore.getState().openEditor({ + mode, + target: { + client: server.client, + clientAccountId: null, + key: 'login/b', + accountId: 'b', + sieveAccountId: 'b', + shared: false, + supportsSieve: true, + mailboxes: [], + }, + rule, + suggestions: [], + sourceMailboxId: null, + }); + render(); +} + +/** Once the account's script has been read and counted. */ +async function counted(server: ReturnType) { + await waitFor(() => expect(server.client.getSieveScriptContent).toHaveBeenCalled()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +afterEach(() => { + useQuickRuleStore.getState().closeEditor(); +}); + +describe('a rule made from a message, against the redirect limit', () => { + it('counts the vacation forwarding that keeps a copy', async () => { + const server = account([], { enabled: true, to: 'vertretung@example.com', keepCopy: true }); + openEditor(server, 'prefill', forwardRule('new')); + expect(await screen.findByText('forward_limit')).toBeInTheDocument(); + }); + + it('leaves out vacation forwarding without a copy: it stops the message before any rule', async () => { + const server = account([], { enabled: true, to: 'vertretung@example.com', keepCopy: false }); + openEditor(server, 'prefill', forwardRule('new')); + await counted(server); + expect(screen.queryByText('forward_limit')).toBeNull(); + }); + + it('leaves out the forwards of the rule being edited', async () => { + const edited = forwardRule('edited'); + const server = account([edited]); + openEditor(server, 'edit', edited); + await counted(server); + expect(screen.queryByText('forward_limit')).toBeNull(); + }); + + it('counts the forwards of the other rules', async () => { + const edited = forwardRule('edited'); + const server = account([edited, forwardRule('other')]); + openEditor(server, 'edit', edited); + expect(await screen.findByText('forward_limit')).toBeInTheDocument(); + }); + + it('leaves out a rule above that forwards and stops: its messages never get here', async () => { + const edited = forwardRule('edited'); + const server = account([{ ...forwardRule('above'), stopProcessing: true }, edited]); + openEditor(server, 'edit', edited); + await counted(server); + expect(screen.queryByText('forward_limit')).toBeNull(); + }); + + it('puts a new rule first, ahead of a rule that forwards and stops', async () => { + const server = account([{ ...forwardRule('below'), stopProcessing: true }]); + openEditor(server, 'prefill', forwardRule('new')); + // A message both match gets the new rule's forward first ... + expect(await screen.findByText('forward_limit')).toBeInTheDocument(); + // ... unless the new rule stops. + fireEvent.click(screen.getByLabelText('stop_processing')); + expect(screen.queryByText('forward_limit')).toBeNull(); + }); + + it('takes an edited rule no longer there for one saved first again', async () => { + const server = account([{ ...forwardRule('below'), stopProcessing: true }]); + openEditor(server, 'edit', forwardRule('gone')); + expect(await screen.findByText('forward_limit')).toBeInTheDocument(); + }); +}); diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index bf4703127..3fca0201f 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,6 +20,8 @@ import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID, c import type { RuleSuggestion } from "@/lib/filters/quick-rules"; import { retroactiveSupport } from "@/lib/filters/retroactive"; import { ruleForwards, ruleStops } from "@/lib/filters/forward-limit"; +import { hasPeriod, isPeriodBoundary } from "@/lib/sieve/period"; +import { fromWallClock, getEffectiveTimeZone, getWallClock } from "@/lib/timezone"; import { useSettingsStore } from "@/stores/settings-store"; import { useKeywordFormat } from "@/hooks/use-keyword-format"; @@ -44,6 +46,8 @@ interface FilterRuleModalProps { */ forwardsBefore?: number; forwardsAfter?: number; + /** The server can confine a rule to a period (Sieve "date" and "relational"). */ + periodsSupported?: boolean; onSave: (rule: FilterRule, options?: { applyToExisting: boolean }) => void; onClose: () => void; } @@ -115,6 +119,37 @@ function makeEmptyAction(): FilterAction { return { type: "move", value: "" }; } +// A period boundary as the "yyyy-mm-ddThh:mm" a datetime-local input shows, +// read on the clock of the user's time zone - the one dates are shown in. +function toPeriodInput(boundary: string | undefined): string { + if (!isPeriodBoundary(boundary)) return ""; + const w = getWallClock(new Date(boundary), getEffectiveTimeZone()); + const pad = (n: number, len = 2) => String(n).padStart(len, "0"); + return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}`; +} + +// The latest moment the period fields offer: a later year has no room in +// the four digits a stored boundary has. +const PERIOD_INPUT_MAX = "9999-12-31T23:59"; + +// The input's wall-clock time in the user's time zone, as the UTC moment the +// rule stores: undefined for an empty field, null for one that holds no +// usable moment (a five-digit year). A boundary left as the dialog showed it +// is kept exactly: the input shows whole minutes, so reading it back would +// drop a saved second, or pick the other one of the two moments in the hour +// a DST switch repeats. +function fromPeriodInput(value: string, saved: string | undefined): string | null | undefined { + if (!value) return undefined; + if (saved !== undefined && value === toPeriodInput(saved)) return saved; + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value); + if (!m) return null; + const [year, month, day, hour, minute, second] = m.slice(1).map((part) => Number(part ?? 0)); + const moment = fromWallClock({ year, month, day, hour, minute, second }, getEffectiveTimeZone()); + if (Number.isNaN(moment.getTime())) return null; + const boundary = moment.toISOString(); + return isPeriodBoundary(boundary) ? boundary : null; +} + export function FilterRuleModal({ rule, initialRule, @@ -124,6 +159,7 @@ export function FilterRuleModal({ maxRedirects, forwardsBefore = 0, forwardsAfter = 0, + periodsSupported = false, onSave, onClose, }: FilterRuleModalProps) { @@ -145,6 +181,12 @@ export function FilterRuleModal({ const [includeSpam, setIncludeSpam] = useState(start?.includeSpam ?? false); const [usedSuggestions, setUsedSuggestions] = useState>(new Set()); const [applyToExisting, setApplyToExisting] = useState(false); + const hadPeriod = !!start && hasPeriod(start); + const [periodOn, setPeriodOn] = useState(hadPeriod); + const [periodStart, setPeriodStart] = useState(toPeriodInput(start?.activeFrom)); + const [periodEnd, setPeriodEnd] = useState(toPeriodInput(start?.activeUntil)); + const periodStartRef = useRef(null); + const periodEndRef = useRef(null); const modalRef = useFocusTrap({ isActive: true, onEscape: onClose }); @@ -217,9 +259,12 @@ export function FilterRuleModal({ // the conditions decide. const canApplyToExisting = useMemo(() => { if (!offerApplyToExisting) return false; + // A rule with a period acts on mail as it arrives within it, which says + // nothing about the mail already there. + if (periodOn) return false; const checkActions: FilterAction[] = validActions.length > 0 ? validActions : [{ type: "mark_read" }]; return retroactiveSupport({ conditions: validConditions, actions: checkActions }).ok; - }, [offerApplyToExisting, validConditions, validActions]); + }, [offerApplyToExisting, validConditions, validActions, periodOn]); const handleSave = useCallback(() => { const trimmedName = name.trim(); @@ -238,6 +283,24 @@ export function FilterRuleModal({ return; } + const activeFrom = periodOn ? fromPeriodInput(periodStart, start?.activeFrom) : undefined; + const activeUntil = periodOn ? fromPeriodInput(periodEnd, start?.activeUntil) : undefined; + // A field the browser holds half filled in (a date without its time) + // reads as empty, and must not pass for an open end of the period. + const halfFilled = !!periodStartRef.current?.validity.badInput || !!periodEndRef.current?.validity.badInput; + if (activeFrom === null || activeUntil === null || (periodOn && halfFilled)) { + toast.error(t("validation_period_invalid")); + return; + } + if (periodOn && !activeFrom && !activeUntil) { + toast.error(t("validation_period_empty")); + return; + } + if (activeFrom && activeUntil && Date.parse(activeUntil) <= Date.parse(activeFrom)) { + toast.error(t("validation_period_order")); + return; + } + onSave({ id: start?.id || generateUUID(), name: trimmedName, @@ -246,9 +309,14 @@ export function FilterRuleModal({ conditions: validConditions, actions: validActions, stopProcessing, - ...(includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? { includeSpam: true } : {}), + // Each optional field this dialog controls is set, to undefined when it + // is off: the settings page merges the result into the stored rule, and + // a field left out would keep its old value there. + includeSpam: includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? true : undefined, + activeFrom, + activeUntil, }, { applyToExisting: applyToExisting && canApplyToExisting }); - }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, start, onSave, t, applyToExisting, canApplyToExisting]); + }, [name, matchType, validConditions, validActions, stopProcessing, includeSpam, periodOn, periodStart, periodEnd, start, onSave, t, applyToExisting, canApplyToExisting]); const visibleSuggestions = suggestions.filter((s) => !usedSuggestions.has(s.id)); @@ -597,7 +665,7 @@ export function FilterRuleModal({ type="email" className="flex-1 min-w-[180px]" /> -