Merge pull request #355 from Luca-Timo/feat/messages-email-client
feat(messages): Outlook-style Messages email client (3 phases, flag-gated)
This commit is contained in:
@@ -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');
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
<Route element={<RequireFeature flag="userManagement" />}>
|
||||
<Route path="users" element={<UserManagementPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireFeature flag="messaging" />}>
|
||||
<Route path="messages" element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<MessagesPage />
|
||||
</Suspense>
|
||||
} />
|
||||
</Route>
|
||||
{/* 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/*
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
@@ -197,6 +197,7 @@
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Veranstaltungen",
|
||||
"messages": "Nachrichten",
|
||||
"settings": "Einstellungen",
|
||||
"systemHealth": "Systemzustand",
|
||||
"archives": "Archive",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Events",
|
||||
"archives": "Archives",
|
||||
"messages": "Messages",
|
||||
"settings": "Settings",
|
||||
"systemHealth": "System health",
|
||||
"eventTypes": "Event Types",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(720px,96vw)] max-h-[92vh] flex flex-col overflow-hidden shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<span className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">{title || t('messages.compose', 'Compose message')}</span>
|
||||
<button onClick={onClose} className="ml-auto w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800" aria-label={t('messages.close', 'Close')}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-3 overflow-y-auto">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="w-16 text-neutral-500 dark:text-neutral-400">{t('messages.to', 'To')}</span>
|
||||
<input className={inputCls} value={to} onChange={(e) => setTo(e.target.value)} placeholder="[email protected]" />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="w-16 text-neutral-500 dark:text-neutral-400">Cc</span>
|
||||
<input className={inputCls} value={cc} onChange={(e) => setCc(e.target.value)} placeholder={t('messages.optional', 'optional')} />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="w-16 text-neutral-500 dark:text-neutral-400">{t('messages.subject', 'Subject')}</span>
|
||||
<input className={inputCls} value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</label>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{t('messages.bodyHint', 'Edit the message freely — add a note anywhere before sending.')}
|
||||
</div>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
className="min-h-[220px] max-h-[46vh] overflow-y-auto rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-950 p-3 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<span className="text-xs text-neutral-400">{t('messages.sendsFromHint', 'Sends from your configured outgoing address.')}</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>{t('messages.cancel', 'Cancel')}</Button>
|
||||
<Button variant="primary" onClick={() => send.mutate()} isLoading={send.isPending} disabled={!canSend} leftIcon={<SendIcon className="w-4 h-4" />}>
|
||||
{t('messages.send', 'Send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageComposer;
|
||||
@@ -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<string, string> = {
|
||||
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<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',
|
||||
};
|
||||
|
||||
export const MessagesPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [activeFolder, setActiveFolder] = useState('auto-sent');
|
||||
const [selection, setSelection] = useState<Selection>(null);
|
||||
const [pdfDocId, setPdfDocId] = useState<number | null>(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 (
|
||||
<div className="flex flex-col h-[calc(100vh-8.5rem)] min-h-[540px]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<Mail className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
|
||||
{t('messages.title', 'Messages')}
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||
{t('messages.subtitle', 'Sent, automated and incoming mail — one place.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white dark:bg-neutral-900">
|
||||
{/* ── account tree ── */}
|
||||
<nav className="w-56 flex-none border-r border-neutral-200 dark:border-neutral-800 overflow-y-auto p-2 bg-neutral-50 dark:bg-neutral-950/40">
|
||||
{accounts.map((a) => (
|
||||
<div key={a.id} className="mb-1.5">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-sm font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
<span className="w-2 h-2 rounded-full flex-none" style={{ background: a.color }} />
|
||||
<span>{a.name}</span>
|
||||
{a.addr && <span className="ml-auto text-[11px] font-medium font-mono text-neutral-400 dark:text-neutral-500">{a.addr}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{a.folders.map((f) => {
|
||||
const c = countFor(f);
|
||||
const active = f.id === activeFolder;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => { setActiveFolder(f.id); setSelection(null); }}
|
||||
className={`flex items-center gap-2 pl-7 pr-2 py-1.5 rounded-lg text-[13.5px] text-left transition-colors ${
|
||||
active
|
||||
? 'bg-blue-50 dark:bg-blue-900/25 text-blue-800 dark:text-blue-200 font-semibold ring-1 ring-inset ring-blue-200 dark:ring-blue-800'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/60'
|
||||
}`}
|
||||
>
|
||||
<f.icon className="w-4 h-4 opacity-80" />
|
||||
<span>{f.name}</span>
|
||||
{typeof c === 'number' && c > 0 && (
|
||||
<span className={`ml-auto tabular-nums text-xs ${active ? 'text-blue-600 dark:text-blue-300' : 'text-neutral-400'}`}>{c}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* ── message list ── */}
|
||||
<section className="w-[22rem] flex-none flex flex-col min-h-0 border-r border-neutral-200 dark:border-neutral-800">
|
||||
<div className="px-4 py-3 border-b border-neutral-200 dark:border-neutral-800 flex-none">
|
||||
<div className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{folder.f.name}</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
{folder.a.addr || (folder.a.id === 'all' ? t('messages.unified', 'Unified across accounts') : t('messages.systemGenerated', 'System-generated'))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<MessageList
|
||||
folder={folder.f}
|
||||
queue={queueFor(folder.f.origin)}
|
||||
received={receivedItems}
|
||||
loading={folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedLoading : false}
|
||||
selection={selection}
|
||||
onSelect={setSelection}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── reading pane ── */}
|
||||
<section className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||
<ReadingPane
|
||||
selection={selection}
|
||||
account={folder.a}
|
||||
lang={i18n.language}
|
||||
onViewDoc={setPdfDocId}
|
||||
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
|
||||
onCompose={(init, title) => setComposer({ init, title })}
|
||||
t={t}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{pdfDocId != null && <PdfModal docId={pdfDocId} onClose={() => setPdfDocId(null)} t={t} />}
|
||||
{composer && (
|
||||
<MessageComposer
|
||||
init={composer.init}
|
||||
title={composer.title}
|
||||
onClose={() => setComposer(null)}
|
||||
onSent={() => { queueQuery.refetch(); setActiveFolder('cust-sent'); }}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────── 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 (
|
||||
<div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<Inbox className="w-8 h-8 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" />
|
||||
{folder.note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) return <div className="p-6"><Loading /></div>;
|
||||
|
||||
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 <div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">{t('messages.noMessages', 'No messages')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{rows.map((r) => (
|
||||
<li key={r.key}>
|
||||
<button
|
||||
onClick={r.onClick}
|
||||
className={`w-full text-left px-4 py-3 border-b border-neutral-100 dark:border-neutral-800/70 border-l-[3px] transition-colors ${
|
||||
r.active
|
||||
? 'border-l-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-l-transparent hover:bg-neutral-50 dark:hover:bg-neutral-800/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[13.5px] text-neutral-800 dark:text-neutral-100 truncate">{r.who}</span>
|
||||
<span className="ml-auto text-[11px] text-neutral-400 tabular-nums whitespace-nowrap">{r.when}</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-neutral-600 dark:text-neutral-300 truncate mt-0.5">{r.subject}</div>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className={`text-[10.5px] font-semibold px-1.5 py-0.5 rounded-full ${STATUS_STYLES[r.status] || 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300'}`}>
|
||||
{r.status}
|
||||
</span>
|
||||
{r.attach > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-neutral-400">
|
||||
<Paperclip className="w-3 h-3" />{r.attach}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────── 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 (
|
||||
<div className="flex-1 grid place-items-center text-center text-neutral-400 dark:text-neutral-500 p-10">
|
||||
<div>
|
||||
<Mail className="w-9 h-9 mx-auto mb-3 text-neutral-300 dark:text-neutral-700" />
|
||||
<div className="text-sm">{t('messages.selectPrompt', 'Select a message to read')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 = `<p><br></p><p style="color:#888;font-size:12px">${t('messages.onWrote', 'On')} ${fmt(it.received_at)}, ${it.from_address}:</p>`;
|
||||
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 (
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<Toolbar isAcct={isAcct} onReply={onReply} onCreate={onCreate} t={t} />
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{selection.kind === 'queue' ? (
|
||||
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
|
||||
<QueueDetail d={detailQuery.data} t={t} />
|
||||
) : (
|
||||
<div className="text-sm text-neutral-500">{t('messages.loadError', 'Could not load this message.')}</div>
|
||||
)
|
||||
) : (
|
||||
<ReceivedDetail item={selection.item} onViewDoc={onViewDoc} onOpenAccounting={onOpenAccounting} t={t} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const QueueDetail: React.FC<{ d: import('../../../services/email.service').EmailQueueDetail; t: (k: string, d?: string) => string }> = ({ d, t }) => (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
||||
{friendlyType(d.emailType)}
|
||||
</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-mono text-xs">no-reply@</span> · {t('messages.to', 'to')}{' '}
|
||||
<span className="font-semibold text-neutral-800 dark:text-neutral-100">{d.recipientEmail}</span>
|
||||
</div>
|
||||
{d.cc && <div className="text-neutral-500 dark:text-neutral-400 text-xs mt-0.5">cc {d.cc}</div>}
|
||||
<div className="text-neutral-400 dark:text-neutral-500 text-xs mt-0.5 tabular-nums">{fmt(d.sentAt || d.createdAt)}</div>
|
||||
</div>
|
||||
|
||||
{d.renderedHtml ? (
|
||||
<div className="mt-4 rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white" style={{ height: '52vh' }}>
|
||||
{/* rendered_html is our own template output — sandboxed, scripts blocked */}
|
||||
<iframe title="Email body" sandbox="allow-same-origin" srcDoc={d.renderedHtml} className="w-full h-full border-0" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('messages.noBody', 'This message was sent before body capture was added, so no preview is available.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.attachments.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<div className="text-[11px] font-bold uppercase tracking-wide text-neutral-400 mb-2">
|
||||
{d.attachments.length} {t('messages.attachments', 'attachment(s)')}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 max-w-md">
|
||||
{d.attachments.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/40">
|
||||
<FileText className="w-5 h-5 text-red-500 flex-none" />
|
||||
<span className="text-[13.5px] font-medium text-neutral-800 dark:text-neutral-100 truncate">{a.filename}</span>
|
||||
<span className="ml-auto text-[11px] text-neutral-400" title={t('messages.sentAttachHint', 'Sent attachments are not archived yet — Phase 2.')}>
|
||||
{t('messages.notArchived', 'not archived yet')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const ReceivedDetail: React.FC<{
|
||||
item: ReceivedEmail;
|
||||
onViewDoc: (id: number) => void;
|
||||
onOpenAccounting: () => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ 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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── toolbar ──
|
||||
const Toolbar: React.FC<{
|
||||
isAcct: boolean;
|
||||
onReply?: () => void;
|
||||
onCreate?: (key: string, label: string) => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ isAcct, onReply, onCreate, t }) => {
|
||||
const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => {
|
||||
const enabled = !!onClick;
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={!enabled}
|
||||
title={enabled ? undefined : t('messages.soon', 'Available in a later phase')}
|
||||
className={`inline-flex items-center gap-1.5 h-8 px-2.5 rounded-lg text-[13px] font-medium ${
|
||||
enabled ? 'hover:bg-neutral-100 dark:hover:bg-neutral-800 ' : 'cursor-not-allowed opacity-50 '
|
||||
}${accent ? 'text-blue-700 dark:text-blue-300 ring-1 ring-inset ring-blue-200 dark:ring-blue-800' : 'text-neutral-600 dark:text-neutral-300'}`}
|
||||
>
|
||||
<Icon className="w-[15px] h-[15px]" />{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const create = (key: string, labelKey: string, fallback: string) =>
|
||||
onCreate ? () => onCreate(key, t(labelKey, fallback)) : undefined;
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-wrap px-3 py-2 border-b border-neutral-200 dark:border-neutral-800 flex-none">
|
||||
<Tb icon={Reply} label={t('messages.reply', 'Reply')} onClick={onReply} />
|
||||
<Tb icon={ReplyAll} label={t('messages.replyAll', 'Reply all')} />
|
||||
<Tb icon={Forward} label={t('messages.forward', 'Forward')} />
|
||||
<span className="w-px h-5 bg-neutral-200 dark:bg-neutral-700 mx-1" />
|
||||
{isAcct ? (
|
||||
<>
|
||||
<Tb icon={ReceiptText} label={t('messages.bookExpense', 'Book as expense')} accent />
|
||||
<Tb icon={Forward} label={t('messages.rebill', 'Re-bill to client')} accent />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tb icon={Quote} label={t('messages.createQuote', 'Quote')} accent onClick={create('quote_sent', 'messages.createQuote', 'Quote')} />
|
||||
<Tb icon={FileSignature} label={t('messages.createContract', 'Contract')} accent onClick={create('contract_sent', 'messages.createContract', 'Contract')} />
|
||||
<Tb icon={ImageIcon} label={t('messages.createGallery', 'Gallery')} accent onClick={create('', 'messages.createGallery', 'Gallery')} />
|
||||
<Tb icon={FileText} label={t('messages.createInvoice', 'Invoice')} accent onClick={create('invoice_sent', 'messages.createInvoice', 'Invoice')} />
|
||||
</>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
<Tb icon={Archive} label={t('messages.archive', 'Archive')} />
|
||||
<Tb icon={Trash2} label={t('messages.delete', 'Delete')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── pdf modal ──
|
||||
const PdfModal: React.FC<{ docId: number; onClose: () => void; t: (k: string, d?: string) => string }> = ({ docId, onClose, t }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [err, setErr] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let revoked: string | null = null;
|
||||
let cancelled = false;
|
||||
setErr(false);
|
||||
setUrl(null);
|
||||
accountingService.getInboundPageBlob(docId, page)
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
const u = URL.createObjectURL(blob);
|
||||
revoked = u;
|
||||
setUrl(u);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setErr(true); });
|
||||
return () => { cancelled = true; if (revoked) URL.revokeObjectURL(revoked); };
|
||||
}, [docId, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-6" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(620px,94vw)] max-h-[90vh] flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<FileText className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100">{t('messages.document', 'Document')}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-40">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-neutral-500 w-6 text-center">{page}</span>
|
||||
<button onClick={() => setPage((p) => p + 1)}
|
||||
className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onClose} aria-label={t('messages.close', 'Close')}
|
||||
className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 ml-1">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto p-5 bg-neutral-100 dark:bg-neutral-800 grid place-items-center min-h-[240px]">
|
||||
{err ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">{t('messages.previewUnavailable', 'Preview unavailable')}</div>
|
||||
) : url ? (
|
||||
<img src={url} alt="" className="max-w-full shadow-lg rounded" />
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-[11px] text-neutral-400 py-2 border-t border-neutral-200 dark:border-neutral-800">
|
||||
{t('messages.rasterNote', 'Server-rendered preview — the raw file never reaches the browser.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagesPage;
|
||||
@@ -15,6 +15,8 @@ export interface EmailQueueItem {
|
||||
eventId: number | null;
|
||||
eventName: string | null;
|
||||
eventSlug: string | null;
|
||||
/** 'system' = app-generated (Automated), 'manual' = admin-composed (Customers Sent). */
|
||||
origin?: 'system' | 'manual';
|
||||
}
|
||||
|
||||
export interface EmailQueueListResponse {
|
||||
@@ -22,6 +24,15 @@ export interface EmailQueueListResponse {
|
||||
pagination: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}
|
||||
|
||||
/** Single sent/queued email including its rendered body — Messages reading pane. */
|
||||
export interface EmailQueueDetail extends EmailQueueItem {
|
||||
/** Exact HTML that was sent (migration 119); null for pre-migration rows. */
|
||||
renderedHtml: string | null;
|
||||
cc: string | null;
|
||||
/** Attachment filenames only — disk paths are never exposed. */
|
||||
attachments: { filename: string; contentType: string | null }[];
|
||||
}
|
||||
|
||||
export interface EmailConfig {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
@@ -116,7 +127,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;
|
||||
@@ -125,11 +138,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> {
|
||||
@@ -172,10 +205,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> {
|
||||
@@ -197,6 +246,7 @@ export const emailService = {
|
||||
async listQueue(params: {
|
||||
status?: EmailQueueStatus;
|
||||
emailType?: string;
|
||||
origin?: 'system' | 'manual';
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -207,6 +257,17 @@ export const emailService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Single sent email with its rendered body + attachment filenames. */
|
||||
async getQueueItem(id: number): Promise<EmailQueueDetail> {
|
||||
const response = await api.get<EmailQueueDetail>(`/admin/email/queue/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Send a human-composed (edited) email — reply or document message. */
|
||||
async sendMessage(payload: { to: string; cc?: string; subject: string; html: string; replyToReceivedId?: number }): Promise<void> {
|
||||
await api.post('/admin/email/send', payload);
|
||||
},
|
||||
|
||||
// Get all email templates
|
||||
async getTemplates(): Promise<EmailTemplate[]> {
|
||||
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
||||
|
||||
Reference in New Issue
Block a user