ooo containing html

This commit is contained in:
2026-04-27 16:35:22 -05:00
parent b03c257de1
commit 31b3fd8c9f
4 changed files with 113 additions and 20 deletions

View File

@@ -135,8 +135,21 @@ mailboxesRouter.get('/:email/rules', async (req, res) => {
mailboxesRouter.put('/:email/rules', async (req, res) => { mailboxesRouter.put('/:email/rules', async (req, res) => {
const email = normalizeEmail(req.params.email); const email = normalizeEmail(req.params.email);
ensureDomain(req, domainFromEmail(email)); ensureDomain(req, domainFromEmail(email));
const body = z.object({ ooo_active: z.boolean().optional(), ooo_message: z.string().optional(), forwards: z.array(z.string().email()).optional() }).parse(req.body); const body = z.object({
const saved = await dynamo.putRules({ email_address: email, ooo_active: body.ooo_active, ooo_message: body.ooo_message, forwards: body.forwards }); ooo_active: z.boolean().optional(),
ooo_message: z.string().optional(),
// 'text' or 'html'. Stored as-is in DynamoDB so the email worker can
// pick the correct Content-Type when sending the auto-reply.
ooo_content_type: z.enum(['text', 'html']).optional(),
forwards: z.array(z.string().email()).optional(),
}).parse(req.body);
const saved = await dynamo.putRules({
email_address: email,
ooo_active: body.ooo_active,
ooo_message: body.ooo_message,
ooo_content_type: body.ooo_content_type,
forwards: body.forwards,
});
await audit(req.user!.email, 'mailbox.rules_update', 'mailbox', email, saved, req.ip); await audit(req.user!.email, 'mailbox.rules_update', 'mailbox', email, saved, req.ip);
res.json(saved); res.json(saved);
}); });

View File

@@ -7,7 +7,9 @@ export interface EmailRule {
email_address: string; email_address: string;
ooo_active?: boolean; ooo_active?: boolean;
ooo_message?: string; ooo_message?: string;
ooo_content_type?: string; // 'text' or 'html' — kept consistent with the config-email app that
// shares the same DynamoDB table so both apps interpret it the same way.
ooo_content_type?: 'text' | 'html';
forwards?: string[]; forwards?: string[];
} }
@@ -16,6 +18,13 @@ export interface BlockList {
blocked_patterns: string[]; blocked_patterns: string[];
} }
// Tolerate legacy values that may already exist in DynamoDB.
function normalizeContentType(v: unknown): 'text' | 'html' {
const s = String(v ?? '').toLowerCase();
if (s === 'html' || s === 'text/html') return 'html';
return 'text';
}
export class DynamoRulesService { export class DynamoRulesService {
private doc = DynamoDBDocumentClient.from(new DynamoDBClient({ region: config.awsRegion }), { private doc = DynamoDBDocumentClient.from(new DynamoDBClient({ region: config.awsRegion }), {
marshallOptions: { removeUndefinedValues: true }, marshallOptions: { removeUndefinedValues: true },
@@ -24,7 +33,14 @@ export class DynamoRulesService {
async getRules(email: string): Promise<EmailRule> { async getRules(email: string): Promise<EmailRule> {
const email_address = normalizeEmail(email); const email_address = normalizeEmail(email);
const resp = await this.doc.send(new GetCommand({ TableName: config.rulesTable, Key: { email_address } })); const resp = await this.doc.send(new GetCommand({ TableName: config.rulesTable, Key: { email_address } }));
return (resp.Item as EmailRule) ?? { email_address, ooo_active: false, ooo_message: '', ooo_content_type: 'text/plain', forwards: [] }; const item = resp.Item as EmailRule | undefined;
if (!item) {
return { email_address, ooo_active: false, ooo_message: '', ooo_content_type: 'text', forwards: [] };
}
return {
...item,
ooo_content_type: normalizeContentType(item.ooo_content_type),
};
} }
async putRules(rule: EmailRule): Promise<EmailRule> { async putRules(rule: EmailRule): Promise<EmailRule> {
@@ -32,7 +48,7 @@ export class DynamoRulesService {
email_address: normalizeEmail(rule.email_address), email_address: normalizeEmail(rule.email_address),
ooo_active: !!rule.ooo_active, ooo_active: !!rule.ooo_active,
ooo_message: rule.ooo_message ?? '', ooo_message: rule.ooo_message ?? '',
ooo_content_type: rule.ooo_content_type ?? 'text/plain', ooo_content_type: normalizeContentType(rule.ooo_content_type),
forwards: (rule.forwards ?? []).map(normalizeEmail).filter(Boolean), forwards: (rule.forwards ?? []).map(normalizeEmail).filter(Boolean),
}; };
await this.doc.send(new PutCommand({ TableName: config.rulesTable, Item: item })); await this.doc.send(new PutCommand({ TableName: config.rulesTable, Item: item }));

View File

@@ -13,6 +13,14 @@ const TABS = [
{ id: 'block', label: 'Blocklist', icon: FiSlash }, { id: 'block', label: 'Blocklist', icon: FiSlash },
]; ];
const emptyRule = (email) => ({
email_address: email,
ooo_active: false,
ooo_message: '',
ooo_content_type: 'text',
forwards: [],
});
const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToast }) => { const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToast }) => {
const [activeTab, setActiveTab] = useState(initialTab); const [activeTab, setActiveTab] = useState(initialTab);
const [rule, setRule] = useState(null); const [rule, setRule] = useState(null);
@@ -29,15 +37,13 @@ const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToas
setLoading(true); setLoading(true);
try { try {
const [r, b] = await Promise.all([ const [r, b] = await Promise.all([
mailboxesAPI.getRules(email).catch(() => ({ mailboxesAPI.getRules(email).catch(() => emptyRule(email)),
email_address: email, ooo_active: false, ooo_message: '', forwards: [],
})),
mailboxesAPI.getBlocklist(email).catch(() => ({ mailboxesAPI.getBlocklist(email).catch(() => ({
email_address: email, blocked_patterns: [], email_address: email, blocked_patterns: [],
})), })),
]); ]);
if (cancelled) return; if (cancelled) return;
setRule(r || { email_address: email, ooo_active: false, ooo_message: '', forwards: [] }); setRule(r ? { ...emptyRule(email), ...r } : emptyRule(email));
setBlocklist(b || { email_address: email, blocked_patterns: [] }); setBlocklist(b || { email_address: email, blocked_patterns: [] });
} catch (err) { } catch (err) {
if (!cancelled) onToast?.(`Failed to load settings: ${err.message}`, 'error'); if (!cancelled) onToast?.(`Failed to load settings: ${err.message}`, 'error');
@@ -50,10 +56,12 @@ const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToas
// Merge updates with existing rule and persist. // Merge updates with existing rule and persist.
const saveRule = async (updates) => { const saveRule = async (updates) => {
const base = rule || emptyRule(email);
const merged = { const merged = {
ooo_active: rule?.ooo_active ?? false, ooo_active: base.ooo_active ?? false,
ooo_message: rule?.ooo_message ?? '', ooo_message: base.ooo_message ?? '',
forwards: rule?.forwards ?? [], ooo_content_type: base.ooo_content_type ?? 'text',
forwards: base.forwards ?? [],
...updates, ...updates,
}; };
try { try {

View File

@@ -1,15 +1,22 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FiCalendar } from 'react-icons/fi'; import { FiCalendar, FiFileText } from 'react-icons/fi';
const OutOfOffice = ({ rule, onSave }) => { const OutOfOffice = ({ rule, onSave }) => {
const [isActive, setIsActive] = useState(rule?.ooo_active || false); const [isActive, setIsActive] = useState(rule?.ooo_active || false);
const [message, setMessage] = useState(rule?.ooo_message || ''); const [message, setMessage] = useState(rule?.ooo_message || '');
const [contentType, setContentType] = useState(
rule?.ooo_content_type === 'html' ? 'html' : 'text'
);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
try { try {
await onSave({ ooo_active: isActive, ooo_message: message }); await onSave({
ooo_active: isActive,
ooo_message: message,
ooo_content_type: contentType,
});
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -17,6 +24,7 @@ const OutOfOffice = ({ rule, onSave }) => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Toggle Active/Inactive */}
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200"> <div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FiCalendar className="w-5 h-5 text-gray-600" /> <FiCalendar className="w-5 h-5 text-gray-600" />
@@ -43,6 +51,40 @@ const OutOfOffice = ({ rule, onSave }) => {
</button> </button>
</div> </div>
{/* Content Type Selector */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Message Format
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setContentType('text')}
className={`flex-1 px-4 py-2 rounded-lg border-2 transition-all ${
contentType === 'text'
? 'border-primary-600 bg-primary-50 text-primary-700 font-semibold'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
<FiFileText className="inline w-4 h-4 mr-2" />
Plain Text
</button>
<button
type="button"
onClick={() => setContentType('html')}
className={`flex-1 px-4 py-2 rounded-lg border-2 transition-all ${
contentType === 'html'
? 'border-primary-600 bg-primary-50 text-primary-700 font-semibold'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
<span className="font-mono text-sm mr-2">&lt;/&gt;</span>
HTML
</button>
</div>
</div>
{/* Message Editor */}
<div> <div>
<label htmlFor="ooo-message" className="block text-sm font-semibold text-gray-700 mb-2"> <label htmlFor="ooo-message" className="block text-sm font-semibold text-gray-700 mb-2">
Auto-Reply Message Auto-Reply Message
@@ -52,22 +94,36 @@ const OutOfOffice = ({ rule, onSave }) => {
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
rows={8} rows={8}
placeholder="I am currently out of office until [date].&#10;&#10;Best regards,&#10;Your Name" placeholder={
contentType === 'html'
? '<p>I am currently out of office until [date].</p>\n<p>Best regards,<br>Your Name</p>'
: 'I am currently out of office until [date].\n\nBest regards,\nYour Name'
}
className="input-field font-mono text-sm resize-none" className="input-field font-mono text-sm resize-none"
disabled={!isActive} disabled={!isActive}
/> />
<p className="mt-2 text-xs text-gray-500"> <p className="mt-2 text-xs text-gray-500">
Plain text message that gets returned automatically to senders. {contentType === 'html'
? 'You can use HTML tags for formatting. The reply will be sent as text/html.'
: 'Plain text message that gets returned automatically to senders.'}
</p> </p>
</div> </div>
{/* Message Preview */}
{isActive && message && ( {isActive && message && (
<div> <div>
<label className="block text-sm font-semibold text-gray-700 mb-2"> <label className="block text-sm font-semibold text-gray-700 mb-2">
Message Preview Message Preview
</label> </label>
<div className="p-4 bg-gray-50 border border-gray-200 rounded-lg"> <div className="p-4 bg-gray-50 border border-gray-200 rounded-lg">
{contentType === 'html' ? (
<div
className="prose prose-sm max-w-none text-sm text-gray-800"
dangerouslySetInnerHTML={{ __html: message }}
/>
) : (
<pre className="text-sm text-gray-800 whitespace-pre-wrap font-sans">{message}</pre> <pre className="text-sm text-gray-800 whitespace-pre-wrap font-sans">{message}</pre>
)}
</div> </div>
</div> </div>
)} )}