submission-tracking.test.js 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  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. function startFakeSmtpServer() {
  96. const messages = [];
  97. const server = net.createServer((socket) => {
  98. socket.setEncoding('utf8');
  99. socket.write('220 relay.test ESMTP ready\r\n');
  100. let buffer = '';
  101. let dataMode = false;
  102. let messageLines = [];
  103. socket.on('data', (chunk) => {
  104. buffer += chunk;
  105. let index;
  106. while ((index = buffer.indexOf('\n')) !== -1) {
  107. const line = buffer.slice(0, index).replace(/\r$/, '');
  108. buffer = buffer.slice(index + 1);
  109. if (dataMode) {
  110. if (line === '.') {
  111. dataMode = false;
  112. messages.push(messageLines.join('\n'));
  113. messageLines = [];
  114. socket.write('250 2.0.0 queued as SUBTRACK123\r\n');
  115. } else {
  116. messageLines.push(line);
  117. }
  118. continue;
  119. }
  120. if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
  121. else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
  122. else if (line === 'DATA') {
  123. dataMode = true;
  124. socket.write('354 end with dot\r\n');
  125. } else if (line === 'QUIT') {
  126. socket.write('221 bye\r\n');
  127. socket.end();
  128. }
  129. }
  130. });
  131. });
  132. return new Promise((resolve, reject) => {
  133. server.once('error', reject);
  134. server.listen(0, '127.0.0.1', () => resolve({
  135. port: server.address().port,
  136. messages,
  137. close: () => closeServer(server)
  138. }));
  139. });
  140. }
  141. function waitForListening(server) {
  142. if (server.listening) return Promise.resolve();
  143. return new Promise((resolve) => server.once('listening', resolve));
  144. }
  145. function closeServer(server) {
  146. return new Promise((resolve, reject) => {
  147. server.close((error) => error ? reject(error) : resolve());
  148. });
  149. }
  150. async function waitFor(predicate, timeoutMs = 5000) {
  151. const startedAt = Date.now();
  152. while (!predicate()) {
  153. if (Date.now() - startedAt > timeoutMs) throw new Error('Timed out waiting for relayed message.');
  154. await new Promise((resolve) => setTimeout(resolve, 20));
  155. }
  156. }