server-send-tracking.test.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  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. async function sendHtml(baseUrl, cookie, data) {
  95. const response = await fetch(`${baseUrl}/api/send`, {
  96. method: 'POST',
  97. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  98. body: JSON.stringify({
  99. from: 'noreply@track-send.example',
  100. to: 'reader@example.net',
  101. text: 'Fallback',
  102. ...data
  103. })
  104. });
  105. assert.equal(response.status, 202);
  106. return response.json();
  107. }
  108. async function login(baseUrl) {
  109. const response = await fetch(`${baseUrl}/api/login`, {
  110. method: 'POST',
  111. headers: { 'Content-Type': 'application/json' },
  112. body: JSON.stringify({ username: 'admin', password: 'password123' })
  113. });
  114. assert.equal(response.status, 200);
  115. return response.headers.get('set-cookie')?.split(';')[0] || '';
  116. }
  117. function decodeHtmlPart(rawMessage) {
  118. const match = rawMessage.match(/Content-Type: text\/html[^]*?\n\n([A-Za-z0-9+/=\n]+?)(?:\n--|$)/i);
  119. assert.ok(match, 'expected an HTML MIME part');
  120. return Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8');
  121. }
  122. function startFakeSmtpServer() {
  123. const messages = [];
  124. const server = net.createServer((socket) => {
  125. socket.setEncoding('utf8');
  126. socket.write('220 relay.test ESMTP ready\r\n');
  127. let buffer = '';
  128. let dataMode = false;
  129. let messageLines = [];
  130. socket.on('data', (chunk) => {
  131. buffer += chunk;
  132. let index;
  133. while ((index = buffer.indexOf('\n')) !== -1) {
  134. const line = buffer.slice(0, index).replace(/\r$/, '');
  135. buffer = buffer.slice(index + 1);
  136. if (dataMode) {
  137. if (line === '.') {
  138. dataMode = false;
  139. messages.push(messageLines.join('\n'));
  140. messageLines = [];
  141. socket.write('250 2.0.0 queued as TRACK123\r\n');
  142. } else {
  143. messageLines.push(line);
  144. }
  145. continue;
  146. }
  147. if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
  148. else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
  149. else if (line === 'DATA') {
  150. dataMode = true;
  151. socket.write('354 end with dot\r\n');
  152. } else if (line === 'QUIT') {
  153. socket.write('221 bye\r\n');
  154. socket.end();
  155. }
  156. }
  157. });
  158. });
  159. return new Promise((resolve, reject) => {
  160. server.once('error', reject);
  161. server.listen(0, '127.0.0.1', () => resolve({
  162. port: server.address().port,
  163. messages,
  164. close: () => new Promise((closeResolve) => server.close(closeResolve))
  165. }));
  166. });
  167. }
  168. function freePort() {
  169. return new Promise((resolve, reject) => {
  170. const server = net.createServer();
  171. server.listen(0, '127.0.0.1', () => {
  172. const { port } = server.address();
  173. server.close((error) => (error ? reject(error) : resolve(port)));
  174. });
  175. server.on('error', reject);
  176. });
  177. }
  178. async function waitFor(predicate, timeoutMs = 5000) {
  179. const startedAt = Date.now();
  180. while (!predicate()) {
  181. if (Date.now() - startedAt > timeoutMs) throw new Error('Timed out waiting for SMTP message.');
  182. await new Promise((resolve) => setTimeout(resolve, 20));
  183. }
  184. }
  185. function waitForOutput(child, value, timeoutMs = 8000) {
  186. return new Promise((resolve, reject) => {
  187. let buffer = '';
  188. const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${value}\n${buffer}`)), timeoutMs);
  189. const onData = (chunk) => {
  190. buffer += String(chunk);
  191. if (!buffer.includes(value)) return;
  192. clearTimeout(timer);
  193. child.stdout?.off('data', onData);
  194. child.stderr?.off('data', onData);
  195. resolve();
  196. };
  197. child.stdout?.on('data', onData);
  198. child.stderr?.on('data', onData);
  199. });
  200. }
  201. function waitForExit(child, timeoutMs) {
  202. return new Promise((resolve) => {
  203. if (child.exitCode != null) return resolve(true);
  204. const timer = setTimeout(() => resolve(false), timeoutMs);
  205. child.once('exit', () => {
  206. clearTimeout(timer);
  207. resolve(true);
  208. });
  209. });
  210. }
  211. function escapeRegExp(value) {
  212. return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  213. }