submission-tracking.test.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync } from 'node:fs';
  3. import net from 'node:net';
  4. import { tmpdir } from 'node:os';
  5. import path from 'node:path';
  6. import { test } from 'node:test';
  7. import {
  8. createDomain,
  9. createUser,
  10. initDatabase,
  11. listSendEvents,
  12. saveSmtpCredential
  13. } from '../src/db.js';
  14. import { createDkimKeyPair } from '../src/dkim.js';
  15. import { sendViaSmtp } from '../src/mailer.js';
  16. import { startSubmissionServer } from '../src/submission.js';
  17. test('SMTP submission instruments HTML before DKIM and relays one tracked message', async () => {
  18. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-tracking-')), 'session-secret');
  19. const user = createUser({ username: 'submission-user', email: 'submission-user@example.com', password: 'password123' });
  20. saveSmtpCredential(user.id, { username: 'smtp-submission-user', password: 'smtp-password' });
  21. const keys = createDkimKeyPair();
  22. createDomain(user.id, {
  23. domain: 'submission-track.example',
  24. selector: 'mh202607',
  25. verificationToken: 'verify',
  26. dkimPublic: keys.publicKey,
  27. dkimPrivate: keys.privateKey,
  28. senderHost: 'mail.submission-track.example',
  29. sendingIp: '192.0.2.10',
  30. spfExtra: '',
  31. dmarcPolicy: 'none',
  32. dmarcRua: ''
  33. });
  34. const relay = await startFakeSmtpServer();
  35. const [submission] = startSubmissionServer({
  36. enabled: true,
  37. listeners: [{ port: 0, protocol: 'smtp' }],
  38. hostname: 'submission-track.example',
  39. allowInsecureAuth: true,
  40. relayHost: '127.0.0.1',
  41. relayPort: relay.port,
  42. relaySecure: false,
  43. relayUsername: '',
  44. relayPassword: '',
  45. relayHelo: 'mail.submission-track.example',
  46. getTrackingSettings: () => ({
  47. enabled: false,
  48. appBaseUrl: 'https://mail.example.com',
  49. secret: 'tracking-secret'
  50. })
  51. });
  52. await waitForListening(submission);
  53. try {
  54. const rawMessage = [
  55. 'From: noreply@submission-track.example',
  56. 'To: reader@example.net',
  57. 'Subject: Submission tracked',
  58. 'MIME-Version: 1.0',
  59. 'X-MailHub-Track: opens,clicks',
  60. 'Content-Type: text/html; charset=UTF-8',
  61. '',
  62. '<html><body><a href="https://example.net/private?token=secret">Open</a></body></html>',
  63. ''
  64. ].join('\r\n');
  65. const response = await sendViaSmtp({
  66. host: '127.0.0.1',
  67. port: submission.address().port,
  68. secure: false,
  69. username: 'smtp-submission-user',
  70. password: 'smtp-password',
  71. helo: 'client.example',
  72. mailFrom: 'noreply@submission-track.example',
  73. recipients: ['reader@example.net'],
  74. rawMessage
  75. });
  76. assert.match(response.message, /Message queued/i);
  77. await waitFor(() => relay.messages.length === 1);
  78. const relayed = relay.messages[0];
  79. assert.match(relayed, /^DKIM-Signature:/m);
  80. assert.doesNotMatch(relayed, /^X-MailHub-Track:/im);
  81. const decoded = relayed.replace(/=\n/g, '').replace(/=3D/gi, '=');
  82. assert.match(decoded, /https:\/\/mail\.example\.com\/t\/o\/[A-Za-z0-9_-]+\.gif/);
  83. assert.match(decoded, /https:\/\/mail\.example\.com\/t\/c\/[A-Za-z0-9_-]+/);
  84. assert.equal(decoded.includes('token=secret'), false);
  85. const [event] = listSendEvents(user.id);
  86. assert.equal(event.status, 'queued');
  87. assert.equal(event.tracking.opens, true);
  88. assert.equal(event.tracking.clicks, true);
  89. assert.equal(event.tracking.messageLevel, false);
  90. } finally {
  91. await closeServer(submission);
  92. await relay.close();
  93. }
  94. });
  95. test('SMTP submission adds deliverability headers before DKIM and honors bounce envelope opt-in', async () => {
  96. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-headers-')), 'session-secret');
  97. const user = createUser({ username: 'submission-headers-user', email: 'submission-headers-user@example.com', password: 'password123' });
  98. saveSmtpCredential(user.id, { username: 'smtp-headers-user', password: 'smtp-password' });
  99. const keys = createDkimKeyPair();
  100. createDomain(user.id, {
  101. domain: 'submission-headers.example',
  102. selector: 'mh202607',
  103. verificationToken: 'verify',
  104. dkimPublic: keys.publicKey,
  105. dkimPrivate: keys.privateKey,
  106. senderHost: 'mail.submission-headers.example',
  107. sendingIp: '192.0.2.10',
  108. spfExtra: '',
  109. dmarcPolicy: 'none',
  110. dmarcRua: ''
  111. });
  112. const relay = await startFakeSmtpServer();
  113. const [submission] = startSubmissionServer({
  114. enabled: true,
  115. listeners: [{ port: 0, protocol: 'smtp' }],
  116. hostname: 'submission-headers.example',
  117. allowInsecureAuth: true,
  118. relayHost: '127.0.0.1',
  119. relayPort: relay.port,
  120. relaySecure: false,
  121. relayUsername: '',
  122. relayPassword: '',
  123. relayHelo: 'mail.submission-headers.example',
  124. getTrackingSettings: () => ({
  125. enabled: false,
  126. appBaseUrl: 'https://mail.example.com',
  127. secret: 'tracking-secret'
  128. }),
  129. getDeliverabilitySettings: () => ({
  130. listUnsubscribeMailto: 'unsubscribe@submission-headers.example',
  131. listUnsubscribeUrl: 'https://submission-headers.example/unsubscribe/{eventId}/{recipient}',
  132. listUnsubscribePostEnabled: true,
  133. feedbackIdEnabled: true,
  134. reportAbuseTo: 'abuse@submission-headers.example',
  135. csaComplaintsTo: 'csa@submission-headers.example',
  136. bounceAddress: 'bounce@submission-headers.example',
  137. bounceEnvelopeEnabled: true,
  138. secret: 'deliverability-secret'
  139. })
  140. });
  141. await waitForListening(submission);
  142. try {
  143. const rawMessage = [
  144. 'From: noreply@submission-headers.example',
  145. 'To: reader@example.net',
  146. 'Subject: Submission headers',
  147. 'MIME-Version: 1.0',
  148. 'Content-Type: text/plain; charset=UTF-8',
  149. '',
  150. 'Hello',
  151. ''
  152. ].join('\r\n');
  153. const response = await sendViaSmtp({
  154. host: '127.0.0.1',
  155. port: submission.address().port,
  156. secure: false,
  157. username: 'smtp-headers-user',
  158. password: 'smtp-password',
  159. helo: 'client.example',
  160. mailFrom: 'noreply@submission-headers.example',
  161. recipients: ['reader@example.net'],
  162. rawMessage
  163. });
  164. assert.match(response.message, /Message queued/i);
  165. await waitFor(() => relay.messages.length === 1);
  166. const relayed = relay.messages[0];
  167. assert.match(relayed, /^DKIM-Signature:[\s\S]*h=from:to:subject:date:message-id:mime-version:content-type:list-unsubscribe:list-unsubscribe-post:feedback-id:x-report-abuse-to:x-csa-complaints:x-sender/im);
  168. assert.match(relayed, /^List-Unsubscribe: <mailto:unsubscribe@submission-headers\.example>,\n <https:\/\/submission-headers\.example\/unsubscribe\/\d+\/reader%40example\.net>$/m);
  169. assert.match(relayed, /^List-Unsubscribe-Post: List-Unsubscribe=One-Click$/m);
  170. assert.match(relayed, /^Feedback-Id: mh\.[a-f0-9]{12}\.[a-f0-9]{12}\.[a-f0-9]{12}:MailHub$/m);
  171. assert.match(relayed, /^X-Report-Abuse-To: abuse@submission-headers\.example$/m);
  172. assert.match(relayed, /^X-CSA-Complaints: csa@submission-headers\.example$/m);
  173. assert.match(relayed, /^X-Sender: noreply@submission-headers\.example$/m);
  174. assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<bounce@submission-headers.example>'));
  175. } finally {
  176. await closeServer(submission);
  177. await relay.close();
  178. }
  179. });
  180. function startFakeSmtpServer() {
  181. const commands = [];
  182. const messages = [];
  183. const server = net.createServer((socket) => {
  184. socket.setEncoding('utf8');
  185. socket.write('220 relay.test ESMTP ready\r\n');
  186. let buffer = '';
  187. let dataMode = false;
  188. let messageLines = [];
  189. socket.on('data', (chunk) => {
  190. buffer += chunk;
  191. let index;
  192. while ((index = buffer.indexOf('\n')) !== -1) {
  193. const line = buffer.slice(0, index).replace(/\r$/, '');
  194. buffer = buffer.slice(index + 1);
  195. if (dataMode) {
  196. if (line === '.') {
  197. dataMode = false;
  198. messages.push(messageLines.join('\n'));
  199. messageLines = [];
  200. socket.write('250 2.0.0 queued as SUBTRACK123\r\n');
  201. } else {
  202. messageLines.push(line);
  203. }
  204. continue;
  205. }
  206. commands.push(line);
  207. if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
  208. else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
  209. else if (line === 'DATA') {
  210. dataMode = true;
  211. socket.write('354 end with dot\r\n');
  212. } else if (line === 'QUIT') {
  213. socket.write('221 bye\r\n');
  214. socket.end();
  215. }
  216. }
  217. });
  218. });
  219. return new Promise((resolve, reject) => {
  220. server.once('error', reject);
  221. server.listen(0, '127.0.0.1', () => resolve({
  222. port: server.address().port,
  223. commands,
  224. messages,
  225. close: () => closeServer(server)
  226. }));
  227. });
  228. }
  229. function waitForListening(server) {
  230. if (server.listening) return Promise.resolve();
  231. return new Promise((resolve) => server.once('listening', resolve));
  232. }
  233. function closeServer(server) {
  234. return new Promise((resolve, reject) => {
  235. server.close((error) => error ? reject(error) : resolve());
  236. });
  237. }
  238. async function waitFor(predicate, timeoutMs = 5000) {
  239. const startedAt = Date.now();
  240. while (!predicate()) {
  241. if (Date.now() - startedAt > timeoutMs) throw new Error('Timed out waiting for relayed message.');
  242. await new Promise((resolve) => setTimeout(resolve, 20));
  243. }
  244. }