feat: implement vacation responder functionality with UI integration and localization
This commit is contained in:
@@ -130,6 +130,19 @@ export default function SettingsPage() {
|
|||||||
});
|
});
|
||||||
}, [checkAuth]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useEmailStore } from "@/stores/email-store";
|
|||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import type { FilterRule } from "@/lib/jmap/sieve-types";
|
import type { FilterRule } from "@/lib/jmap/sieve-types";
|
||||||
|
import { useVacationStore } from "@/stores/vacation-store";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Filter,
|
Filter,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
PalmtreeIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
function RuleSummary({ rule }: { rule: FilterRule }) {
|
function RuleSummary({ rule }: { rule: FilterRule }) {
|
||||||
@@ -137,6 +139,7 @@ export function FilterSettings() {
|
|||||||
isSupported,
|
isSupported,
|
||||||
isOpaque,
|
isOpaque,
|
||||||
rawScript,
|
rawScript,
|
||||||
|
vacationSettings,
|
||||||
fetchFilters,
|
fetchFilters,
|
||||||
saveFilters,
|
saveFilters,
|
||||||
addRule,
|
addRule,
|
||||||
@@ -149,6 +152,8 @@ export function FilterSettings() {
|
|||||||
validateScript,
|
validateScript,
|
||||||
} = useFilterStore();
|
} = useFilterStore();
|
||||||
|
|
||||||
|
const vacationEnabled = useVacationStore((s) => s.isEnabled) || vacationSettings?.isEnabled;
|
||||||
|
|
||||||
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
|
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
|
||||||
const [showRuleModal, setShowRuleModal] = useState(false);
|
const [showRuleModal, setShowRuleModal] = useState(false);
|
||||||
const [showSieveEditor, setShowSieveEditor] = useState(false);
|
const [showSieveEditor, setShowSieveEditor] = useState(false);
|
||||||
@@ -389,7 +394,33 @@ export function FilterSettings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOpaque && rules.length === 0 && (
|
{!isOpaque && vacationEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'vacation'); } catch { /* ignore */ }
|
||||||
|
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: 'vacation' }));
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 w-full p-3 rounded-md border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/40">
|
||||||
|
<PalmtreeIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-green-700 dark:text-green-400">
|
||||||
|
{t("vacation_active")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-green-600/70 dark:text-green-400/70">
|
||||||
|
{t("vacation_active_description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||||
|
{t("vacation_configure")} →
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isOpaque && rules.length === 0 && !vacationEnabled && (
|
||||||
<div className="flex flex-col items-center py-8 text-muted-foreground">
|
<div className="flex flex-col items-center py-8 text-muted-foreground">
|
||||||
<Filter className="w-10 h-10 mb-3 opacity-40" />
|
<Filter className="w-10 h-10 mb-3 opacity-40" />
|
||||||
<p className="text-sm">{t("no_rules")}</p>
|
<p className="text-sm">{t("no_rules")}</p>
|
||||||
|
|||||||
+45
-1
@@ -40,9 +40,53 @@ function isValidRule(rule: unknown): rule is FilterRule {
|
|||||||
return r.conditions.every(isValidCondition) && r.actions.every(isValidAction);
|
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 {
|
export function parseScript(content: string): ParseResult {
|
||||||
const beginIdx = content.indexOf(METADATA_BEGIN);
|
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);
|
const endIdx = content.indexOf(METADATA_END, beginIdx);
|
||||||
if (endIdx === -1) return OPAQUE;
|
if (endIdx === -1) return OPAQUE;
|
||||||
|
|||||||
@@ -1187,6 +1187,9 @@
|
|||||||
"add_rule": "Add Rule",
|
"add_rule": "Add Rule",
|
||||||
"no_rules": "No filter rules",
|
"no_rules": "No filter rules",
|
||||||
"no_rules_description": "Create rules to automatically organize your incoming emails",
|
"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",
|
"edit_rule": "Edit Rule",
|
||||||
"new_rule": "New Rule",
|
"new_rule": "New Rule",
|
||||||
"delete_rule": "Delete Rule",
|
"delete_rule": "Delete Rule",
|
||||||
|
|||||||
Reference in New Issue
Block a user