diff --git a/backend/migrations/core/154_mail_accounts_and_bodies.js b/backend/migrations/core/154_mail_accounts_and_bodies.js new file mode 100644 index 00000000..d92e78e0 --- /dev/null +++ b/backend/migrations/core/154_mail_accounts_and_bodies.js @@ -0,0 +1,52 @@ +/** + * Messages Phase 2 — additional inbound mailboxes + captured message bodies. + * + * `mail_accounts` holds inbound mailboxes BEYOND the primary accounting IMAP + * that already lives in `email_configs` (e.g. the customer `hello@` mailbox). + * The intake poller (emailIntakeService) polls the accounting mailbox AND every + * enabled row here; customer mail is logged with its body but not routed to the + * accounting inbox. + * + * The new `received_emails` columns capture the parsed message so the Messages + * reading pane can show it: `account_key` tags which mailbox it came from, + * `body_html`/`body_text` hold the (server-sanitized) body, `to_address` the + * envelope recipient. All additive + guarded. + */ +exports.up = async function up(knex) { + const hasAccounts = await knex.schema.hasTable('mail_accounts'); + if (!hasAccounts) { + await knex.schema.createTable('mail_accounts', (t) => { + t.increments('id').primary(); + t.string('account_key', 64).notNullable().unique(); // e.g. 'customers' + t.string('label', 120); + t.string('imap_host', 255); + t.integer('imap_port').defaultTo(993); + t.boolean('imap_secure').defaultTo(true); + t.string('imap_user', 255); + t.string('imap_pass', 512); + t.string('imap_folder', 255).defaultTo('INBOX'); + t.boolean('enabled').defaultTo(false); + t.timestamp('created_at').defaultTo(knex.fn.now()); + t.timestamp('updated_at').defaultTo(knex.fn.now()); + }); + } + + const cols = [ + ['account_key', (t) => t.string('account_key', 64)], + ['to_address', (t) => t.string('to_address', 512)], + ['body_html', (t) => t.text('body_html')], + ['body_text', (t) => t.text('body_text')], + ]; + for (const [name, add] of cols) { + // eslint-disable-next-line no-await-in-loop + const has = await knex.schema.hasColumn('received_emails', name); + // eslint-disable-next-line no-await-in-loop + if (!has) await knex.schema.alterTable('received_emails', add); + } +}; + +exports.down = async function down(knex) { + // Non-destructive on the audit log: leave the added columns in place (they're + // nullable and harmless). Only drop the new table. + await knex.schema.dropTableIfExists('mail_accounts'); +}; diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index ae18a9eb..69942a43 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -260,16 +260,107 @@ router.get('/received', adminAuth, requirePermission('email.view'), async (req, try { const page = Math.max(1, parseInt(req.query.page, 10) || 1); const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25)); - const base = db('received_emails'); - const countRow = await base.clone().count({ c: '*' }).first(); + const account = req.query.account ? String(req.query.account) : null; + // 'accounting' matches legacy rows too (account_key was NULL before mig 154). + const applyAccount = (qb) => { + if (account === 'accounting') qb.where((b) => b.where('account_key', 'accounting').orWhereNull('account_key')); + else if (account) qb.where('account_key', account); + return qb; + }; + const countRow = await applyAccount(db('received_emails')).count({ c: '*' }).first(); const total = parseInt(countRow?.c || 0, 10); - const items = await base.clone().orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize); + // Bodies are excluded from the list (can be large); fetched per-message. + const items = await applyAccount(db('received_emails')) + .select('id', 'message_id', 'account_key', 'from_address', 'to_address', 'subject', + 'received_at', 'attachment_count', 'status', 'inbound_document_id', 'error') + .orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize); res.json({ items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } }); } catch (error) { errorResponse(res, error, 500, 'Failed to fetch received emails'); } }); +// Single received email WITH its captured (server-sanitized) body — Messages +// reading pane. body_html was already sanitized on ingest; the viewer renders +// it in a script-less sandboxed iframe as well. +router.get('/received/:id', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' }); + const row = await db('received_emails').where({ id }).first(); + if (!row) return res.status(404).json({ error: 'Email not found' }); + res.json(row); + } catch (error) { + errorResponse(res, error, 500, 'Failed to fetch email'); + } +}); + +// Additional inbound mailboxes (beyond the primary accounting IMAP in +// email_configs) — e.g. the customer hello@ box. Passwords are masked out. +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 ? '********' : '' })) }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to load mail accounts'); + } +}); + +// 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) => { + try { + const b = req.body || {}; + if (!b.account_key) return res.status(400).json({ error: 'account_key is required' }); + const patch = { + label: b.label || null, + imap_host: b.imap_host || null, + imap_port: b.imap_port ? parseInt(b.imap_port, 10) : 993, + imap_secure: b.imap_secure !== false, + imap_user: b.imap_user || null, + imap_folder: b.imap_folder || 'INBOX', + enabled: !!b.enabled, + updated_at: new Date(), + }; + if (b.imap_pass && b.imap_pass !== '********') patch.imap_pass = b.imap_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); + } else { + await db('mail_accounts').insert({ + account_key: b.account_key, + imap_pass: (b.imap_pass && b.imap_pass !== '********') ? b.imap_pass : '', + created_at: new Date(), + ...patch, + }); + } + res.json({ ok: true }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to save mail account'); + } +}); + +// Test an inbound mailbox's IMAP connection (before or after saving). Resolves +// a masked/blank password from the stored row for the given account_key. +router.post('/accounts/test', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const b = req.body || {}; + let pass = b.imap_pass; + if ((!pass || pass === '********') && b.account_key) { + const stored = await db('mail_accounts').where({ account_key: b.account_key }).first(); + pass = stored?.imap_pass || ''; + } + const emailIntakeService = require('../services/emailIntakeService'); + const result = await emailIntakeService.testConnection({ + host: b.imap_host, port: b.imap_port, secure: b.imap_secure, + user: b.imap_user, pass, folder: b.imap_folder || 'INBOX', + }); + res.json(result); + } catch (error) { + res.status(422).json({ ok: false, error: `Mailbox test failed (${error.message}).` }); + } +}); + // Test email configuration router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => { try { diff --git a/backend/src/services/emailIntakeService.js b/backend/src/services/emailIntakeService.js index e96bff60..955fc1cf 100644 --- a/backend/src/services/emailIntakeService.js +++ b/backend/src/services/emailIntakeService.js @@ -16,6 +16,7 @@ const { db } = require('../database/db'); const logger = require('../utils/logger'); const { getStoragePath } = require('../config/storage'); const expenseService = require('./expenseService'); +const sanitizeHtml = require('sanitize-html'); const { isUniqueViolation } = require('../utils/dbErrors'); const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png']; @@ -230,14 +231,36 @@ async function roundTripTest({ timeoutMs = 30000, intervalMs = 3000 } = {}) { } } -/** Poll the mailbox once. Safe to call repeatedly; self-skips when busy/off. */ -async function pollOnce() { - if (polling) return { skipped: 'busy' }; - if (!(await isEnabled())) return { skipped: 'disabled' }; - const cfg = await getImapConfig(); - if (!cfg) return { skipped: 'unconfigured' }; +// Sanitize an inbound HTML body before storing it. Inbound mail is untrusted, +// so this strips scripts/handlers/unknown schemes (the viewer ALSO renders it +// in a script-less sandboxed iframe — defense in depth). Remote images are kept +// (many legit emails embed them) but that is the only tracking-vector allowed. +function sanitizeBody(html) { + if (!html) return null; + try { + return sanitizeHtml(html, { + allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + img: ['src', 'alt', 'width', 'height'], + '*': ['style'], + }, + allowedSchemes: ['http', 'https', 'mailto', 'cid'], + }); + } catch (_) { + return null; + } +} - polling = true; +/** + * Poll ONE mailbox once and return the count of newly-processed messages. + * `opts.accountKey` tags each received_emails row; `opts.routeToExpenses` + * controls whether PDF/image attachments are dropped into the accounting inbox + * (true for the primary rechnungen@ mailbox) or only logged with the body + * (customer mail, e.g. hello@). The claim/dedup/stale-recovery logic is + * identical for every mailbox. + */ +async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses = true } = {}) { const client = makeImapClient(cfg); let processed = 0; try { @@ -304,6 +327,7 @@ async function pollOnce() { try { await db('received_emails').insert({ message_id: claimKey, + account_key: accountKey, status: 'processing', attachment_count: 0, received_at: new Date(), @@ -315,37 +339,47 @@ async function pollOnce() { throw ce; } - // Ingest attachments. Isolate each so one bad file can't prevent the - // audit row (the symptom: doc lands in Incoming invoices but the - // email never shows under Received). - const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType)); + // Attachment handling. The accounting mailbox drops PDF/image + // attachments into the incoming-invoices inbox (isolated so one bad + // file can't prevent the audit row). Customer mailboxes only COUNT + // attachments — they aren't supplier invoices. let inboundId = null; let count = 0; const attErrors = []; - for (const att of atts) { - try { - const filePath = await saveAttachment(att); - const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null); - inboundId = doc.id; count += 1; - } catch (ae) { - attErrors.push(ae.message); - logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`); + if (routeToExpenses) { + const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType)); + for (const att of atts) { + try { + const filePath = await saveAttachment(att); + const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null); + inboundId = doc.id; count += 1; + } catch (ae) { + attErrors.push(ae.message); + logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`); + } } + } else { + count = (parsed.attachments || []).length; } // A malformed Date: header yields an Invalid Date, which throws on a // Postgres timestamp insert — coerce to now. const receivedAt = (parsed.date instanceof Date && !Number.isNaN(parsed.date.getTime())) ? parsed.date : new Date(); - const status = count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment'); + const status = routeToExpenses + ? (count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment')) + : 'received'; // Finalise the claimed row — every processed message ends up in the - // Received tab, even attachment-less ones. + // Received log with its (sanitized) body, even attachment-less ones. await db('received_emails').where({ message_id: claimKey }).update({ from_address: ((parsed.from && parsed.from.text) || '').slice(0, 512) || null, + to_address: ((parsed.to && parsed.to.text) || '').slice(0, 512) || null, subject: parsed.subject || null, received_at: receivedAt, attachment_count: count, status, inbound_document_id: inboundId, + body_html: sanitizeBody(parsed.html || null), + body_text: parsed.text || null, error: attErrors.length ? attErrors.join('; ').slice(0, 2000) : null, }); await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true }); @@ -360,7 +394,7 @@ async function pollOnce() { await db('received_emails').where({ message_id: claimKey }) .update({ status: 'error', error: String(e.message).slice(0, 2000) }); } else { - await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() }); + await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, account_key: accountKey, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() }); } } catch (ie) { logger.error?.(`emailIntake: could not even write the error row (received_emails insert failing): ${ie.message}`); @@ -373,11 +407,55 @@ async function pollOnce() { /* eslint-enable no-await-in-loop */ await client.logout(); } catch (e) { - logger.error?.(`emailIntake: poll failed: ${e.message}`); + logger.error?.(`emailIntake: poll failed (${accountKey}): ${e.message}`); try { await client.close(); } catch (_e) { /* ignore */ } + } + return processed; +} + +/** + * Poll ALL configured inbound mailboxes once: the primary accounting IMAP + * (email_configs) plus every enabled row in mail_accounts (e.g. hello@). + * Safe to call repeatedly; self-skips when busy/off. + */ +async function pollOnce() { + if (polling) return { skipped: 'busy' }; + if (!(await isEnabled())) return { skipped: 'disabled' }; + polling = true; + let processed = 0; + let anyConfigured = false; + try { + // 1) Primary accounting mailbox — routes attachments to the invoices inbox. + const acctCfg = await getImapConfig(); + if (acctCfg) { + anyConfigured = true; + processed += await pollAccountOnce(acctCfg, { accountKey: 'accounting', routeToExpenses: true }); + } + // 2) Additional mailboxes (customers/hello@) — body captured, no expense + // routing. Guarded so a pre-migration DB simply polls the accounting box. + let extras = []; + try { + if (await db.schema.hasTable('mail_accounts')) { + extras = await db('mail_accounts').where({ enabled: true }); + } + } catch (_) { extras = []; } + for (const a of extras) { + if (!a.imap_host || !a.imap_user) continue; + anyConfigured = true; + const cfg = { + host: a.imap_host, + port: a.imap_port || 993, + secure: a.imap_secure !== false && a.imap_secure !== 0, + auth: { user: a.imap_user, pass: a.imap_pass || '' }, + folder: a.imap_folder || 'INBOX', + }; + // eslint-disable-next-line no-await-in-loop + processed += await pollAccountOnce(cfg, { accountKey: a.account_key, routeToExpenses: false }); + } } finally { polling = false; } + if (!anyConfigured) return { skipped: 'unconfigured' }; return { processed }; } diff --git a/frontend/src/components/admin/CustomerMailboxCard.tsx b/frontend/src/components/admin/CustomerMailboxCard.tsx new file mode 100644 index 00000000..e02c7e4b --- /dev/null +++ b/frontend/src/components/admin/CustomerMailboxCard.tsx @@ -0,0 +1,123 @@ +/** + * Customer mailbox (hello@) configuration — a second inbound IMAP box beyond + * the accounting rechnungen@ one, stored in `mail_accounts` under the fixed + * account_key 'customers'. Its mail feeds Messages → Customers ▸ Inbox (body + * captured, attachments NOT routed to accounting). Shown when the `messaging` + * feature flag is on. Styled to match the Incoming Mail card. + */ +import React, { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Save, Server, User, Lock, Eye, EyeOff, PlugZap, Inbox } from 'lucide-react'; +import { Button, Card, Input, Loading } from '../common'; +import { emailService, type MailAccount } from '../../services/email.service'; +import { useMutationWithToast, useModal } from '../../hooks'; + +const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1'; +const selectCls = 'w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark'; + +const ACCOUNT_KEY = 'customers'; + +export const CustomerMailboxCard: React.FC = () => { + const { t } = useTranslation(); + const { data, isLoading } = useQuery({ queryKey: ['mail-accounts'], queryFn: () => emailService.listMailAccounts() }); + const [cfg, setCfg] = useState({ account_key: ACCOUNT_KEY, imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '', imap_folder: 'INBOX', enabled: false }); + const passwordVisibility = useModal(); + + useEffect(() => { + if (!data) return; + const row = data.find((a) => a.account_key === ACCOUNT_KEY); + if (row) setCfg({ ...row, imap_pass: row.imap_pass || '' }); + }, [data]); + + const set = (k: keyof MailAccount, v: any) => setCfg((c) => ({ ...c, [k]: v })); + + const save = useMutationWithToast({ + mutationFn: () => { + if (!cfg.imap_host || !cfg.imap_port || !cfg.imap_user) { + return Promise.reject(new Error(t('email.customerMailbox.requiredFields', 'Host, port and username are required.'))); + } + return emailService.saveMailAccount({ ...cfg, account_key: ACCOUNT_KEY, label: 'Customers' }); + }, + successMessage: t('email.customerMailbox.savedToast', 'Customer mailbox saved.'), + invalidateKeys: [['mail-accounts']], + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', + }); + + const test = useMutationWithToast({ + mutationFn: () => emailService.testMailAccount({ ...cfg, account_key: ACCOUNT_KEY }), + successMessage: (r) => t('email.customerMailbox.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen }), + errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.customerMailbox.testFailed', 'Connection failed.'), + }); + + if (isLoading) return ; + + return ( + +

+ + {t('email.customerMailbox.title', 'Customer mailbox (hello@)')} +

+

+ {t('email.customerMailbox.subtitle', 'A second inbound mailbox for customer conversations. Its mail appears under Messages → Customers; attachments are not routed to Accounting.')} +

+ +
+ + +
+ + set('imap_host', e.target.value)} placeholder="imap.example.com" leftIcon={} /> +
+ +
+
+ + set('imap_port', parseInt(e.target.value, 10) || 0)} placeholder="993" /> +
+
+ + +
+
+ +
+ + set('imap_user', e.target.value)} autoComplete="off" placeholder="hello@yourdomain.com" leftIcon={} /> +
+ +
+ +
+ set('imap_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={} /> + +
+
+ +
+ + set('imap_folder', e.target.value)} placeholder="INBOX" /> +
+ +
+ + +
+
+
+ ); +}; + +export default CustomerMailboxCard; diff --git a/frontend/src/pages/admin/EmailConfigPage.tsx b/frontend/src/pages/admin/EmailConfigPage.tsx index 8f494acf..670c4bdd 100644 --- a/frontend/src/pages/admin/EmailConfigPage.tsx +++ b/frontend/src/pages/admin/EmailConfigPage.tsx @@ -21,6 +21,7 @@ import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor' import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel'; import { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel'; import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard'; +import { CustomerMailboxCard } from '../../components/admin/CustomerMailboxCard'; import { Palette, RefreshCw, Info } from 'lucide-react'; import { useQuery, useMutation } from '@tanstack/react-query'; import { useModal, useMutationWithToast } from '../../hooks'; @@ -802,6 +803,7 @@ export const EmailConfigPage: React.FC = () => { {/* Email Templates Tab */} {/* Incoming mail (IMAP) — a second block under SMTP, flag-gated. */} {activeTab === 'smtp' && featureFlags.incomingMail && } + {activeTab === 'smtp' && featureFlags.messaging && } {activeTab === 'templates' && (
diff --git a/frontend/src/pages/admin/messages/MessagesPage.tsx b/frontend/src/pages/admin/messages/MessagesPage.tsx index a2a35d45..61b37cc9 100644 --- a/frontend/src/pages/admin/messages/MessagesPage.tsx +++ b/frontend/src/pages/admin/messages/MessagesPage.tsx @@ -20,7 +20,7 @@ import { Loading } from '../../../components/common'; */ type FolderSrc = 'queue' | 'received' | 'empty'; -interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; note?: string; } +interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; account?: string; note?: string; } interface Account { id: string; name: string; addr?: string; color: string; folders: Folder[]; } type Selection = @@ -50,6 +50,7 @@ const fmt = (s?: string | null) => const STATUS_STYLES: Record = { sent: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', ingested: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', + received: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', pending: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', error: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', @@ -67,14 +68,20 @@ export const MessagesPage: React.FC = () => { queryFn: () => emailService.listQueue({ pageSize: 100 }), refetchInterval: 60000, }); - const receivedQuery = useQuery({ - queryKey: ['messages', 'received'], - queryFn: () => emailService.listReceived({ pageSize: 100 }), + const acctQuery = useQuery({ + queryKey: ['messages', 'received', 'accounting'], + queryFn: () => emailService.listReceived({ account: 'accounting', pageSize: 100 }), + refetchInterval: 60000, + }); + const custQuery = useQuery({ + queryKey: ['messages', 'received', 'customers'], + queryFn: () => emailService.listReceived({ account: 'customers', pageSize: 100 }), refetchInterval: 60000, }); const queueTotal = queueQuery.data?.pagination.total; - const receivedTotal = receivedQuery.data?.pagination.total; + const acctTotal = acctQuery.data?.pagination.total; + const custTotal = custQuery.data?.pagination.total; const accounts: Account[] = useMemo(() => [ { id: 'all', name: t('messages.account.all', 'All mail'), color: '#64748b', folders: [ @@ -82,13 +89,12 @@ export const MessagesPage: React.FC = () => { { 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-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'empty', - note: t('messages.empty.customerInbox', 'Connect the hello@ mailbox to see customer replies here (next phase).') }, + { 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: 'empty', - note: t('messages.empty.customerSent', 'Your hand-written replies will appear here once compose ships.') }, + note: t('messages.empty.customerSent', 'Your hand-written replies will appear here once compose ships (next phase).') }, ] }, { id: 'acct', name: t('messages.account.accounting', 'Accounting'), addr: 'rechnungen@', color: '#12876a', folders: [ - { id: 'acct-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received' }, + { 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-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue' }, @@ -100,8 +106,31 @@ export const MessagesPage: React.FC = () => { return { a: accounts[0], f: accounts[0].folders[0] }; }, [accounts, activeFolder]); - const countFor = (src: FolderSrc) => - src === 'queue' ? queueTotal : src === 'received' ? receivedTotal : undefined; + const countFor = (f: Folder): number | undefined => { + if (f.src === 'queue') return queueTotal; + if (f.src === 'received') { + if (f.account === 'customers') return custTotal; + if (f.account === 'accounting') return acctTotal; + return (acctTotal || 0) + (custTotal || 0); + } + return undefined; + }; + + // Which received rows feed the active folder (customer / accounting / union). + const receivedItems = useMemo(() => { + if (folder.f.src !== 'received') return undefined; + const a = acctQuery.data?.items || []; + const c = custQuery.data?.items || []; + if (folder.f.account === 'customers') return c; + if (folder.f.account === 'accounting') return a; + return [...a, ...c].sort((x, y) => (y.received_at || '').localeCompare(x.received_at || '')); + }, [folder, acctQuery.data, custQuery.data]); + + const receivedLoading = folder.f.account === 'customers' + ? custQuery.isLoading + : folder.f.account === 'accounting' + ? acctQuery.isLoading + : acctQuery.isLoading || custQuery.isLoading; return (
@@ -129,7 +158,7 @@ export const MessagesPage: React.FC = () => {
{a.folders.map((f) => { - const c = countFor(f.src); + const c = countFor(f); const active = f.id === activeFolder; return (