/** * Dependency-free SMTP submission client. * * Speaks just enough SMTP to authenticate against Stalwart's plaintext * submission listener (AUTH LOGIN, no STARTTLS) and inject a message. Used to * simulate real inbound mail so the webmail's sync behaviour can be observed. * A raw socket keeps the test harness free of a nodemailer dependency. */ import net from 'node:net'; import { SMTP_HOST, SMTP_PORT } from './config'; interface SendOptions { host?: string; port?: number; /** Envelope + auth sender, e.g. "alice@example.org". */ from: string; /** Auth username; defaults to `from`. */ authUser?: string; authPass: string; /** One or more envelope recipients. */ to: string | string[]; subject: string; /** Plain-text body. */ body: string; /** * Optional HTML alternative, sent as multipart/alternative alongside `body`. * * Added for 13-electron-offline-replica.spec.ts, which has to prove the offline * replica stores a real HTML body and not just the plain-text excerpt the search * index keeps - so the message needs a genuine distinct text/html part. */ html?: string; /** Extra headers (e.g. custom Message-ID / In-Reply-To for threading). */ headers?: Record; /** Optional single attachment (sent as multipart/mixed, base64). */ attachment?: { filename: string; contentType: string; content: string }; /** * Optional inline image referenced by the HTML body via `cid:`. Sent as * multipart/related; `base64` is the pre-encoded image payload. */ inlineImage?: { cid: string; contentType: string; base64: string; html: string }; } class SmtpError extends Error {} function crlf(s: string): string { return s.replace(/\r?\n/g, '\r\n'); } /** * Submit a single message. Resolves once the server has accepted it (250 after * end-of-DATA). Rejects on any non-2xx/3xx reply or socket error. */ export async function sendMail(opts: SendOptions): Promise { const host = opts.host ?? SMTP_HOST; const port = opts.port ?? SMTP_PORT; const recipients = Array.isArray(opts.to) ? opts.to : [opts.to]; const authUser = opts.authUser ?? opts.from; const socket = net.createConnection({ host, port }); socket.setEncoding('utf8'); socket.setTimeout(15000); let buffer = ''; let resolveLine: ((line: string) => void) | null = null; let pendingError: Error | null = null; socket.on('data', (chunk: string) => { buffer += chunk; // A complete reply ends with " ...\r\n" (space, not hyphen, after code). const lines = buffer.split('\r\n'); for (let i = 0; i < lines.length - 1; i++) { const line = lines[i]; if (/^\d{3} /.test(line) && resolveLine) { const r = resolveLine; resolveLine = null; buffer = lines.slice(i + 1).join('\r\n'); r(line); return; } } }); socket.on('timeout', () => { pendingError = new SmtpError('SMTP timeout'); socket.destroy(); }); socket.on('error', (e) => { pendingError = e; }); const waitReply = (expect: string): Promise => new Promise((resolve, reject) => { if (pendingError) return reject(pendingError); resolveLine = (line) => { if (!line.startsWith(expect)) { reject(new SmtpError(`Expected ${expect}, got: ${line}`)); } else { resolve(line); } }; }); const send = (line: string): void => { socket.write(line + '\r\n'); }; const b64 = (s: string) => Buffer.from(s).toString('base64'); try { await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('error', reject); }); await waitReply('220'); send('EHLO integration-tests'); await waitReply('250'); send('AUTH LOGIN'); await waitReply('334'); send(b64(authUser)); await waitReply('334'); send(b64(opts.authPass)); await waitReply('235'); send(`MAIL FROM:<${opts.from}>`); await waitReply('250'); for (const rcpt of recipients) { send(`RCPT TO:<${rcpt}>`); await waitReply('250'); } send('DATA'); await waitReply('354'); const headers: Record = { From: opts.from, To: recipients.join(', '), Subject: opts.subject, ...opts.headers, }; let mime: string; if (opts.inlineImage) { const boundary = 'itrelated_boundary_0001'; headers['MIME-Version'] = '1.0'; headers['Content-Type'] = `multipart/related; boundary="${boundary}"`; const b64 = opts.inlineImage.base64.replace(/(.{76})/g, '$1\r\n'); mime = [ `--${boundary}`, 'Content-Type: text/html; charset=utf-8', '', crlf(opts.inlineImage.html), `--${boundary}`, `Content-Type: ${opts.inlineImage.contentType}`, `Content-ID: <${opts.inlineImage.cid}>`, 'Content-Disposition: inline', 'Content-Transfer-Encoding: base64', '', b64, `--${boundary}--`, ].join('\r\n'); } else if (opts.attachment) { const boundary = 'itmixed_boundary_0001'; headers['MIME-Version'] = '1.0'; headers['Content-Type'] = `multipart/mixed; boundary="${boundary}"`; const b64 = Buffer.from(opts.attachment.content).toString('base64').replace(/(.{76})/g, '$1\r\n'); mime = [ `--${boundary}`, 'Content-Type: text/plain; charset=utf-8', '', crlf(opts.body), `--${boundary}`, `Content-Type: ${opts.attachment.contentType}; name="${opts.attachment.filename}"`, `Content-Disposition: attachment; filename="${opts.attachment.filename}"`, 'Content-Transfer-Encoding: base64', '', b64, `--${boundary}--`, ].join('\r\n'); } else if (opts.html) { const boundary = 'italt_boundary_0001'; headers['MIME-Version'] = '1.0'; headers['Content-Type'] = `multipart/alternative; boundary="${boundary}"`; // text first, html second: multipart/alternative is least-to-most preferred. mime = [ `--${boundary}`, 'Content-Type: text/plain; charset=utf-8', '', crlf(opts.body), `--${boundary}`, 'Content-Type: text/html; charset=utf-8', '', crlf(opts.html), `--${boundary}--`, ].join('\r\n'); } else { headers['Content-Type'] = 'text/plain; charset=utf-8'; mime = crlf(opts.body); } const headerBlock = Object.entries(headers) .map(([k, v]) => `${k}: ${v}`) .join('\r\n'); // Dot-stuff any line that begins with '.' const safeBody = mime.replace(/\r\n\./g, '\r\n..'); send(`${headerBlock}\r\n\r\n${safeBody}\r\n.`); await waitReply('250'); send('QUIT'); await waitReply('221').catch(() => { /* some servers drop before 221 */ }); } finally { socket.destroy(); } }