Merge pull request #357 from Luca-Timo/feat/messages-email-client
fix(messages): dynamic addresses, branding accent, compose/sync, per-identity SMTP
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) => {
|
router.get('/accounts', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = await db('mail_accounts').orderBy('id');
|
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) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to load mail accounts');
|
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
|
// Upsert a mailbox by account_key. A masked password ('********') keeps the
|
||||||
// stored value so the admin never has to re-type it.
|
// stored value so the admin never has to re-type it.
|
||||||
router.post('/accounts', adminAuth, requirePermission('email.edit'), async (req, res) => {
|
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_secure: b.imap_secure !== false,
|
||||||
imap_user: b.imap_user || null,
|
imap_user: b.imap_user || null,
|
||||||
imap_folder: b.imap_folder || 'INBOX',
|
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,
|
enabled: !!b.enabled,
|
||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
};
|
};
|
||||||
if (b.imap_pass && b.imap_pass !== '********') patch.imap_pass = b.imap_pass;
|
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();
|
const existing = await db('mail_accounts').where({ account_key: b.account_key }).first();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await db('mail_accounts').where({ account_key: b.account_key }).update(patch);
|
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({
|
await db('mail_accounts').insert({
|
||||||
account_key: b.account_key,
|
account_key: b.account_key,
|
||||||
imap_pass: (b.imap_pass && b.imap_pass !== '********') ? b.imap_pass : '',
|
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(),
|
created_at: new Date(),
|
||||||
...patch,
|
...patch,
|
||||||
});
|
});
|
||||||
@@ -691,9 +726,10 @@ router.post('/send', adminAuth, requirePermission('email.send'), async (req, res
|
|||||||
allowedSchemes: ['http', 'https', 'mailto', 'cid', 'data'],
|
allowedSchemes: ['http', 'https', 'mailto', 'cid', 'data'],
|
||||||
});
|
});
|
||||||
const cc = b.cc ? String(b.cc).trim() : null;
|
const cc = b.cc ? String(b.cc).trim() : null;
|
||||||
|
const accountKey = b.accountKey ? String(b.accountKey) : undefined;
|
||||||
|
|
||||||
const emailProcessor = require('../services/emailProcessor');
|
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({
|
await db('email_queue').insert({
|
||||||
recipient_email: to,
|
recipient_email: to,
|
||||||
|
|||||||
@@ -781,18 +781,44 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
|||||||
* messages. Uses the configured SMTP identity + from address. Returns
|
* messages. Uses the configured SMTP identity + from address. Returns
|
||||||
* { messageId, html } so the caller can persist rendered_html for the record.
|
* { messageId, html } so the caller can persist rendered_html for the record.
|
||||||
*/
|
*/
|
||||||
async function sendRawEmail({ to, cc, subject, html, text, attachments } = {}) {
|
async function sendRawEmail({ to, cc, subject, html, text, attachments, accountKey } = {}) {
|
||||||
transporter = await initializeTransporter();
|
let tx = null;
|
||||||
if (!transporter) throw new Error('Email service not configured');
|
let fromEmail = null;
|
||||||
const config = await db('email_configs').first();
|
let fromName = null;
|
||||||
if (!config || !config.from_email) throw new Error('Email service not configured');
|
|
||||||
|
// 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 ccList = Array.isArray(cc) ? cc.filter(Boolean) : (cc ? [cc] : undefined);
|
||||||
const atts = Array.isArray(attachments)
|
const atts = Array.isArray(attachments)
|
||||||
? attachments.filter((a) => a && (a.contentPath || a.path || a.content))
|
? 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 }))
|
.map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType }))
|
||||||
: undefined;
|
: undefined;
|
||||||
const info = await transporter.sendMail({
|
const info = await tx.sendMail({
|
||||||
from: `${config.from_name} <${config.from_email}>`,
|
from: `${fromName || 'picpeak'} <${fromEmail}>`,
|
||||||
to,
|
to,
|
||||||
cc: ccList,
|
cc: ccList,
|
||||||
subject,
|
subject,
|
||||||
|
|||||||
@@ -107,6 +107,46 @@ export const CustomerMailboxCard: React.FC = () => {
|
|||||||
<Input type="text" value={cfg.imap_folder || 'INBOX'} onChange={(e) => set('imap_folder', e.target.value)} placeholder="INBOX" />
|
<Input type="text" value={cfg.imap_folder || 'INBOX'} onChange={(e) => set('imap_folder', e.target.value)} placeholder="INBOX" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 mt-1 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div className="text-sm font-semibold text-neutral-800 dark:text-neutral-200">
|
||||||
|
{t('email.customerMailbox.outgoing', 'Outgoing (SMTP)')}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 mb-3">
|
||||||
|
{t('email.customerMailbox.outgoingHint', 'Replies from this mailbox send from here. Leave blank to fall back to the global outgoing address.')}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('email.customerMailbox.fromEmail', 'From address')}</label>
|
||||||
|
<Input type="text" value={cfg.from_email || ''} onChange={(e) => set('from_email', e.target.value)} placeholder="[email protected]" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('email.customerMailbox.smtpHost', 'SMTP Host')}</label>
|
||||||
|
<Input type="text" value={cfg.smtp_host || ''} onChange={(e) => set('smtp_host', e.target.value)} placeholder="smtp.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')}</label>
|
||||||
|
<Input type="number" value={cfg.smtp_port ?? 587} onChange={(e) => set('smtp_port', parseInt(e.target.value, 10) || 0)} placeholder="587" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
|
||||||
|
<select className={selectCls} value={cfg.smtp_secure ? 'ssl' : 'starttls'} onChange={(e) => set('smtp_secure', e.target.value === 'ssl')}>
|
||||||
|
<option value="ssl">{t('email.customerMailbox.smtpSsl', 'SSL (465)')}</option>
|
||||||
|
<option value="starttls">{t('email.customerMailbox.smtpStarttls', 'STARTTLS (587)')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('email.customerMailbox.smtpUser', 'SMTP Username')}</label>
|
||||||
|
<Input type="text" value={cfg.smtp_user || ''} onChange={(e) => set('smtp_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.customerMailbox.smtpPass', 'SMTP Password')}</label>
|
||||||
|
<Input type={passwordVisibility.isOpen ? 'text' : 'password'} value={cfg.smtp_pass || ''} onChange={(e) => set('smtp_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<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">
|
<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')}
|
{t('email.incoming.test', 'Test connection')}
|
||||||
|
|||||||
@@ -19,15 +19,16 @@ export interface ComposerInit {
|
|||||||
replyToReceivedId?: number;
|
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';
|
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-accent';
|
||||||
|
|
||||||
export const MessageComposer: React.FC<{
|
export const MessageComposer: React.FC<{
|
||||||
init: ComposerInit;
|
init: ComposerInit;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
accountKey?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSent: () => void;
|
onSent: () => void;
|
||||||
t: (k: string, d?: string) => string;
|
t: (k: string, d?: string) => string;
|
||||||
}> = ({ init, title, onClose, onSent, t }) => {
|
}> = ({ init, title, accountKey, onClose, onSent, t }) => {
|
||||||
const [to, setTo] = useState(init.to);
|
const [to, setTo] = useState(init.to);
|
||||||
const [cc, setCc] = useState(init.cc || '');
|
const [cc, setCc] = useState(init.cc || '');
|
||||||
const [subject, setSubject] = useState(init.subject);
|
const [subject, setSubject] = useState(init.subject);
|
||||||
@@ -52,6 +53,7 @@ export const MessageComposer: React.FC<{
|
|||||||
subject: subject.trim(),
|
subject: subject.trim(),
|
||||||
html: bodyRef.current?.innerHTML || '',
|
html: bodyRef.current?.innerHTML || '',
|
||||||
replyToReceivedId: init.replyToReceivedId,
|
replyToReceivedId: init.replyToReceivedId,
|
||||||
|
accountKey,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => { toast.success(t('messages.sentToast', 'Message sent.')); onSent(); onClose(); },
|
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.')),
|
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.sendFailed', 'Failed to send message.')),
|
||||||
@@ -61,7 +63,7 @@ export const MessageComposer: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4" onClick={onClose}>
|
<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="bg-white dark:bg-neutral-900 rounded-xl w-[min(920px,97vw)] h-[min(780px,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">
|
<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>
|
<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')}>
|
<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')}>
|
||||||
@@ -69,7 +71,7 @@ export const MessageComposer: React.FC<{
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-3 overflow-y-auto">
|
<div className="p-4 flex flex-col gap-3 overflow-y-auto flex-1 min-h-0">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<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>
|
<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]" />
|
<input className={inputCls} value={to} onChange={(e) => setTo(e.target.value)} placeholder="[email protected]" />
|
||||||
@@ -82,7 +84,7 @@ export const MessageComposer: React.FC<{
|
|||||||
<span className="w-16 text-neutral-500 dark:text-neutral-400">{t('messages.subject', 'Subject')}</span>
|
<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)} />
|
<input className={inputCls} value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
<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.')}
|
{t('messages.bodyHint', 'Edit the message freely — add a note anywhere before sending.')}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +94,7 @@ export const MessageComposer: React.FC<{
|
|||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
import {
|
import {
|
||||||
Inbox, Send, Reply, ReplyAll, Forward, Archive, Trash2, Paperclip,
|
Inbox, Send, Reply, ReplyAll, Forward, Archive, Trash2, Paperclip,
|
||||||
FileText, Quote, FileSignature, Image as ImageIcon, ReceiptText,
|
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';
|
} 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 { accountingService } from '../../../services/accounting.service';
|
||||||
import { Loading } from '../../../components/common';
|
import { Loading } from '../../../components/common';
|
||||||
import { MessageComposer, type ComposerInit } from './MessageComposer';
|
import { MessageComposer, type ComposerInit } from './MessageComposer';
|
||||||
@@ -63,7 +64,26 @@ export const MessagesPage: React.FC = () => {
|
|||||||
const [activeFolder, setActiveFolder] = useState('auto-sent');
|
const [activeFolder, setActiveFolder] = useState('auto-sent');
|
||||||
const [selection, setSelection] = useState<Selection>(null);
|
const [selection, setSelection] = useState<Selection>(null);
|
||||||
const [pdfDocId, setPdfDocId] = useState<number | null>(null);
|
const [pdfDocId, setPdfDocId] = useState<number | null>(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({
|
const queueQuery = useQuery({
|
||||||
queryKey: ['messages', 'queue'],
|
queryKey: ['messages', 'queue'],
|
||||||
@@ -81,6 +101,12 @@ export const MessagesPage: React.FC = () => {
|
|||||||
refetchInterval: 60000,
|
refetchInterval: 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const identitiesQuery = useQuery({
|
||||||
|
queryKey: ['messages', 'identities'],
|
||||||
|
queryFn: () => emailService.getIdentities(),
|
||||||
|
});
|
||||||
|
const identities = identitiesQuery.data;
|
||||||
|
|
||||||
const queueTotal = queueQuery.data?.pagination.total;
|
const queueTotal = queueQuery.data?.pagination.total;
|
||||||
const acctTotal = acctQuery.data?.pagination.total;
|
const acctTotal = acctQuery.data?.pagination.total;
|
||||||
const custTotal = custQuery.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-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: '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-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: '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: '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' },
|
{ 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
|
// Sent stream is split client-side by origin: system (Automated) vs manual
|
||||||
// (human composed → Customers ▸ Sent). Legacy rows (origin undefined) = system.
|
// (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.')}
|
{t('messages.subtitle', 'Sent, automated and incoming mail — one place.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => sync.mutate()}
|
||||||
|
disabled={sync.isPending}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-neutral-300 dark:border-neutral-700 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${sync.isPending ? 'animate-spin' : ''}`} />
|
||||||
|
{t('messages.sync', 'Sync')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={openNewMessage}
|
||||||
|
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-lg bg-accent-dark text-white text-sm font-medium hover:opacity-90"
|
||||||
|
>
|
||||||
|
<PenSquare className="w-4 h-4" />
|
||||||
|
{t('messages.newMessage', 'New message')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</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">
|
<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">
|
||||||
@@ -175,14 +218,14 @@ export const MessagesPage: React.FC = () => {
|
|||||||
onClick={() => { setActiveFolder(f.id); setSelection(null); }}
|
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 ${
|
className={`flex items-center gap-2 pl-7 pr-2 py-1.5 rounded-lg text-[13.5px] text-left transition-colors ${
|
||||||
active
|
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'
|
: '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" />
|
<f.icon className="w-4 h-4 opacity-80" />
|
||||||
<span>{f.name}</span>
|
<span>{f.name}</span>
|
||||||
{typeof c === 'number' && c > 0 && (
|
{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>
|
<span className={`ml-auto tabular-nums text-xs ${active ? 'text-on-accent-soft' : 'text-neutral-400'}`}>{c}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -219,9 +262,10 @@ export const MessagesPage: React.FC = () => {
|
|||||||
selection={selection}
|
selection={selection}
|
||||||
account={folder.a}
|
account={folder.a}
|
||||||
lang={i18n.language}
|
lang={i18n.language}
|
||||||
|
identities={identities}
|
||||||
onViewDoc={setPdfDocId}
|
onViewDoc={setPdfDocId}
|
||||||
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
|
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
|
||||||
onCompose={(init, title) => setComposer({ init, title })}
|
onCompose={(init, title) => setComposer({ init, title, accountKey: 'customers' })}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -232,6 +276,7 @@ export const MessagesPage: React.FC = () => {
|
|||||||
<MessageComposer
|
<MessageComposer
|
||||||
init={composer.init}
|
init={composer.init}
|
||||||
title={composer.title}
|
title={composer.title}
|
||||||
|
accountKey={composer.accountKey}
|
||||||
onClose={() => setComposer(null)}
|
onClose={() => setComposer(null)}
|
||||||
onSent={() => { queueQuery.refetch(); setActiveFolder('cust-sent'); }}
|
onSent={() => { queueQuery.refetch(); setActiveFolder('cust-sent'); }}
|
||||||
t={t}
|
t={t}
|
||||||
@@ -296,7 +341,7 @@ const MessageList: React.FC<{
|
|||||||
onClick={r.onClick}
|
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 ${
|
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
|
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'
|
: 'border-l-transparent hover:bg-neutral-50 dark:hover:bg-neutral-800/40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -327,11 +372,12 @@ const ReadingPane: React.FC<{
|
|||||||
selection: Selection;
|
selection: Selection;
|
||||||
account: Account;
|
account: Account;
|
||||||
lang: string;
|
lang: string;
|
||||||
|
identities?: MailIdentities | null;
|
||||||
onViewDoc: (id: number) => void;
|
onViewDoc: (id: number) => void;
|
||||||
onOpenAccounting: () => void;
|
onOpenAccounting: () => void;
|
||||||
onCompose: (init: ComposerInit, title?: string) => void;
|
onCompose: (init: ComposerInit, title?: string) => void;
|
||||||
t: (k: string, d?: string) => string;
|
t: (k: string, d?: string) => string;
|
||||||
}> = ({ selection, account, lang, onViewDoc, onOpenAccounting, onCompose, t }) => {
|
}> = ({ selection, account, lang, identities, onViewDoc, onOpenAccounting, onCompose, t }) => {
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
|
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
|
||||||
queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id),
|
queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id),
|
||||||
@@ -389,26 +435,32 @@ const ReadingPane: React.FC<{
|
|||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
{selection.kind === 'queue' ? (
|
{selection.kind === 'queue' ? (
|
||||||
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
|
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
|
||||||
<QueueDetail d={detailQuery.data} t={t} />
|
<QueueDetail d={detailQuery.data} fromAddr={identities?.automated} t={t} />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-neutral-500">{t('messages.loadError', 'Could not load this message.')}</div>
|
<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} />
|
<ReceivedDetail
|
||||||
|
item={selection.item}
|
||||||
|
mailboxAddr={selection.item.account_key === 'customers' ? identities?.customers : identities?.accounting}
|
||||||
|
onViewDoc={onViewDoc}
|
||||||
|
onOpenAccounting={onOpenAccounting}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 }) => (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
||||||
{friendlyType(d.emailType)}
|
{friendlyType(d.emailType)}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-3 pb-4 border-b border-neutral-200 dark:border-neutral-800 text-sm">
|
<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">
|
<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')}{' '}
|
{t('messages.from', 'from')} <span className="font-mono text-xs">{fromAddr || '—'}</span> · {t('messages.to', 'to')}{' '}
|
||||||
<span className="font-semibold text-neutral-800 dark:text-neutral-100">{d.recipientEmail}</span>
|
<span className="font-semibold text-neutral-800 dark:text-neutral-100">{d.recipientEmail}</span>
|
||||||
</div>
|
</div>
|
||||||
{d.cc && <div className="text-neutral-500 dark:text-neutral-400 text-xs mt-0.5">cc {d.cc}</div>}
|
{d.cc && <div className="text-neutral-500 dark:text-neutral-400 text-xs mt-0.5">cc {d.cc}</div>}
|
||||||
@@ -449,16 +501,16 @@ const QueueDetail: React.FC<{ d: import('../../../services/email.service').Email
|
|||||||
|
|
||||||
const ReceivedDetail: React.FC<{
|
const ReceivedDetail: React.FC<{
|
||||||
item: ReceivedEmail;
|
item: ReceivedEmail;
|
||||||
|
mailboxAddr?: string | null;
|
||||||
onViewDoc: (id: number) => void;
|
onViewDoc: (id: number) => void;
|
||||||
onOpenAccounting: () => void;
|
onOpenAccounting: () => void;
|
||||||
t: (k: string, d?: string) => string;
|
t: (k: string, d?: string) => string;
|
||||||
}> = ({ item, onViewDoc, onOpenAccounting, t }) => {
|
}> = ({ item, mailboxAddr, onViewDoc, onOpenAccounting, t }) => {
|
||||||
const detail = useQuery({
|
const detail = useQuery({
|
||||||
queryKey: ['messages', 'received', 'item', item.id],
|
queryKey: ['messages', 'received', 'item', item.id],
|
||||||
queryFn: () => emailService.getReceivedItem(item.id),
|
queryFn: () => emailService.getReceivedItem(item.id),
|
||||||
});
|
});
|
||||||
const toAddr = detail.data?.to_address || item.to_address
|
const toAddr = detail.data?.to_address || item.to_address || mailboxAddr || '—';
|
||||||
|| (item.account_key === 'customers' ? 'hello@' : 'rechnungen@');
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
||||||
@@ -492,7 +544,7 @@ const ReceivedDetail: React.FC<{
|
|||||||
<div className="mt-5 flex flex-wrap gap-2">
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onViewDoc(item.inbound_document_id as number)}
|
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"
|
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-accent-dark hover:opacity-90 text-white text-sm font-medium"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4" />{t('messages.viewDocument', 'View document')}
|
<FileText className="w-4 h-4" />{t('messages.viewDocument', 'View document')}
|
||||||
</button>
|
</button>
|
||||||
@@ -527,7 +579,7 @@ const Toolbar: React.FC<{
|
|||||||
title={enabled ? undefined : t('messages.soon', 'Available in a later phase')}
|
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 ${
|
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 '
|
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'}`}
|
||||||
>
|
>
|
||||||
<Icon className="w-[15px] h-[15px]" />{label}
|
<Icon className="w-[15px] h-[15px]" />{label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -160,9 +160,24 @@ export interface MailAccount {
|
|||||||
imap_user?: string | null;
|
imap_user?: string | null;
|
||||||
imap_pass?: string;
|
imap_pass?: string;
|
||||||
imap_folder?: 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;
|
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 = {
|
export const emailService = {
|
||||||
// Get email configuration
|
// Get email configuration
|
||||||
async getConfig(): Promise<EmailConfig> {
|
async getConfig(): Promise<EmailConfig> {
|
||||||
@@ -264,10 +279,16 @@ export const emailService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** Send a human-composed (edited) email — reply or document message. */
|
/** 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> {
|
async sendMessage(payload: { to: string; cc?: string; subject: string; html: string; replyToReceivedId?: number; accountKey?: string }): Promise<void> {
|
||||||
await api.post('/admin/email/send', payload);
|
await api.post('/admin/email/send', payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Resolved sender/mailbox addresses for the Messages sidebar. */
|
||||||
|
async getIdentities(): Promise<MailIdentities> {
|
||||||
|
const response = await api.get<MailIdentities>('/admin/email/identities');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get all email templates
|
// Get all email templates
|
||||||
async getTemplates(): Promise<EmailTemplate[]> {
|
async getTemplates(): Promise<EmailTemplate[]> {
|
||||||
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
||||||
|
|||||||
Reference in New Issue
Block a user