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:
Luca
2026-06-12 14:52:14 +02:00
parent f017649bd5
commit 04be51a008
6 changed files with 134 additions and 4 deletions
+21
View File
@@ -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);
+73 -1
View File
@@ -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 } };