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/migrations/core/156_mail_accounts_smtp.js b/backend/migrations/core/156_mail_accounts_smtp.js new file mode 100644 index 00000000..7d0ec232 --- /dev/null +++ b/backend/migrations/core/156_mail_accounts_smtp.js @@ -0,0 +1,34 @@ +/** + * Messages Phase 3 follow-up — outgoing (SMTP) settings per mail account. + * + * The customer mailbox (hello@) needs BOTH incoming (IMAP, migration 154) and + * outgoing (SMTP) config, so replies to customers send from hello@ instead of + * the global no-reply@ identity. All additive/guarded. + */ +exports.up = async function up(knex) { + const cols = [ + ['smtp_host', (t) => t.string('smtp_host', 255)], + ['smtp_port', (t) => t.integer('smtp_port')], + ['smtp_secure', (t) => t.boolean('smtp_secure').defaultTo(false)], + ['smtp_user', (t) => t.string('smtp_user', 255)], + ['smtp_pass', (t) => t.string('smtp_pass', 512)], + ['from_email', (t) => t.string('from_email', 255)], + ['from_name', (t) => t.string('from_name', 120)], + ]; + for (const [name, add] of cols) { + // eslint-disable-next-line no-await-in-loop + const has = await knex.schema.hasColumn('mail_accounts', name); + // eslint-disable-next-line no-await-in-loop + if (!has) await knex.schema.alterTable('mail_accounts', add); + } +}; + +exports.down = async function down(knex) { + const cols = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'from_email', 'from_name']; + for (const name of cols) { + // eslint-disable-next-line no-await-in-loop + const has = await knex.schema.hasColumn('mail_accounts', name); + // eslint-disable-next-line no-await-in-loop + if (has) await knex.schema.alterTable('mail_accounts', (t) => t.dropColumn(name)); + } +}; diff --git a/backend/migrations/core/157_mailbox_state.js b/backend/migrations/core/157_mailbox_state.js new file mode 100644 index 00000000..00f48e7f --- /dev/null +++ b/backend/migrations/core/157_mailbox_state.js @@ -0,0 +1,33 @@ +/** + * Messages — Archive / Delete (trash) support. + * + * `mailbox_state` on both mail tables: 'active' (normal folders), 'archived' + * (Archived folder), or 'deleted' (Deleted/trash folder). Delete is soft — the + * row moves to 'deleted' and is only removed for good when purged FROM the + * Deleted folder. Legacy rows have NULL, treated as 'active'. Additive/guarded. + */ +exports.up = async function up(knex) { + for (const table of ['email_queue', 'received_emails']) { + // eslint-disable-next-line no-await-in-loop + const has = await knex.schema.hasColumn(table, 'mailbox_state'); + // eslint-disable-next-line no-await-in-loop + if (!has) { + // eslint-disable-next-line no-await-in-loop + await knex.schema.alterTable(table, (t) => { + t.string('mailbox_state', 16).defaultTo('active'); + }); + } + } +}; + +exports.down = async function down(knex) { + for (const table of ['email_queue', 'received_emails']) { + // eslint-disable-next-line no-await-in-loop + const has = await knex.schema.hasColumn(table, 'mailbox_state'); + // eslint-disable-next-line no-await-in-loop + if (has) { + // eslint-disable-next-line no-await-in-loop + await knex.schema.alterTable(table, (t) => { t.dropColumn('mailbox_state'); }); + } + } +}; diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 5fa85ca1..d5fe0e20 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -4,6 +4,10 @@ const { body, query, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +// Gate the NEW Messages routes on the `messaging` flag (per-route, NOT the whole +// /email mount — the pre-existing config/queue/received endpoints stay ungated). +const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); +const messagingGate = requireFeatureFlag('messaging'); const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor'); const { errorResponse } = require('../utils/routeHelpers'); const logger = require('../utils/logger'); @@ -260,16 +264,196 @@ 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; + // mailbox_state filter: no param → active (+ legacy NULL); else exact. + const state = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active'; + // Optional full-table search (sender / subject) so results aren't truncated + // to the first page before matching. + const q = req.query.q ? String(req.query.q).trim().slice(0, 255) : ''; + // '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); + if (state === 'active') qb.where((b) => b.where('mailbox_state', 'active').orWhereNull('mailbox_state')); + else qb.where('mailbox_state', state); + if (q) qb.where((b) => b.where('from_address', 'like', `%${q}%`).orWhere('subject', 'like', `%${q}%`)); + 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, messagingGate, 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'); + } +}); + +// Move an email between mailbox states: Archive / Delete (soft) or Restore +// (back to active). kind = 'queue' | 'received'. Delete is a soft move to the +// trash; the row is only removed for good by the DELETE handler below. +router.post('/item/:kind/:id/state', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => { + try { + const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null; + if (!table) return res.status(400).json({ error: 'Invalid kind' }); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' }); + const state = String(req.body?.state || ''); + if (!['active', 'archived', 'deleted'].includes(state)) return res.status(400).json({ error: 'Invalid state' }); + const n = await db(table).where({ id }).update({ mailbox_state: state }); + if (!n) return res.status(404).json({ error: 'Not found' }); + res.json({ ok: true }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to update email'); + } +}); + +// Permanently delete an email row — only offered from the Deleted folder. +router.delete('/item/:kind/:id', adminAuth, messagingGate, requirePermission('email.edit'), async (req, res) => { + try { + const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null; + if (!table) return res.status(400).json({ error: 'Invalid kind' }); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' }); + await db(table).where({ id }).del(); + res.json({ ok: true }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to delete 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, messagingGate, 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 ? '********' : '', + smtp_pass: a.smtp_pass ? '********' : '', + })) }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to load mail accounts'); + } +}); + +// Resolved sender/mailbox addresses for the Messages UI — so the sidebar shows +// the REAL configured addresses instead of hardcoded placeholders. Accounting = +// the primary IMAP login (rechnungen@); customers = the hello@ mailbox; the +// automated stream sends from the global SMTP from-address. +router.get('/identities', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => { + try { + const cfg = await db('email_configs').first(); + let customers = null; + try { + const cust = await db('mail_accounts').where({ account_key: 'customers' }).first(); + customers = cust?.imap_user || cust?.from_email || null; + } catch (_) { customers = null; } + res.json({ + automated: cfg?.from_email || null, + accounting: cfg?.imap_user || null, + customers, + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to load mail identities'); + } +}); + +// 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, messagingGate, 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' }); + // SSRF guard — mirror /config + /incoming-config: neither the IMAP nor the + // SMTP host may point at a private/internal address. + const { isPrivateIP } = require('../utils/networkValidation'); + if (b.imap_host && isPrivateIP(b.imap_host)) { + return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' }); + } + if (b.smtp_host && isPrivateIP(b.smtp_host)) { + return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' }); + } + 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', + // Outgoing (SMTP) identity — replies from this mailbox send from here. + smtp_host: b.smtp_host || null, + smtp_port: b.smtp_port ? parseInt(b.smtp_port, 10) : 587, + smtp_secure: b.smtp_secure === true, + smtp_user: b.smtp_user || null, + from_email: b.from_email || null, + from_name: b.from_name || null, + enabled: !!b.enabled, + updated_at: new Date(), + }; + if (b.imap_pass && b.imap_pass !== '********') patch.imap_pass = b.imap_pass; + if (b.smtp_pass && b.smtp_pass !== '********') patch.smtp_pass = b.smtp_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 : '', + smtp_pass: (b.smtp_pass && b.smtp_pass !== '********') ? b.smtp_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, messagingGate, requirePermission('email.view'), async (req, res) => { + try { + const b = req.body || {}; + const { isPrivateIP } = require('../utils/networkValidation'); + if (b.imap_host && isPrivateIP(b.imap_host)) { + return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' }); + } + 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 +622,8 @@ 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('state').optional({ values: 'falsy' }).isIn(['active', 'archived', 'deleted']), query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }), query('from').optional({ values: 'falsy' }).isISO8601(), query('to').optional({ values: 'falsy' }).isISO8601(), @@ -456,6 +642,13 @@ 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')); + // mailbox_state: default active (+ legacy NULL); Archived/Deleted folders pass it explicitly. + const st = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active'; + if (st === 'active') qb.where((b) => b.where('email_queue.mailbox_state', 'active').orWhereNull('email_queue.mailbox_state')); + else qb.where('email_queue.mailbox_state', st); 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 +677,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 +697,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 +713,111 @@ 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, messagingGate, 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, messagingGate, 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'); + // Match the stricter inbound sanitizeBody allowlist: no