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