server-send-tracking.test.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. import assert from 'node:assert/strict';
  2. import { spawn } from 'node:child_process';
  3. import { mkdtempSync } from 'node:fs';
  4. import net from 'node:net';
  5. import { tmpdir } from 'node:os';
  6. import path from 'node:path';
  7. import { test } from 'node:test';
  8. test('instruments tracked API HTML before DKIM and supports per-send opt-out', async () => {
  9. const relay = await startFakeSmtpServer();
  10. const port = await freePort();
  11. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-send-tracking-'));
  12. const child = spawn(process.execPath, ['src/server.js'], {
  13. cwd: process.cwd(),
  14. env: {
  15. ...process.env,
  16. PORT: String(port),
  17. DATA_DIR: dataDir,
  18. APP_BASE_URL: `http://127.0.0.1:${port}`,
  19. ADMIN_PASSWORD: 'password123',
  20. SESSION_SECRET: 'session-secret',
  21. TRACKING_SECRET: 'tracking-secret',
  22. SMTP_HOST: '127.0.0.1',
  23. SMTP_PORT: String(relay.port),
  24. SMTP_HELO: 'mail.track-send.example',
  25. SEND_REQUIRES_VERIFIED: 'false',
  26. SUBMISSION_ENABLED: 'false',
  27. WEBHOOK_WORKER_ENABLED: '0',
  28. DNS_AUTO_CHECK_ENABLED: 'false',
  29. DELIVERY_TRACKING_ENABLED: 'false'
  30. },
  31. stdio: ['ignore', 'pipe', 'pipe']
  32. });
  33. try {
  34. await waitForOutput(child, 'MailHub listening');
  35. const baseUrl = `http://127.0.0.1:${port}`;
  36. const cookie = await login(baseUrl);
  37. const domainResponse = await fetch(`${baseUrl}/api/domains`, {
  38. method: 'POST',
  39. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  40. body: JSON.stringify({ domain: 'track-send.example' })
  41. });
  42. assert.equal(domainResponse.status, 201);
  43. const settings = await fetch(`${baseUrl}/api/admin/settings`, {
  44. method: 'PATCH',
  45. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  46. body: JSON.stringify({ engagementTrackingEnabled: true })
  47. });
  48. assert.equal(settings.status, 200);
  49. assert.equal((await settings.json()).settings.engagementTrackingEnabled, true);
  50. const tracked = await sendHtml(baseUrl, cookie, {
  51. subject: 'Tracked HTML',
  52. html: '<html><body><a href="https://example.net/reset?token=secret">Reset</a></body></html>'
  53. });
  54. assert.equal(tracked.eventId > 0, true);
  55. assert.deepEqual(tracked.tracking, { enabled: true, opens: true, clicks: true, messageLevel: false });
  56. await waitFor(() => relay.messages.length === 1);
  57. const trackedMessage = relay.messages[0];
  58. assert.match(trackedMessage, /^DKIM-Signature:/m);
  59. const trackedHtml = decodeHtmlPart(trackedMessage);
  60. assert.match(trackedHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/o/[A-Za-z0-9_-]+\\.gif`));
  61. assert.match(trackedHtml, new RegExp(`${escapeRegExp(baseUrl)}/t/c/[A-Za-z0-9_-]+`));
  62. assert.equal(trackedHtml.includes('token=secret'), false);
  63. const optedOut = await sendHtml(baseUrl, cookie, {
  64. subject: 'Untracked HTML',
  65. html: '<a href="https://example.net/private">Private</a>',
  66. tracking: { opens: false, clicks: false }
  67. });
  68. assert.deepEqual(optedOut.tracking, { enabled: false, opens: false, clicks: false, messageLevel: false });
  69. await waitFor(() => relay.messages.length === 2);
  70. const untrackedHtml = decodeHtmlPart(relay.messages[1]);
  71. assert.match(untrackedHtml, /https:\/\/example\.net\/private/);
  72. assert.equal(untrackedHtml.includes('/t/o/'), false);
  73. assert.equal(untrackedHtml.includes('/t/c/'), false);
  74. const noTrackableLinks = await sendHtml(baseUrl, cookie, {
  75. subject: 'No trackable links',
  76. html: '<p>No links here.</p>',
  77. tracking: { opens: false, clicks: true }
  78. });
  79. assert.deepEqual(noTrackableLinks.tracking, { enabled: false, opens: false, clicks: false, messageLevel: false });
  80. await waitFor(() => relay.messages.length === 3);
  81. const noTrackableLinksHtml = decodeHtmlPart(relay.messages[2]);
  82. assert.equal(noTrackableLinksHtml.includes('/t/c/'), false);
  83. const events = await fetch(`${baseUrl}/api/events`, { headers: { Cookie: cookie } }).then((response) => response.json());
  84. assert.equal(events.events.length, 3);
  85. assert.equal(events.events.every((event) => event.status === 'queued'), true);
  86. assert.equal(events.events.find((event) => event.subject === 'Tracked HTML').tracking.enabled, true);
  87. assert.equal(events.events.find((event) => event.subject === 'No trackable links').tracking.enabled, false);
  88. } finally {
  89. child.kill('SIGTERM');
  90. await waitForExit(child, 1000);
  91. await relay.close();
  92. }
  93. });
  94. test('API sends configured deliverability headers without changing the default envelope sender', async () => {
  95. const relay = await startFakeSmtpServer();
  96. const port = await freePort();
  97. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-send-headers-'));
  98. const child = spawn(process.execPath, ['src/server.js'], {
  99. cwd: process.cwd(),
  100. env: {
  101. ...process.env,
  102. PORT: String(port),
  103. DATA_DIR: dataDir,
  104. APP_BASE_URL: `http://127.0.0.1:${port}`,
  105. ADMIN_PASSWORD: 'password123',
  106. SESSION_SECRET: 'session-secret',
  107. TRACKING_SECRET: 'tracking-secret',
  108. SMTP_HOST: '127.0.0.1',
  109. SMTP_PORT: String(relay.port),
  110. SMTP_HELO: 'mail.headers.example',
  111. SEND_REQUIRES_VERIFIED: 'false',
  112. SUBMISSION_ENABLED: 'false',
  113. WEBHOOK_WORKER_ENABLED: '0',
  114. DNS_AUTO_CHECK_ENABLED: 'false',
  115. DELIVERY_TRACKING_ENABLED: 'false'
  116. },
  117. stdio: ['ignore', 'pipe', 'pipe']
  118. });
  119. try {
  120. await waitForOutput(child, 'MailHub listening');
  121. const baseUrl = `http://127.0.0.1:${port}`;
  122. const cookie = await login(baseUrl);
  123. const domainResponse = await fetch(`${baseUrl}/api/domains`, {
  124. method: 'POST',
  125. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  126. body: JSON.stringify({ domain: 'headers.example' })
  127. });
  128. assert.equal(domainResponse.status, 201);
  129. const settings = await fetch(`${baseUrl}/api/admin/settings`, {
  130. method: 'PATCH',
  131. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  132. body: JSON.stringify({
  133. listUnsubscribeMailto: 'unsubscribe@headers.example',
  134. listUnsubscribeUrl: 'https://headers.example/unsubscribe/{eventId}/{recipient}',
  135. listUnsubscribePostEnabled: true,
  136. feedbackIdEnabled: true,
  137. reportAbuseTo: 'abuse@headers.example',
  138. csaComplaintsTo: 'csa@headers.example',
  139. bounceAddress: 'bounce@headers.example',
  140. bounceEnvelopeEnabled: false
  141. })
  142. });
  143. assert.equal(settings.status, 200);
  144. const settingsPayload = await settings.json();
  145. assert.equal(settingsPayload.settings.listUnsubscribePostEnabled, true);
  146. assert.equal(settingsPayload.settings.bounceEnvelopeEnabled, false);
  147. await sendHtml(baseUrl, cookie, {
  148. from: 'noreply@headers.example',
  149. to: 'reader@example.net',
  150. subject: 'Headers',
  151. html: '<p>Hello</p>'
  152. });
  153. await waitFor(() => relay.messages.length === 1);
  154. const rawMessage = relay.messages[0];
  155. assert.match(rawMessage, /^List-Unsubscribe: <mailto:unsubscribe@headers\.example>,\n <https:\/\/headers\.example\/unsubscribe\/\d+\/reader%40example\.net>$/m);
  156. assert.match(rawMessage, /^List-Unsubscribe-Post: List-Unsubscribe=One-Click$/m);
  157. assert.match(rawMessage, /^Feedback-Id: mh\.[a-f0-9]{12}\.[a-f0-9]{12}\.[a-f0-9]{12}:MailHub$/m);
  158. assert.match(rawMessage, /^X-Report-Abuse-To: abuse@headers\.example$/m);
  159. assert.match(rawMessage, /^X-CSA-Complaints: csa@headers\.example$/m);
  160. assert.match(rawMessage, /^X-Sender: noreply@headers\.example$/m);
  161. assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<noreply@headers.example>'));
  162. assert.ok(!relay.commands.some((command) => command === 'MAIL FROM:<bounce@headers.example>'));
  163. const bounceSettings = await fetch(`${baseUrl}/api/admin/settings`, {
  164. method: 'PATCH',
  165. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  166. body: JSON.stringify({ bounceEnvelopeEnabled: true })
  167. });
  168. assert.equal(bounceSettings.status, 200);
  169. await sendHtml(baseUrl, cookie, {
  170. from: 'noreply@headers.example',
  171. to: 'reader@example.net',
  172. subject: 'Bounce envelope',
  173. html: '<p>Hello again</p>'
  174. });
  175. await waitFor(() => relay.messages.length === 2);
  176. assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<bounce@headers.example>'));
  177. } finally {
  178. child.kill('SIGTERM');
  179. await waitForExit(child, 1000);
  180. await relay.close();
  181. }
  182. });
  183. async function sendHtml(baseUrl, cookie, data) {
  184. const response = await fetch(`${baseUrl}/api/send`, {
  185. method: 'POST',
  186. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  187. body: JSON.stringify({
  188. from: 'noreply@track-send.example',
  189. to: 'reader@example.net',
  190. text: 'Fallback',
  191. ...data
  192. })
  193. });
  194. assert.equal(response.status, 202);
  195. return response.json();
  196. }
  197. async function login(baseUrl) {
  198. const response = await fetch(`${baseUrl}/api/login`, {
  199. method: 'POST',
  200. headers: { 'Content-Type': 'application/json' },
  201. body: JSON.stringify({ username: 'admin', password: 'password123' })
  202. });
  203. assert.equal(response.status, 200);
  204. return response.headers.get('set-cookie')?.split(';')[0] || '';
  205. }
  206. function decodeHtmlPart(rawMessage) {
  207. const match = rawMessage.match(/Content-Type: text\/html[^]*?\n\n([A-Za-z0-9+/=\n]+?)(?:\n--|$)/i);
  208. assert.ok(match, 'expected an HTML MIME part');
  209. return Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8');
  210. }
  211. function startFakeSmtpServer() {
  212. const commands = [];
  213. const messages = [];
  214. const server = net.createServer((socket) => {
  215. socket.setEncoding('utf8');
  216. socket.write('220 relay.test ESMTP ready\r\n');
  217. let buffer = '';
  218. let dataMode = false;
  219. let messageLines = [];
  220. socket.on('data', (chunk) => {
  221. buffer += chunk;
  222. let index;
  223. while ((index = buffer.indexOf('\n')) !== -1) {
  224. const line = buffer.slice(0, index).replace(/\r$/, '');
  225. buffer = buffer.slice(index + 1);
  226. if (dataMode) {
  227. if (line === '.') {
  228. dataMode = false;
  229. messages.push(messageLines.join('\n'));
  230. messageLines = [];
  231. socket.write('250 2.0.0 queued as TRACK123\r\n');
  232. } else {
  233. messageLines.push(line);
  234. }
  235. continue;
  236. }
  237. commands.push(line);
  238. if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
  239. else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
  240. else if (line === 'DATA') {
  241. dataMode = true;
  242. socket.write('354 end with dot\r\n');
  243. } else if (line === 'QUIT') {
  244. socket.write('221 bye\r\n');
  245. socket.end();
  246. }
  247. }
  248. });
  249. });
  250. return new Promise((resolve, reject) => {
  251. server.once('error', reject);
  252. server.listen(0, '127.0.0.1', () => resolve({
  253. port: server.address().port,
  254. commands,
  255. messages,
  256. close: () => new Promise((closeResolve) => server.close(closeResolve))
  257. }));
  258. });
  259. }
  260. function freePort() {
  261. return new Promise((resolve, reject) => {
  262. const server = net.createServer();
  263. server.listen(0, '127.0.0.1', () => {
  264. const { port } = server.address();
  265. server.close((error) => (error ? reject(error) : resolve(port)));
  266. });
  267. server.on('error', reject);
  268. });
  269. }
  270. async function waitFor(predicate, timeoutMs = 5000) {
  271. const startedAt = Date.now();
  272. while (!predicate()) {
  273. if (Date.now() - startedAt > timeoutMs) throw new Error('Timed out waiting for SMTP message.');
  274. await new Promise((resolve) => setTimeout(resolve, 20));
  275. }
  276. }
  277. function waitForOutput(child, value, timeoutMs = 8000) {
  278. return new Promise((resolve, reject) => {
  279. let buffer = '';
  280. const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${value}\n${buffer}`)), timeoutMs);
  281. const onData = (chunk) => {
  282. buffer += String(chunk);
  283. if (!buffer.includes(value)) return;
  284. clearTimeout(timer);
  285. child.stdout?.off('data', onData);
  286. child.stderr?.off('data', onData);
  287. resolve();
  288. };
  289. child.stdout?.on('data', onData);
  290. child.stderr?.on('data', onData);
  291. });
  292. }
  293. function waitForExit(child, timeoutMs) {
  294. return new Promise((resolve) => {
  295. if (child.exitCode != null) return resolve(true);
  296. const timer = setTimeout(() => resolve(false), timeoutMs);
  297. child.once('exit', () => {
  298. clearTimeout(timer);
  299. resolve(true);
  300. });
  301. });
  302. }
  303. function escapeRegExp(value) {
  304. return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  305. }