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:
@@ -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;
|
||||
@@ -21,6 +21,8 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
||||
quotes: false,
|
||||
bills: false,
|
||||
messaging: false,
|
||||
// Incoming mail (migration 128) — IMAP intake. Standalone, default off.
|
||||
incomingMail: false,
|
||||
analytics: true,
|
||||
userManagement: true,
|
||||
// Top-level Clients section (#354 follow-up). Migration 097 mirrors
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Images,
|
||||
BellRing,
|
||||
MessageSquare,
|
||||
Mailbox,
|
||||
CalendarDays,
|
||||
FileSignature,
|
||||
ScrollText,
|
||||
@@ -162,6 +163,21 @@ export const FeaturesTab: React.FC = () => {
|
||||
onToggle={(next) => setFlag('reminderEmails', next)}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={Mailbox}
|
||||
title={t('settings.features.incomingMail.title', 'Incoming mail')}
|
||||
description={t(
|
||||
'settings.features.incomingMail.description',
|
||||
'Poll a dedicated mailbox (IMAP) every minute and drop invoice attachments into Accounting → Incoming invoices. Configure the mailbox under Settings → Email.',
|
||||
)}
|
||||
status="new"
|
||||
statusLabel={statusLabel('new')}
|
||||
sidebarHidden
|
||||
sidebarHiddenLabel={sidebarHiddenLabel}
|
||||
enabled={staged.incomingMail}
|
||||
onToggle={(next) => setFlag('incomingMail', next)}
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
icon={MessageSquare}
|
||||
title={t('settings.features.messaging.title', 'Messaging')}
|
||||
|
||||
@@ -1603,6 +1603,10 @@
|
||||
"description": "Der Kern von PicPeak. Immer verfügbar.",
|
||||
"locked": "Galerien sind die Grundlage von PicPeak und können nicht deaktiviert werden."
|
||||
},
|
||||
"incomingMail": {
|
||||
"title": "Eingehende E-Mails",
|
||||
"description": "Ruft ein dediziertes Postfach (IMAP) jede Minute ab und legt Rechnungsanhänge in Buchhaltung → Eingangsrechnungen ab. Postfach unter Einstellungen → E-Mail konfigurieren."
|
||||
},
|
||||
"reminderEmails": {
|
||||
"title": "Erinnerungs-E-Mails",
|
||||
"description": "Automatische Vor-Event-Erinnerung an Kunden N Tage vor dem Eventdatum. Vorlagen pro Kategorie (Konzert, Firma, Hochzeit, …) unter Einstellungen → Erinnerungsvorlagen; Übersteuerung pro Event auf der Event-Detailseite."
|
||||
@@ -2471,6 +2475,34 @@
|
||||
"success": "Warteschlange geleert – {{sent}} gesendet, {{failed}} fehlgeschlagen",
|
||||
"empty": "Keine ausstehenden E-Mails zum Senden"
|
||||
},
|
||||
"incoming": {
|
||||
"title": "Eingehende E-Mails (IMAP)",
|
||||
"subtitle": "Ein dediziertes Postfach, jede Minute abgerufen; Anhänge landen in Buchhaltung → Eingangsrechnungen.",
|
||||
"host": "IMAP-Host",
|
||||
"port": "Port",
|
||||
"security": "Sicherheit",
|
||||
"ssl": "SSL/TLS (993)",
|
||||
"plain": "Keine / STARTTLS (143)",
|
||||
"user": "Benutzername",
|
||||
"pass": "Passwort",
|
||||
"folder": "Ordner",
|
||||
"savedToast": "Einstellungen für eingehende E-Mails gespeichert."
|
||||
},
|
||||
"received": {
|
||||
"tab": "Empfangene E-Mails",
|
||||
"from": "Von",
|
||||
"subject": "Betreff",
|
||||
"received": "Empfangen",
|
||||
"status": "Status",
|
||||
"empty": "Noch keine empfangenen E-Mails. Eingehende E-Mails aktivieren und Postfach konfigurieren.",
|
||||
"inbox": "Eingang",
|
||||
"statusValue": {
|
||||
"ingested": "Erfasst",
|
||||
"no_attachment": "Kein Anhang",
|
||||
"duplicate": "Duplikat",
|
||||
"error": "Fehler"
|
||||
}
|
||||
},
|
||||
"sentEmails": {
|
||||
"tab": "Gesendete E-Mails",
|
||||
"title": "Gesendete E-Mails",
|
||||
|
||||
@@ -1161,6 +1161,10 @@
|
||||
"description": "The core PicPeak surface. Always available.",
|
||||
"locked": "Galleries are the foundation of PicPeak and can't be turned off."
|
||||
},
|
||||
"incomingMail": {
|
||||
"title": "Incoming mail",
|
||||
"description": "Poll a dedicated mailbox (IMAP) every minute and drop invoice attachments into Accounting → Incoming invoices. Configure the mailbox under Settings → Email."
|
||||
},
|
||||
"reminderEmails": {
|
||||
"title": "Reminder Emails",
|
||||
"description": "Automatic pre-event nudge to customers N days before their event date. Per-category templates (concert, corporate, wedding, …) editable in Settings → Reminder templates; per-event override on the event detail page."
|
||||
@@ -2044,6 +2048,34 @@
|
||||
"success": "Email queue flushed — {{sent}} sent, {{failed}} failed",
|
||||
"empty": "No pending emails to send"
|
||||
},
|
||||
"incoming": {
|
||||
"title": "Incoming mail (IMAP)",
|
||||
"subtitle": "A dedicated mailbox polled every minute; attachments land in Accounting → Incoming invoices.",
|
||||
"host": "IMAP host",
|
||||
"port": "Port",
|
||||
"security": "Security",
|
||||
"ssl": "SSL/TLS (993)",
|
||||
"plain": "None / STARTTLS (143)",
|
||||
"user": "Username",
|
||||
"pass": "Password",
|
||||
"folder": "Folder",
|
||||
"savedToast": "Incoming mail settings saved."
|
||||
},
|
||||
"received": {
|
||||
"tab": "Received emails",
|
||||
"from": "From",
|
||||
"subject": "Subject",
|
||||
"received": "Received",
|
||||
"status": "Status",
|
||||
"empty": "No received emails yet. Enable incoming mail and configure the mailbox.",
|
||||
"inbox": "inbox",
|
||||
"statusValue": {
|
||||
"ingested": "Ingested",
|
||||
"no_attachment": "No attachment",
|
||||
"duplicate": "Duplicate",
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"sentEmails": {
|
||||
"tab": "Sent emails",
|
||||
"title": "Sent emails",
|
||||
|
||||
@@ -19,6 +19,8 @@ import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
|
||||
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 { Palette, RefreshCw, Info } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
|
||||
@@ -130,7 +132,7 @@ The Photo Sharing Team`,
|
||||
|
||||
export const EmailConfigPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<'smtp' | 'templates' | 'sent'>('smtp');
|
||||
const [activeTab, setActiveTab] = useState<'smtp' | 'templates' | 'sent' | 'received'>('smtp');
|
||||
const [selectedTemplateKey, setSelectedTemplateKey] = useState<string>('gallery_created');
|
||||
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
|
||||
const [editingLang, setEditingLang] = useState<string>('en');
|
||||
@@ -496,12 +498,27 @@ export const EmailConfigPage: React.FC = () => {
|
||||
>
|
||||
{t('email.sentEmails.tab', 'Sent emails')}
|
||||
</button>
|
||||
{featureFlags.incomingMail && (
|
||||
<button
|
||||
onClick={() => setActiveTab('received')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'received'
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{t('email.received.tab', 'Received emails')}
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Sent emails Tab */}
|
||||
{activeTab === 'sent' && <SentEmailsPanel />}
|
||||
|
||||
{/* Received emails Tab */}
|
||||
{activeTab === 'received' && <ReceivedEmailsPanel />}
|
||||
|
||||
{/* SMTP Settings Tab */}
|
||||
{activeTab === 'smtp' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@@ -800,6 +817,9 @@ export const EmailConfigPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Email Templates Tab */}
|
||||
{/* Incoming mail (IMAP) — a second block under SMTP, flag-gated. */}
|
||||
{activeTab === 'smtp' && featureFlags.incomingMail && <IncomingMailConfigCard />}
|
||||
|
||||
{activeTab === 'templates' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card padding="sm">
|
||||
|
||||
@@ -79,6 +79,32 @@ export interface EmailPreview {
|
||||
body_text: string;
|
||||
}
|
||||
|
||||
export interface IncomingMailConfig {
|
||||
imap_host: string;
|
||||
imap_port: number;
|
||||
imap_secure: boolean;
|
||||
imap_user: string;
|
||||
imap_pass: string;
|
||||
imap_folder: string;
|
||||
}
|
||||
|
||||
export interface ReceivedEmail {
|
||||
id: number;
|
||||
message_id: string | null;
|
||||
from_address: string | null;
|
||||
subject: string | null;
|
||||
received_at: string | null;
|
||||
attachment_count: number;
|
||||
status: string;
|
||||
inbound_document_id: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ReceivedEmailsResponse {
|
||||
items: ReceivedEmail[];
|
||||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||||
}
|
||||
|
||||
export const emailService = {
|
||||
// Get email configuration
|
||||
async getConfig(): Promise<EmailConfig> {
|
||||
@@ -91,6 +117,19 @@ export const emailService = {
|
||||
await api.post('/admin/email/config', config);
|
||||
},
|
||||
|
||||
// Incoming mail (IMAP) configuration
|
||||
async getIncomingConfig(): Promise<IncomingMailConfig> {
|
||||
const response = await api.get<IncomingMailConfig>('/admin/email/incoming-config');
|
||||
return response.data;
|
||||
},
|
||||
async updateIncomingConfig(config: IncomingMailConfig): Promise<void> {
|
||||
await api.post('/admin/email/incoming-config', config);
|
||||
},
|
||||
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
|
||||
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Test email configuration
|
||||
async testEmail(testEmail: string): Promise<void> {
|
||||
await api.post('/admin/email/test', { test_email: testEmail });
|
||||
|
||||
@@ -8,6 +8,9 @@ export type FeatureKey =
|
||||
| 'quotes'
|
||||
| 'bills'
|
||||
| 'messaging'
|
||||
// Incoming mail (migration 128) — IMAP polling into the incoming-invoices
|
||||
// inbox. Standalone toggle.
|
||||
| 'incomingMail'
|
||||
| 'analytics'
|
||||
| 'userManagement'
|
||||
// Top-level "Clients" section (#354 follow-up). Parent flag that
|
||||
|
||||
Reference in New Issue
Block a user