feat(email): incoming mail UI - IMAP config block + Received emails tab

Frontend for the incoming-mail feature.

- Settings -> Email: an "Incoming mail (IMAP)" block under the outgoing SMTP
  settings (same field shape: host/port/security/user/pass/folder), shown only
  when the incomingMail flag is on (IncomingMailConfigCard, self-contained
  load/save).
- A "Received emails" tab next to "Sent emails" (ReceivedEmailsPanel) listing
  the received_emails log with from/subject/received/status + attachment count
  and a link to the incoming-invoices inbox.
- `incomingMail` flag in the frontend (type + context default, standalone) +
  a Communication-section Features card.
- email.service: getIncomingConfig / updateIncomingConfig / listReceived.
- i18n: settings.features.incomingMail, email.incoming, email.received (EN+DE).

Verified: tsc --noEmit clean (0 errors); en/de JSON valid; npm run build green.
This commit is contained in:
Luca
2026-06-11 16:19:55 +02:00
parent 5645c304ab
commit 31280e1f7a
9 changed files with 287 additions and 1 deletions
@@ -0,0 +1,62 @@
/**
* Incoming mail (IMAP) configuration — a second block under the outgoing SMTP
* settings. Same field shape as SMTP. Self-contained: loads + saves its own
* config. Shown only when the `incomingMail` feature flag is on.
*/
import React, { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save } from 'lucide-react';
import { Button, Card, Input, Loading } from '../common';
import { emailService, type IncomingMailConfig } from '../../services/email.service';
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
export const IncomingMailConfigCard: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['incoming-mail-config'], queryFn: () => emailService.getIncomingConfig() });
const [cfg, setCfg] = useState<IncomingMailConfig>({ imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '', imap_folder: 'INBOX' });
useEffect(() => { if (data) setCfg(data); }, [data]);
const save = useMutation({
mutationFn: () => emailService.updateIncomingConfig(cfg),
onSuccess: () => { toast.success(t('email.incoming.savedToast', 'Incoming mail settings saved.')); qc.invalidateQueries({ queryKey: ['incoming-mail-config'] }); },
onError: (e: any) => toast.error(e?.response?.data?.error || e?.response?.data?.errors?.[0]?.msg || e.message || 'Failed'),
});
const set = (k: keyof IncomingMailConfig, v: any) => setCfg((c) => ({ ...c, [k]: v }));
if (isLoading) return <Loading />;
return (
<Card className="p-6 mt-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">{t('email.incoming.title', 'Incoming mail (IMAP)')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">{t('email.incoming.subtitle', 'A dedicated mailbox polled every minute; attachments land in Accounting → Incoming invoices.')}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2"><label className={labelCls}>{t('email.incoming.host', 'IMAP host')}</label>
<Input value={cfg.imap_host} onChange={(e) => set('imap_host', e.target.value)} placeholder="imap.example.com" /></div>
<div><label className={labelCls}>{t('email.incoming.port', 'Port')}</label>
<Input type="number" value={cfg.imap_port} onChange={(e) => set('imap_port', parseInt(e.target.value, 10) || 0)} /></div>
<div><label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
<select className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm"
value={cfg.imap_secure ? 'ssl' : 'plain'} onChange={(e) => set('imap_secure', e.target.value === 'ssl')}>
<option value="ssl">{t('email.incoming.ssl', 'SSL/TLS (993)')}</option>
<option value="plain">{t('email.incoming.plain', 'None / STARTTLS (143)')}</option>
</select></div>
<div><label className={labelCls}>{t('email.incoming.user', 'Username')}</label>
<Input value={cfg.imap_user} onChange={(e) => set('imap_user', e.target.value)} autoComplete="off" /></div>
<div><label className={labelCls}>{t('email.incoming.pass', 'Password')}</label>
<Input type="password" value={cfg.imap_pass} onChange={(e) => set('imap_pass', e.target.value)} autoComplete="new-password" /></div>
<div className="sm:col-span-2"><label className={labelCls}>{t('email.incoming.folder', 'Folder')}</label>
<Input value={cfg.imap_folder} onChange={(e) => set('imap_folder', e.target.value)} placeholder="INBOX" /></div>
</div>
<div className="mt-4">
<Button onClick={() => save.mutate()} disabled={save.isPending}><Save className="w-4 h-4 mr-2" /> {save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
</div>
</Card>
);
};
export default IncomingMailConfigCard;
@@ -0,0 +1,80 @@
/**
* Received-emails feed — read-only, paginated view of the received_emails log
* (the IMAP poller's audit trail). Rendered as the "Received emails" tab in
* EmailConfigPage, next to "Sent emails".
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Inbox, Paperclip } from 'lucide-react';
import { Card, Loading, Button } from '../common';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { emailService } from '../../services/email.service';
const statusClass = (s: string): string =>
s === 'ingested' ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: s === 'error' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
: 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300';
export const ReceivedEmailsPanel: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({ queryKey: ['received-emails', page], queryFn: () => emailService.listReceived({ page, pageSize: 25 }) });
if (isLoading) return <Loading />;
const items = data?.items ?? [];
const pg = data?.pagination;
if (items.length === 0) {
return (
<Card className="p-8 text-center">
<Inbox className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('email.received.empty', 'No received emails yet. Enable incoming mail and configure the mailbox.')}</p>
</Card>
);
}
return (
<Card className="p-0 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800/50 text-left text-xs uppercase text-neutral-500 dark:text-neutral-400">
<tr>
<th className="px-4 py-2">{t('email.received.from', 'From')}</th>
<th className="px-4 py-2">{t('email.received.subject', 'Subject')}</th>
<th className="px-4 py-2">{t('email.received.received', 'Received')}</th>
<th className="px-4 py-2">{t('email.received.status', 'Status')}</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{items.map((r) => (
<tr key={r.id}>
<td className="px-4 py-2 text-neutral-700 dark:text-neutral-300 truncate max-w-[14rem]">{r.from_address || '—'}</td>
<td className="px-4 py-2 text-neutral-900 dark:text-neutral-100">
<span className="truncate inline-block max-w-[18rem] align-middle">{r.subject || '—'}</span>
{r.attachment_count > 0 && (
<span className="ml-2 inline-flex items-center gap-0.5 text-xs text-neutral-500">
<Paperclip className="w-3 h-3" />{r.attachment_count}
{r.inbound_document_id && <Link to="/admin/accounting/inbox" className="ml-1 text-primary-600 hover:underline">{t('email.received.inbox', 'inbox')}</Link>}
</span>
)}
</td>
<td className="px-4 py-2 text-neutral-500 dark:text-neutral-400 whitespace-nowrap">{r.received_at ? fmtDateTime(r.received_at) : '—'}</td>
<td className="px-4 py-2"><span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${statusClass(r.status)}`}>{t(`email.received.statusValue.${r.status}`, r.status)}</span></td>
</tr>
))}
</tbody>
</table>
{pg && pg.totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-100 dark:border-neutral-800 text-sm">
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('common.previous', 'Previous')}</Button>
<span className="text-neutral-500">{page} / {pg.totalPages}</span>
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(pg.totalPages, p + 1))} disabled={page >= pg.totalPages}>{t('common.next', 'Next')}</Button>
</div>
)}
</Card>
);
};
export default ReceivedEmailsPanel;