diff --git a/backend/src/routes/mailboxes.ts b/backend/src/routes/mailboxes.ts index 4025a88..612756a 100644 --- a/backend/src/routes/mailboxes.ts +++ b/backend/src/routes/mailboxes.ts @@ -135,8 +135,21 @@ mailboxesRouter.get('/:email/rules', async (req, res) => { mailboxesRouter.put('/:email/rules', async (req, res) => { const email = normalizeEmail(req.params.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 saved = await dynamo.putRules({ email_address: email, ooo_active: body.ooo_active, ooo_message: body.ooo_message, forwards: body.forwards }); + const body = z.object({ + 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); res.json(saved); }); diff --git a/backend/src/services/dynamodb.ts b/backend/src/services/dynamodb.ts index 69ec7f2..ef47609 100644 --- a/backend/src/services/dynamodb.ts +++ b/backend/src/services/dynamodb.ts @@ -7,7 +7,9 @@ export interface EmailRule { email_address: string; ooo_active?: boolean; 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[]; } @@ -16,6 +18,13 @@ export interface BlockList { 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 { private doc = DynamoDBDocumentClient.from(new DynamoDBClient({ region: config.awsRegion }), { marshallOptions: { removeUndefinedValues: true }, @@ -24,7 +33,14 @@ export class DynamoRulesService { async getRules(email: string): Promise { const email_address = normalizeEmail(email); 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 { @@ -32,7 +48,7 @@ export class DynamoRulesService { email_address: normalizeEmail(rule.email_address), ooo_active: !!rule.ooo_active, 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), }; await this.doc.send(new PutCommand({ TableName: config.rulesTable, Item: item })); @@ -57,4 +73,4 @@ export class DynamoRulesService { await this.doc.send(new PutCommand({ TableName: config.blockedTable, Item: item })); return item; } -} +} \ No newline at end of file diff --git a/frontend/src/components/MailboxSettingsModal.jsx b/frontend/src/components/MailboxSettingsModal.jsx index 24cf3b9..c93f9d2 100644 --- a/frontend/src/components/MailboxSettingsModal.jsx +++ b/frontend/src/components/MailboxSettingsModal.jsx @@ -13,6 +13,14 @@ const TABS = [ { 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 [activeTab, setActiveTab] = useState(initialTab); const [rule, setRule] = useState(null); @@ -29,15 +37,13 @@ const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToas setLoading(true); try { const [r, b] = await Promise.all([ - mailboxesAPI.getRules(email).catch(() => ({ - email_address: email, ooo_active: false, ooo_message: '', forwards: [], - })), + mailboxesAPI.getRules(email).catch(() => emptyRule(email)), mailboxesAPI.getBlocklist(email).catch(() => ({ email_address: email, blocked_patterns: [], })), ]); 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: [] }); } catch (err) { 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. const saveRule = async (updates) => { + const base = rule || emptyRule(email); const merged = { - ooo_active: rule?.ooo_active ?? false, - ooo_message: rule?.ooo_message ?? '', - forwards: rule?.forwards ?? [], + ooo_active: base.ooo_active ?? false, + ooo_message: base.ooo_message ?? '', + ooo_content_type: base.ooo_content_type ?? 'text', + forwards: base.forwards ?? [], ...updates, }; try { @@ -127,4 +135,4 @@ const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToas ); }; -export default MailboxSettingsModal; +export default MailboxSettingsModal; \ No newline at end of file diff --git a/frontend/src/components/OutOfOffice.jsx b/frontend/src/components/OutOfOffice.jsx index 4e3ac58..781de6f 100644 --- a/frontend/src/components/OutOfOffice.jsx +++ b/frontend/src/components/OutOfOffice.jsx @@ -1,15 +1,22 @@ import React, { useState } from 'react'; -import { FiCalendar } from 'react-icons/fi'; +import { FiCalendar, FiFileText } from 'react-icons/fi'; const OutOfOffice = ({ rule, onSave }) => { const [isActive, setIsActive] = useState(rule?.ooo_active || false); const [message, setMessage] = useState(rule?.ooo_message || ''); + const [contentType, setContentType] = useState( + rule?.ooo_content_type === 'html' ? 'html' : 'text' + ); const [isSaving, setIsSaving] = useState(false); const handleSave = async () => { setIsSaving(true); try { - await onSave({ ooo_active: isActive, ooo_message: message }); + await onSave({ + ooo_active: isActive, + ooo_message: message, + ooo_content_type: contentType, + }); } finally { setIsSaving(false); } @@ -17,6 +24,7 @@ const OutOfOffice = ({ rule, onSave }) => { return (
+ {/* Toggle Active/Inactive */}
@@ -43,6 +51,40 @@ const OutOfOffice = ({ rule, onSave }) => {
+ {/* Content Type Selector */} +
+ +
+ + +
+
+ + {/* Message Editor */}
+ {/* Message Preview */} {isActive && message && (
-
{message}
+ {contentType === 'html' ? ( +
+ ) : ( +
{message}
+ )}
)} @@ -85,4 +141,4 @@ const OutOfOffice = ({ rule, onSave }) => { ); }; -export default OutOfOffice; +export default OutOfOffice; \ No newline at end of file