submission-inbound.test.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  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. createInboundMailbox,
  10. createUser,
  11. initDatabase,
  12. listInboundMessages
  13. } from '../src/db.js';
  14. import { sendViaSmtp } from '../src/mailer.js';
  15. import { startSubmissionServer } from '../src/submission.js';
  16. test('SMTP accepts unauthenticated inbound mail for local mailboxes', async () => {
  17. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-')), 'inbound-secret');
  18. const user = createUser({ username: 'inbound-smtp', email: 'inbound-smtp@example.com', password: 'password123' });
  19. createDomain(user.id, {
  20. domain: 'inbound.example',
  21. selector: 'mh',
  22. verificationToken: 'verify',
  23. dkimPublic: 'public',
  24. dkimPrivate: 'private',
  25. senderHost: 'mail.inbound.example',
  26. sendingIp: '192.0.2.10',
  27. spfExtra: '',
  28. dmarcPolicy: 'none',
  29. dmarcRua: ''
  30. });
  31. createInboundMailbox(user.id, { address: 'support@inbound.example', displayName: 'Support' });
  32. const [server] = startSubmissionServer({
  33. enabled: true,
  34. listeners: [{ port: 0, protocol: 'smtp' }],
  35. hostname: 'mx.inbound.example',
  36. allowInsecureAuth: true,
  37. inboundEnabled: true,
  38. relayHost: '',
  39. relayPort: 25,
  40. relaySecure: false,
  41. relayUsername: '',
  42. relayPassword: '',
  43. relayHelo: 'mx.inbound.example'
  44. });
  45. await waitForListening(server);
  46. try {
  47. const rawMessage = [
  48. 'From: Alice <alice@example.net>',
  49. 'To: Support <support@inbound.example>',
  50. 'Subject: Hello inbound SMTP',
  51. 'Message-ID: <hello-inbound@example.net>',
  52. 'Content-Type: text/plain; charset=UTF-8',
  53. '',
  54. 'Hello through SMTP.',
  55. ''
  56. ].join('\r\n');
  57. const response = await sendViaSmtp({
  58. host: '127.0.0.1',
  59. port: server.address().port,
  60. secure: false,
  61. username: '',
  62. password: '',
  63. helo: 'sender.example.net',
  64. mailFrom: 'alice@example.net',
  65. recipients: ['support@inbound.example'],
  66. rawMessage
  67. });
  68. assert.match(response.message, /Message accepted/i);
  69. const [message] = listInboundMessages(user.id);
  70. assert.equal(message.sender, 'alice@example.net');
  71. assert.deepEqual(message.recipients, ['support@inbound.example']);
  72. assert.equal(message.subject, 'Hello inbound SMTP');
  73. assert.equal(message.preview, 'Hello through SMTP.');
  74. } finally {
  75. await closeServer(server);
  76. }
  77. });
  78. test('SMTP rejects unauthenticated inbound mail for unknown recipients', async () => {
  79. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-reject-')), 'inbound-secret');
  80. const [server] = startSubmissionServer({
  81. enabled: true,
  82. listeners: [{ port: 0, protocol: 'smtp' }],
  83. hostname: 'mx.inbound.example',
  84. allowInsecureAuth: true,
  85. inboundEnabled: true
  86. });
  87. await waitForListening(server);
  88. try {
  89. const transcript = await smtpTranscript(server.address().port, [
  90. 'EHLO sender.example.net',
  91. 'MAIL FROM:<alice@example.net>',
  92. 'RCPT TO:<nobody@external.example>'
  93. ]);
  94. assert.match(transcript.at(-1), /^550 /);
  95. assert.equal(listInboundMessages(1).length, 0);
  96. } finally {
  97. await closeServer(server);
  98. }
  99. });
  100. test('SMTP stores each inbound recipient without exposing other envelope recipients', async () => {
  101. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-multi-')), 'inbound-secret');
  102. const supportUser = createUser({ username: 'support-user', email: 'support@example.com', password: 'password123' });
  103. const privateUser = createUser({ username: 'private-user', email: 'private@example.com', password: 'password123' });
  104. createDomain(supportUser.id, {
  105. domain: 'support.example',
  106. selector: 'mh',
  107. verificationToken: 'verify',
  108. dkimPublic: 'public',
  109. dkimPrivate: 'private',
  110. senderHost: 'mail.support.example',
  111. sendingIp: '192.0.2.12',
  112. spfExtra: '',
  113. dmarcPolicy: 'none',
  114. dmarcRua: ''
  115. });
  116. createDomain(privateUser.id, {
  117. domain: 'private.example',
  118. selector: 'mh',
  119. verificationToken: 'verify',
  120. dkimPublic: 'public',
  121. dkimPrivate: 'private',
  122. senderHost: 'mail.private.example',
  123. sendingIp: '192.0.2.13',
  124. spfExtra: '',
  125. dmarcPolicy: 'none',
  126. dmarcRua: ''
  127. });
  128. createInboundMailbox(supportUser.id, { address: 'support@support.example' });
  129. createInboundMailbox(privateUser.id, { address: 'private@private.example' });
  130. const [server] = startSubmissionServer({
  131. enabled: true,
  132. listeners: [{ port: 0, protocol: 'smtp' }],
  133. hostname: 'mx.inbound.example',
  134. allowInsecureAuth: true,
  135. inboundEnabled: true
  136. });
  137. await waitForListening(server);
  138. try {
  139. const rawMessage = [
  140. 'From: Alice <alice@example.net>',
  141. 'To: Support <support@support.example>',
  142. 'Subject: Multi recipient',
  143. '',
  144. 'Hello both.',
  145. ''
  146. ].join('\r\n');
  147. await sendViaSmtp({
  148. host: '127.0.0.1',
  149. port: server.address().port,
  150. secure: false,
  151. username: '',
  152. password: '',
  153. helo: 'sender.example.net',
  154. mailFrom: 'alice@example.net',
  155. recipients: ['support@support.example', 'private@private.example'],
  156. rawMessage
  157. });
  158. assert.deepEqual(listInboundMessages(supportUser.id)[0].recipients, ['support@support.example']);
  159. assert.deepEqual(listInboundMessages(privateUser.id)[0].recipients, ['private@private.example']);
  160. } finally {
  161. await closeServer(server);
  162. }
  163. });
  164. test('SMTP accepts unauthenticated inbound bounces with an empty envelope sender', async () => {
  165. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-bounce-')), 'inbound-secret');
  166. const user = createUser({ username: 'bounce-user', email: 'bounce@example.com', password: 'password123' });
  167. createDomain(user.id, {
  168. domain: 'bounce.example',
  169. selector: 'mh',
  170. verificationToken: 'verify',
  171. dkimPublic: 'public',
  172. dkimPrivate: 'private',
  173. senderHost: 'mail.bounce.example',
  174. sendingIp: '192.0.2.14',
  175. spfExtra: '',
  176. dmarcPolicy: 'none',
  177. dmarcRua: ''
  178. });
  179. createInboundMailbox(user.id, { address: 'postmaster@bounce.example' });
  180. const [server] = startSubmissionServer({
  181. enabled: true,
  182. listeners: [{ port: 0, protocol: 'smtp' }],
  183. hostname: 'mx.bounce.example',
  184. allowInsecureAuth: true,
  185. inboundEnabled: true
  186. });
  187. await waitForListening(server);
  188. try {
  189. await sendViaSmtp({
  190. host: '127.0.0.1',
  191. port: server.address().port,
  192. secure: false,
  193. username: '',
  194. password: '',
  195. helo: 'sender.example.net',
  196. mailFrom: '',
  197. recipients: ['postmaster@bounce.example'],
  198. rawMessage: 'From: MAILER-DAEMON <>\r\nSubject: Delivery status\r\n\r\nBounced.'
  199. });
  200. const [message] = listInboundMessages(user.id);
  201. assert.equal(message.sender, '');
  202. assert.deepEqual(message.recipients, ['postmaster@bounce.example']);
  203. } finally {
  204. await closeServer(server);
  205. }
  206. });
  207. test('SMTP rejects oversized unauthenticated inbound messages without storing them', async () => {
  208. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-size-')), 'inbound-secret');
  209. const user = createUser({ username: 'size-user', email: 'size@example.com', password: 'password123' });
  210. createDomain(user.id, {
  211. domain: 'size.example',
  212. selector: 'mh',
  213. verificationToken: 'verify',
  214. dkimPublic: 'public',
  215. dkimPrivate: 'private',
  216. senderHost: 'mail.size.example',
  217. sendingIp: '192.0.2.15',
  218. spfExtra: '',
  219. dmarcPolicy: 'none',
  220. dmarcRua: ''
  221. });
  222. createInboundMailbox(user.id, { address: 'support@size.example' });
  223. const [server] = startSubmissionServer({
  224. enabled: true,
  225. listeners: [{ port: 0, protocol: 'smtp' }],
  226. hostname: 'mx.size.example',
  227. allowInsecureAuth: true,
  228. inboundEnabled: true,
  229. maxMessageBytes: 64
  230. });
  231. await waitForListening(server);
  232. try {
  233. const transcript = await smtpTranscript(server.address().port, [
  234. 'EHLO sender.example.net',
  235. 'MAIL FROM:<alice@example.net>',
  236. 'RCPT TO:<support@size.example>',
  237. 'DATA',
  238. [
  239. 'Subject: Oversized inbound',
  240. '',
  241. 'This body is intentionally longer than the configured inbound message size limit.',
  242. '.'
  243. ].join('\r\n')
  244. ]);
  245. assert.match(transcript.at(-1), /^552 /);
  246. assert.equal(listInboundMessages(user.id).length, 0);
  247. } finally {
  248. await closeServer(server);
  249. }
  250. });
  251. function waitForListening(server) {
  252. if (server.listening) return Promise.resolve();
  253. return new Promise((resolve) => server.once('listening', resolve));
  254. }
  255. function closeServer(server) {
  256. return new Promise((resolve, reject) => {
  257. server.close((error) => error ? reject(error) : resolve());
  258. });
  259. }
  260. async function smtpTranscript(port, commands) {
  261. return await new Promise((resolve, reject) => {
  262. const socket = net.createConnection({ host: '127.0.0.1', port });
  263. socket.setEncoding('utf8');
  264. socket.setTimeout(3000);
  265. const responses = [];
  266. let buffer = '';
  267. let index = -1;
  268. socket.on('data', (chunk) => {
  269. buffer += chunk;
  270. let lineEnd;
  271. while ((lineEnd = buffer.indexOf('\n')) !== -1) {
  272. const line = buffer.slice(0, lineEnd).replace(/\r$/, '');
  273. buffer = buffer.slice(lineEnd + 1);
  274. if (!/^\d{3}[ -]/.test(line)) continue;
  275. responses.push(line);
  276. if (/^\d{3} /.test(line)) {
  277. index += 1;
  278. if (index >= commands.length) {
  279. socket.end('QUIT\r\n');
  280. resolve(responses);
  281. return;
  282. }
  283. socket.write(`${commands[index]}\r\n`);
  284. }
  285. }
  286. });
  287. socket.once('error', reject);
  288. socket.once('timeout', () => reject(new Error('SMTP transcript timed out')));
  289. });
  290. }