diff --git a/backend/migrations/core/156_mail_accounts_smtp.js b/backend/migrations/core/156_mail_accounts_smtp.js
new file mode 100644
index 00000000..7d0ec232
--- /dev/null
+++ b/backend/migrations/core/156_mail_accounts_smtp.js
@@ -0,0 +1,34 @@
+/**
+ * Messages Phase 3 follow-up — outgoing (SMTP) settings per mail account.
+ *
+ * The customer mailbox (hello@) needs BOTH incoming (IMAP, migration 154) and
+ * outgoing (SMTP) config, so replies to customers send from hello@ instead of
+ * the global no-reply@ identity. All additive/guarded.
+ */
+exports.up = async function up(knex) {
+ const cols = [
+ ['smtp_host', (t) => t.string('smtp_host', 255)],
+ ['smtp_port', (t) => t.integer('smtp_port')],
+ ['smtp_secure', (t) => t.boolean('smtp_secure').defaultTo(false)],
+ ['smtp_user', (t) => t.string('smtp_user', 255)],
+ ['smtp_pass', (t) => t.string('smtp_pass', 512)],
+ ['from_email', (t) => t.string('from_email', 255)],
+ ['from_name', (t) => t.string('from_name', 120)],
+ ];
+ for (const [name, add] of cols) {
+ // eslint-disable-next-line no-await-in-loop
+ const has = await knex.schema.hasColumn('mail_accounts', name);
+ // eslint-disable-next-line no-await-in-loop
+ if (!has) await knex.schema.alterTable('mail_accounts', add);
+ }
+};
+
+exports.down = async function down(knex) {
+ const cols = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'from_email', 'from_name'];
+ for (const name of cols) {
+ // eslint-disable-next-line no-await-in-loop
+ const has = await knex.schema.hasColumn('mail_accounts', name);
+ // eslint-disable-next-line no-await-in-loop
+ if (has) await knex.schema.alterTable('mail_accounts', (t) => t.dropColumn(name));
+ }
+};
diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js
index b424379e..04362c63 100644
--- a/backend/src/routes/adminEmail.js
+++ b/backend/src/routes/adminEmail.js
@@ -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,
diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js
index a33e815f..c87662e7 100644
--- a/backend/src/services/emailProcessor.js
+++ b/backend/src/services/emailProcessor.js
@@ -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,
diff --git a/frontend/src/components/admin/CustomerMailboxCard.tsx b/frontend/src/components/admin/CustomerMailboxCard.tsx
index e02c7e4b..5bd6642d 100644
--- a/frontend/src/components/admin/CustomerMailboxCard.tsx
+++ b/frontend/src/components/admin/CustomerMailboxCard.tsx
@@ -107,6 +107,46 @@ export const CustomerMailboxCard: React.FC = () => {
set('imap_folder', e.target.value)} placeholder="INBOX" />
+
+
+ {t('email.customerMailbox.outgoing', 'Outgoing (SMTP)')}
+
+
+ {t('email.customerMailbox.outgoingHint', 'Replies from this mailbox send from here. Leave blank to fall back to the global outgoing address.')}
+
+
+
+
+ set('from_email', e.target.value)} placeholder="hello@yourdomain.com" leftIcon={} />
+
+
+
+ set('smtp_host', e.target.value)} placeholder="smtp.example.com" leftIcon={} />
+
+
+
+
+ set('smtp_port', parseInt(e.target.value, 10) || 0)} placeholder="587" />
+
+
+
+
+
+
+
+
+ set('smtp_user', e.target.value)} autoComplete="off" placeholder="hello@yourdomain.com" leftIcon={} />
+
+
+
+ set('smtp_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={} />
+
+
+
+