From f9c2b4ed75b8a22182226a6328be0029c54d7bc4 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:53:09 +0200 Subject: [PATCH] fix(messages): dynamic addresses, branding accent, compose/sync, per-identity SMTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../migrations/core/156_mail_accounts_smtp.js | 34 +++++++ backend/src/routes/adminEmail.js | 40 +++++++- backend/src/services/emailProcessor.js | 40 ++++++-- .../components/admin/CustomerMailboxCard.tsx | 40 ++++++++ .../pages/admin/messages/MessageComposer.tsx | 14 +-- .../src/pages/admin/messages/MessagesPage.tsx | 96 ++++++++++++++----- frontend/src/services/email.service.ts | 23 ++++- 7 files changed, 249 insertions(+), 38 deletions(-) create mode 100644 backend/migrations/core/156_mail_accounts_smtp.js 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={} /> +
+
+
+
-
+
-
+
{t('messages.bodyHint', 'Edit the message freely — add a note anywhere before sending.')}
@@ -92,7 +94,7 @@ export const MessageComposer: React.FC<{ 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" + className="min-h-[240px] flex-1 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-accent" />
diff --git a/frontend/src/pages/admin/messages/MessagesPage.tsx b/frontend/src/pages/admin/messages/MessagesPage.tsx index 81c0e8f3..ac11eaa6 100644 --- a/frontend/src/pages/admin/messages/MessagesPage.tsx +++ b/frontend/src/pages/admin/messages/MessagesPage.tsx @@ -1,13 +1,14 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; import { Inbox, Send, Reply, ReplyAll, Forward, Archive, Trash2, Paperclip, FileText, Quote, FileSignature, Image as ImageIcon, ReceiptText, - Link2, X, ChevronLeft, ChevronRight, Mail, type LucideIcon, + Link2, X, ChevronLeft, ChevronRight, Mail, RefreshCw, PenSquare, type LucideIcon, } from 'lucide-react'; -import { emailService, type ReceivedEmail } from '../../../services/email.service'; +import { emailService, type ReceivedEmail, type MailIdentities } from '../../../services/email.service'; import { accountingService } from '../../../services/accounting.service'; import { Loading } from '../../../components/common'; import { MessageComposer, type ComposerInit } from './MessageComposer'; @@ -63,7 +64,26 @@ export const MessagesPage: React.FC = () => { const [activeFolder, setActiveFolder] = useState('auto-sent'); const [selection, setSelection] = useState(null); const [pdfDocId, setPdfDocId] = useState(null); - const [composer, setComposer] = useState<{ init: ComposerInit; title?: string } | null>(null); + const [composer, setComposer] = useState<{ init: ComposerInit; title?: string; accountKey?: string } | null>(null); + + // "Sync" = poll the inbound mailboxes now instead of waiting for the 60s loop. + const sync = useMutation({ + mutationFn: () => emailService.pollIncoming(), + onSuccess: (r) => { + if (r.skipped === 'disabled') toast.info(t('messages.syncDisabled', 'Incoming mail is off — enable it under Settings → Features.')); + else if (r.skipped === 'unconfigured') toast.info(t('messages.syncUnconfigured', 'Configure a mailbox under Settings → Email first.')); + else if (r.skipped === 'busy') toast.info(t('messages.syncBusy', 'A sync is already running.')); + else toast.success(t('messages.syncOk', 'Checked mailboxes — {{count}} new.', { count: r.processed || 0 })); + acctQuery.refetch(); custQuery.refetch(); queueQuery.refetch(); + }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.syncFailed', 'Sync failed.')), + }); + + const openNewMessage = () => setComposer({ + init: { to: '', subject: '', html: '' }, + title: t('messages.newMessage', 'New message'), + accountKey: 'customers', + }); const queueQuery = useQuery({ queryKey: ['messages', 'queue'], @@ -81,6 +101,12 @@ export const MessagesPage: React.FC = () => { refetchInterval: 60000, }); + const identitiesQuery = useQuery({ + queryKey: ['messages', 'identities'], + queryFn: () => emailService.getIdentities(), + }); + const identities = identitiesQuery.data; + const queueTotal = queueQuery.data?.pagination.total; const acctTotal = acctQuery.data?.pagination.total; const custTotal = custQuery.data?.pagination.total; @@ -90,17 +116,17 @@ export const MessagesPage: React.FC = () => { { 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', name: t('messages.account.customers', 'Customers'), addr: identities?.customers || undefined, 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', name: t('messages.account.accounting', 'Accounting'), addr: identities?.accounting || undefined, 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', name: t('messages.account.automated', 'Automated'), addr: identities?.automated || undefined, color: '#7a52d6', folders: [ { id: 'auto-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'system' }, ] }, - ], [t]); + ], [t, identities]); // Sent stream is split client-side by origin: system (Automated) vs manual // (human composed → Customers ▸ Sent). Legacy rows (origin undefined) = system. @@ -153,6 +179,23 @@ export const MessagesPage: React.FC = () => { {t('messages.subtitle', 'Sent, automated and incoming mail — one place.')}

+
+ + +
@@ -175,14 +218,14 @@ export const MessagesPage: React.FC = () => { 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' + ? 'bg-accent-soft text-on-accent-soft font-semibold' : 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/60' }`} > {f.name} {typeof c === 'number' && c > 0 && ( - {c} + {c} )} ); @@ -219,9 +262,10 @@ export const MessagesPage: React.FC = () => { selection={selection} account={folder.a} lang={i18n.language} + identities={identities} onViewDoc={setPdfDocId} onOpenAccounting={() => navigate('/admin/accounting/inbox')} - onCompose={(init, title) => setComposer({ init, title })} + onCompose={(init, title) => setComposer({ init, title, accountKey: 'customers' })} t={t} /> @@ -232,6 +276,7 @@ export const MessagesPage: React.FC = () => { setComposer(null)} onSent={() => { queueQuery.refetch(); setActiveFolder('cust-sent'); }} t={t} @@ -296,7 +341,7 @@ const MessageList: React.FC<{ 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-accent-dark bg-accent-soft' : 'border-l-transparent hover:bg-neutral-50 dark:hover:bg-neutral-800/40' }`} > @@ -327,11 +372,12 @@ const ReadingPane: React.FC<{ selection: Selection; account: Account; lang: string; + identities?: MailIdentities | null; 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 }) => { +}> = ({ selection, account, lang, identities, 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), @@ -389,26 +435,32 @@ const ReadingPane: React.FC<{
{selection.kind === 'queue' ? ( detailQuery.isLoading ? : detailQuery.data ? ( - + ) : (
{t('messages.loadError', 'Could not load this message.')}
) ) : ( - + )}
); }; -const QueueDetail: React.FC<{ d: import('../../../services/email.service').EmailQueueDetail; t: (k: string, d?: string) => string }> = ({ d, t }) => ( +const QueueDetail: React.FC<{ d: import('../../../services/email.service').EmailQueueDetail; fromAddr?: string | null; t: (k: string, d?: string) => string }> = ({ d, fromAddr, t }) => ( <>

{friendlyType(d.emailType)}

- {t('messages.from', 'from')} no-reply@ · {t('messages.to', 'to')}{' '} + {t('messages.from', 'from')} {fromAddr || '—'} · {t('messages.to', 'to')}{' '} {d.recipientEmail}
{d.cc &&
cc {d.cc}
} @@ -449,16 +501,16 @@ const QueueDetail: React.FC<{ d: import('../../../services/email.service').Email const ReceivedDetail: React.FC<{ item: ReceivedEmail; + mailboxAddr?: string | null; onViewDoc: (id: number) => void; onOpenAccounting: () => void; t: (k: string, d?: string) => string; -}> = ({ item, onViewDoc, onOpenAccounting, t }) => { +}> = ({ item, mailboxAddr, 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@'); + const toAddr = detail.data?.to_address || item.to_address || mailboxAddr || '—'; return ( <>

@@ -492,7 +544,7 @@ const ReceivedDetail: React.FC<{
@@ -527,7 +579,7 @@ const Toolbar: React.FC<{ 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'}`} + }${accent ? 'text-accent-dark font-semibold' : 'text-neutral-600 dark:text-neutral-300'}`} > {label} diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index 640cda41..ad008a90 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -160,9 +160,24 @@ export interface MailAccount { imap_user?: string | null; imap_pass?: string; imap_folder?: string; + // Outgoing (SMTP) identity — replies from this mailbox send from here. + smtp_host?: string | null; + smtp_port?: number; + smtp_secure?: boolean; + smtp_user?: string | null; + smtp_pass?: string; + from_email?: string | null; + from_name?: string | null; enabled?: boolean; } +/** Resolved sender/mailbox addresses for the Messages sidebar. */ +export interface MailIdentities { + automated: string | null; + accounting: string | null; + customers: string | null; +} + export const emailService = { // Get email configuration async getConfig(): Promise { @@ -264,10 +279,16 @@ export const emailService = { }, /** Send a human-composed (edited) email — reply or document message. */ - async sendMessage(payload: { to: string; cc?: string; subject: string; html: string; replyToReceivedId?: number }): Promise { + async sendMessage(payload: { to: string; cc?: string; subject: string; html: string; replyToReceivedId?: number; accountKey?: string }): Promise { await api.post('/admin/email/send', payload); }, + /** Resolved sender/mailbox addresses for the Messages sidebar. */ + async getIdentities(): Promise { + const response = await api.get('/admin/email/identities'); + return response.data; + }, + // Get all email templates async getTemplates(): Promise { const response = await api.get('/admin/email/templates');