From 42734f16c357326a6474cc66d94e7596e8a00cf8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:32:02 +0100 Subject: [PATCH] feat: implement vacation responder functionality with UI integration and localization --- app/[locale]/settings/page.tsx | 13 +++++++ components/settings/filter-settings.tsx | 33 +++++++++++++++++- lib/sieve/parser.ts | 46 ++++++++++++++++++++++++- locales/en/common.json | 3 ++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 28aaee0c..0d15900a 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -130,6 +130,19 @@ export default function SettingsPage() { }); }, [checkAuth]); + // Listen for tab change events from child components + useEffect(() => { + const handler = (e: Event) => { + const tab = (e as CustomEvent).detail as Tab; + if (tab) { + setActiveTab(tab); + try { localStorage.setItem('settings-active-tab', tab); } catch { /* ignore */ } + } + }; + window.addEventListener('settings-tab-change', handler); + return () => window.removeEventListener('settings-tab-change', handler); + }, []); + useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } diff --git a/components/settings/filter-settings.tsx b/components/settings/filter-settings.tsx index a7101d55..ed28bc97 100644 --- a/components/settings/filter-settings.tsx +++ b/components/settings/filter-settings.tsx @@ -12,6 +12,7 @@ import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; import type { FilterRule } from "@/lib/jmap/sieve-types"; +import { useVacationStore } from "@/stores/vacation-store"; import { Plus, GripVertical, @@ -21,6 +22,7 @@ import { Loader2, Filter, RotateCcw, + PalmtreeIcon, } from "lucide-react"; function RuleSummary({ rule }: { rule: FilterRule }) { @@ -137,6 +139,7 @@ export function FilterSettings() { isSupported, isOpaque, rawScript, + vacationSettings, fetchFilters, saveFilters, addRule, @@ -149,6 +152,8 @@ export function FilterSettings() { validateScript, } = useFilterStore(); + const vacationEnabled = useVacationStore((s) => s.isEnabled) || vacationSettings?.isEnabled; + const [editingRule, setEditingRule] = useState(); const [showRuleModal, setShowRuleModal] = useState(false); const [showSieveEditor, setShowSieveEditor] = useState(false); @@ -389,7 +394,33 @@ export function FilterSettings() { )} - {!isOpaque && rules.length === 0 && ( + {!isOpaque && vacationEnabled && ( + + )} + + {!isOpaque && rules.length === 0 && !vacationEnabled && (

{t("no_rules")}

diff --git a/lib/sieve/parser.ts b/lib/sieve/parser.ts index 9f153a82..4807dd3b 100644 --- a/lib/sieve/parser.ts +++ b/lib/sieve/parser.ts @@ -40,9 +40,53 @@ function isValidRule(rule: unknown): rule is FilterRule { return r.conditions.every(isValidCondition) && r.actions.every(isValidAction); } +/** + * Detect Stalwart-generated vacation-only scripts (no metadata). + * These contain `vacation` command but no other filter logic we need to preserve. + */ +function detectVacationOnlyScript(content: string): ParseResult | null { + // Must contain a vacation command + if (!/\bvacation\b/.test(content)) return null; + + // Strip requires, comments, and whitespace to see if only vacation remains + const stripped = content + .replace(/^\s*require\s+\[[^\]]*\]\s*;/gm, '') + .replace(/#[^\n]*/g, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + .trim(); + + // After stripping, the only meaningful statement should be a vacation command. + // Check there are no if/elsif/else blocks (i.e., no filter rules). + if (/\b(?:if|elsif|else)\b/.test(stripped)) return null; + + // Extract subject if present + const subjectMatch = stripped.match(/:subject\s+"((?:[^"\\]|\\.)*)"/); + const subject = subjectMatch ? subjectMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : ''; + + // Extract the body text (last quoted string in the vacation command) + // Stalwart uses MIME format; extract the plain text after the MIME headers + const mimeBodyMatch = stripped.match(/Content-Transfer-Encoding:[^\n]*\n\n([\s\S]*?)"\s*;\s*$/); + const simpleBodyMatch = stripped.match(/vacation[^;]*"((?:[^"\\]|\\.)*)"\s*;\s*$/); + let textBody = ''; + if (mimeBodyMatch) { + textBody = mimeBodyMatch[1].trim(); + } else if (simpleBodyMatch) { + textBody = simpleBodyMatch[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } + + return { + rules: [], + isOpaque: false, + vacation: { isEnabled: true, subject, textBody }, + }; +} + export function parseScript(content: string): ParseResult { const beginIdx = content.indexOf(METADATA_BEGIN); - if (beginIdx === -1) return OPAQUE; + if (beginIdx === -1) { + // No metadata — check if it's a Stalwart vacation-only script + return detectVacationOnlyScript(content) || OPAQUE; + } const endIdx = content.indexOf(METADATA_END, beginIdx); if (endIdx === -1) return OPAQUE; diff --git a/locales/en/common.json b/locales/en/common.json index 65fe3b3b..c2feb1f6 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1187,6 +1187,9 @@ "add_rule": "Add Rule", "no_rules": "No filter rules", "no_rules_description": "Create rules to automatically organize your incoming emails", + "vacation_active": "Vacation Responder is active", + "vacation_active_description": "Auto-reply is enabled for incoming messages", + "vacation_configure": "Configure", "edit_rule": "Edit Rule", "new_rule": "New Rule", "delete_rule": "Delete Rule",