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:
@@ -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));
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user