fix(messages): dynamic addresses, branding accent, compose/sync, per-identity SMTP

Addresses dev-test feedback:
- Sidebar + reading-pane addresses (rechnungen@ / hello@ / no-reply@) are now
  read from the mail config via GET /admin/email/identities, not hardcoded.
- Highlight/selection now uses the branding accent (bg-accent-soft /
  text-on-accent-soft / accent-dark) instead of hardcoded blue, so it follows
  the admin's CI colour like the sidebar.
- Header gains "New message" (compose) and "Sync" (poll mailboxes now) buttons.
- Composer modal enlarged (920px, taller editable body).
- Customer mailbox (hello@) now has BOTH incoming (IMAP) and outgoing (SMTP)
  settings — migration 156 adds smtp_* + from_* to mail_accounts;
  emailProcessor.sendRawEmail takes an accountKey and sends via that mailbox's
  SMTP identity (falls back to the global from). Manual/reply sends from the
  Messages UI use the 'customers' identity, so replies come from hello@.

Frontend build + migration boot (156) verified.
This commit is contained in:
Luca
2026-07-07 11:58:41 +02:00
parent 976d52280e
commit f9c2b4ed75
7 changed files with 249 additions and 38 deletions
@@ -0,0 +1,34 @@
/**
* Messages Phase 3 follow-up — outgoing (SMTP) settings per mail account.
*
* The customer mailbox (hello@) needs BOTH incoming (IMAP, migration 154) and
* outgoing (SMTP) config, so replies to customers send from hello@ instead of
* the global no-reply@ identity. All additive/guarded.
*/
exports.up = async function up(knex) {
const cols = [
['smtp_host', (t) => t.string('smtp_host', 255)],
['smtp_port', (t) => t.integer('smtp_port')],
['smtp_secure', (t) => t.boolean('smtp_secure').defaultTo(false)],
['smtp_user', (t) => t.string('smtp_user', 255)],
['smtp_pass', (t) => t.string('smtp_pass', 512)],
['from_email', (t) => t.string('from_email', 255)],
['from_name', (t) => t.string('from_name', 120)],
];
for (const [name, add] of cols) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn('mail_accounts', name);
// eslint-disable-next-line no-await-in-loop
if (!has) await knex.schema.alterTable('mail_accounts', add);
}
};
exports.down = async function down(knex) {
const cols = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'from_email', 'from_name'];
for (const name of cols) {
// eslint-disable-next-line no-await-in-loop
const has = await knex.schema.hasColumn('mail_accounts', name);
// eslint-disable-next-line no-await-in-loop
if (has) await knex.schema.alterTable('mail_accounts', (t) => t.dropColumn(name));
}
};
+38 -2
View File
@@ -300,12 +300,38 @@ router.get('/received/:id', adminAuth, requirePermission('email.view'), async (r
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 ? '********' : '' })) });
res.json({ items: rows.map((a) => ({
...a,
imap_pass: a.imap_pass ? '********' : '',
smtp_pass: a.smtp_pass ? '********' : '',
})) });
} catch (error) {
errorResponse(res, error, 500, 'Failed to load mail accounts');
}
});
// Resolved sender/mailbox addresses for the Messages UI — so the sidebar shows
// the REAL configured addresses instead of hardcoded placeholders. Accounting =
// the primary IMAP login (rechnungen@); customers = the hello@ mailbox; the
// automated stream sends from the global SMTP from-address.
router.get('/identities', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const cfg = await db('email_configs').first();
let customers = null;
try {
const cust = await db('mail_accounts').where({ account_key: 'customers' }).first();
customers = cust?.imap_user || cust?.from_email || null;
} catch (_) { customers = null; }
res.json({
automated: cfg?.from_email || null,
accounting: cfg?.imap_user || null,
customers,
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to load mail identities');
}
});
// Upsert a mailbox by account_key. A masked password ('********') keeps the
// stored value so the admin never has to re-type it.
router.post('/accounts', adminAuth, requirePermission('email.edit'), async (req, res) => {
@@ -319,10 +345,18 @@ router.post('/accounts', adminAuth, requirePermission('email.edit'), async (req,
imap_secure: b.imap_secure !== false,
imap_user: b.imap_user || null,
imap_folder: b.imap_folder || 'INBOX',
// Outgoing (SMTP) identity — replies from this mailbox send from here.
smtp_host: b.smtp_host || null,
smtp_port: b.smtp_port ? parseInt(b.smtp_port, 10) : 587,
smtp_secure: b.smtp_secure === true,
smtp_user: b.smtp_user || null,
from_email: b.from_email || null,
from_name: b.from_name || null,
enabled: !!b.enabled,
updated_at: new Date(),
};
if (b.imap_pass && b.imap_pass !== '********') patch.imap_pass = b.imap_pass;
if (b.smtp_pass && b.smtp_pass !== '********') patch.smtp_pass = b.smtp_pass;
const existing = await db('mail_accounts').where({ account_key: b.account_key }).first();
if (existing) {
await db('mail_accounts').where({ account_key: b.account_key }).update(patch);
@@ -330,6 +364,7 @@ router.post('/accounts', adminAuth, requirePermission('email.edit'), async (req,
await db('mail_accounts').insert({
account_key: b.account_key,
imap_pass: (b.imap_pass && b.imap_pass !== '********') ? b.imap_pass : '',
smtp_pass: (b.smtp_pass && b.smtp_pass !== '********') ? b.smtp_pass : '',
created_at: new Date(),
...patch,
});
@@ -691,9 +726,10 @@ router.post('/send', adminAuth, requirePermission('email.send'), async (req, res
allowedSchemes: ['http', 'https', 'mailto', 'cid', 'data'],
});
const cc = b.cc ? String(b.cc).trim() : null;
const accountKey = b.accountKey ? String(b.accountKey) : undefined;
const emailProcessor = require('../services/emailProcessor');
const result = await emailProcessor.sendRawEmail({ to, cc, subject, html });
const result = await emailProcessor.sendRawEmail({ to, cc, subject, html, accountKey });
await db('email_queue').insert({
recipient_email: to,
+33 -7
View File
@@ -781,18 +781,44 @@ async function sendTemplateEmail(to, templateKey, variables) {
* 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');
async function sendRawEmail({ to, cc, subject, html, text, attachments, accountKey } = {}) {
let tx = null;
let fromEmail = null;
let fromName = null;
// Prefer a per-account outgoing identity (e.g. hello@) when the mail account
// has its own SMTP config, so customer replies send from that address instead
// of the global no-reply@. Falls back to the global SMTP transport.
if (accountKey) {
const acct = await db('mail_accounts').where({ account_key: accountKey }).first();
if (acct && acct.smtp_host && (acct.smtp_user || acct.from_email)) {
const nodemailer = require('nodemailer');
tx = nodemailer.createTransport({
host: acct.smtp_host,
port: parseInt(acct.smtp_port, 10) || 587,
secure: acct.smtp_secure === true || acct.smtp_secure === 1,
auth: acct.smtp_user && acct.smtp_pass ? { user: acct.smtp_user, pass: acct.smtp_pass } : undefined,
});
fromEmail = acct.from_email || acct.smtp_user;
fromName = acct.from_name || '';
}
}
if (!tx) {
tx = await initializeTransporter();
if (!tx) 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');
fromEmail = config.from_email;
fromName = config.from_name;
}
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}>`,
const info = await tx.sendMail({
from: `${fromName || 'picpeak'} <${fromEmail}>`,
to,
cc: ccList,
subject,