diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 7f815d83..74ae1f1a 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -197,6 +197,31 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view } }); +// Test the incoming-mail connection: log in + open the configured folder and +// report message/unread counts. Accepts current form creds (test before save). +router.post('/incoming-config/test', adminAuth, requirePermission('email.view'), async (req, res) => { + try { + const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = 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 result = await emailIntakeService.testConnection( + imap_host ? { host: imap_host, port: imap_port, secure: imap_secure, user: imap_user, pass: imap_pass, folder: imap_folder } : undefined + ); + if (result && result.ok === false) { + return res.status(400).json({ error: 'Incoming mail is not configured yet — enter host, username and password first.' }); + } + res.json(result); + } catch (error) { + console.error('IMAP connection test error:', error); + res.status(502).json({ error: 'Could not connect to the mailbox. Check host, port, credentials and folder.' }); + } +}); + 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 24ff6a18..ff91ba11 100644 --- a/backend/src/services/emailIntakeService.js +++ b/backend/src/services/emailIntakeService.js @@ -83,6 +83,43 @@ async function listFolders(override) { } } +/** + * Test the IMAP connection: log in, open the configured folder, and report + * the message + unread counts. Non-destructive (marks nothing seen, ingests + * nothing) — proves host/port/user/pass AND that the chosen folder opens. + * Accepts an `override` ({ host, port, secure, user, pass, folder }) so the + * admin can test before saving; a masked/blank password falls back to stored. + */ +async function testConnection(override) { + let cfg; let folder; + 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 || '' }, + }; + folder = override.folder || 'INBOX'; + if (!cfg.auth.pass || cfg.auth.pass === '********') { + const stored = await getImapConfig(); + cfg.auth.pass = stored?.auth?.pass || ''; + } + } else { + const c = await getImapConfig(); + if (!c) return { ok: false, error: 'unconfigured' }; + cfg = { host: c.host, port: c.port, secure: c.secure, auth: c.auth }; + folder = c.folder; + } + const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false }); + await client.connect(); + try { + const status = await client.status(folder, { messages: true, unseen: true }); + return { ok: true, folder, messages: status.messages || 0, unseen: status.unseen || 0 }; + } 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' }; @@ -155,4 +192,4 @@ function startIncomingMailPoller() { logger.info?.('Incoming-mail poller started (every 60s when enabled)'); } -module.exports = { pollOnce, startIncomingMailPoller, listFolders, _internal: { getImapConfig, isEnabled, saveAttachment } }; +module.exports = { pollOnce, startIncomingMailPoller, listFolders, testConnection, _internal: { getImapConfig, isEnabled, saveAttachment } }; diff --git a/frontend/src/components/admin/IncomingMailConfigCard.tsx b/frontend/src/components/admin/IncomingMailConfigCard.tsx index 472b0fdd..ace929c3 100644 --- a/frontend/src/components/admin/IncomingMailConfigCard.tsx +++ b/frontend/src/components/admin/IncomingMailConfigCard.tsx @@ -11,7 +11,7 @@ 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, Server, User, Lock, Eye, EyeOff, FolderSearch } from 'lucide-react'; +import { Save, Server, User, Lock, Eye, EyeOff, FolderSearch, PlugZap } from 'lucide-react'; import { Button, Card, Input, Loading } from '../common'; import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service'; @@ -44,6 +44,12 @@ export const IncomingMailConfigCard: React.FC = () => { onError: (e: any) => toast.error(e?.response?.data?.error || e?.response?.data?.errors?.[0]?.msg || e.message || 'Failed'), }); + const test = useMutation({ + mutationFn: () => emailService.testIncoming(cfg), + onSuccess: (r) => toast.success(t('email.incoming.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen })), + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.testFailed', 'Connection failed.')), + }); + const detect = useMutation({ mutationFn: () => emailService.listIncomingFolders(cfg), onSuccess: (list) => { @@ -152,9 +158,21 @@ export const IncomingMailConfigCard: React.FC = () => {

{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 9fc69175..608142ee 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2479,6 +2479,9 @@ "title": "Eingehende E-Mails (IMAP)", "subtitle": "Ein dediziertes Postfach, jede Minute abgerufen; Anhänge landen in Buchhaltung → Eingangsrechnungen.", "host": "IMAP-Host", + "test": "Verbindung testen", + "testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.", + "testFailed": "Verbindung fehlgeschlagen.", "port": "Port", "security": "Sicherheit", "ssl": "SSL/TLS", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index bf3057b8..aafa0982 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2051,7 +2051,10 @@ "incoming": { "title": "Incoming mail (IMAP)", "subtitle": "A dedicated mailbox polled every minute; attachments land in Accounting → Incoming invoices.", - "host": "IMAP host", + "host": "IMAP Host", + "test": "Test connection", + "testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.", + "testFailed": "Connection failed.", "port": "Port", "security": "Security", "ssl": "SSL/TLS", diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index b0d8b2b9..f79ad8a9 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -95,6 +95,13 @@ export interface ImapFolder { specialUse: string | null; } +export interface ImapTestResult { + ok: boolean; + folder: string; + messages: number; + unseen: number; +} + export interface ReceivedEmail { id: number; message_id: string | null; @@ -138,6 +145,11 @@ export const emailService = { const response = await api.post<{ folders: ImapFolder[] }>('/admin/email/incoming-config/folders', config || {}); return response.data.folders; }, + // Test the IMAP connection: opens the configured folder, reports counts. + async testIncoming(config?: Partial): Promise { + const response = await api.post('/admin/email/incoming-config/test', config || {}); + return response.data; + }, async listReceived(params: { page?: number; pageSize?: number } = {}): Promise { const response = await api.get('/admin/email/received', { params }); return response.data;