diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 74ae1f1a..5683c62b 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -222,6 +222,27 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'), } }); +// End-to-end round-trip: send via SMTP to the IMAP mailbox, then confirm it +// arrives. Uses saved config for both sides (real passwords needed). +router.post('/incoming-config/roundtrip', adminAuth, requirePermission('email.send'), async (req, res) => { + try { + const emailIntakeService = require('../services/emailIntakeService'); + const result = await emailIntakeService.roundTripTest(); + if (result.ok) return res.json(result); + const map = { + smtp_unconfigured: 'Configure and save the outgoing SMTP settings first.', + imap_unconfigured: 'Configure and save the incoming IMAP settings first.', + send_failed: `Could not send the test email${result.error ? `: ${result.error}` : ''}.`, + not_received: 'The email was sent but did not arrive within 30s — possible delivery delay/greylisting. Check the Received emails tab in a moment.', + }; + return res.status(result.reason === 'not_received' ? 504 : 400) + .json({ error: map[result.reason] || 'Round-trip test failed.', sent: !!result.sent, recipient: result.recipient }); + } catch (error) { + console.error('Round-trip test error:', error); + res.status(502).json({ error: 'Round-trip test failed — check both SMTP and IMAP settings.' }); + } +}); + 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 ff91ba11..ebcd1220 100644 --- a/backend/src/services/emailIntakeService.js +++ b/backend/src/services/emailIntakeService.js @@ -120,6 +120,78 @@ async function testConnection(override) { } } +/** + * End-to-end round-trip test: send a uniquely-tagged email through the saved + * SMTP (outgoing) config TO the IMAP mailbox, then poll IMAP until it arrives. + * Proves the whole pipeline (outgoing delivery → incoming reception) in one + * click. Uses SAVED config for both sides (real passwords needed to send + + * read). Cleans up: the test message is deleted once found, so it never + * reaches the accounting inbox. + * + * Returns { ok, seconds, recipient } on success, or { ok:false, sent, reason }. + */ +async function roundTripTest({ timeoutMs = 30000, intervalMs = 3000 } = {}) { + const nodemailer = require('nodemailer'); + const crypto = require('crypto'); + const c = await db('email_configs').first(); + if (!c || !c.smtp_host || !c.smtp_port) return { ok: false, sent: false, reason: 'smtp_unconfigured' }; + if (!c.imap_host || !c.imap_user) return { ok: false, sent: false, reason: 'imap_unconfigured' }; + + // Recipient = the mailbox we poll. imap_user is the mailbox address in the + // typical setup (e.g. rechnungen@…). + const recipient = c.imap_user; + const token = `ppk-rt-${Date.now()}-${crypto.randomBytes(5).toString('hex')}`; + const subject = `picpeak round-trip test ${token}`; + + // 1) Send via the saved SMTP config (mirror the /test route's transport). + const transporter = nodemailer.createTransport({ + host: c.smtp_host, + port: parseInt(c.smtp_port, 10), + secure: c.smtp_secure === true || c.smtp_secure === 1, + auth: c.smtp_user && c.smtp_pass ? { user: c.smtp_user, pass: c.smtp_pass } : undefined, + tls: { rejectUnauthorized: c.tls_reject_unauthorized !== false }, + }); + try { + await transporter.sendMail({ + from: `${c.from_name || 'picpeak'} <${c.from_email || c.smtp_user}>`, + to: recipient, + subject, + text: `This is an automated picpeak round-trip test. Token: ${token}. Safe to ignore — it is deleted automatically.`, + }); + } catch (err) { + return { ok: false, sent: false, reason: 'send_failed', error: err.message }; + } + + // 2) Poll IMAP for the tagged message until timeout. + const cfg = await getImapConfig(); + const folder = cfg?.folder || 'INBOX'; + const client = new ImapFlow({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: cfg.auth, logger: false }); + await client.connect(); + const started = Date.now(); + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const lock = await client.getMailboxLock(folder); + try { + const uids = await client.search({ subject: token }, { uid: true }); + if (uids && uids.length) { + await client.messageDelete(uids, { uid: true }).catch(() => {}); + return { ok: true, seconds: Math.round((Date.now() - started) / 1000), recipient }; + } + } finally { + lock.release(); + } + if (Date.now() - started > timeoutMs) { + return { ok: false, sent: true, reason: 'not_received', recipient }; + } + // eslint-disable-next-line no-await-in-loop + await new Promise((r) => setTimeout(r, intervalMs)); + } + } 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' }; @@ -192,4 +264,4 @@ function startIncomingMailPoller() { logger.info?.('Incoming-mail poller started (every 60s when enabled)'); } -module.exports = { pollOnce, startIncomingMailPoller, listFolders, testConnection, _internal: { getImapConfig, isEnabled, saveAttachment } }; +module.exports = { pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } }; diff --git a/frontend/src/components/admin/IncomingMailConfigCard.tsx b/frontend/src/components/admin/IncomingMailConfigCard.tsx index ace929c3..17b15ec7 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, PlugZap } from 'lucide-react'; +import { Save, Server, User, Lock, Eye, EyeOff, FolderSearch, PlugZap, Mailbox } from 'lucide-react'; import { Button, Card, Input, Loading } from '../common'; import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service'; @@ -50,6 +50,12 @@ export const IncomingMailConfigCard: React.FC = () => { onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.testFailed', 'Connection failed.')), }); + const roundTrip = useMutation({ + mutationFn: () => emailService.roundTripIncoming(), + onSuccess: (r) => toast.success(t('email.incoming.roundTripOk', 'Round-trip OK — delivered to {{recipient}} in {{seconds}}s.', { recipient: r.recipient, seconds: r.seconds })), + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.roundTripFailed', 'Round-trip test failed.')), + }); + const detect = useMutation({ mutationFn: () => emailService.listIncomingFolders(cfg), onSuccess: (list) => { @@ -158,7 +164,7 @@ 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 608142ee..bdc155f8 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2482,6 +2482,10 @@ "test": "Verbindung testen", "testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.", "testFailed": "Verbindung fehlgeschlagen.", + "roundTrip": "Rundlauf-Test", + "roundTripHint": "Sendet über die SMTP-Einstellungen eine Test-E-Mail an dieses Postfach und prüft, ob sie ankommt. Beide vorher speichern.", + "roundTripOk": "Rundlauf OK — an {{recipient}} zugestellt in {{seconds}}s.", + "roundTripFailed": "Rundlauf-Test 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 aafa0982..490d422a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2055,6 +2055,10 @@ "test": "Test connection", "testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.", "testFailed": "Connection failed.", + "roundTrip": "Round-trip test", + "roundTripHint": "Sends a test email via your SMTP settings to this mailbox and confirms it arrives. Save both first.", + "roundTripOk": "Round-trip OK — delivered to {{recipient}} in {{seconds}}s.", + "roundTripFailed": "Round-trip test 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 f79ad8a9..1c047a08 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -102,6 +102,12 @@ export interface ImapTestResult { unseen: number; } +export interface ImapRoundTripResult { + ok: boolean; + seconds?: number; + recipient?: string; +} + export interface ReceivedEmail { id: number; message_id: string | null; @@ -150,6 +156,12 @@ export const emailService = { const response = await api.post('/admin/email/incoming-config/test', config || {}); return response.data; }, + // End-to-end: send via SMTP to the IMAP mailbox and confirm it arrives. + // Uses saved config for both sides — no body. May take up to ~30s. + async roundTripIncoming(): Promise { + const response = await api.post('/admin/email/incoming-config/roundtrip', {}); + return response.data; + }, async listReceived(params: { page?: number; pageSize?: number } = {}): Promise { const response = await api.get('/admin/email/received', { params }); return response.data;