feat: set up vacation forwarding, reply recipients, rule periods and all-messages rules on the phone #1152

This commit is contained in:
Linus Rath
2026-10-29 03:07:09 +01:00
parent 2167a9d700
commit df1a5ab4b6
10 changed files with 542 additions and 56 deletions
@@ -47,7 +47,7 @@ const TRANSLATED_PANES: Record<string, string[]> = {
'settings/device-sync/DeviceSyncAccountRow.tsx': [], 'settings/device-sync/DeviceSyncAccountRow.tsx': [],
'settings/device-sync/DeviceSyncCollectionsSheet.tsx': [], 'settings/device-sync/DeviceSyncCollectionsSheet.tsx': [],
'settings/device-sync/status.ts': [], 'settings/device-sync/status.ts': [],
'filters/FilterRuleModal.tsx': [], 'filters/FilterRuleModal.tsx': ['YYYY-MM-DD HH:MM'],
'filters/SieveEditorSheet.tsx': [], 'filters/SieveEditorSheet.tsx': [],
}; };
+90 -21
View File
@@ -14,9 +14,10 @@ import { useLocaleStore } from '../../stores/locale-store';
import { useKeywordsStore } from '../../stores/keywords-store'; import { useKeywordsStore } from '../../stores/keywords-store';
import { generateUUID } from '../../lib/uuid'; import { generateUUID } from '../../lib/uuid';
import { import {
comparatorsFor,
inputStringToValue, inputStringToValue,
isConditionValueEmpty, isConditionValueEmpty,
isHasAnyCondition, takesNoValue,
valueToInputString, valueToInputString,
} from '../../lib/sieve/condition-value'; } from '../../lib/sieve/condition-value';
import { import {
@@ -29,6 +30,8 @@ import {
withMailboxTarget, withMailboxTarget,
} from '../../lib/sieve/rule-actions'; } from '../../lib/sieve/rule-actions';
import { isValidSieveHeaderName } from '../../lib/sieve/generator'; 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 { Mailbox } from '../../api/types';
import type { import type {
FilterRule, FilterRule,
@@ -41,18 +44,9 @@ import type {
import { SafeAreaModal } from '../SafeAreaModal'; import { SafeAreaModal } from '../SafeAreaModal';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment']; const ALL_FIELDS: FilterConditionField[] = ['from', 'to', 'cc', 'subject', 'header', 'size', 'body', 'attachment', 'all'];
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'];
function comparatorsFor(field: FilterConditionField): FilterComparator[] { const isHasAny = takesNoValue;
if (field === 'size') return SIZE_COMPARATORS;
if (field === 'attachment') return ATTACHMENT_COMPARATORS;
return TEXT_COMPARATORS;
}
const isHasAny = isHasAnyCondition;
function seedConditions(rule?: FilterRule): FilterCondition[] { function seedConditions(rule?: FilterRule): FilterCondition[] {
return rule?.conditions.length ? rule.conditions.map((cond) => ({ ...cond })) : [makeEmptyCondition()]; return rule?.conditions.length ? rule.conditions.map((cond) => ({ ...cond })) : [makeEmptyCondition()];
@@ -73,11 +67,15 @@ interface FilterRuleModalProps {
visible: boolean; visible: boolean;
rule?: FilterRule; rule?: FilterRule;
mailboxes: Mailbox[]; mailboxes: Mailbox[];
/** The server can confine a rule to a period (Sieve "date" and "relational"). */
periodsSupported?: boolean;
onSave: (rule: FilterRule) => void; onSave: (rule: FilterRule) => void;
onClose: () => 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 c = useColors();
const styles = useMemo(() => makeStyles(c), [c]); const styles = useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
@@ -90,6 +88,10 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
const [actions, setActions] = useState<FilterAction[]>(() => seedActions(rule)); const [actions, setActions] = useState<FilterAction[]>(() => seedActions(rule));
const [stopProcessing, setStopProcessing] = useState(rule?.stopProcessing ?? false); const [stopProcessing, setStopProcessing] = useState(rule?.stopProcessing ?? false);
const [includeSpam, setIncludeSpam] = useState(rule?.includeSpam ?? 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 // 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 // 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)); setActions(seedActions(rule));
setStopProcessing(rule?.stopProcessing ?? false); setStopProcessing(rule?.stopProcessing ?? false);
setIncludeSpam(rule?.includeSpam ?? false); setIncludeSpam(rule?.includeSpam ?? false);
setPeriodOn(!!rule && hasPeriod(rule));
setPeriodStart(periodToInput(rule?.activeFrom));
setPeriodEnd(periodToInput(rule?.activeUntil));
}, [visible, rule]); }, [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 mailboxTargets = useMemo(() => buildMailboxTargets(mailboxes), [mailboxes]);
const fieldOptions = useMemo( 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')); Alert.alert(t('settings.filters.validation_empty_actions', 'At least one action is required'));
return; 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({ onSave({
id: rule?.id || generateUUID(), id: rule?.id || generateUUID(),
name: trimmedName, 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 // Only folder moves are kept out of Junk, so the opt-in only means
// something (and is only stored) while the rule has one. // something (and is only stored) while the rule has one.
...(includeSpam && validActions.some((a) => ACTIONS_WITH_MAILBOX.has(a.type)) ? { includeSpam: true } : {}), ...(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 ( return (
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent> <SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} animationType="slide" onRequestClose={onClose} statusBarTranslucent>
@@ -332,14 +356,16 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
/> />
)} )}
<Select {condition.field !== 'all' && (
value={condition.comparator} <Select
onChange={(v) => updateCondition(index, { comparator: v as FilterComparator })} value={condition.comparator}
options={comparatorOptions(condition.field)} onChange={(v) => updateCondition(index, { comparator: v as FilterComparator })}
style={{ alignSelf: 'flex-start' }} 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) && ( {!isHasAny(condition) && (
<Input <Input
value={valueToInputString(condition.value)} value={valueToInputString(condition.value)}
@@ -493,6 +519,48 @@ export function FilterRuleModal({ visible, rule, mailboxes, onSave, onClose }: F
/> />
</View> </View>
)} )}
{/* Offered where the server supports it; a rule that already has a
period always shows it, so it can be removed. */}
{(periodsSupported || hadPeriod) && (
<View style={{ gap: spacing.sm }}>
<View style={styles.stopRow}>
<Text style={styles.stopLabel}>
{t('settings.filters.period_toggle', 'Only active during a period')}
</Text>
<ToggleSwitch
checked={periodOn}
onChange={setPeriodOn}
accessibilityLabel={t('settings.filters.period_toggle', 'Only active during a period')}
/>
</View>
{periodOn && (
<>
<Text style={styles.optionLabel}>{t('settings.filters.period_start', 'Start date')}</Text>
<Input
value={periodStart}
onChangeText={setPeriodStart}
placeholder="YYYY-MM-DD HH:MM"
accessibilityLabel={t('settings.filters.period_start', 'Start date')}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.optionLabel}>{t('settings.filters.period_end', 'End date')}</Text>
<Input
value={periodEnd}
onChangeText={setPeriodEnd}
placeholder="YYYY-MM-DD HH:MM"
accessibilityLabel={t('settings.filters.period_end', 'End date')}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.hint}>
{t('settings.filters.period_hint', 'Applies to messages that arrive during this period. Leave a field empty to keep that end open.')}
</Text>
</>
)}
</View>
)}
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
@@ -572,6 +640,7 @@ function makeStyles(c: ThemePalette) {
gap: spacing.md, gap: spacing.md,
}, },
optionLabel: { ...typography.body, color: c.text, flex: 1 }, optionLabel: { ...typography.body, color: c.text, flex: 1 },
hint: { ...typography.caption, color: c.mutedForeground },
footer: { footer: {
flexDirection: 'row', flexDirection: 'row',
+19 -1
View File
@@ -21,6 +21,7 @@ import { FilterRuleModal } from '../filters/FilterRuleModal';
import { SieveEditorSheet } from '../filters/SieveEditorSheet'; import { SieveEditorSheet } from '../filters/SieveEditorSheet';
import type { FilterRule } from '../../lib/sieve/types'; import type { FilterRule } from '../../lib/sieve/types';
import { formatConditionValue, summarizeRule } from '../../lib/sieve/condition-value'; import { formatConditionValue, summarizeRule } from '../../lib/sieve/condition-value';
import { periodStatus, supportsPeriods } from '../../lib/sieve/period';
import Badge from '../Badge'; import Badge from '../Badge';
type Translate = (key: string, fallback?: string) => string; type Translate = (key: string, fallback?: string) => string;
@@ -29,6 +30,18 @@ function isReadonlyRule(r: FilterRule): boolean {
return r.origin === 'external' || r.origin === 'opaque'; return r.origin === 'external' || r.origin === 'opaque';
} }
/** Whether a rule with a period is waiting for it, in it, or past it. */
function PeriodBadge({ rule, t }: { rule: FilterRule; t: Translate }) {
const status = periodStatus(rule);
if (!status) return null;
const label = status === 'scheduled'
? t('settings.filters.period_status_scheduled', 'scheduled')
: status === 'active'
? t('settings.filters.period_status_active', 'active')
: t('settings.filters.period_status_expired', 'expired');
return <Badge>{label}</Badge>;
}
// Expanded "IF ... / THEN ..." view (webmail VisualRuleSummary, 1.4.6): every // 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. // 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 }) { 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, selectAccount, saveFilters, addRule, updateRule, deleteRule, reorderRules, toggleRule,
setOpaqueScript, resetToVisualBuilder, validateScript, setOpaqueScript, resetToVisualBuilder, validateScript,
} = useFilterStore(); } = useFilterStore();
const periodsSupported = useFilterStore((s) => supportsPeriods(s.sieveCapabilities?.sieveExtensions));
const [editingRule, setEditingRule] = useState<FilterRule | undefined>(); const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
const [showRuleModal, setShowRuleModal] = useState(false); const [showRuleModal, setShowRuleModal] = useState(false);
@@ -397,7 +411,10 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
style={{ flex: 1, minWidth: 0 }} style={{ flex: 1, minWidth: 0 }}
onPress={() => { setEditingRule(rule); setShowRuleModal(true); }} onPress={() => { setEditingRule(rule); setShowRuleModal(true); }}
> >
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text> <View style={styles.ruleNameRow}>
<Text style={styles.ruleName} numberOfLines={1}>{rule.name}</Text>
<PeriodBadge rule={rule} t={t} />
</View>
{expandedView ? ( {expandedView ? (
<VisualRuleSummary rule={rule} t={t} c={c} /> <VisualRuleSummary rule={rule} t={t} c={c} />
) : ( ) : (
@@ -487,6 +504,7 @@ export function FilterSettings({ onOpenVacation }: FilterSettingsProps = {}) {
visible={showRuleModal} visible={showRuleModal}
rule={editingRule} rule={editingRule}
mailboxes={mailboxes} mailboxes={mailboxes}
periodsSupported={periodsSupported}
onSave={handleSaveRule} onSave={handleSaveRule}
onClose={() => { setShowRuleModal(false); setEditingRule(undefined); }} onClose={() => { setShowRuleModal(false); setEditingRule(undefined); }}
/> />
+142 -7
View File
@@ -4,14 +4,18 @@ import { WebView } from 'react-native-webview';
import { import {
AlertTriangle, Eye, EyeOff, Bold, Italic, Underline, List as ListIcon, AlertTriangle, Eye, EyeOff, Bold, Italic, Underline, List as ListIcon,
} from '../icons'; } from '../icons';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import Button from '../Button'; import Button from '../Button';
import RichTextEditor, { type RichTextEditorHandle } from '../RichTextEditor'; import RichTextEditor, { type RichTextEditorHandle } from '../RichTextEditor';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors'; 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 { useManagedAccountStore } from '../../stores/managed-account-store';
import { useLocaleStore } from '../../stores/locale-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 { htmlToPlainText } from '../../lib/compose-html';
import { stripDangerousTags, escapeHtml } from '../../lib/email-html'; import { stripDangerousTags, escapeHtml } from '../../lib/email-html';
import { import {
@@ -26,6 +30,16 @@ import {
// are typed as local wall-clock time and sent as RFC 8621 UTCDate strings; // 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 // 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. // 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() { export function VacationSettings() {
const c = useColors(); const c = useColors();
@@ -46,6 +60,13 @@ export function VacationSettings() {
const editorRef = useRef<RichTextEditorHandle>(null); const editorRef = useRef<RichTextEditorHandle>(null);
// Seed the editor once per load; RichTextEditor only reads initialHtml on mount. // Seed the editor once per load; RichTextEditor only reads initialHtml on mount.
const [editorSeed, setEditorSeed] = useState(store.htmlBody || ''); const [editorSeed, setEditorSeed] = useState(store.htmlBody || '');
const [forwardEnabled, setForwardEnabled] = useState(store.forward?.enabled ?? false);
const [forwardTo, setForwardTo] = useState(store.forward?.to ?? '');
const [forwardKeep, setForwardKeep] = useState(store.forward?.keepCopy ?? false);
const [audienceOnly, setAudienceOnly] = useState<AudienceChoice>(store.audience?.only ?? 'all');
// "Internal" are the account's own domains, as its identities give them.
const identities = useSettingsStore((s) => s.identities);
const domains = useMemo(() => ownDomains(identities.map((i) => i.email)), [identities]);
// The store reuses `error` for save failures too, so remember whether the // 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). // initial fetch itself failed (that's the only case that blanks the form).
@@ -66,11 +87,41 @@ export function VacationSettings() {
setHtmlEnabled(!!store.htmlBody); setHtmlEnabled(!!store.htmlBody);
setHtmlBody(store.htmlBody || ''); setHtmlBody(store.htmlBody || '');
setEditorSeed(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.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 fromParsed = parseLocalInput(fromDate);
const toParsed = parseLocalInput(toDate); const toParsed = parseLocalInput(toDate);
const formatError = !isValidLocalInput(fromDate) || !isValidLocalInput(toDate); const formatError = !isValidLocalInput(fromDate) || !isValidLocalInput(toDate);
@@ -99,9 +150,11 @@ export function VacationSettings() {
(formatError ? true : localInputToUtcIso(toDate) !== normalizeUtcIso(store.toDate)) || (formatError ? true : localInputToUtcIso(toDate) !== normalizeUtcIso(store.toDate)) ||
subject !== store.subject || subject !== store.subject ||
body !== store.textBody || 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 () => { const handleSave = useCallback(async () => {
// Read the live editor DOM rather than trusting onChange state (issue #9). // Read the live editor DOM rather than trusting onChange state (issue #9).
@@ -129,6 +182,8 @@ export function VacationSettings() {
subject: subject.trim(), subject: subject.trim(),
textBody, textBody,
htmlBody: sanitizedHtml, htmlBody: sanitizedHtml,
forward: forwardChanged ? forwardSettings : undefined,
audience: audienceChanged ? audienceSettings : undefined,
}); });
if (!sanitizedHtml) { if (!sanitizedHtml) {
setHtmlEnabled(false); setHtmlEnabled(false);
@@ -137,11 +192,16 @@ export function VacationSettings() {
Alert.alert(t('notifications.vacation_saved', 'Vacation responder settings saved')); Alert.alert(t('notifications.vacation_saved', 'Vacation responder settings saved'));
} catch (err) { } catch (err) {
Alert.alert( 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, 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 title = t('settings.vacation.title', 'Vacation Responder');
const description = t('settings.vacation.description', "Automatically reply to incoming emails while you're away"); const description = t('settings.vacation.description', "Automatically reply to incoming emails while you're away");
@@ -202,8 +262,34 @@ export function VacationSettings() {
<ToggleSwitch checked={enabled} onChange={setEnabled} /> <ToggleSwitch checked={enabled} onChange={setEnabled} />
</View> </View>
</SettingItem> </SettingItem>
{canNarrow && (
<SettingItem
label={t('settings.vacation.audience.label', 'Reply to')}
description={t('settings.vacation.audience.description', 'Internal senders: {domains}', { domains: domains.join(', ') })}
noBorder
>
<Select
value={audienceOnly}
onChange={(v) => setAudienceOnly(v as AudienceChoice)}
options={[
{ value: 'all', label: t('settings.vacation.audience.all', 'All senders') },
{ value: 'internal', label: t('settings.vacation.audience.internal', 'Internal senders only') },
{ value: 'external', label: t('settings.vacation.audience.external', 'External senders only') },
]}
/>
</SettingItem>
)}
</SettingsSection> </SettingsSection>
{store.notRunning && (
<View style={styles.warnRow}>
<AlertTriangle size={14} color={c.warning} />
<Text style={styles.warnText}>
{t('settings.vacation.warnings.not_running', 'Forwarding or the reply recipients are saved but not active right now. Save to turn them back on.')}
</Text>
</View>
)}
<SettingsSection <SettingsSection
title={t('settings.vacation.date_range.title', 'Date Range')} title={t('settings.vacation.date_range.title', 'Date Range')}
description={t('settings.vacation.date_range.description', 'Optionally limit the auto-reply to a specific period')} description={t('settings.vacation.date_range.description', 'Optionally limit the auto-reply to a specific period')}
@@ -333,6 +419,55 @@ export function VacationSettings() {
</SettingsSection> </SettingsSection>
)} )}
{store.forwardAvailable && (
<SettingsSection
title={t('settings.vacation.forward.title', 'Forwarding')}
description={t('settings.vacation.forward.description', 'Passes incoming messages on to another address, with or without the auto-reply, within the period above (without an end date, until switched off). Spam is not forwarded.')}
>
<SettingItem label={t('settings.vacation.forward.enabled_label', 'Forward messages')} noBorder={!forwardEnabled}>
<ToggleSwitch
checked={forwardEnabled}
onChange={setForwardEnabled}
accessibilityLabel={t('settings.vacation.forward.enabled_label', 'Forward messages')}
/>
</SettingItem>
{forwardEnabled && (
<>
<View style={styles.bodyBlock}>
<Text style={styles.bodyLabel}>{t('settings.vacation.forward.to_label', 'Forward to')}</Text>
<TextInput
value={forwardTo}
onChangeText={setForwardTo}
placeholder={t('settings.vacation.forward.to_placeholder', 'email@example.com')}
placeholderTextColor={c.mutedForeground}
accessibilityLabel={t('settings.vacation.forward.to_label', 'Forward to')}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
style={[styles.subjectInput, forwardInvalid && !!forwardAddress && styles.inputInvalid]}
/>
{forwardInvalid && (
<Text style={[styles.bodyDesc, { color: c.error }]}>
{t('settings.vacation.warnings.forward_address', 'Enter a valid address to forward to')}
</Text>
)}
</View>
<SettingItem
label={t('settings.vacation.forward.keep_label', 'Keep in inbox')}
description={t('settings.vacation.forward.keep_description', 'Otherwise the message is only forwarded: it is not kept in this mailbox, and no filter rule runs on it.')}
noBorder
>
<ToggleSwitch
checked={forwardKeep}
onChange={setForwardKeep}
accessibilityLabel={t('settings.vacation.forward.keep_label', 'Keep in inbox')}
/>
</SettingItem>
</>
)}
</SettingsSection>
)}
{warnings.length > 0 && ( {warnings.length > 0 && (
<View style={styles.warnings}> <View style={styles.warnings}>
{warnings.map((w, i) => ( {warnings.map((w, i) => (
@@ -5,6 +5,8 @@ import {
isConditionValueEmpty, isConditionValueEmpty,
describeCondition, describeCondition,
summarizeRule, summarizeRule,
comparatorsFor,
takesNoValue,
} from '../condition-value'; } from '../condition-value';
import type { FilterRule } from '../types'; import type { FilterRule } from '../types';
@@ -58,4 +60,24 @@ describe('summaries', () => {
'from contains "@a.com" or "@b.com" or attachment has_any (+1) → move "Archive", stop', 'from contains "@a.com" or "@b.com" or attachment has_any (+1) → move "Archive", stop',
); );
}); });
it('describes "all messages" by its field alone', () => {
expect(describeCondition({ field: 'all', comparator: 'any', value: '' }, t)).toBe('all');
});
});
describe('the comparators the editor offers', () => {
it('offers exact address and domain on the address fields only', () => {
for (const field of ['from', 'to', 'cc'] as const) {
expect(comparatorsFor(field)).toEqual(expect.arrayContaining(['address_is', 'domain_is']));
}
expect(comparatorsFor('subject')).not.toContain('address_is');
expect(comparatorsFor('header')).not.toContain('domain_is');
});
it('offers "all messages" without a value', () => {
expect(comparatorsFor('all')).toEqual(['any']);
expect(takesNoValue({ field: 'all', comparator: 'any', value: '' })).toBe(true);
expect(takesNoValue({ field: 'subject', comparator: 'contains', value: '' })).toBe(false);
});
}); });
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { periodError, periodFromInput, periodToInput } from '../rule-period';
import { utcIsoToLocalInput } from '../../vacation-dates';
describe('rule period fields', () => {
it('shows a stored boundary in local time and an unreadable one as empty', () => {
expect(periodToInput('2026-11-02T07:00:00.000Z')).toBe(utcIsoToLocalInput('2026-11-02T07:00:00.000Z'));
expect(periodToInput('next monday')).toBe('');
expect(periodToInput(undefined)).toBe('');
});
it('stores the typed local time as a UTC moment', () => {
const local = new Date(2026, 10, 2, 9, 30);
expect(periodFromInput('2026-11-02 09:30', undefined)).toBe(local.toISOString());
});
it('keeps a stored boundary exactly while its field is left as shown', () => {
const saved = '2026-11-02T07:00:42.000Z';
expect(periodFromInput(periodToInput(saved), saved)).toBe(saved);
});
it('reads an empty field as an open end and garbage as unusable', () => {
expect(periodFromInput(' ', undefined)).toBeUndefined();
expect(periodFromInput('2026-13-45', undefined)).toBeNull();
});
it('says what is wrong with a period', () => {
expect(periodError(undefined, undefined)).toBe('empty');
expect(periodError(null, undefined)).toBe('invalid');
expect(periodError('2026-11-02T10:00:00.000Z', '2026-11-02T09:00:00.000Z')).toBe('order');
expect(periodError('2026-11-02T10:00:00.000Z', undefined)).toBeNull();
});
});
+27 -2
View File
@@ -1,4 +1,4 @@
import type { FilterCondition, FilterRule } from './types'; import type { FilterComparator, FilterCondition, FilterConditionField, FilterRule } from './types';
// Helpers for the string | string[] condition value (1.7.3 "extended rules"). // Helpers for the string | string[] condition value (1.7.3 "extended rules").
// Mirrors the webmail's filter-rule-modal.tsx: conditions are stored as // Mirrors the webmail's filter-rule-modal.tsx: conditions are stored as
@@ -27,12 +27,35 @@ export function isHasAnyCondition(cond: FilterCondition): boolean {
return cond.field === 'attachment' && cond.comparator === 'has_any'; return cond.field === 'attachment' && cond.comparator === 'has_any';
} }
/** Conditions that test without a value: "has an attachment" and "all messages". */
export function takesNoValue(cond: FilterCondition): boolean {
return cond.field === 'all' || isHasAnyCondition(cond);
}
const TEXT_COMPARATORS: FilterComparator[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'matches'];
// The `address` test (webmail 1.13): exact address or exact domain, where
// "contains" would also match joanna@ for anna@, or acme.com.evil for acme.com.
const ADDRESS_COMPARATORS: FilterComparator[] = [...TEXT_COMPARATORS, 'address_is', 'domain_is'];
const SIZE_COMPARATORS: FilterComparator[] = ['greater_than', 'less_than'];
const ATTACHMENT_COMPARATORS: FilterComparator[] = ['has_any', 'has_type'];
// "All messages" (#1154): Sieve `true`, nothing to compare.
const ALL_MESSAGES_COMPARATORS: FilterComparator[] = ['any'];
/** The comparators the rule editor offers for a field (webmail filter-rule-modal.tsx). */
export function comparatorsFor(field: FilterConditionField): FilterComparator[] {
if (field === 'size') return SIZE_COMPARATORS;
if (field === 'attachment') return ATTACHMENT_COMPARATORS;
if (field === 'all') return ALL_MESSAGES_COMPARATORS;
if (field === 'from' || field === 'to' || field === 'cc') return ADDRESS_COMPARATORS;
return TEXT_COMPARATORS;
}
type Translate = (key: string, fallback?: string) => string; type Translate = (key: string, fallback?: string) => string;
// Human-readable value part of a condition: `"a"`, or `"a" or "b"` for lists // Human-readable value part of a condition: `"a"`, or `"a" or "b"` for lists
// using the locale's OR glue, or nothing for the value-less has_any test. // using the locale's OR glue, or nothing for the value-less has_any test.
export function formatConditionValue(cond: FilterCondition, t: Translate): string { export function formatConditionValue(cond: FilterCondition, t: Translate): string {
if (isHasAnyCondition(cond)) return ''; if (isHasAnyCondition(cond) || cond.field === 'all') return '';
if (Array.isArray(cond.value)) { if (Array.isArray(cond.value)) {
return cond.value.map((v) => `"${v}"`).join(` ${t('settings.filters.or', 'or')} `); return cond.value.map((v) => `"${v}"`).join(` ${t('settings.filters.or', 'or')} `);
} }
@@ -41,6 +64,8 @@ export function formatConditionValue(cond: FilterCondition, t: Translate): strin
export function describeCondition(cond: FilterCondition, t: Translate): string { export function describeCondition(cond: FilterCondition, t: Translate): string {
const field = t(`settings.filters.condition_fields.${cond.field}`, cond.field); const field = t(`settings.filters.condition_fields.${cond.field}`, cond.field);
// "All messages" says it all; it has no comparator to show.
if (cond.field === 'all') return field;
const comparator = t(`settings.filters.comparators.${cond.comparator}`, cond.comparator); const comparator = t(`settings.filters.comparators.${cond.comparator}`, cond.comparator);
const value = formatConditionValue(cond, t); const value = formatConditionValue(cond, t);
return value ? `${field} ${comparator} ${value}` : `${field} ${comparator}`; return value ? `${field} ${comparator} ${value}` : `${field} ${comparator}`;
+37
View File
@@ -0,0 +1,37 @@
import { isPeriodBoundary } from './period';
import { isValidLocalInput, localInputToUtcIso, utcIsoToLocalInput } from '../vacation-dates';
// The rule editor's period fields (webmail filter-rule-modal.tsx). The user
// types local wall-clock time as for the vacation dates ("YYYY-MM-DD HH:MM");
// the rule stores the UTC moment, so the script does not depend on the time
// zone of whoever saves it next.
/** A stored boundary as the field shows it; '' for none or one it cannot read. */
export function periodToInput(boundary: string | undefined): string {
return isPeriodBoundary(boundary) ? utcIsoToLocalInput(boundary) : '';
}
/**
* The field's text as the boundary the rule stores: undefined for an empty
* field, null for one that holds no usable moment. A boundary left as the
* dialog showed it is kept exactly: the field shows whole minutes, so
* reading it back would drop a saved second.
*/
export function periodFromInput(value: string, saved: string | undefined): string | null | undefined {
const text = value.trim();
if (!text) return undefined;
if (saved !== undefined && text === periodToInput(saved)) return saved;
if (!isValidLocalInput(text)) return null;
const boundary = localInputToUtcIso(text);
return boundary && isPeriodBoundary(boundary) ? boundary : null;
}
export type PeriodError = 'invalid' | 'empty' | 'order';
/** What is wrong with a period, read from its two fields (see periodFromInput). */
export function periodError(from: string | null | undefined, until: string | null | undefined): PeriodError | null {
if (from === null || until === null) return 'invalid';
if (!from && !until) return 'empty';
if (from && until && Date.parse(until) <= Date.parse(from)) return 'order';
return null;
}
+54 -5
View File
@@ -8,8 +8,13 @@ vi.mock('../../api/vacation', () => ({
vi.mock('../../api/sieve', () => ({ isSieveSupported: vi.fn(() => false) })); vi.mock('../../api/sieve', () => ({ isSieveSupported: vi.fn(() => false) }));
const NO_FILTERS = {
includesVacation: false, forward: null, forwardAvailable: false,
audience: null, audienceAvailable: false, notRunning: false,
};
vi.mock('../filter-store', () => ({ vi.mock('../filter-store', () => ({
isVacationIncludedInFilters: vi.fn(async () => false), readVacationFilters: vi.fn(async () => NO_FILTERS),
syncVacationWithFilters: vi.fn(async () => undefined), syncVacationWithFilters: vi.fn(async () => undefined),
})); }));
@@ -39,7 +44,7 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
api.isVacationSupported.mockReturnValue(true); api.isVacationSupported.mockReturnValue(true);
sieveApi.isSieveSupported.mockReturnValue(false); sieveApi.isSieveSupported.mockReturnValue(false);
filterStore.isVacationIncludedInFilters.mockResolvedValue(false); filterStore.readVacationFilters.mockResolvedValue(NO_FILTERS);
useVacationStore.getState().reset(); useVacationStore.getState().reset();
}); });
@@ -96,12 +101,12 @@ describe('vacation-store account scoping', () => {
describe('vacation-store and the filters script (webmail 198a3c0d)', () => { describe('vacation-store and the filters script (webmail 198a3c0d)', () => {
it('reports the auto-reply as on while the filters script includes it', async () => { it('reports the auto-reply as on while the filters script includes it', async () => {
sieveApi.isSieveSupported.mockReturnValue(true); sieveApi.isSieveSupported.mockReturnValue(true);
filterStore.isVacationIncludedInFilters.mockResolvedValue(true); filterStore.readVacationFilters.mockResolvedValue({ ...NO_FILTERS, includesVacation: true });
api.getVacationResponse.mockResolvedValue(responder({ isEnabled: false, subject: 'Away' })); api.getVacationResponse.mockResolvedValue(responder({ isEnabled: false, subject: 'Away' }));
await useVacationStore.getState().fetch('team'); await useVacationStore.getState().fetch('team');
expect(filterStore.isVacationIncludedInFilters).toHaveBeenCalledWith('team'); expect(filterStore.readVacationFilters).toHaveBeenCalledWith('team');
expect(useVacationStore.getState()).toMatchObject({ isEnabled: true, subject: 'Away' }); expect(useVacationStore.getState()).toMatchObject({ isEnabled: true, subject: 'Away' });
}); });
@@ -111,7 +116,9 @@ describe('vacation-store and the filters script (webmail 198a3c0d)', () => {
await useVacationStore.getState().fetch(); await useVacationStore.getState().fetch();
await useVacationStore.getState().save({ isEnabled: true }); await useVacationStore.getState().save({ isEnabled: true });
expect(filterStore.syncVacationWithFilters).toHaveBeenCalledWith(true, undefined); expect(filterStore.syncVacationWithFilters).toHaveBeenCalledWith(
true, undefined, undefined, undefined, { from: null, until: null },
);
await useVacationStore.getState().save({ subject: 'Only the subject' }); await useVacationStore.getState().save({ subject: 'Only the subject' });
expect(filterStore.syncVacationWithFilters).toHaveBeenCalledTimes(1); expect(filterStore.syncVacationWithFilters).toHaveBeenCalledTimes(1);
@@ -127,6 +134,48 @@ describe('vacation-store and the filters script (webmail 198a3c0d)', () => {
warn.mockRestore(); warn.mockRestore();
}); });
// #1152: forwarding and who gets the reply live in the filters script.
it('loads the stored forwarding and audience', async () => {
sieveApi.isSieveSupported.mockReturnValue(true);
const forward = { enabled: true, to: 'backup@example.org', keepCopy: false };
const audience = { only: 'internal' as const, domains: ['example.org'] };
filterStore.readVacationFilters.mockResolvedValue({
...NO_FILTERS, forward, forwardAvailable: true, audience, audienceAvailable: true,
});
api.getVacationResponse.mockResolvedValue(responder());
await useVacationStore.getState().fetch();
expect(useVacationStore.getState()).toMatchObject({
forward, forwardAvailable: true, audience, audienceAvailable: true,
});
});
it('saves forwarding and audience in the vacation period, not to the responder', async () => {
sieveApi.isSieveSupported.mockReturnValue(true);
api.getVacationResponse.mockResolvedValue(responder({ fromDate: '2026-11-02T08:00:00Z' }));
await useVacationStore.getState().fetch();
const forward = { enabled: true, to: 'backup@example.org', keepCopy: true };
const audience = { only: 'external' as const, domains: ['example.org'] };
await useVacationStore.getState().save({ toDate: '2026-11-14T00:00:00Z', forward, audience });
expect(api.setVacationResponse).toHaveBeenCalledWith({ toDate: '2026-11-14T00:00:00Z' }, undefined);
expect(filterStore.syncVacationWithFilters).toHaveBeenCalledWith(
false, undefined, forward, audience, { from: '2026-11-02T08:00:00Z', until: '2026-11-14T00:00:00Z' },
);
});
it('reports a forwarding that could not be saved', async () => {
sieveApi.isSieveSupported.mockReturnValue(true);
filterStore.syncVacationWithFilters.mockRejectedValueOnce(new Error('boom'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
await expect(useVacationStore.getState().save({
forward: { enabled: true, to: 'backup@example.org', keepCopy: false },
})).rejects.toThrow('boom');
expect(useVacationStore.getState().isSaving).toBe(false);
warn.mockRestore();
});
it('leaves the filters alone on servers without Sieve', async () => { it('leaves the filters alone on servers without Sieve', async () => {
await useVacationStore.getState().save({ isEnabled: true }); await useVacationStore.getState().save({ isEnabled: true });
expect(filterStore.syncVacationWithFilters).not.toHaveBeenCalled(); expect(filterStore.syncVacationWithFilters).not.toHaveBeenCalled();
+117 -19
View File
@@ -6,7 +6,24 @@ import {
type VacationResponse, type VacationResponse,
} from '../api/vacation'; } from '../api/vacation';
import { isSieveSupported } from '../api/sieve'; import { isSieveSupported } from '../api/sieve';
import { isVacationIncludedInFilters, syncVacationWithFilters } from './filter-store'; import type { VacationAudience, VacationForward } from '../lib/sieve/types';
import { withVacationPeriod } from '../lib/sieve/vacation-forward';
import { readVacationFilters, syncVacationWithFilters, type VacationFilters } from './filter-store';
/** What the vacation card sets for forwarding; the period comes from the vacation. */
export type VacationForwardSettings = Pick<VacationForward, 'enabled' | 'to' | 'keepCopy'>;
/**
* The vacation response was saved, but what the filters script carries for
* it (forwarding, who gets the auto-reply) was not: forwarding may not run
* or still run, and the auto-reply may go to the wrong senders.
*/
export class VacationFiltersError extends Error {
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : 'Failed to save the forwarding');
this.name = 'VacationFiltersError';
}
}
export interface VacationState { export interface VacationState {
isEnabled: boolean; isEnabled: boolean;
@@ -15,6 +32,16 @@ export interface VacationState {
subject: string; subject: string;
textBody: string; textBody: string;
htmlBody: string | null; htmlBody: string | null;
/** Forwarding as stored in the filters script; null when none is set up. */
forward: VacationForward | null;
/** The account's filters script can carry forwarding (see readVacationFilters). */
forwardAvailable: boolean;
/** Who gets the auto-reply as stored; null when everyone does. */
audience: VacationAudience | null;
/** The auto-reply can be narrowed to some senders (see readVacationFilters). */
audienceAvailable: boolean;
/** Stored forwarding or recipients do not run until the card is saved (see readVacationFilters). */
notRunning: boolean;
isLoading: boolean; isLoading: boolean;
isSaving: boolean; isSaving: boolean;
error: string | null; error: string | null;
@@ -24,10 +51,27 @@ export interface VacationState {
accountId: string | null; accountId: string | null;
fetch: (accountId?: string) => Promise<void>; fetch: (accountId?: string) => Promise<void>;
save: (updates: Partial<Omit<VacationResponse, 'id'>>) => Promise<void>; /**
* Saves the out-of-office reply. Throws VacationFiltersError when only the
* filters script part (forwarding, recipients) could not be saved.
*/
save: (updates: Partial<Omit<VacationResponse, 'id'>> & {
/** null removes the forwarding, leaving it out keeps it as it is. */
forward?: VacationForwardSettings | null;
/** null lets everyone have the auto-reply, leaving it out keeps it as it is. */
audience?: VacationAudience | null;
}) => Promise<void>;
reset: () => void; reset: () => void;
} }
const NO_FILTERS = {
forward: null,
forwardAvailable: false,
audience: null,
audienceAvailable: false,
notRunning: false,
};
const INITIAL = { const INITIAL = {
accountId: null, accountId: null,
isEnabled: false, isEnabled: false,
@@ -36,6 +80,7 @@ const INITIAL = {
subject: '', subject: '',
textBody: '', textBody: '',
htmlBody: null, htmlBody: null,
...NO_FILTERS,
isLoading: false, isLoading: false,
isSaving: false, isSaving: false,
error: null, error: null,
@@ -43,6 +88,17 @@ const INITIAL = {
hasLoaded: false, hasLoaded: false,
}; };
/** The card's part of what the filters script holds. */
function fromFilters(filters: VacationFilters) {
return {
forward: filters.forward,
forwardAvailable: filters.forwardAvailable,
audience: filters.audience,
audienceAvailable: filters.audienceAvailable,
notRunning: filters.notRunning,
};
}
export const useVacationStore = create<VacationState>((set, get) => ({ export const useVacationStore = create<VacationState>((set, get) => ({
...INITIAL, ...INITIAL,
@@ -58,11 +114,13 @@ export const useVacationStore = create<VacationState>((set, get) => ({
const vacation = await getVacationResponse(accountId); const vacation = await getVacationResponse(accountId);
if (stale()) return; if (stale()) return;
let isEnabled = vacation.isEnabled; let isEnabled = vacation.isEnabled;
if (!isEnabled && isSieveSupported(accountId)) { let filters: VacationFilters | null = null;
if (isSieveSupported(accountId)) {
filters = await readVacationFilters(accountId).catch(() => null);
if (stale()) return;
// Running from the filters script leaves the vacation script itself // Running from the filters script leaves the vacation script itself
// inactive, so VacationResponse reports it as off. // inactive, so VacationResponse reports it as off.
isEnabled = await isVacationIncludedInFilters(accountId).catch(() => false); if (filters) isEnabled = isEnabled || filters.includesVacation;
if (stale()) return;
} }
set({ set({
isEnabled, isEnabled,
@@ -71,6 +129,8 @@ export const useVacationStore = create<VacationState>((set, get) => ({
subject: vacation.subject ?? '', subject: vacation.subject ?? '',
textBody: vacation.textBody ?? '', textBody: vacation.textBody ?? '',
htmlBody: vacation.htmlBody, htmlBody: vacation.htmlBody,
...NO_FILTERS,
...(filters ? fromFilters(filters) : {}),
isLoading: false, isLoading: false,
hasLoaded: true, hasLoaded: true,
}); });
@@ -85,22 +145,13 @@ export const useVacationStore = create<VacationState>((set, get) => ({
}, },
save: async (updates) => { save: async (updates) => {
const { forward: forwardSettings, audience, ...vacationUpdates } = updates;
const accountId = get().accountId ?? undefined;
// What the save leaves in the store is dropped once another account took over.
const superseded = () => (get().accountId ?? undefined) !== accountId;
set({ isSaving: true, error: null }); set({ isSaving: true, error: null });
try { try {
const accountId = get().accountId ?? undefined; await setVacationResponse(vacationUpdates, accountId);
await setVacationResponse(updates, accountId);
if (updates.isEnabled !== undefined && isSieveSupported(accountId)) {
try {
await syncVacationWithFilters(updates.isEnabled, accountId);
} catch (err) {
console.warn('[vacation] Failed to keep filters active next to the vacation response:', err);
}
}
set((s) => ({
...s,
...updates,
isSaving: false,
}));
} catch (err) { } catch (err) {
set({ set({
isSaving: false, isSaving: false,
@@ -108,6 +159,53 @@ export const useVacationStore = create<VacationState>((set, get) => ({
}); });
throw err; throw err;
} }
// Forwarding runs in the vacation's period, so it takes the dates along.
const state = get();
const fromDate = vacationUpdates.fromDate !== undefined ? vacationUpdates.fromDate : state.fromDate;
const toDate = vacationUpdates.toDate !== undefined ? vacationUpdates.toDate : state.toDate;
const period = { from: fromDate, until: toDate };
let filtersError: VacationFiltersError | null = null;
let filtersState: Partial<VacationState> = {};
const needsSync = vacationUpdates.isEnabled !== undefined || forwardSettings !== undefined || audience !== undefined;
if (needsSync && isSieveSupported(accountId)) {
let synced = false;
try {
await syncVacationWithFilters(
vacationUpdates.isEnabled ?? state.isEnabled, accountId, forwardSettings, audience, period,
);
synced = true;
} catch (err) {
console.warn('[vacation] Failed to keep filters active next to the vacation response:', err);
// Forwarding that is or was on may now not run, or still run; an
// auto-reply meant for some senders may go to everyone, or the other
// way round.
if (forwardSettings?.enabled || state.forward?.enabled || audience || state.audience) {
filtersError = new VacationFiltersError(err);
}
}
// What the filters script holds now, and whether it runs. After a
// failed save the card keeps what the user set, to save it again.
const filters = await readVacationFilters(accountId).catch(() => null);
if (filters) {
filtersState = synced ? fromFilters(filters) : { notRunning: filters.notRunning };
} else if (synced) {
filtersState = {
...(forwardSettings !== undefined
? { forward: forwardSettings && withVacationPeriod(forwardSettings, period) }
: {}),
...(audience !== undefined ? { audience } : {}),
notRunning: false,
};
}
}
set((s) => (superseded()
? { ...s, isSaving: false }
: { ...s, ...vacationUpdates, ...filtersState, isSaving: false }));
// The reply itself is saved even when its filters part failed.
if (filtersError) throw filtersError;
}, },
reset: () => set({ ...INITIAL }), reset: () => set({ ...INITIAL }),