From 31280e1f7ab374f3ebd6e2b3efa5313cbde860ab Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:19:55 +0200 Subject: [PATCH] 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. --- .../admin/IncomingMailConfigCard.tsx | 62 ++++++++++++++ .../components/admin/ReceivedEmailsPanel.tsx | 80 +++++++++++++++++++ frontend/src/contexts/FeatureFlagsContext.tsx | 2 + .../features/settings/tabs/FeaturesTab.tsx | 16 ++++ frontend/src/i18n/locales/de.json | 32 ++++++++ frontend/src/i18n/locales/en.json | 32 ++++++++ frontend/src/pages/admin/EmailConfigPage.tsx | 22 ++++- frontend/src/services/email.service.ts | 39 +++++++++ frontend/src/services/featureFlags.service.ts | 3 + 9 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/admin/IncomingMailConfigCard.tsx create mode 100644 frontend/src/components/admin/ReceivedEmailsPanel.tsx diff --git a/frontend/src/components/admin/IncomingMailConfigCard.tsx b/frontend/src/components/admin/IncomingMailConfigCard.tsx new file mode 100644 index 00000000..93e453ce --- /dev/null +++ b/frontend/src/components/admin/IncomingMailConfigCard.tsx @@ -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({ 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 ; + + return ( + +

{t('email.incoming.title', 'Incoming mail (IMAP)')}

+

{t('email.incoming.subtitle', 'A dedicated mailbox polled every minute; attachments land in Accounting → Incoming invoices.')}

+
+
+ set('imap_host', e.target.value)} placeholder="imap.example.com" />
+
+ set('imap_port', parseInt(e.target.value, 10) || 0)} />
+
+
+
+ set('imap_user', e.target.value)} autoComplete="off" />
+
+ set('imap_pass', e.target.value)} autoComplete="new-password" />
+
+ set('imap_folder', e.target.value)} placeholder="INBOX" />
+
+
+ +
+
+ ); +}; + +export default IncomingMailConfigCard; diff --git a/frontend/src/components/admin/ReceivedEmailsPanel.tsx b/frontend/src/components/admin/ReceivedEmailsPanel.tsx new file mode 100644 index 00000000..c2169816 --- /dev/null +++ b/frontend/src/components/admin/ReceivedEmailsPanel.tsx @@ -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 ; + const items = data?.items ?? []; + const pg = data?.pagination; + + if (items.length === 0) { + return ( + + +

{t('email.received.empty', 'No received emails yet. Enable incoming mail and configure the mailbox.')}

+
+ ); + } + + return ( + + + + + + + + + + + + {items.map((r) => ( + + + + + + + ))} + +
{t('email.received.from', 'From')}{t('email.received.subject', 'Subject')}{t('email.received.received', 'Received')}{t('email.received.status', 'Status')}
{r.from_address || '—'} + {r.subject || '—'} + {r.attachment_count > 0 && ( + + {r.attachment_count} + {r.inbound_document_id && {t('email.received.inbox', 'inbox')}} + + )} + {r.received_at ? fmtDateTime(r.received_at) : '—'}{t(`email.received.statusValue.${r.status}`, r.status)}
+ {pg && pg.totalPages > 1 && ( +
+ + {page} / {pg.totalPages} + +
+ )} +
+ ); +}; + +export default ReceivedEmailsPanel; diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index 7cdc74fb..359196fb 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -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 diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index e9845e4a..92af8eb9 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -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)} /> + setFlag('incomingMail', next)} + /> + { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState<'smtp' | 'templates' | 'sent'>('smtp'); + const [activeTab, setActiveTab] = useState<'smtp' | 'templates' | 'sent' | 'received'>('smtp'); const [selectedTemplateKey, setSelectedTemplateKey] = useState('gallery_created'); const [editedTemplate, setEditedTemplate] = useState>({}); const [editingLang, setEditingLang] = useState('en'); @@ -496,12 +498,27 @@ export const EmailConfigPage: React.FC = () => { > {t('email.sentEmails.tab', 'Sent emails')} + {featureFlags.incomingMail && ( + + )} {/* Sent emails Tab */} {activeTab === 'sent' && } + {/* Received emails Tab */} + {activeTab === 'received' && } + {/* SMTP Settings Tab */} {activeTab === 'smtp' && (
@@ -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 && } + {activeTab === 'templates' && (
diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index b56e91e9..a7c50942 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -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 { @@ -91,6 +117,19 @@ export const emailService = { await api.post('/admin/email/config', config); }, + // Incoming mail (IMAP) configuration + async getIncomingConfig(): Promise { + const response = await api.get('/admin/email/incoming-config'); + return response.data; + }, + async updateIncomingConfig(config: IncomingMailConfig): Promise { + await api.post('/admin/email/incoming-config', config); + }, + async listReceived(params: { page?: number; pageSize?: number } = {}): Promise { + const response = await api.get('/admin/email/received', { params }); + return response.data; + }, + // Test email configuration async testEmail(testEmail: string): Promise { await api.post('/admin/email/test', { test_email: testEmail }); diff --git a/frontend/src/services/featureFlags.service.ts b/frontend/src/services/featureFlags.service.ts index e2d5a668..5c486615 100644 --- a/frontend/src/services/featureFlags.service.ts +++ b/frontend/src/services/featureFlags.service.ts @@ -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