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) => {
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 }));
|
||||||
@@ -57,4 +73,4 @@ export class DynamoRulesService {
|
|||||||
await this.doc.send(new PutCommand({ TableName: config.blockedTable, Item: item }));
|
await this.doc.send(new PutCommand({ TableName: config.blockedTable, Item: item }));
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
@@ -127,4 +135,4 @@ const MailboxSettingsModal = ({ open, email, initialTab = 'fwd', onClose, onToas
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MailboxSettingsModal;
|
export default MailboxSettingsModal;
|
||||||
@@ -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"></></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]. 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"
|
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">
|
||||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap font-sans">{message}</pre>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -85,4 +141,4 @@ const OutOfOffice = ({ rule, onSave }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OutOfOffice;
|
export default OutOfOffice;
|
||||||
Reference in New Issue
Block a user