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
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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<IncomingMailConfig>({ imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '', imap_folder: 'INBOX' });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [folders, setFolders] = useState<ImapFolder[] | null>(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 <Loading />;
|
||||
|
||||
return (
|
||||
<Card className="p-6 mt-6">
|
||||
<Card padding="md" className="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 className="space-y-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.host', 'IMAP Host')}</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={cfg.imap_host}
|
||||
onChange={(e) => set('imap_host', e.target.value)}
|
||||
placeholder="imap.example.com"
|
||||
leftIcon={<Server className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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)} placeholder="993" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
|
||||
<select className={selectCls} 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>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.user', 'Username')}</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={cfg.imap_user}
|
||||
onChange={(e) => set('imap_user', e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder="[email protected]"
|
||||
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.pass', 'Password')}</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={cfg.imap_pass}
|
||||
onChange={(e) => set('imap_pass', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder={t('email.enterPassword', 'Enter password')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600">
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.folder', 'Folder')}</label>
|
||||
<div className="flex gap-2">
|
||||
{folders && folders.length > 0 ? (
|
||||
<select className={selectCls} value={cfg.imap_folder} onChange={(e) => set('imap_folder', e.target.value)}>
|
||||
{folders.some((f) => f.path === cfg.imap_folder) ? null : <option value={cfg.imap_folder}>{cfg.imap_folder}</option>}
|
||||
{folders.map((f) => <option key={f.path} value={f.path}>{f.path}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<Input type="text" value={cfg.imap_folder} onChange={(e) => set('imap_folder', e.target.value)} placeholder="INBOX" />
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => detect.mutate()}
|
||||
isLoading={detect.isPending}
|
||||
disabled={!cfg.imap_host || !cfg.imap_user}
|
||||
leftIcon={<FolderSearch className="w-4 h-4" />}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{t('email.incoming.detectFolders', 'Detect')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('email.incoming.folderHint', 'Enter host, username and password, then Detect to list the mailbox folders.')}</p>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="w-full">
|
||||
{t('email.incoming.save', 'Save Incoming Mail Settings')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<void> {
|
||||
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<IncomingMailConfig>): Promise<ImapFolder[]> {
|
||||
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<ReceivedEmailsResponse> {
|
||||
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
|
||||
return response.data;
|
||||
|
||||
Reference in New Issue
Block a user