feat(email): round-trip test — send via SMTP to the IMAP mailbox and confirm arrival
- emailIntakeService.roundTripTest(): sends a uniquely-tagged email through the
saved SMTP config to the IMAP mailbox (imap_user), then polls IMAP up to 30s
for that subject token; deletes the test message on arrival so it never hits
the accounting inbox. Returns {ok, seconds, recipient} or a typed reason.
- route POST /admin/email/incoming-config/roundtrip (email.send)
- IMAP card: 'Round-trip test' button beside 'Test connection' + Save; toast
reports recipient + delivery time. Distinct reasons mapped (smtp/imap
unconfigured, send_failed, not_received→504).
- en/de strings
This commit is contained in:
@@ -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) => {
|
router.get('/received', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||||
|
|||||||
@@ -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. */
|
/** Poll the mailbox once. Safe to call repeatedly; self-skips when busy/off. */
|
||||||
async function pollOnce() {
|
async function pollOnce() {
|
||||||
if (polling) return { skipped: 'busy' };
|
if (polling) return { skipped: 'busy' };
|
||||||
@@ -192,4 +264,4 @@ function startIncomingMailPoller() {
|
|||||||
logger.info?.('Incoming-mail poller started (every 60s when enabled)');
|
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 } };
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'react-toastify';
|
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 { Button, Card, Input, Loading } from '../common';
|
||||||
import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service';
|
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.')),
|
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({
|
const detect = useMutation({
|
||||||
mutationFn: () => emailService.listIncomingFolders(cfg),
|
mutationFn: () => emailService.listIncomingFolders(cfg),
|
||||||
onSuccess: (list) => {
|
onSuccess: (list) => {
|
||||||
@@ -158,7 +164,7 @@ export const IncomingMailConfigCard: React.FC = () => {
|
|||||||
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => test.mutate()}
|
onClick={() => test.mutate()}
|
||||||
@@ -169,7 +175,18 @@ export const IncomingMailConfigCard: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{t('email.incoming.test', 'Test connection')}
|
{t('email.incoming.test', 'Test connection')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => roundTrip.mutate()}
|
||||||
|
isLoading={roundTrip.isPending}
|
||||||
|
disabled={!cfg.imap_host || !cfg.imap_user}
|
||||||
|
leftIcon={<Mailbox className="w-5 h-5" />}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
title={t('email.incoming.roundTripHint', 'Sends a test email via your SMTP settings to this mailbox and confirms it arrives. Save both first.') as string}
|
||||||
|
>
|
||||||
|
{t('email.incoming.roundTrip', 'Round-trip test')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1 min-w-[12rem]">
|
||||||
{t('email.incoming.save', 'Save Incoming Mail Settings')}
|
{t('email.incoming.save', 'Save Incoming Mail Settings')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2482,6 +2482,10 @@
|
|||||||
"test": "Verbindung testen",
|
"test": "Verbindung testen",
|
||||||
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
|
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
|
||||||
"testFailed": "Verbindung fehlgeschlagen.",
|
"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",
|
"port": "Port",
|
||||||
"security": "Sicherheit",
|
"security": "Sicherheit",
|
||||||
"ssl": "SSL/TLS",
|
"ssl": "SSL/TLS",
|
||||||
|
|||||||
@@ -2055,6 +2055,10 @@
|
|||||||
"test": "Test connection",
|
"test": "Test connection",
|
||||||
"testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.",
|
"testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.",
|
||||||
"testFailed": "Connection failed.",
|
"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",
|
"port": "Port",
|
||||||
"security": "Security",
|
"security": "Security",
|
||||||
"ssl": "SSL/TLS",
|
"ssl": "SSL/TLS",
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ export interface ImapTestResult {
|
|||||||
unseen: number;
|
unseen: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImapRoundTripResult {
|
||||||
|
ok: boolean;
|
||||||
|
seconds?: number;
|
||||||
|
recipient?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReceivedEmail {
|
export interface ReceivedEmail {
|
||||||
id: number;
|
id: number;
|
||||||
message_id: string | null;
|
message_id: string | null;
|
||||||
@@ -150,6 +156,12 @@ export const emailService = {
|
|||||||
const response = await api.post<ImapTestResult>('/admin/email/incoming-config/test', config || {});
|
const response = await api.post<ImapTestResult>('/admin/email/incoming-config/test', config || {});
|
||||||
return response.data;
|
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<ImapRoundTripResult> {
|
||||||
|
const response = await api.post<ImapRoundTripResult>('/admin/email/incoming-config/roundtrip', {});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
|
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
|
||||||
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
|
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user