server-send-tracking.test.js 12 KB

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