feat: support HTML body in vacation responder

This commit is contained in:
Linus Rath
2026-07-22 17:56:05 +02:00
parent 5a8c69dac2
commit b354319b82
24 changed files with 177 additions and 48 deletions
+85 -25
View File
@@ -4,9 +4,12 @@ import { useState, useEffect, useCallback } from 'react';
import { useTranslations } from 'next-intl';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { RichTextEditor } from '@/components/email/rich-text-editor';
import { useVacationStore } from '@/stores/vacation-store';
import { useAuthStore } from '@/stores/auth-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { sanitizeEmailHtml } from '@/lib/email-sanitization';
import { htmlToPlainText } from '@/lib/html-to-text';
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { toast } from '@/stores/toast-store';
@@ -27,6 +30,7 @@ export function VacationSettings() {
toDate,
subject,
textBody,
htmlBody,
isLoading,
isSaving,
error,
@@ -40,6 +44,8 @@ export function VacationSettings() {
const [localToDate, setLocalToDate] = useState(toDate || '');
const [localSubject, setLocalSubject] = useState(subject);
const [localTextBody, setLocalTextBody] = useState(textBody);
const [htmlEnabled, setHtmlEnabled] = useState(!!htmlBody);
const [localHtmlBody, setLocalHtmlBody] = useState(htmlBody || '');
const [showPreview, setShowPreview] = useState(false);
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
@@ -55,7 +61,9 @@ export function VacationSettings() {
setLocalToDate(toDate || '');
setLocalSubject(subject);
setLocalTextBody(textBody);
}, [isEnabled, fromDate, toDate, subject, textBody]);
setHtmlEnabled(!!htmlBody);
setLocalHtmlBody(htmlBody || '');
}, [isEnabled, fromDate, toDate, subject, textBody, htmlBody]);
const validate = useCallback(() => {
const warnings: string[] = [];
@@ -70,13 +78,14 @@ export function VacationSettings() {
warnings.push(t('warnings.start_in_past'));
}
if (localEnabled && !localTextBody.trim()) {
const hasHtmlContent = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
if (localEnabled && !localTextBody.trim() && !hasHtmlContent) {
warnings.push(t('warnings.empty_body'));
}
setValidationWarnings(warnings);
return warnings;
}, [localFromDate, localToDate, localEnabled, localTextBody, t]);
}, [localFromDate, localToDate, localEnabled, localTextBody, htmlEnabled, localHtmlBody, t]);
useEffect(() => {
validate();
@@ -87,7 +96,8 @@ export function VacationSettings() {
(localFromDate || null) !== (fromDate || null) ||
(localToDate || null) !== (toDate || null) ||
localSubject !== subject ||
localTextBody !== textBody;
localTextBody !== textBody ||
(htmlEnabled ? localHtmlBody : '') !== (htmlBody || '');
const hasBlockingError = !!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate));
@@ -96,13 +106,25 @@ export function VacationSettings() {
validate();
if (hasBlockingError) return;
const sanitizedHtml =
htmlEnabled && htmlToPlainText(localHtmlBody).trim()
? sanitizeEmailHtml(localHtmlBody)
: null;
// Keep a plain-text part as the fallback for clients that don't render
// HTML. If the user left it blank, derive it from the HTML body.
const textBody =
localTextBody.trim() || !sanitizedHtml
? localTextBody
: htmlToPlainText(sanitizedHtml, { paragraphSpacing: true });
try {
await updateVacationResponse(client, {
isEnabled: localEnabled,
fromDate: localFromDate || null,
toDate: localToDate || null,
subject: localSubject,
textBody: localTextBody,
textBody,
htmlBody: sanitizedHtml,
}, managedAccountId ?? undefined);
toast.success(tNotifications('vacation_saved'));
@@ -217,28 +239,66 @@ export function VacationSettings() {
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground resize-y"
/>
</div>
<SettingItem
label={t('message.html_label')}
description={t('message.html_description')}
>
<ToggleSwitch checked={htmlEnabled} onChange={setHtmlEnabled} />
</SettingItem>
{htmlEnabled && (
<div className="pb-3">
<div className="rounded-md border border-border overflow-hidden">
<RichTextEditor
content={localHtmlBody}
onChange={setLocalHtmlBody}
placeholder={t('message.html_placeholder')}
/>
</div>
</div>
)}
</SettingsSection>
{localTextBody.trim() && (
<SettingsSection title={t('preview.title')}>
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showPreview ? t('preview.hide') : t('preview.show')}
</button>
{showPreview && (
<div className="mt-3 p-4 rounded border border-border bg-background">
{localSubject && (
<p className="font-medium text-foreground mb-2">{localSubject}</p>
)}
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
</div>
)}
</SettingsSection>
)}
{(() => {
const showHtmlPreview = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
if (!localTextBody.trim() && !showHtmlPreview) return null;
return (
<SettingsSection title={t('preview.title')}>
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="flex items-center gap-2 text-sm text-primary hover:underline"
>
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showPreview ? t('preview.hide') : t('preview.show')}
</button>
{showPreview && (
<div className="mt-3 p-4 rounded border border-border bg-background">
{localSubject && (
<p className="font-medium text-foreground mb-2">{localSubject}</p>
)}
{showHtmlPreview ? (
<div
className="text-sm text-foreground [&_a]:text-primary [&_a]:underline"
// Preview renders into the app's own DOM. Intercept anchor
// clicks so following a link doesn't navigate the whole app
// away (and lose the unsaved responder), opening a new tab.
onClick={(e) => {
const anchor = (e.target as HTMLElement).closest('a');
if (anchor?.href) {
e.preventDefault();
window.open(anchor.href, '_blank', 'noopener,noreferrer');
}
}}
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(localHtmlBody) }}
/>
) : (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{localTextBody}</p>
)}
</div>
)}
</SettingsSection>
);
})()}
{validationWarnings.length > 0 && (
<div className="space-y-2">