// E-Mail transport: authenticated SMTP submission (send) + IMAP (receive). // // Deliverability by design: all mail is sent through the account's own // submission server (MAIL_HOST) with SMTP AUTH, so the server applies its // DKIM signature and uses its reputable IP/PTR. Combined with a From address // on the same domain, this satisfies SPF and DKIM alignment — required here // because the domain publishes SPF "-all" and DMARC "p=reject". Sending mail // directly to the recipient MX (bypassing the submission server) would fail // both and land in spam / get rejected, so we never do that. const nodemailer = require('nodemailer'); const { ImapFlow } = require('imapflow'); const { simpleParser } = require('mailparser'); function cfg() { const smtpPort = Number(process.env.MAIL_SMTP_PORT || 587); return { host: process.env.MAIL_HOST || '', smtpPort, imapPort: Number(process.env.MAIL_IMAP_PORT || 993), user: process.env.MAIL_USER || '', pass: process.env.MAIL_PASSWORD || '', fromName: process.env.MAIL_FROM_NAME || '', fromAddr: process.env.MAIL_FROM || process.env.MAIL_USER || '', mailbox: process.env.MAIL_IMAP_MAILBOX || 'INBOX', // secure=true means implicit TLS (465); 587 uses STARTTLS (requireTLS). smtpSecure: smtpPort === 465, }; } function isConfigured() { const c = cfg(); return Boolean(c.host && c.user && c.pass); } // "Anzeigename " for the From header. function fromField() { const c = cfg(); return c.fromName ? `${c.fromName} <${c.fromAddr}>` : c.fromAddr; } function fromAddress() { return cfg().fromAddr; } function buildTransport() { const c = cfg(); return nodemailer.createTransport({ host: c.host, port: c.smtpPort, secure: c.smtpSecure, requireTLS: !c.smtpSecure, // enforce STARTTLS on 587 auth: { user: c.user, pass: c.pass }, // Present a sane FQDN in EHLO (matches the sending domain). name: (c.fromAddr.split('@')[1] || c.host || undefined), }); } // Verify SMTP connectivity + credentials (used by a health check / startup log). async function verify() { const t = buildTransport(); try { await t.verify(); return true; } finally { t.close(); } } // Minimal HTML rendering of a plain-text body: escape, then turn newlines into // explicit
tags. Sending multipart/alternative (text + html) reads as // normal personal mail and avoids the "text-only, no html" heuristic some // filters apply. // // The line breaks MUST be real
tags, not merely CSS white-space:pre-wrap // on literal newlines: pre-wrap renders correctly on first receipt, but when // the recipient's client (e.g. Outlook) quotes the message back to us it // re-parses the HTML, drops the pre-wrap style and collapses the bare newlines // into spaces — turning our carefully formatted reply into a run-on wall of // text in the quoted thread. Explicit
survives that round-trip. function textToHtml(text) { const esc = String(text || '') .replace(/&/g, '&').replace(//g, '>') .replace(/\r\n?/g, '\n') .replace(/\n/g, '
\n'); return `
${esc}
`; } // Send one message via authenticated submission. Returns nodemailer info // (includes messageId). `attachments` is an array of {filename, path|content, contentType}. async function sendMail({ to, subject, text, html, attachments, inReplyTo, references, cc, bcc }) { const t = buildTransport(); try { const headers = {}; if (inReplyTo) headers['In-Reply-To'] = inReplyTo; if (references) headers['References'] = references; return await t.sendMail({ from: fromField(), to, cc: cc || undefined, bcc: bcc || undefined, subject: subject || '', text: text || '', html: html || textToHtml(text), attachments: attachments || [], headers, // Message-ID auto-generated on the From domain → clean threading + DMARC. }); } finally { t.close(); } } // Fetch messages from the mailbox with a UID strictly greater than `sinceUid`. // Returns { messages: [...parsed...], maxUid }. Each parsed message exposes the // UID plus the fields we persist. Guards the classic IMAP "N:*" quirk (a range // whose start exceeds the highest UID still returns the last message) by // filtering uid > sinceUid in code. async function fetchSince(sinceUid = 0) { const c = cfg(); const client = new ImapFlow({ host: c.host, port: c.imapPort, secure: true, auth: { user: c.user, pass: c.pass }, logger: false, }); const messages = []; let maxUid = sinceUid; await client.connect(); const lock = await client.getMailboxLock(c.mailbox); try { const box = client.mailbox; if (box && box.exists > 0) { const from = Math.max(1, Number(sinceUid) + 1); for await (const msg of client.fetch(`${from}:*`, { uid: true, source: true, flags: true }, { uid: true })) { if (msg.uid <= sinceUid) continue; // dodge the N:* quirk if (msg.uid > maxUid) maxUid = msg.uid; let parsed = null; try { parsed = await simpleParser(msg.source); } catch (e) { parsed = null; } if (!parsed) continue; const fromV = (parsed.from && parsed.from.value && parsed.from.value[0]) || {}; const toText = parsed.to && parsed.to.text ? parsed.to.text : ''; messages.push({ uid: msg.uid, messageId: stripBrackets(parsed.messageId), inReplyTo: stripBrackets(parsed.inReplyTo), references: Array.isArray(parsed.references) ? parsed.references.map(stripBrackets).join(' ') : stripBrackets(parsed.references), fromAddr: (fromV.address || '').toLowerCase(), fromName: fromV.name || '', toAddr: toText, subject: parsed.subject || '', text: parsed.text || (parsed.html ? htmlToText(parsed.html) : ''), html: parsed.html || '', date: parsed.date ? new Date(parsed.date) : new Date(), seen: !!(msg.flags && msg.flags.has && msg.flags.has('\\Seen')), attachments: (parsed.attachments || []) .filter((a) => a.content && a.filename) .map((a) => ({ filename: a.filename, contentType: a.contentType || 'application/octet-stream', content: a.content })), }); } } } finally { lock.release(); await client.logout().catch(() => {}); } return { messages, maxUid }; } function stripBrackets(v) { if (!v) return ''; return String(v).replace(/[<>]/g, '').trim(); } // Very small HTML→text fallback for html-only mails. function htmlToText(html) { return String(html || '') .replace(/<\s*br\s*\/?>/gi, '\n') .replace(/<\s*\/p\s*>/gi, '\n\n') .replace(/<[^>]+>/g, '') .replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/\n{3,}/g, '\n\n') .trim(); } module.exports = { isConfigured, verify, sendMail, fetchSince, fromField, fromAddress, textToHtml, htmlToText, config: cfg, };