From abb23f01c745d4285fbc07994c136a719a66b1d0 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:11:31 +0200 Subject: [PATCH] fix(email): match IMAP card to SMTP styling + auto-detect mailbox folders - IncomingMailConfigCard rebuilt to mirror the outgoing SMTP card: Card padding=md, icon inputs (Server/User/Lock), password eye toggle, stacked full-width fields, full-width primary Save button - Folder is now a dropdown auto-populated by a 'Detect' button instead of a free-text path: backend emailIntakeService.listFolders() lists IMAP mailboxes (POST /admin/email/incoming-config/folders, accepts current form creds, masked password falls back to stored); UI auto-selects the inbox (special-use) folder - en/de strings added --- backend/src/routes/adminEmail.js | 23 +++ backend/src/services/emailIntakeService.js | 37 ++++- .../admin/IncomingMailConfigCard.tsx | 145 ++++++++++++++---- frontend/src/i18n/locales/de.json | 6 + frontend/src/i18n/locales/en.json | 6 + frontend/src/services/email.service.ts | 13 ++ 6 files changed, 203 insertions(+), 27 deletions(-) diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 2a7281b0..da7f4f20 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -171,6 +171,29 @@ router.post('/incoming-config', [ }); // Received-emails log (the IMAP poller's audit trail) — "Received emails" tab. +// List IMAP folders so the UI can offer a dropdown (auto-detect) instead of a +// free-text path. Accepts optional creds in the body to detect before saving; +// falls back to the stored config (and stored password when masked). +router.post('/incoming-config/folders', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const { imap_host, imap_port, imap_secure, imap_user, imap_pass } = req.body || {}; + if (imap_host) { + const { isPrivateIP } = require('../utils/networkValidation'); + if (isPrivateIP(imap_host)) { + return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' }); + } + } + const emailIntakeService = require('../services/emailIntakeService'); + const folders = await emailIntakeService.listFolders( + imap_host ? { host: imap_host, port: imap_port, secure: imap_secure, user: imap_user, pass: imap_pass } : undefined + ); + res.json({ folders }); + } catch (error) { + console.error('IMAP folder detection error:', error); + res.status(502).json({ error: 'Could not connect to the mailbox. Check host, port and credentials.' }); + } +}); + router.get('/received', adminAuth, requirePermission('email.view'), async (req, res) => { try { const page = Math.max(1, parseInt(req.query.page, 10) || 1); diff --git a/backend/src/services/emailIntakeService.js b/backend/src/services/emailIntakeService.js index 1c76b62c..24ff6a18 100644 --- a/backend/src/services/emailIntakeService.js +++ b/backend/src/services/emailIntakeService.js @@ -48,6 +48,41 @@ async function saveAttachment(att) { return filePath; } +/** + * List the mailbox folders on the IMAP server so the UI can offer a + * dropdown instead of a free-text path. Uses the saved config; an + * `override` ({ host, port, secure, user, pass }) lets the admin detect + * folders BEFORE saving. A masked/blank override password falls back to + * the stored one. Returns [{ path, name, specialUse }] (specialUse like + * '\\Inbox' lets the caller auto-select the inbox). + */ +async function listFolders(override) { + let cfg; + if (override && override.host && override.user) { + cfg = { + host: override.host, + port: override.port || 993, + secure: override.secure !== false && override.secure !== 0, + auth: { user: override.user, pass: override.pass || '' }, + }; + if (!cfg.auth.pass || cfg.auth.pass === '********') { + const stored = await getImapConfig(); + cfg.auth.pass = stored?.auth?.pass || ''; + } + } else { + cfg = await getImapConfig(); + } + if (!cfg) return []; + const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false }); + await client.connect(); + try { + const list = await client.list(); + return (list || []).map((m) => ({ path: m.path, name: m.name, specialUse: m.specialUse || null })); + } finally { + await client.logout().catch(() => {}); + } +} + /** Poll the mailbox once. Safe to call repeatedly; self-skips when busy/off. */ async function pollOnce() { if (polling) return { skipped: 'busy' }; @@ -120,4 +155,4 @@ function startIncomingMailPoller() { logger.info?.('Incoming-mail poller started (every 60s when enabled)'); } -module.exports = { pollOnce, startIncomingMailPoller, _internal: { getImapConfig, isEnabled, saveAttachment } }; +module.exports = { pollOnce, startIncomingMailPoller, listFolders, _internal: { getImapConfig, isEnabled, saveAttachment } }; diff --git a/frontend/src/components/admin/IncomingMailConfigCard.tsx b/frontend/src/components/admin/IncomingMailConfigCard.tsx index 93e453ce..c9d2f10f 100644 --- a/frontend/src/components/admin/IncomingMailConfigCard.tsx +++ b/frontend/src/components/admin/IncomingMailConfigCard.tsx @@ -1,59 +1,152 @@ /** * 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. + * settings, styled to match the SMTP card (icon inputs, password eye toggle, + * full-width Save). Shown only when the `incomingMail` feature flag is on. + * + * The Folder field auto-detects: "Detect folders" lists the mailboxes on the + * server and offers them as a dropdown (auto-selecting the inbox), instead of + * making the admin type a path. */ 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 { Save, Server, User, Lock, Eye, EyeOff, FolderSearch } from 'lucide-react'; import { Button, Card, Input, Loading } from '../common'; -import { emailService, type IncomingMailConfig } from '../../services/email.service'; +import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service'; 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'; 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' }); + const [showPassword, setShowPassword] = useState(false); + const [folders, setFolders] = useState(null); useEffect(() => { if (data) setCfg(data); }, [data]); + const set = (k: keyof IncomingMailConfig, v: any) => setCfg((c) => ({ ...c, [k]: v })); + 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 })); + const detect = useMutation({ + mutationFn: () => emailService.listIncomingFolders(cfg), + onSuccess: (list) => { + setFolders(list); + if (list.length) { + // Auto-select the inbox (special-use '\Inbox', else a path named INBOX) + // when the current folder isn't one of the detected ones. + const has = list.some((f) => f.path === cfg.imap_folder); + if (!has) { + const inbox = list.find((f) => (f.specialUse || '').toLowerCase().includes('inbox')) + || list.find((f) => f.path.toUpperCase() === 'INBOX') || list[0]; + if (inbox) set('imap_folder', inbox.path); + } + toast.success(t('email.incoming.foldersDetected', '{{count}} folders found.', { count: list.length })); + } else { + toast.info(t('email.incoming.noFolders', 'No folders returned by the server.')); + } + }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.detectFailed', 'Could not detect folders.')), + }); + 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" />
-
-
- + +
+
+ + 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="rechnungen@yourdomain.com" + leftIcon={} + /> +
+ +
+ +
+ set('imap_pass', e.target.value)} + autoComplete="new-password" + placeholder={t('email.enterPassword', 'Enter password')} + leftIcon={} + /> + +
+
+ +
+ +
+ {folders && folders.length > 0 ? ( + + ) : ( + set('imap_folder', e.target.value)} placeholder="INBOX" /> + )} + +
+

{t('email.incoming.folderHint', 'Enter host, username and password, then Detect to list the mailbox folders.')}

+
+ +
); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index cefc4cdf..efd3cd60 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2486,6 +2486,12 @@ "user": "Benutzername", "pass": "Passwort", "folder": "Ordner", + "save": "Eingangs-E-Mail-Einstellungen speichern", + "detectFolders": "Erkennen", + "folderHint": "Host, Benutzername und Passwort eingeben, dann „Erkennen“, um die Postfach-Ordner aufzulisten.", + "foldersDetected": "{{count}} Ordner gefunden.", + "noFolders": "Der Server hat keine Ordner zurückgegeben.", + "detectFailed": "Ordner konnten nicht erkannt werden.", "savedToast": "Einstellungen für eingehende E-Mails gespeichert." }, "received": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 05a2d52a..ae2a192f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2059,6 +2059,12 @@ "user": "Username", "pass": "Password", "folder": "Folder", + "save": "Save Incoming Mail Settings", + "detectFolders": "Detect", + "folderHint": "Enter host, username and password, then Detect to list the mailbox folders.", + "foldersDetected": "{{count}} folders found.", + "noFolders": "No folders returned by the server.", + "detectFailed": "Could not detect folders.", "savedToast": "Incoming mail settings saved." }, "received": { diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index a7c50942..b0d8b2b9 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -88,6 +88,13 @@ export interface IncomingMailConfig { imap_folder: string; } +export interface ImapFolder { + path: string; + name: string; + /** IMAP special-use flag, e.g. '\\Inbox', '\\Sent' — used to auto-select. */ + specialUse: string | null; +} + export interface ReceivedEmail { id: number; message_id: string | null; @@ -125,6 +132,12 @@ export const emailService = { async updateIncomingConfig(config: IncomingMailConfig): Promise { await api.post('/admin/email/incoming-config', config); }, + // Auto-detect mailbox folders. Sends the current form values so detection + // works before the config is saved (masked password falls back server-side). + async listIncomingFolders(config?: Partial): Promise { + const response = await api.post<{ folders: ImapFolder[] }>('/admin/email/incoming-config/folders', config || {}); + return response.data.folders; + }, async listReceived(params: { page?: number; pageSize?: number } = {}): Promise { const response = await api.get('/admin/email/received', { params }); return response.data;