| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329 |
- import assert from 'node:assert/strict';
- import { spawn } from 'node:child_process';
- import { mkdtempSync } from 'node:fs';
- import net from 'node:net';
- import { tmpdir } from 'node:os';
- import path from 'node:path';
- import { test } from 'node:test';
- test('instruments tracked API HTML before DKIM and supports per-send opt-out', async () => {
- const relay = await startFakeSmtpServer();
- const port = await freePort();
- const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-send-tracking-'));
- const child = spawn(process.execPath, ['src/server.js'], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- PORT: String(port),
- DATA_DIR: dataDir,
- APP_BASE_URL: `http://127.0.0.1:${port}`,
- ADMIN_PASSWORD: 'password123',
- SESSION_SECRET: 'session-secret',
- TRACKING_SECRET: 'tracking-secret',
- SMTP_HOST: '127.0.0.1',
- SMTP_PORT: String(relay.port),
- SMTP_HELO: 'mail.track-send.example',
- SEND_REQUIRES_VERIFIED: 'false',
- SUBMISSION_ENABLED: 'false',
- WEBHOOK_WORKER_ENABLED: '0',
- DNS_AUTO_CHECK_ENABLED: 'false',
- DELIVERY_TRACKING_ENABLED: 'false'
- },
- stdio: ['ignore', 'pipe', 'pipe']
- });
- try {
- await waitForOutput(child, 'MailHub listening');
- const baseUrl = `http://127.0.0.1:${port}`;
- const cookie = await login(baseUrl);
- const domainResponse = await fetch(`${baseUrl}/api/domains`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: cookie },
- body: JSON.stringify({ domain: 'track-send.example' })
- });
- assert.equal(domainResponse.status, 201);
- const settings = await fetch(`${baseUrl}/api/admin/settings`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json', Cookie: cookie },
- body: JSON.stringify({ engagementTrackingEnabled: true })
- });
- assert.equal(settings.status, 200);
- assert.equal((await settings.json()).settings.engagementTrackingEnabled, true);
- const tracked = await sendHtml(baseUrl, cookie, {
- subject: 'Tracked HTML',
- html: '<html><body><a href="https://example.net/reset?token=secret">Reset</a></body></html>'
- });
- assert.equal(tracked.eventId > 0, true);
- assert.deepEqual(tracked.tracking, { enabled: true, opens: true, clicks: true, messageLevel: false });
- await waitFor(() => relay.messages.length === 1);
- const trackedMessage = relay.messages[0];
- assert.match(trackedMessage, /^DKIM-Signature:/m);
- const trackedHtml = decodeHtmlPart(trackedMessage);
- assert.match(trackedHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/o/[A-Za-z0-9_-]+\\.gif`));
- assert.match(trackedHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/c/[A-Za-z0-9_-]+`));
- assert.equal(trackedHtml.includes('token=secret'), false);
- const optedOut = await sendHtml(baseUrl, cookie, {
- subject: 'Untracked HTML',
- html: '<a href="https://example.net/private">Private</a>',
- tracking: { opens: false, clicks: false }
- });
- assert.deepEqual(optedOut.tracking, { enabled: false, opens: false, clicks: false, messageLevel: false });
- await waitFor(() => relay.messages.length === 2);
- const untrackedHtml = decodeHtmlPart(relay.messages[1]);
- assert.match(untrackedHtml, /https:\/\/example\.net\/private/);
- assert.equal(untrackedHtml.includes('/t/o/'), false);
- assert.equal(untrackedHtml.includes('/t/c/'), false);
- const noTrackableLinks = await sendHtml(baseUrl, cookie, {
- subject: 'No trackable links',
- html: '<p>No links here.</p>',
- tracking: { opens: false, clicks: true }
- });
- assert.deepEqual(noTrackableLinks.tracking, { enabled: false, opens: false, clicks: false, messageLevel: false });
- await waitFor(() => relay.messages.length === 3);
- const noTrackableLinksHtml = decodeHtmlPart(relay.messages[2]);
- assert.equal(noTrackableLinksHtml.includes('/t/c/'), false);
- const events = await fetch(`${baseUrl}/api/events`, { headers: { Cookie: cookie } }).then((response) => response.json());
- assert.equal(events.events.length, 3);
- assert.equal(events.events.every((event) => event.status === 'queued'), true);
- assert.equal(events.events.find((event) => event.subject === 'Tracked HTML').tracking.enabled, true);
- assert.equal(events.events.find((event) => event.subject === 'No trackable links').tracking.enabled, false);
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- await relay.close();
- }
- });
- test('API sends configured deliverability headers without changing the default envelope sender', async () => {
- const relay = await startFakeSmtpServer();
- const port = await freePort();
- const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-send-headers-'));
- const child = spawn(process.execPath, ['src/server.js'], {
- cwd: process.cwd(),
- env: {
- ...process.env,
- PORT: String(port),
- DATA_DIR: dataDir,
- APP_BASE_URL: `http://127.0.0.1:${port}`,
- ADMIN_PASSWORD: 'password123',
- SESSION_SECRET: 'session-secret',
- TRACKING_SECRET: 'tracking-secret',
- SMTP_HOST: '127.0.0.1',
- SMTP_PORT: String(relay.port),
- SMTP_HELO: 'mail.headers.example',
- SEND_REQUIRES_VERIFIED: 'false',
- SUBMISSION_ENABLED: 'false',
- WEBHOOK_WORKER_ENABLED: '0',
- DNS_AUTO_CHECK_ENABLED: 'false',
- DELIVERY_TRACKING_ENABLED: 'false'
- },
- stdio: ['ignore', 'pipe', 'pipe']
- });
- try {
- await waitForOutput(child, 'MailHub listening');
- const baseUrl = `http://127.0.0.1:${port}`;
- const cookie = await login(baseUrl);
- const domainResponse = await fetch(`${baseUrl}/api/domains`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: cookie },
- body: JSON.stringify({ domain: 'headers.example' })
- });
- assert.equal(domainResponse.status, 201);
- const settings = await fetch(`${baseUrl}/api/admin/settings`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json', Cookie: cookie },
- body: JSON.stringify({
- listUnsubscribeMailto: 'unsubscribe@headers.example',
- listUnsubscribeUrl: 'https://headers.example/unsubscribe/{eventId}/{recipient}',
- listUnsubscribePostEnabled: true,
- feedbackIdEnabled: true,
- reportAbuseTo: 'abuse@headers.example',
- csaComplaintsTo: 'csa@headers.example',
- bounceAddress: 'bounce@headers.example',
- bounceEnvelopeEnabled: false
- })
- });
- assert.equal(settings.status, 200);
- const settingsPayload = await settings.json();
- assert.equal(settingsPayload.settings.listUnsubscribePostEnabled, true);
- assert.equal(settingsPayload.settings.bounceEnvelopeEnabled, false);
- await sendHtml(baseUrl, cookie, {
- from: 'noreply@headers.example',
- to: 'reader@example.net',
- subject: 'Headers',
- html: '<p>Hello</p>'
- });
- await waitFor(() => relay.messages.length === 1);
- const rawMessage = relay.messages[0];
- assert.match(rawMessage, /^List-Unsubscribe: <mailto:unsubscribe@headers\.example>,\n <https:\/\/headers\.example\/unsubscribe\/\d+\/reader%40example\.net>$/m);
- assert.match(rawMessage, /^List-Unsubscribe-Post: List-Unsubscribe=One-Click$/m);
- assert.match(rawMessage, /^Feedback-Id: mh\.[a-f0-9]{12}\.[a-f0-9]{12}\.[a-f0-9]{12}:MailHub$/m);
- assert.match(rawMessage, /^X-Report-Abuse-To: abuse@headers\.example$/m);
- assert.match(rawMessage, /^X-CSA-Complaints: csa@headers\.example$/m);
- assert.match(rawMessage, /^X-Sender: noreply@headers\.example$/m);
- assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<noreply@headers.example>'));
- assert.ok(!relay.commands.some((command) => command === 'MAIL FROM:<bounce@headers.example>'));
- const bounceSettings = await fetch(`${baseUrl}/api/admin/settings`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json', Cookie: cookie },
- body: JSON.stringify({ bounceEnvelopeEnabled: true })
- });
- assert.equal(bounceSettings.status, 200);
- await sendHtml(baseUrl, cookie, {
- from: 'noreply@headers.example',
- to: 'reader@example.net',
- subject: 'Bounce envelope',
- html: '<p>Hello again</p>'
- });
- await waitFor(() => relay.messages.length === 2);
- assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<bounce@headers.example>'));
- } finally {
- child.kill('SIGTERM');
- await waitForExit(child, 1000);
- await relay.close();
- }
- });
- async function sendHtml(baseUrl, cookie, data) {
- const response = await fetch(`${baseUrl}/api/send`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: cookie },
- body: JSON.stringify({
- from: 'noreply@track-send.example',
- to: 'reader@example.net',
- text: 'Fallback',
- ...data
- })
- });
- assert.equal(response.status, 202);
- return response.json();
- }
- async function login(baseUrl) {
- const response = await fetch(`${baseUrl}/api/login`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username: 'admin', password: 'password123' })
- });
- assert.equal(response.status, 200);
- return response.headers.get('set-cookie')?.split(';')[0] || '';
- }
- function decodeHtmlPart(rawMessage) {
- const match = rawMessage.match(/Content-Type: text\/html[^]*?\n\n([A-Za-z0-9+/=\n]+?)(?:\n--|$)/i);
- assert.ok(match, 'expected an HTML MIME part');
- return Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8');
- }
- function startFakeSmtpServer() {
- const commands = [];
- const messages = [];
- const server = net.createServer((socket) => {
- socket.setEncoding('utf8');
- socket.write('220 relay.test ESMTP ready\r\n');
- let buffer = '';
- let dataMode = false;
- let messageLines = [];
- socket.on('data', (chunk) => {
- buffer += chunk;
- let index;
- while ((index = buffer.indexOf('\n')) !== -1) {
- const line = buffer.slice(0, index).replace(/\r$/, '');
- buffer = buffer.slice(index + 1);
- if (dataMode) {
- if (line === '.') {
- dataMode = false;
- messages.push(messageLines.join('\n'));
- messageLines = [];
- socket.write('250 2.0.0 queued as TRACK123\r\n');
- } else {
- messageLines.push(line);
- }
- continue;
- }
- commands.push(line);
- if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
- else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
- else if (line === 'DATA') {
- dataMode = true;
- socket.write('354 end with dot\r\n');
- } else if (line === 'QUIT') {
- socket.write('221 bye\r\n');
- socket.end();
- }
- }
- });
- });
- return new Promise((resolve, reject) => {
- server.once('error', reject);
- server.listen(0, '127.0.0.1', () => resolve({
- port: server.address().port,
- commands,
- messages,
- close: () => new Promise((closeResolve) => server.close(closeResolve))
- }));
- });
- }
- function freePort() {
- return new Promise((resolve, reject) => {
- const server = net.createServer();
- server.listen(0, '127.0.0.1', () => {
- const { port } = server.address();
- server.close((error) => (error ? reject(error) : resolve(port)));
- });
- server.on('error', reject);
- });
- }
- async function waitFor(predicate, timeoutMs = 5000) {
- const startedAt = Date.now();
- while (!predicate()) {
- if (Date.now() - startedAt > timeoutMs) throw new Error('Timed out waiting for SMTP message.');
- await new Promise((resolve) => setTimeout(resolve, 20));
- }
- }
- function waitForOutput(child, value, timeoutMs = 8000) {
- return new Promise((resolve, reject) => {
- let buffer = '';
- const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${value}\n${buffer}`)), timeoutMs);
- const onData = (chunk) => {
- buffer += String(chunk);
- if (!buffer.includes(value)) return;
- clearTimeout(timer);
- child.stdout?.off('data', onData);
- child.stderr?.off('data', onData);
- resolve();
- };
- child.stdout?.on('data', onData);
- child.stderr?.on('data', onData);
- });
- }
- function waitForExit(child, timeoutMs) {
- return new Promise((resolve) => {
- if (child.exitCode != null) return resolve(true);
- const timer = setTimeout(() => resolve(false), timeoutMs);
- child.once('exit', () => {
- clearTimeout(timer);
- resolve(true);
- });
- });
- }
- function escapeRegExp(value) {
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- }
|