diff --git a/backend/migrations/core/154_mail_accounts_and_bodies.js b/backend/migrations/core/154_mail_accounts_and_bodies.js new file mode 100644 index 00000000..d92e78e0 --- /dev/null +++ b/backend/migrations/core/154_mail_accounts_and_bodies.js @@ -0,0 +1,52 @@ +/** + * Messages Phase 2 — additional inbound mailboxes + captured message bodies. + * + * `mail_accounts` holds inbound mailboxes BEYOND the primary accounting IMAP + * that already lives in `email_configs` (e.g. the customer `hello@` mailbox). + * The intake poller (emailIntakeService) polls the accounting mailbox AND every + * enabled row here; customer mail is logged with its body but not routed to the + * accounting inbox. + * + * The new `received_emails` columns capture the parsed message so the Messages + * reading pane can show it: `account_key` tags which mailbox it came from, + * `body_html`/`body_text` hold the (server-sanitized) body, `to_address` the + * envelope recipient. All additive + guarded. + */ +exports.up = async function up(knex) { + const hasAccounts = await knex.schema.hasTable('mail_accounts'); + if (!hasAccounts) { + await knex.schema.createTable('mail_accounts', (t) => { + t.increments('id').primary(); + t.string('account_key', 64).notNullable().unique(); // e.g. 'customers' + t.string('label', 120); + t.string('imap_host', 255); + t.integer('imap_port').defaultTo(993); + t.boolean('imap_secure').defaultTo(true); + t.string('imap_user', 255); + t.string('imap_pass', 512); + t.string('imap_folder', 255).defaultTo('INBOX'); + t.boolean('enabled').defaultTo(false); + t.timestamp('created_at').defaultTo(knex.fn.now()); + t.timestamp('updated_at').defaultTo(knex.fn.now()); + }); + } + + const cols = [ + ['account_key', (t) => t.string('account_key', 64)], + ['to_address', (t) => t.string('to_address', 512)], + ['body_html', (t) => t.text('body_html')], + ['body_text', (t) => t.text('body_text')], + ]; + for (const [name, add] of cols) { + // eslint-disable-next-line no-await-in-loop + const has = await knex.schema.hasColumn('received_emails', name); + // eslint-disable-next-line no-await-in-loop + if (!has) await knex.schema.alterTable('received_emails', add); + } +}; + +exports.down = async function down(knex) { + // Non-destructive on the audit log: leave the added columns in place (they're + // nullable and harmless). Only drop the new table. + await knex.schema.dropTableIfExists('mail_accounts'); +}; diff --git a/backend/migrations/core/155_email_queue_origin.js b/backend/migrations/core/155_email_queue_origin.js new file mode 100644 index 00000000..106bc4ef --- /dev/null +++ b/backend/migrations/core/155_email_queue_origin.js @@ -0,0 +1,25 @@ +/** + * Messages Phase 3 — distinguish human-composed sends from system mail. + * + * `origin` is 'system' for everything the app queues automatically (invoices, + * reminders, gallery notices — the Automated stream) and 'manual' for emails an + * admin composed/edited in the Messages composer (replies + document messages — + * the Customers ▸ Sent stream). Existing rows default to 'system'. + */ +exports.up = async function up(knex) { + const has = await knex.schema.hasColumn('email_queue', 'origin'); + if (!has) { + await knex.schema.alterTable('email_queue', (t) => { + t.string('origin', 16).defaultTo('system'); + }); + } +}; + +exports.down = async function down(knex) { + const has = await knex.schema.hasColumn('email_queue', 'origin'); + if (has) { + await knex.schema.alterTable('email_queue', (t) => { + t.dropColumn('origin'); + }); + } +}; diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 5fa85ca1..b424379e 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -260,16 +260,107 @@ router.get('/received', adminAuth, requirePermission('email.view'), async (req, try { const page = Math.max(1, parseInt(req.query.page, 10) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25)); - const base = db('received_emails'); - const countRow = await base.clone().count({ c: '*' }).first(); + const account = req.query.account ? String(req.query.account) : null; + // 'accounting' matches legacy rows too (account_key was NULL before mig 154). + const applyAccount = (qb) => { + if (account === 'accounting') qb.where((b) => b.where('account_key', 'accounting').orWhereNull('account_key')); + else if (account) qb.where('account_key', account); + return qb; + }; + const countRow = await applyAccount(db('received_emails')).count({ c: '*' }).first(); const total = parseInt(countRow?.c || 0, 10); - const items = await base.clone().orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize); + // Bodies are excluded from the list (can be large); fetched per-message. + const items = await applyAccount(db('received_emails')) + .select('id', 'message_id', 'account_key', 'from_address', 'to_address', 'subject', + 'received_at', 'attachment_count', 'status', 'inbound_document_id', 'error') + .orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize); res.json({ items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } }); } catch (error) { errorResponse(res, error, 500, 'Failed to fetch received emails'); } }); +// Single received email WITH its captured (server-sanitized) body — Messages +// reading pane. body_html was already sanitized on ingest; the viewer renders +// it in a script-less sandboxed iframe as well. +router.get('/received/:id', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' }); + const row = await db('received_emails').where({ id }).first(); + if (!row) return res.status(404).json({ error: 'Email not found' }); + res.json(row); + } catch (error) { + errorResponse(res, error, 500, 'Failed to fetch email'); + } +}); + +// Additional inbound mailboxes (beyond the primary accounting IMAP in +// email_configs) — e.g. the customer hello@ box. Passwords are masked out. +router.get('/accounts', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const rows = await db('mail_accounts').orderBy('id'); + res.json({ items: rows.map((a) => ({ ...a, imap_pass: a.imap_pass ? '********' : '' })) }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to load mail accounts'); + } +}); + +// Upsert a mailbox by account_key. A masked password ('********') keeps the +// stored value so the admin never has to re-type it. +router.post('/accounts', adminAuth, requirePermission('email.edit'), async (req, res) => { + try { + const b = req.body || {}; + if (!b.account_key) return res.status(400).json({ error: 'account_key is required' }); + const patch = { + label: b.label || null, + imap_host: b.imap_host || null, + imap_port: b.imap_port ? parseInt(b.imap_port, 10) : 993, + imap_secure: b.imap_secure !== false, + imap_user: b.imap_user || null, + imap_folder: b.imap_folder || 'INBOX', + enabled: !!b.enabled, + updated_at: new Date(), + }; + if (b.imap_pass && b.imap_pass !== '********') patch.imap_pass = b.imap_pass; + const existing = await db('mail_accounts').where({ account_key: b.account_key }).first(); + if (existing) { + await db('mail_accounts').where({ account_key: b.account_key }).update(patch); + } else { + await db('mail_accounts').insert({ + account_key: b.account_key, + imap_pass: (b.imap_pass && b.imap_pass !== '********') ? b.imap_pass : '', + created_at: new Date(), + ...patch, + }); + } + res.json({ ok: true }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to save mail account'); + } +}); + +// Test an inbound mailbox's IMAP connection (before or after saving). Resolves +// a masked/blank password from the stored row for the given account_key. +router.post('/accounts/test', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const b = req.body || {}; + let pass = b.imap_pass; + if ((!pass || pass === '********') && b.account_key) { + const stored = await db('mail_accounts').where({ account_key: b.account_key }).first(); + pass = stored?.imap_pass || ''; + } + const emailIntakeService = require('../services/emailIntakeService'); + const result = await emailIntakeService.testConnection({ + host: b.imap_host, port: b.imap_port, secure: b.imap_secure, + user: b.imap_user, pass, folder: b.imap_folder || 'INBOX', + }); + res.json(result); + } catch (error) { + res.status(422).json({ ok: false, error: `Mailbox test failed (${error.message}).` }); + } +}); + // Test email configuration router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => { try { @@ -438,6 +529,7 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r router.get('/queue', adminAuth, requirePermission('email.view'), [ query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']), query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }), + query('origin').optional({ values: 'falsy' }).isIn(['system', 'manual']), query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), query('from').optional({ values: 'falsy' }).isISO8601(), query('to').optional({ values: 'falsy' }).isISO8601(), @@ -456,6 +548,9 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [ const applyFilters = (qb) => { if (req.query.status) qb.where('email_queue.status', req.query.status); if (req.query.emailType) qb.where('email_queue.email_type', req.query.emailType); + // 'system' includes legacy rows (origin was NULL before migration 155). + if (req.query.origin === 'manual') qb.where('email_queue.origin', 'manual'); + else if (req.query.origin === 'system') qb.where((b) => b.where('email_queue.origin', 'system').orWhereNull('email_queue.origin')); if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from)); if (req.query.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to)); if (req.query.q) { @@ -484,6 +579,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [ 'email_queue.sent_at', 'email_queue.error_message', 'email_queue.retry_count', + 'email_queue.origin', 'email_queue.event_id', 'events.event_name as event_name', 'events.slug as event_slug' @@ -503,6 +599,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [ sentAt: r.sent_at, errorMessage: r.error_message, retryCount: r.retry_count, + origin: r.origin || 'system', eventId: r.event_id, eventName: r.event_name || null, eventSlug: r.event_slug || null, @@ -518,6 +615,108 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [ } }); +// Single queued/sent email WITH its rendered body — powers the Messages +// reading pane. `rendered_html` is the exact HTML that was sent (migration +// 119); rows sent before that migration have none. Attachment disk paths in +// `email_data` are never exposed — only the filenames, so the pane can list +// attachments without leaking storage paths (same PII posture as the list). +router.get('/queue/:id', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' }); + const row = await db('email_queue') + .leftJoin('events', 'events.id', 'email_queue.event_id') + .select('email_queue.*', 'events.event_name as event_name', 'events.slug as event_slug') + .where('email_queue.id', id) + .first(); + if (!row) return res.status(404).json({ error: 'Email not found' }); + + let cc = null; + let attachments = []; + try { + const data = row.email_data ? JSON.parse(row.email_data) : {}; + if (data.cc) cc = Array.isArray(data.cc) ? data.cc.join(', ') : String(data.cc); + if (Array.isArray(data.attachments)) { + attachments = data.attachments + .filter((a) => a && a.filename) + .map((a) => ({ filename: a.filename, contentType: a.contentType || null })); + } + } catch (_) { /* malformed email_data → no cc/attachments, still return the body */ } + + res.json({ + id: row.id, + recipientEmail: row.recipient_email, + emailType: row.email_type, + status: row.status, + createdAt: row.created_at, + scheduledAt: row.scheduled_at, + sentAt: row.sent_at, + errorMessage: row.error_message, + retryCount: row.retry_count, + eventId: row.event_id, + eventName: row.event_name || null, + eventSlug: row.event_slug || null, + renderedHtml: row.rendered_html || null, + cc, + attachments, + }); + } catch (error) { + logger.error('Get email queue item error:', error); + res.status(500).json({ error: 'Failed to load email', details: error.message }); + } +}); + +// Send a human-composed email from the Messages composer. The admin already +// edited the body (reply or document message), so it is sent as-is — no +// template render — after a sanitize pass. Recorded in email_queue as a +// 'manual' send so it surfaces under Customers > Sent. +router.post('/send', adminAuth, requirePermission('email.send'), async (req, res) => { + try { + const b = req.body || {}; + const to = String(b.to || '').trim(); + const subject = String(b.subject || '').trim(); + if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) { + return res.status(400).json({ error: 'A valid recipient email is required.' }); + } + if (!subject) return res.status(400).json({ error: 'A subject is required.' }); + + const sanitizeHtml = require('sanitize-html'); + const html = sanitizeHtml(String(b.html || ''), { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'style']), + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + img: ['src', 'alt', 'width', 'height'], + '*': ['style', 'class'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'cid', 'data'], + }); + const cc = b.cc ? String(b.cc).trim() : null; + + const emailProcessor = require('../services/emailProcessor'); + const result = await emailProcessor.sendRawEmail({ to, cc, subject, html }); + + await db('email_queue').insert({ + recipient_email: to, + email_type: 'manual_message', + email_data: JSON.stringify({ + subject, + cc: cc || undefined, + replyToReceivedId: b.replyToReceivedId || undefined, + messageId: result.messageId, + }), + status: 'sent', + origin: 'manual', + rendered_html: html, + created_at: new Date(), + sent_at: new Date(), + }); + res.json({ ok: true }); + } catch (error) { + logger.error('Manual send error:', error); + res.status(500).json({ error: 'Failed to send message', details: error.message }); + } +}); + // Helper: parse variables JSON safely function parseVariables(template) { try { diff --git a/backend/src/services/emailIntakeService.js b/backend/src/services/emailIntakeService.js index e96bff60..955fc1cf 100644 --- a/backend/src/services/emailIntakeService.js +++ b/backend/src/services/emailIntakeService.js @@ -16,6 +16,7 @@ const { db } = require('../database/db'); const logger = require('../utils/logger'); const { getStoragePath } = require('../config/storage'); const expenseService = require('./expenseService'); +const sanitizeHtml = require('sanitize-html'); const { isUniqueViolation } = require('../utils/dbErrors'); const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png']; @@ -230,14 +231,36 @@ async function roundTripTest({ timeoutMs = 30000, intervalMs = 3000 } = {}) { } } -/** Poll the mailbox once. Safe to call repeatedly; self-skips when busy/off. */ -async function pollOnce() { - if (polling) return { skipped: 'busy' }; - if (!(await isEnabled())) return { skipped: 'disabled' }; - const cfg = await getImapConfig(); - if (!cfg) return { skipped: 'unconfigured' }; +// Sanitize an inbound HTML body before storing it. Inbound mail is untrusted, +// so this strips scripts/handlers/unknown schemes (the viewer ALSO renders it +// in a script-less sandboxed iframe — defense in depth). Remote images are kept +// (many legit emails embed them) but that is the only tracking-vector allowed. +function sanitizeBody(html) { + if (!html) return null; + try { + return sanitizeHtml(html, { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + img: ['src', 'alt', 'width', 'height'], + '*': ['style'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'cid'], + }); + } catch (_) { + return null; + } +} - polling = true; +/** + * Poll ONE mailbox once and return the count of newly-processed messages. + * `opts.accountKey` tags each received_emails row; `opts.routeToExpenses` + * controls whether PDF/image attachments are dropped into the accounting inbox + * (true for the primary rechnungen@ mailbox) or only logged with the body + * (customer mail, e.g. hello@). The claim/dedup/stale-recovery logic is + * identical for every mailbox. + */ +async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses = true } = {}) { const client = makeImapClient(cfg); let processed = 0; try { @@ -304,6 +327,7 @@ async function pollOnce() { try { await db('received_emails').insert({ message_id: claimKey, + account_key: accountKey, status: 'processing', attachment_count: 0, received_at: new Date(), @@ -315,37 +339,47 @@ async function pollOnce() { throw ce; } - // Ingest attachments. Isolate each so one bad file can't prevent the - // audit row (the symptom: doc lands in Incoming invoices but the - // email never shows under Received). - const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType)); + // Attachment handling. The accounting mailbox drops PDF/image + // attachments into the incoming-invoices inbox (isolated so one bad + // file can't prevent the audit row). Customer mailboxes only COUNT + // attachments — they aren't supplier invoices. let inboundId = null; let count = 0; const attErrors = []; - for (const att of atts) { - try { - const filePath = await saveAttachment(att); - const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null); - inboundId = doc.id; count += 1; - } catch (ae) { - attErrors.push(ae.message); - logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`); + if (routeToExpenses) { + const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType)); + for (const att of atts) { + try { + const filePath = await saveAttachment(att); + const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null); + inboundId = doc.id; count += 1; + } catch (ae) { + attErrors.push(ae.message); + logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`); + } } + } else { + count = (parsed.attachments || []).length; } // A malformed Date: header yields an Invalid Date, which throws on a // Postgres timestamp insert — coerce to now. const receivedAt = (parsed.date instanceof Date && !Number.isNaN(parsed.date.getTime())) ? parsed.date : new Date(); - const status = count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment'); + const status = routeToExpenses + ? (count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment')) + : 'received'; // Finalise the claimed row — every processed message ends up in the - // Received tab, even attachment-less ones. + // Received log with its (sanitized) body, even attachment-less ones. await db('received_emails').where({ message_id: claimKey }).update({ from_address: ((parsed.from && parsed.from.text) || '').slice(0, 512) || null, + to_address: ((parsed.to && parsed.to.text) || '').slice(0, 512) || null, subject: parsed.subject || null, received_at: receivedAt, attachment_count: count, status, inbound_document_id: inboundId, + body_html: sanitizeBody(parsed.html || null), + body_text: parsed.text || null, error: attErrors.length ? attErrors.join('; ').slice(0, 2000) : null, }); await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true }); @@ -360,7 +394,7 @@ async function pollOnce() { await db('received_emails').where({ message_id: claimKey }) .update({ status: 'error', error: String(e.message).slice(0, 2000) }); } else { - await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() }); + await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, account_key: accountKey, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() }); } } catch (ie) { logger.error?.(`emailIntake: could not even write the error row (received_emails insert failing): ${ie.message}`); @@ -373,11 +407,55 @@ async function pollOnce() { /* eslint-enable no-await-in-loop */ await client.logout(); } catch (e) { - logger.error?.(`emailIntake: poll failed: ${e.message}`); + logger.error?.(`emailIntake: poll failed (${accountKey}): ${e.message}`); try { await client.close(); } catch (_e) { /* ignore */ } + } + return processed; +} + +/** + * Poll ALL configured inbound mailboxes once: the primary accounting IMAP + * (email_configs) plus every enabled row in mail_accounts (e.g. hello@). + * Safe to call repeatedly; self-skips when busy/off. + */ +async function pollOnce() { + if (polling) return { skipped: 'busy' }; + if (!(await isEnabled())) return { skipped: 'disabled' }; + polling = true; + let processed = 0; + let anyConfigured = false; + try { + // 1) Primary accounting mailbox — routes attachments to the invoices inbox. + const acctCfg = await getImapConfig(); + if (acctCfg) { + anyConfigured = true; + processed += await pollAccountOnce(acctCfg, { accountKey: 'accounting', routeToExpenses: true }); + } + // 2) Additional mailboxes (customers/hello@) — body captured, no expense + // routing. Guarded so a pre-migration DB simply polls the accounting box. + let extras = []; + try { + if (await db.schema.hasTable('mail_accounts')) { + extras = await db('mail_accounts').where({ enabled: true }); + } + } catch (_) { extras = []; } + for (const a of extras) { + if (!a.imap_host || !a.imap_user) continue; + anyConfigured = true; + const cfg = { + host: a.imap_host, + port: a.imap_port || 993, + secure: a.imap_secure !== false && a.imap_secure !== 0, + auth: { user: a.imap_user, pass: a.imap_pass || '' }, + folder: a.imap_folder || 'INBOX', + }; + // eslint-disable-next-line no-await-in-loop + processed += await pollAccountOnce(cfg, { accountKey: a.account_key, routeToExpenses: false }); + } } finally { polling = false; } + if (!anyConfigured) return { skipped: 'unconfigured' }; return { processed }; } diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 4de4ab27..a33e815f 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -775,6 +775,35 @@ async function sendTemplateEmail(to, templateKey, variables) { } } +/** + * Send a fully-composed email (subject + HTML the admin already edited in the + * Messages composer) WITHOUT a template. Used for replies + human-sent document + * messages. Uses the configured SMTP identity + from address. Returns + * { messageId, html } so the caller can persist rendered_html for the record. + */ +async function sendRawEmail({ to, cc, subject, html, text, attachments } = {}) { + transporter = await initializeTransporter(); + if (!transporter) throw new Error('Email service not configured'); + const config = await db('email_configs').first(); + if (!config || !config.from_email) throw new Error('Email service not configured'); + const ccList = Array.isArray(cc) ? cc.filter(Boolean) : (cc ? [cc] : undefined); + const atts = Array.isArray(attachments) + ? attachments.filter((a) => a && (a.contentPath || a.path || a.content)) + .map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType })) + : undefined; + const info = await transporter.sendMail({ + from: `${config.from_name} <${config.from_email}>`, + to, + cc: ccList, + subject, + html, + text: text || htmlToText(html), + attachments: atts, + }); + logger.info(`Manual email sent: ${info.messageId}`); + return { messageId: info.messageId, html }; +} + /** * Render a queued email's HTML WITHOUT sending it. Used by the Project * Overview cockpit to preview emails that predate the rendered_html column @@ -1108,6 +1137,7 @@ module.exports = { initializeTransporter, startEmailQueueProcessor, sendTemplateEmail, + sendRawEmail, renderQueuedEmail, processEmailQueue, queueEmail, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 27629a4d..854369a9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,6 +42,7 @@ import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage'; // (carved into its own chunk in vite.config.ts) doesn't ship with the // main app. Only pages that visit /admin/clients/calendar fetch it. const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage }))); +const MessagesPage = lazy(() => import('./pages/admin/messages/MessagesPage').then((m) => ({ default: m.MessagesPage }))); import { QuoteResponsePage } from './pages/public/QuoteResponsePage'; import { ContractResponsePage } from './pages/public/ContractResponsePage'; import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage'; @@ -241,6 +242,13 @@ function App() { }> } /> + }> + }> + + + } /> + {/* Clients section (#354 follow-up). Parent route gated by the top-level `clients` flag — when off the sidebar entry is hidden and every /admin/clients/* diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 6a18cd09..3d154f9a 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -11,6 +11,7 @@ import { Users, Briefcase, Landmark, + Mail, Workflow, PanelLeftClose, PanelLeftOpen, @@ -64,6 +65,7 @@ const navigation: NavItem[] = [ { nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false }, { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' }, { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' }, + { nameKey: 'navigation.messages', href: '/admin/messages', icon: Mail, permission: 'email.view', featureFlag: 'messaging' }, { nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' }, { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' }, { nameKey: 'navigation.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' }, diff --git a/frontend/src/components/admin/CustomerMailboxCard.tsx b/frontend/src/components/admin/CustomerMailboxCard.tsx new file mode 100644 index 00000000..e02c7e4b --- /dev/null +++ b/frontend/src/components/admin/CustomerMailboxCard.tsx @@ -0,0 +1,123 @@ +/** + * Customer mailbox (hello@) configuration — a second inbound IMAP box beyond + * the accounting rechnungen@ one, stored in `mail_accounts` under the fixed + * account_key 'customers'. Its mail feeds Messages → Customers ▸ Inbox (body + * captured, attachments NOT routed to accounting). Shown when the `messaging` + * feature flag is on. Styled to match the Incoming Mail card. + */ +import React, { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Save, Server, User, Lock, Eye, EyeOff, PlugZap, Inbox } from 'lucide-react'; +import { Button, Card, Input, Loading } from '../common'; +import { emailService, type MailAccount } from '../../services/email.service'; +import { useMutationWithToast, useModal } from '../../hooks'; + +const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1'; +const selectCls = 'w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark'; + +const ACCOUNT_KEY = 'customers'; + +export const CustomerMailboxCard: React.FC = () => { + const { t } = useTranslation(); + const { data, isLoading } = useQuery({ queryKey: ['mail-accounts'], queryFn: () => emailService.listMailAccounts() }); + const [cfg, setCfg] = useState({ account_key: ACCOUNT_KEY, imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '', imap_folder: 'INBOX', enabled: false }); + const passwordVisibility = useModal(); + + useEffect(() => { + if (!data) return; + const row = data.find((a) => a.account_key === ACCOUNT_KEY); + if (row) setCfg({ ...row, imap_pass: row.imap_pass || '' }); + }, [data]); + + const set = (k: keyof MailAccount, v: any) => setCfg((c) => ({ ...c, [k]: v })); + + const save = useMutationWithToast({ + mutationFn: () => { + if (!cfg.imap_host || !cfg.imap_port || !cfg.imap_user) { + return Promise.reject(new Error(t('email.customerMailbox.requiredFields', 'Host, port and username are required.'))); + } + return emailService.saveMailAccount({ ...cfg, account_key: ACCOUNT_KEY, label: 'Customers' }); + }, + successMessage: t('email.customerMailbox.savedToast', 'Customer mailbox saved.'), + invalidateKeys: [['mail-accounts']], + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', + }); + + const test = useMutationWithToast({ + mutationFn: () => emailService.testMailAccount({ ...cfg, account_key: ACCOUNT_KEY }), + successMessage: (r) => t('email.customerMailbox.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen }), + errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.customerMailbox.testFailed', 'Connection failed.'), + }); + + if (isLoading) return ; + + return ( + +

+ + {t('email.customerMailbox.title', 'Customer mailbox (hello@)')} +

+

+ {t('email.customerMailbox.subtitle', 'A second inbound mailbox for customer conversations. Its mail appears under Messages → Customers; attachments are not routed to Accounting.')} +

+ +
+ + +
+ + set('imap_host', e.target.value)} placeholder="imap.example.com" leftIcon={} /> +
+ +
+
+ + set('imap_port', parseInt(e.target.value, 10) || 0)} placeholder="993" /> +
+
+ + +
+
+ +
+ + set('imap_user', e.target.value)} autoComplete="off" placeholder="hello@yourdomain.com" leftIcon={} /> +
+ +
+ +
+ set('imap_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={} /> + +
+
+ +
+ + set('imap_folder', e.target.value)} placeholder="INBOX" /> +
+ +
+ + +
+
+
+ ); +}; + +export default CustomerMailboxCard; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 1903b87b..81de4a04 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -197,6 +197,7 @@ "navigation": { "dashboard": "Dashboard", "events": "Veranstaltungen", + "messages": "Nachrichten", "settings": "Einstellungen", "systemHealth": "Systemzustand", "archives": "Archive", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 694eb762..692cbb12 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -198,6 +198,7 @@ "dashboard": "Dashboard", "events": "Events", "archives": "Archives", + "messages": "Messages", "settings": "Settings", "systemHealth": "System health", "eventTypes": "Event Types", diff --git a/frontend/src/pages/admin/EmailConfigPage.tsx b/frontend/src/pages/admin/EmailConfigPage.tsx index 8f494acf..670c4bdd 100644 --- a/frontend/src/pages/admin/EmailConfigPage.tsx +++ b/frontend/src/pages/admin/EmailConfigPage.tsx @@ -21,6 +21,7 @@ import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor' import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel'; import { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel'; import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard'; +import { CustomerMailboxCard } from '../../components/admin/CustomerMailboxCard'; import { Palette, RefreshCw, Info } from 'lucide-react'; import { useQuery, useMutation } from '@tanstack/react-query'; import { useModal, useMutationWithToast } from '../../hooks'; @@ -802,6 +803,7 @@ export const EmailConfigPage: React.FC = () => { {/* Email Templates Tab */} {/* Incoming mail (IMAP) — a second block under SMTP, flag-gated. */} {activeTab === 'smtp' && featureFlags.incomingMail && } + {activeTab === 'smtp' && featureFlags.messaging && } {activeTab === 'templates' && (
diff --git a/frontend/src/pages/admin/messages/MessageComposer.tsx b/frontend/src/pages/admin/messages/MessageComposer.tsx new file mode 100644 index 00000000..21ca67ce --- /dev/null +++ b/frontend/src/pages/admin/messages/MessageComposer.tsx @@ -0,0 +1,114 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { X, Send as SendIcon } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { emailService } from '../../../services/email.service'; +import { Button } from '../../../components/common'; + +/** + * Compose / reply modal. The body is pre-loaded with the rendered template (or a + * reply stub) and is FULLY EDITABLE — the admin can rewrite it or drop a note + * anywhere before sending. On send it goes out as-is (server-sanitized), no + * template re-render, and is recorded as a manual send (Customers ▸ Sent). + */ +export interface ComposerInit { + to: string; + cc?: string; + subject: string; + html: string; + replyToReceivedId?: number; +} + +const inputCls = 'flex-1 px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-950 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500'; + +export const MessageComposer: React.FC<{ + init: ComposerInit; + title?: string; + onClose: () => void; + onSent: () => void; + t: (k: string, d?: string) => string; +}> = ({ init, title, onClose, onSent, t }) => { + const [to, setTo] = useState(init.to); + const [cc, setCc] = useState(init.cc || ''); + const [subject, setSubject] = useState(init.subject); + const bodyRef = useRef(null); + + useEffect(() => { + if (bodyRef.current) bodyRef.current.innerHTML = init.html || ''; + // Load initial body exactly once; further edits are the admin's. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', h); + return () => window.removeEventListener('keydown', h); + }, [onClose]); + + const send = useMutation({ + mutationFn: () => emailService.sendMessage({ + to: to.trim(), + cc: cc.trim() || undefined, + subject: subject.trim(), + html: bodyRef.current?.innerHTML || '', + replyToReceivedId: init.replyToReceivedId, + }), + onSuccess: () => { toast.success(t('messages.sentToast', 'Message sent.')); onSent(); onClose(); }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.sendFailed', 'Failed to send message.')), + }); + + const canSend = !!to.trim() && !!subject.trim() && !send.isPending; + + return ( +
+
e.stopPropagation()}> +
+ {title || t('messages.compose', 'Compose message')} + +
+ +
+ + + +
+
+ {t('messages.bodyHint', 'Edit the message freely — add a note anywhere before sending.')} +
+
+
+
+ +
+ {t('messages.sendsFromHint', 'Sends from your configured outgoing address.')} +
+ + +
+
+
+
+ ); +}; + +export default MessageComposer; diff --git a/frontend/src/pages/admin/messages/MessagesPage.tsx b/frontend/src/pages/admin/messages/MessagesPage.tsx new file mode 100644 index 00000000..81c0e8f3 --- /dev/null +++ b/frontend/src/pages/admin/messages/MessagesPage.tsx @@ -0,0 +1,631 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { + Inbox, Send, Reply, ReplyAll, Forward, Archive, Trash2, Paperclip, + FileText, Quote, FileSignature, Image as ImageIcon, ReceiptText, + Link2, X, ChevronLeft, ChevronRight, Mail, type LucideIcon, +} from 'lucide-react'; +import { emailService, type ReceivedEmail } from '../../../services/email.service'; +import { accountingService } from '../../../services/accounting.service'; +import { Loading } from '../../../components/common'; +import { MessageComposer, type ComposerInit } from './MessageComposer'; + +/** + * Admin "Messages" — read-only viewer over the mail picpeak already + * has: the Automated stream (email_queue, incl. rendered bodies from migration + * 119) and the Accounting inbox (received_emails / supplier invoices). The + * Customers (hello@) mailbox and reply/compose land in later phases; those + * folders render an explanatory empty state so the full IA is visible now. + */ + +type FolderSrc = 'queue' | 'received' | 'empty'; +interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; account?: string; origin?: 'system' | 'manual'; note?: string; } +interface Account { id: string; name: string; addr?: string; color: string; folders: Folder[]; } + +type Selection = + | { kind: 'queue'; id: number } + | { kind: 'received'; item: ReceivedEmail } + | null; + +const TYPE_LABELS: Record = { + invoice_sent: 'Invoice sent', + invoice_reminder_first: 'Payment reminder', + invoice_reminder_second: 'Payment reminder', + invoice_reminder_final: 'Final reminder', + invoice_payment_check: 'Payment check', + invoice_collections_handoff: 'Collections handoff', + invoice_paid_admin_notification: 'Payment received', + expiration_warning: 'Gallery expiring', + gallery_expired: 'Gallery expired', + quote_sent: 'Quote sent', + contract_sent: 'Contract sent', +}; +const friendlyType = (t: string) => + TYPE_LABELS[t] || t.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + +const fmt = (s?: string | null) => + s ? new Date(s).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : ''; + +const STATUS_STYLES: Record = { + sent: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', + ingested: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', + received: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', + pending: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', + error: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', +}; + +export const MessagesPage: React.FC = () => { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const [activeFolder, setActiveFolder] = useState('auto-sent'); + const [selection, setSelection] = useState(null); + const [pdfDocId, setPdfDocId] = useState(null); + const [composer, setComposer] = useState<{ init: ComposerInit; title?: string } | null>(null); + + const queueQuery = useQuery({ + queryKey: ['messages', 'queue'], + queryFn: () => emailService.listQueue({ pageSize: 100 }), + refetchInterval: 60000, + }); + const acctQuery = useQuery({ + queryKey: ['messages', 'received', 'accounting'], + queryFn: () => emailService.listReceived({ account: 'accounting', pageSize: 100 }), + refetchInterval: 60000, + }); + const custQuery = useQuery({ + queryKey: ['messages', 'received', 'customers'], + queryFn: () => emailService.listReceived({ account: 'customers', pageSize: 100 }), + refetchInterval: 60000, + }); + + const queueTotal = queueQuery.data?.pagination.total; + const acctTotal = acctQuery.data?.pagination.total; + const custTotal = custQuery.data?.pagination.total; + + const accounts: Account[] = useMemo(() => [ + { id: 'all', name: t('messages.account.all', 'All mail'), color: '#64748b', folders: [ + { id: 'all-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received' }, + { id: 'all-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue' }, + ] }, + { id: 'cust', name: t('messages.account.customers', 'Customers'), addr: 'hello@', color: '#2563c9', folders: [ + { id: 'cust-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received', account: 'customers' }, + { id: 'cust-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'manual' }, + ] }, + { id: 'acct', name: t('messages.account.accounting', 'Accounting'), addr: 'rechnungen@', color: '#12876a', folders: [ + { id: 'acct-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received', account: 'accounting' }, + ] }, + { id: 'auto', name: t('messages.account.automated', 'Automated'), addr: 'no-reply@', color: '#7a52d6', folders: [ + { id: 'auto-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'system' }, + ] }, + ], [t]); + + // Sent stream is split client-side by origin: system (Automated) vs manual + // (human composed → Customers ▸ Sent). Legacy rows (origin undefined) = system. + const queueItemsAll = queueQuery.data?.items || []; + const queueFor = (origin?: 'system' | 'manual') => + origin === 'manual' ? queueItemsAll.filter((i) => i.origin === 'manual') + : origin === 'system' ? queueItemsAll.filter((i) => i.origin !== 'manual') + : queueItemsAll; + + const folder = useMemo(() => { + for (const a of accounts) for (const f of a.folders) if (f.id === activeFolder) return { a, f }; + return { a: accounts[0], f: accounts[0].folders[0] }; + }, [accounts, activeFolder]); + + const countFor = (f: Folder): number | undefined => { + if (f.src === 'queue') return f.origin ? queueFor(f.origin).length : queueTotal; + if (f.src === 'received') { + if (f.account === 'customers') return custTotal; + if (f.account === 'accounting') return acctTotal; + return (acctTotal || 0) + (custTotal || 0); + } + return undefined; + }; + + // Which received rows feed the active folder (customer / accounting / union). + const receivedItems = useMemo(() => { + if (folder.f.src !== 'received') return undefined; + const a = acctQuery.data?.items || []; + const c = custQuery.data?.items || []; + if (folder.f.account === 'customers') return c; + if (folder.f.account === 'accounting') return a; + return [...a, ...c].sort((x, y) => (y.received_at || '').localeCompare(x.received_at || '')); + }, [folder, acctQuery.data, custQuery.data]); + + const receivedLoading = folder.f.account === 'customers' + ? custQuery.isLoading + : folder.f.account === 'accounting' + ? acctQuery.isLoading + : acctQuery.isLoading || custQuery.isLoading; + + return ( +
+
+
+

+ + {t('messages.title', 'Messages')} +

+

+ {t('messages.subtitle', 'Sent, automated and incoming mail — one place.')} +

+
+
+ +
+ {/* ── account tree ── */} + + + {/* ── message list ── */} +
+
+
{folder.f.name}
+
+ {folder.a.addr || (folder.a.id === 'all' ? t('messages.unified', 'Unified across accounts') : t('messages.systemGenerated', 'System-generated'))} +
+
+
+ +
+
+ + {/* ── reading pane ── */} +
+ navigate('/admin/accounting/inbox')} + onCompose={(init, title) => setComposer({ init, title })} + t={t} + /> +
+
+ + {pdfDocId != null && setPdfDocId(null)} t={t} />} + {composer && ( + setComposer(null)} + onSent={() => { queueQuery.refetch(); setActiveFolder('cust-sent'); }} + t={t} + /> + )} +
+ ); +}; + +// ─────────────────────────────────────────────────────────── message list ── +const MessageList: React.FC<{ + folder: Folder; + queue?: import('../../../services/email.service').EmailQueueItem[]; + received?: ReceivedEmail[]; + loading: boolean; + selection: Selection; + onSelect: (s: Selection) => void; + t: (k: string, d?: string) => string; +}> = ({ folder, queue, received, loading, selection, onSelect, t }) => { + if (folder.src === 'empty') { + return ( +
+ + {folder.note} +
+ ); + } + if (loading) return
; + + const rows = + folder.src === 'queue' + ? (queue || []).map((m) => ({ + key: `q${m.id}`, + onClick: () => onSelect({ kind: 'queue', id: m.id }), + active: selection?.kind === 'queue' && selection.id === m.id, + who: m.recipientEmail, + subject: friendlyType(m.emailType), + when: fmt(m.sentAt || m.createdAt), + status: m.status, + attach: 0, + })) + : (received || []).map((m) => ({ + key: `r${m.id}`, + onClick: () => onSelect({ kind: 'received', item: m }), + active: selection?.kind === 'received' && selection.item.id === m.id, + who: m.from_address || '—', + subject: m.subject || t('messages.noSubject', '(no subject)'), + when: fmt(m.received_at), + status: m.status, + attach: m.attachment_count, + })); + + if (rows.length === 0) { + return
{t('messages.noMessages', 'No messages')}
; + } + + return ( +
    + {rows.map((r) => ( +
  • + +
  • + ))} +
+ ); +}; + +// ─────────────────────────────────────────────────────────── reading pane ── +const ReadingPane: React.FC<{ + selection: Selection; + account: Account; + lang: string; + onViewDoc: (id: number) => void; + onOpenAccounting: () => void; + onCompose: (init: ComposerInit, title?: string) => void; + t: (k: string, d?: string) => string; +}> = ({ selection, account, lang, onViewDoc, onOpenAccounting, onCompose, t }) => { + const detailQuery = useQuery({ + queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null], + queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id), + enabled: selection?.kind === 'queue', + }); + + if (!selection) { + return ( +
+
+ +
{t('messages.selectPrompt', 'Select a message to read')}
+
+
+ ); + } + + // Accounting toolbar only for the rechnungen@ stream; customer mail (inbound + // or the automated/sent streams) gets the CRM action set. + const isAcct = selection.kind === 'received' + ? selection.item.account_key !== 'customers' + : account.id === 'acct'; + + const recipient = selection.kind === 'received' + ? (selection.item.from_address || '') + : (detailQuery.data?.recipientEmail || ''); + + // Reply only makes sense for an inbound message with a sender. + const onReply = selection.kind === 'received' && selection.item.from_address + ? () => { + const it = selection.item; + const subj = /^re:/i.test(it.subject || '') ? (it.subject || '') : `Re: ${it.subject || ''}`; + const quoted = `


${t('messages.onWrote', 'On')} ${fmt(it.received_at)}, ${it.from_address}:

`; + onCompose({ to: it.from_address || '', subject: subj, html: quoted, replyToReceivedId: it.id }, t('messages.reply', 'Reply')); + } + : undefined; + + // Create-from-template opens the composer with the rendered template loaded + // (freely editable); empty key = blank compose (gallery has no send template). + const onCreate = !isAcct + ? async (key: string, label: string) => { + if (!key) { onCompose({ to: recipient, subject: '', html: '' }, label); return; } + try { + const p = await emailService.previewTemplate(key, { customer_name: recipient.split('@')[0] || '' }, lang || 'en'); + onCompose({ to: recipient, subject: p.subject, html: p.body_html }, label); + } catch { + onCompose({ to: recipient, subject: label, html: '' }, label); + } + } + : undefined; + + return ( +
+ +
+ {selection.kind === 'queue' ? ( + detailQuery.isLoading ? : detailQuery.data ? ( + + ) : ( +
{t('messages.loadError', 'Could not load this message.')}
+ ) + ) : ( + + )} +
+
+ ); +}; + +const QueueDetail: React.FC<{ d: import('../../../services/email.service').EmailQueueDetail; t: (k: string, d?: string) => string }> = ({ d, t }) => ( + <> +

+ {friendlyType(d.emailType)} +

+
+
+ {t('messages.from', 'from')} no-reply@ · {t('messages.to', 'to')}{' '} + {d.recipientEmail} +
+ {d.cc &&
cc {d.cc}
} +
{fmt(d.sentAt || d.createdAt)}
+
+ + {d.renderedHtml ? ( +
+ {/* rendered_html is our own template output — sandboxed, scripts blocked */} +