ooo containing html
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<EmailRule> {
|
||||
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<EmailRule> {
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<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 gap-3">
|
||||
<FiCalendar className="w-5 h-5 text-gray-600" />
|
||||
@@ -43,6 +51,40 @@ const OutOfOffice = ({ rule, onSave }) => {
|
||||
</button>
|
||||
</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"></></span>
|
||||
HTML
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Editor */}
|
||||
<div>
|
||||
<label htmlFor="ooo-message" className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Auto-Reply Message
|
||||
@@ -52,22 +94,36 @@ const OutOfOffice = ({ rule, onSave }) => {
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={8}
|
||||
placeholder="I am currently out of office until [date]. Best regards, 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"
|
||||
disabled={!isActive}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Message Preview */}
|
||||
{isActive && message && (
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Message Preview
|
||||
</label>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user