feat(messages): Phase 2 — customer (hello@) mailbox + inbound body capture

Second inbound mailbox and real message bodies for the Messages viewer.

Backend:
- Migration 154: mail_accounts table (additional inbound mailboxes beyond the
  primary accounting IMAP) + received_emails.{account_key,to_address,body_html,
  body_text}. Additive/guarded.
- emailIntakeService now polls the accounting mailbox AND every enabled
  mail_accounts row. Extracted pollAccountOnce(cfg, {accountKey, routeToExpenses});
  accounting keeps its exact attachment->expenses behavior, customer mail is
  logged with its body and NOT routed to accounting. Inbound HTML is sanitized
  server-side (sanitize-html) on ingest.
- adminEmail: /received gains an account filter + returns account_key/to_address
  (bodies excluded from the list); new GET /received/:id returns the body;
  GET/POST /accounts + /accounts/test manage the extra mailboxes.

Frontend:
- Customers inbox now pulls the hello@ mailbox; reading pane renders the
  sanitized body in a strict (script-less, no same-origin) sandboxed iframe.
  Accounting inbox shows bodies too. Toolbar context keys off the mailbox.
- CustomerMailboxCard in Settings -> Email (behind the messaging flag) to
  configure + test the hello@ IMAP box.

No behavior change to the existing accounting inbound flow. Frontend build +
migration boot verified.
This commit is contained in:
Luca
2026-07-07 10:11:58 +02:00
parent 26eeb76197
commit ee46cf2125
7 changed files with 514 additions and 77 deletions
@@ -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');
};
+94 -3
View File
@@ -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 {
+101 -23
View File
@@ -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 };
}
@@ -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<MailAccount>({ 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 <Loading />;
return (
<Card padding="md" className="mt-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
<Inbox className="w-5 h-5 text-neutral-400" />
{t('email.customerMailbox.title', 'Customer mailbox (hello@)')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('email.customerMailbox.subtitle', 'A second inbound mailbox for customer conversations. Its mail appears under Messages → Customers; attachments are not routed to Accounting.')}
</p>
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input type="checkbox" checked={!!cfg.enabled} onChange={(e) => set('enabled', e.target.checked)} />
{t('email.customerMailbox.enabled', 'Poll this mailbox every minute')}
</label>
<div>
<label className={labelCls}>{t('email.incoming.host', 'IMAP Host')} <span className="text-red-500">*</span></label>
<Input type="text" value={cfg.imap_host || ''} onChange={(e) => set('imap_host', e.target.value)} placeholder="imap.example.com" leftIcon={<Server className="w-5 h-5 text-neutral-400" />} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>{t('email.incoming.port', 'Port')} <span className="text-red-500">*</span></label>
<Input type="number" value={cfg.imap_port ?? 993} onChange={(e) => set('imap_port', parseInt(e.target.value, 10) || 0)} placeholder="993" />
</div>
<div>
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
<select className={selectCls} value={cfg.imap_secure ? 'ssl' : 'plain'} onChange={(e) => set('imap_secure', e.target.value === 'ssl')}>
<option value="ssl">{t('email.incoming.ssl', 'SSL/TLS')}</option>
<option value="plain">{t('email.incoming.plain', 'None / STARTTLS')}</option>
</select>
</div>
</div>
<div>
<label className={labelCls}>{t('email.incoming.user', 'Username')} <span className="text-red-500">*</span></label>
<Input type="text" value={cfg.imap_user || ''} onChange={(e) => set('imap_user', e.target.value)} autoComplete="off" placeholder="[email protected]" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
</div>
<div>
<label className={labelCls}>{t('email.incoming.pass', 'Password')}</label>
<div className="relative">
<Input type={passwordVisibility.isOpen ? 'text' : 'password'} value={cfg.imap_pass || ''} onChange={(e) => set('imap_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} />
<button type="button" onClick={passwordVisibility.toggle} className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600">
{passwordVisibility.isOpen ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className={labelCls}>{t('email.incoming.folder', 'Folder')}</label>
<Input type="text" value={cfg.imap_folder || 'INBOX'} onChange={(e) => set('imap_folder', e.target.value)} placeholder="INBOX" />
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => test.mutate()} isLoading={test.isPending} disabled={!cfg.imap_host || !cfg.imap_user} leftIcon={<PlugZap className="w-5 h-5" />} className="whitespace-nowrap">
{t('email.incoming.test', 'Test connection')}
</Button>
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1 min-w-[12rem]">
{t('email.customerMailbox.save', 'Save Customer Mailbox')}
</Button>
</div>
</div>
</Card>
);
};
export default CustomerMailboxCard;
@@ -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 && <IncomingMailConfigCard />}
{activeTab === 'smtp' && featureFlags.messaging && <CustomerMailboxCard />}
{activeTab === 'templates' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -20,7 +20,7 @@ import { Loading } from '../../../components/common';
*/
type FolderSrc = 'queue' | 'received' | 'empty';
interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; note?: string; }
interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; account?: string; note?: string; }
interface Account { id: string; name: string; addr?: string; color: string; folders: Folder[]; }
type Selection =
@@ -50,6 +50,7 @@ const fmt = (s?: string | null) =>
const STATUS_STYLES: Record<string, string> = {
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',
@@ -67,14 +68,20 @@ export const MessagesPage: React.FC = () => {
queryFn: () => emailService.listQueue({ pageSize: 100 }),
refetchInterval: 60000,
});
const receivedQuery = useQuery({
queryKey: ['messages', 'received'],
queryFn: () => emailService.listReceived({ pageSize: 100 }),
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 receivedTotal = receivedQuery.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: [
@@ -82,13 +89,12 @@ export const MessagesPage: React.FC = () => {
{ 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: 'empty',
note: t('messages.empty.customerInbox', 'Connect the hello@ mailbox to see customer replies here (next phase).') },
{ 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: 'empty',
note: t('messages.empty.customerSent', 'Your hand-written replies will appear here once compose ships.') },
note: t('messages.empty.customerSent', 'Your hand-written replies will appear here once compose ships (next phase).') },
] },
{ 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' },
{ 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' },
@@ -100,8 +106,31 @@ export const MessagesPage: React.FC = () => {
return { a: accounts[0], f: accounts[0].folders[0] };
}, [accounts, activeFolder]);
const countFor = (src: FolderSrc) =>
src === 'queue' ? queueTotal : src === 'received' ? receivedTotal : undefined;
const countFor = (f: Folder): number | undefined => {
if (f.src === 'queue') return 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 (
<div className="flex flex-col h-[calc(100vh-8.5rem)] min-h-[540px]">
@@ -129,7 +158,7 @@ export const MessagesPage: React.FC = () => {
</div>
<div className="flex flex-col gap-0.5">
{a.folders.map((f) => {
const c = countFor(f.src);
const c = countFor(f);
const active = f.id === activeFolder;
return (
<button
@@ -166,8 +195,8 @@ export const MessagesPage: React.FC = () => {
<MessageList
folder={folder.f}
queue={queueQuery.data?.items}
received={receivedQuery.data?.items}
loading={folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedQuery.isLoading : false}
received={receivedItems}
loading={folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedLoading : false}
selection={selection}
onSelect={setSelection}
t={t}
@@ -298,7 +327,11 @@ const ReadingPane: React.FC<{
);
}
const isAcct = account.id === 'acct' || (selection.kind === 'received');
// 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';
return (
<div className="flex flex-col min-h-0 flex-1">
<Toolbar isAcct={isAcct} readonly={selection.kind === 'queue'} t={t} />
@@ -368,44 +401,64 @@ const ReceivedDetail: React.FC<{
onViewDoc: (id: number) => void;
onOpenAccounting: () => void;
t: (k: string, d?: string) => string;
}> = ({ item, onViewDoc, onOpenAccounting, t }) => (
<>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
{item.subject || t('messages.noSubject', '(no subject)')}
</h2>
<div className="mt-3 pb-4 border-b border-neutral-200 dark:border-neutral-800 text-sm">
<div className="text-neutral-600 dark:text-neutral-300">
{t('messages.from', 'from')} <span className="font-semibold text-neutral-800 dark:text-neutral-100">{item.from_address || '—'}</span>
{' · '}{t('messages.to', 'to')} <span className="font-mono text-xs">rechnungen@</span>
}> = ({ item, onViewDoc, onOpenAccounting, t }) => {
const detail = useQuery({
queryKey: ['messages', 'received', 'item', item.id],
queryFn: () => emailService.getReceivedItem(item.id),
});
const toAddr = detail.data?.to_address || item.to_address
|| (item.account_key === 'customers' ? 'hello@' : 'rechnungen@');
return (
<>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
{item.subject || t('messages.noSubject', '(no subject)')}
</h2>
<div className="mt-3 pb-4 border-b border-neutral-200 dark:border-neutral-800 text-sm">
<div className="text-neutral-600 dark:text-neutral-300">
{t('messages.from', 'from')} <span className="font-semibold text-neutral-800 dark:text-neutral-100">{item.from_address || '—'}</span>
{' · '}{t('messages.to', 'to')} <span className="font-mono text-xs">{toAddr}</span>
</div>
<div className="text-neutral-400 dark:text-neutral-500 text-xs mt-0.5 tabular-nums">{fmt(item.received_at)}</div>
</div>
<div className="text-neutral-400 dark:text-neutral-500 text-xs mt-0.5 tabular-nums">{fmt(item.received_at)}</div>
</div>
<div className="mt-4 text-sm text-neutral-500 dark:text-neutral-400 italic">
{t('messages.inboundBodyPending', 'Full message bodies are captured from the next phase. For now this shows the envelope and any attached document.')}
</div>
{detail.isLoading ? (
<div className="mt-4"><Loading /></div>
) : detail.data?.body_html ? (
<div className="mt-4 rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white" style={{ height: '48vh' }}>
{/* Sanitized server-side; rendered with a strict (script-less, no
same-origin) sandbox as a second layer against untrusted mail. */}
<iframe title="Email body" sandbox="" srcDoc={detail.data.body_html} className="w-full h-full border-0" />
</div>
) : detail.data?.body_text ? (
<pre className="mt-4 whitespace-pre-wrap text-sm text-neutral-700 dark:text-neutral-300 font-sans">{detail.data.body_text}</pre>
) : (
<div className="mt-4 text-sm text-neutral-500 dark:text-neutral-400 italic">
{t('messages.noInboundBody', 'No message body was captured for this email.')}
</div>
)}
{item.inbound_document_id != null && (
<div className="mt-5 flex flex-wrap gap-2">
<button
onClick={() => onViewDoc(item.inbound_document_id as number)}
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium"
>
<FileText className="w-4 h-4" />{t('messages.viewDocument', 'View document')}
</button>
<button
onClick={onOpenAccounting}
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border border-neutral-300 dark:border-neutral-700 text-neutral-700 dark:text-neutral-200 text-sm font-medium hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
<Link2 className="w-4 h-4" />{t('messages.openInAccounting', 'Open in Accounting inbox')}
</button>
</div>
)}
{item.error && (
<div className="mt-4 text-sm text-red-600 dark:text-red-400">{item.error}</div>
)}
</>
);
{item.inbound_document_id != null && (
<div className="mt-5 flex flex-wrap gap-2">
<button
onClick={() => onViewDoc(item.inbound_document_id as number)}
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium"
>
<FileText className="w-4 h-4" />{t('messages.viewDocument', 'View document')}
</button>
<button
onClick={onOpenAccounting}
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border border-neutral-300 dark:border-neutral-700 text-neutral-700 dark:text-neutral-200 text-sm font-medium hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
<Link2 className="w-4 h-4" />{t('messages.openInAccounting', 'Open in Accounting inbox')}
</button>
</div>
)}
{item.error && (
<div className="mt-4 text-sm text-red-600 dark:text-red-400">{item.error}</div>
)}
</>
);
};
// ─────────────────────────────────────────────────────────────── toolbar ──
const Toolbar: React.FC<{ isAcct: boolean; readonly: boolean; t: (k: string, d?: string) => string }> = ({ isAcct, readonly, t }) => {
+39 -1
View File
@@ -125,7 +125,9 @@ export interface ImapPollResult {
export interface ReceivedEmail {
id: number;
message_id: string | null;
account_key?: string | null;
from_address: string | null;
to_address?: string | null;
subject: string | null;
received_at: string | null;
attachment_count: number;
@@ -134,11 +136,31 @@ export interface ReceivedEmail {
error: string | null;
}
/** Single received email including its captured, server-sanitized body. */
export interface ReceivedEmailDetail extends ReceivedEmail {
body_html: string | null;
body_text: string | null;
}
export interface ReceivedEmailsResponse {
items: ReceivedEmail[];
pagination: { page: number; pageSize: number; total: number; totalPages: number };
}
/** An additional inbound mailbox beyond the primary accounting IMAP. */
export interface MailAccount {
id?: number;
account_key: string;
label?: string | null;
imap_host?: string | null;
imap_port?: number;
imap_secure?: boolean;
imap_user?: string | null;
imap_pass?: string;
imap_folder?: string;
enabled?: boolean;
}
export const emailService = {
// Get email configuration
async getConfig(): Promise<EmailConfig> {
@@ -181,10 +203,26 @@ export const emailService = {
const response = await api.post<ImapPollResult>('/admin/email/incoming-config/poll', {});
return response.data;
},
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
async listReceived(params: { page?: number; pageSize?: number; account?: string } = {}): Promise<ReceivedEmailsResponse> {
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
return response.data;
},
async getReceivedItem(id: number): Promise<ReceivedEmailDetail> {
const response = await api.get<ReceivedEmailDetail>(`/admin/email/received/${id}`);
return response.data;
},
// Additional inbound mailboxes (e.g. the customer hello@ box).
async listMailAccounts(): Promise<MailAccount[]> {
const response = await api.get<{ items: MailAccount[] }>('/admin/email/accounts');
return response.data.items;
},
async saveMailAccount(account: MailAccount): Promise<void> {
await api.post('/admin/email/accounts', account);
},
async testMailAccount(account: Partial<MailAccount>): Promise<ImapTestResult> {
const response = await api.post<ImapTestResult>('/admin/email/accounts/test', account);
return response.data;
},
// Test email configuration
async testEmail(testEmail: string): Promise<void> {