From df1a5ab4b67d1bb1b7d8791d55c5dd85d199f525 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:07:09 +0100 Subject: [PATCH] feat: set up vacation forwarding, reply recipients, rule periods and all-messages rules on the phone #1152 --- .../__tests__/settings-i18n.test.ts | 2 +- src/components/filters/FilterRuleModal.tsx | 111 ++++++++++--- src/components/settings/FilterSettings.tsx | 20 ++- src/components/settings/VacationSettings.tsx | 149 +++++++++++++++++- .../sieve/__tests__/condition-value.test.ts | 22 +++ src/lib/sieve/__tests__/rule-period.test.ts | 33 ++++ src/lib/sieve/condition-value.ts | 29 +++- src/lib/sieve/rule-period.ts | 37 +++++ src/stores/__tests__/vacation-store.test.ts | 59 ++++++- src/stores/vacation-store.ts | 136 +++++++++++++--- 10 files changed, 542 insertions(+), 56 deletions(-) create mode 100644 src/lib/sieve/__tests__/rule-period.test.ts create mode 100644 src/lib/sieve/rule-period.ts diff --git a/src/components/__tests__/settings-i18n.test.ts b/src/components/__tests__/settings-i18n.test.ts index e22520e3..12f71d1e 100644 --- a/src/components/__tests__/settings-i18n.test.ts +++ b/src/components/__tests__/settings-i18n.test.ts @@ -47,7 +47,7 @@ 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': [], }; diff --git a/src/components/filters/FilterRuleModal.tsx b/src/components/filters/FilterRuleModal.tsx index 5564cccd..6461fd15 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()]; @@ -73,11 +67,15 @@ interface FilterRuleModalProps { visible: boolean; rule?: 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, mailboxes, periodsSupported = false, onSave, onClose, +}: FilterRuleModalProps) { const c = useColors(); const styles = useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -90,6 +88,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 +105,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 +234,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 +253,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 +356,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 +640,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/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 && ( + +