submission-inbound.test.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  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. updateDomain
  14. } from '../src/db.js';
  15. import { sendViaSmtp } from '../src/mailer.js';
  16. import { startSubmissionServer } from '../src/submission.js';
  17. test('SMTP accepts unauthenticated inbound mail for local mailboxes', async () => {
  18. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-')), 'inbound-secret');
  19. const user = createUser({ username: 'inbound-smtp', email: 'inbound-smtp@example.com', password: 'password123' });
  20. createDomain(user.id, {
  21. domain: 'inbound.example',
  22. selector: 'mh',
  23. verificationToken: 'verify',
  24. dkimPublic: 'public',
  25. dkimPrivate: 'private',
  26. senderHost: 'mail.inbound.example',
  27. sendingIp: '192.0.2.10',
  28. spfExtra: '',
  29. dmarcPolicy: 'none',
  30. dmarcRua: ''
  31. });
  32. createInboundMailbox(user.id, { address: 'support@inbound.example', displayName: 'Support' });
  33. const [server] = startSubmissionServer({
  34. enabled: true,
  35. listeners: [{ port: 0, protocol: 'smtp' }],
  36. hostname: 'mx.inbound.example',
  37. allowInsecureAuth: true,
  38. inboundEnabled: true,
  39. relayHost: '',
  40. relayPort: 25,
  41. relaySecure: false,
  42. relayUsername: '',
  43. relayPassword: '',
  44. relayHelo: 'mx.inbound.example'
  45. });
  46. await waitForListening(server);
  47. try {
  48. const rawMessage = [
  49. 'From: Alice <alice@example.net>',
  50. 'To: Support <support@inbound.example>',
  51. 'Subject: Hello inbound SMTP',
  52. 'Message-ID: <hello-inbound@example.net>',
  53. 'Content-Type: text/plain; charset=UTF-8',
  54. '',
  55. 'Hello through SMTP.',
  56. ''
  57. ].join('\r\n');
  58. const response = await sendViaSmtp({
  59. host: '127.0.0.1',
  60. port: server.address().port,
  61. secure: false,
  62. username: '',
  63. password: '',
  64. helo: 'sender.example.net',
  65. mailFrom: 'alice@example.net',
  66. recipients: ['support@inbound.example'],
  67. rawMessage
  68. });
  69. assert.match(response.message, /Message accepted/i);
  70. const [message] = listInboundMessages(user.id);
  71. assert.equal(message.sender, 'alice@example.net');
  72. assert.deepEqual(message.recipients, ['support@inbound.example']);
  73. assert.equal(message.subject, 'Hello inbound SMTP');
  74. assert.equal(message.preview, 'Hello through SMTP.');
  75. } finally {
  76. await closeServer(server);
  77. }
  78. });
  79. test('SMTP rejects unauthenticated inbound mail for unknown recipients', async () => {
  80. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-reject-')), 'inbound-secret');
  81. const [server] = startSubmissionServer({
  82. enabled: true,
  83. listeners: [{ port: 0, protocol: 'smtp' }],
  84. hostname: 'mx.inbound.example',
  85. allowInsecureAuth: true,
  86. inboundEnabled: true
  87. });
  88. await waitForListening(server);
  89. try {
  90. const transcript = await smtpTranscript(server.address().port, [
  91. 'EHLO sender.example.net',
  92. 'MAIL FROM:<alice@example.net>',
  93. 'RCPT TO:<nobody@external.example>'
  94. ]);
  95. assert.match(transcript.at(-1), /^550 /);
  96. assert.equal(listInboundMessages(1).length, 0);
  97. } finally {
  98. await closeServer(server);
  99. }
  100. });
  101. test('SMTP authenticates with a mailbox account address and password', async () => {
  102. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-mailbox-auth-')), 'inbound-secret');
  103. const user = createUser({ username: 'mailbox-auth', email: 'mailbox-auth@example.com', password: 'password123' });
  104. createDomain(user.id, {
  105. domain: 'authmail.example',
  106. selector: 'mh',
  107. verificationToken: 'verify',
  108. dkimPublic: 'public',
  109. dkimPrivate: 'private',
  110. senderHost: 'mail.authmail.example',
  111. sendingIp: '192.0.2.16',
  112. spfExtra: '',
  113. dmarcPolicy: 'none',
  114. dmarcRua: ''
  115. });
  116. createInboundMailbox(user.id, {
  117. address: 'admin@authmail.example',
  118. password: 'mailbox-pass-123'
  119. });
  120. const [server] = startSubmissionServer({
  121. enabled: true,
  122. listeners: [{ port: 0, protocol: 'smtp' }],
  123. hostname: 'mx.authmail.example',
  124. allowInsecureAuth: true,
  125. inboundEnabled: true
  126. });
  127. await waitForListening(server);
  128. try {
  129. const auth = Buffer.from('\u0000admin@authmail.example\u0000mailbox-pass-123').toString('base64');
  130. const transcript = await smtpTranscript(server.address().port, [
  131. 'EHLO sender.example.net',
  132. `AUTH PLAIN ${auth}`
  133. ]);
  134. assert.match(transcript.at(-1), /^235 /);
  135. } finally {
  136. await closeServer(server);
  137. }
  138. });
  139. test('SMTP routes unknown inbound recipients to the domain catch-all mailbox', async () => {
  140. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-catchall-')), 'inbound-secret');
  141. const user = createUser({ username: 'catchall-smtp', email: 'catchall-smtp@example.com', password: 'password123' });
  142. const domain = createDomain(user.id, {
  143. domain: 'catchall.example',
  144. selector: 'mh',
  145. verificationToken: 'verify',
  146. dkimPublic: 'public',
  147. dkimPrivate: 'private',
  148. senderHost: 'mail.catchall.example',
  149. sendingIp: '192.0.2.17',
  150. spfExtra: '',
  151. dmarcPolicy: 'none',
  152. dmarcRua: ''
  153. });
  154. createInboundMailbox(user.id, { address: 'share@catchall.example' });
  155. updateDomain(domain.id, user.id, { catchAllAddress: 'share@catchall.example' });
  156. const [server] = startSubmissionServer({
  157. enabled: true,
  158. listeners: [{ port: 0, protocol: 'smtp' }],
  159. hostname: 'mx.catchall.example',
  160. allowInsecureAuth: true,
  161. inboundEnabled: true
  162. });
  163. await waitForListening(server);
  164. try {
  165. await sendViaSmtp({
  166. host: '127.0.0.1',
  167. port: server.address().port,
  168. secure: false,
  169. username: '',
  170. password: '',
  171. helo: 'sender.example.net',
  172. mailFrom: 'alice@example.net',
  173. recipients: ['missing@catchall.example'],
  174. rawMessage: 'From: alice@example.net\r\nSubject: Catch all\r\n\r\nHello catch-all.'
  175. });
  176. const [message] = listInboundMessages(user.id);
  177. assert.equal(message.mailboxAddress, 'share@catchall.example');
  178. assert.deepEqual(message.recipients, ['missing@catchall.example']);
  179. assert.equal(message.subject, 'Catch all');
  180. } finally {
  181. await closeServer(server);
  182. }
  183. });
  184. test('SMTP forwards inbound messages without storing them when keepForwarded is false', async () => {
  185. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-forward-')), 'inbound-secret');
  186. const relay = await startFakeSmtpServer();
  187. const user = createUser({ username: 'forward-smtp', email: 'forward-smtp@example.com', password: 'password123' });
  188. createDomain(user.id, {
  189. domain: 'forward.example',
  190. selector: 'mh',
  191. verificationToken: 'verify',
  192. dkimPublic: 'public',
  193. dkimPrivate: 'private',
  194. senderHost: 'mail.forward.example',
  195. sendingIp: '192.0.2.18',
  196. spfExtra: '',
  197. dmarcPolicy: 'none',
  198. dmarcRua: ''
  199. });
  200. createInboundMailbox(user.id, {
  201. address: 'ops@forward.example',
  202. forwardTo: 'archive@example.net',
  203. keepForwarded: false
  204. });
  205. const [server] = startSubmissionServer({
  206. enabled: true,
  207. listeners: [{ port: 0, protocol: 'smtp' }],
  208. hostname: 'mx.forward.example',
  209. allowInsecureAuth: true,
  210. inboundEnabled: true,
  211. relayHost: '127.0.0.1',
  212. relayPort: relay.port,
  213. relaySecure: false,
  214. relayUsername: '',
  215. relayPassword: '',
  216. relayHelo: 'mx.forward.example'
  217. });
  218. await waitForListening(server);
  219. try {
  220. await sendViaSmtp({
  221. host: '127.0.0.1',
  222. port: server.address().port,
  223. secure: false,
  224. username: '',
  225. password: '',
  226. helo: 'sender.example.net',
  227. mailFrom: 'alice@example.net',
  228. recipients: ['ops@forward.example'],
  229. rawMessage: 'From: alice@example.net\r\nSubject: Forward only\r\n\r\nForward this.'
  230. });
  231. assert.equal(listInboundMessages(user.id).length, 0);
  232. assert.ok(relay.commands.includes('RCPT TO:<archive@example.net>'));
  233. assert.match(relay.messages[0], /Subject: Forward only/);
  234. } finally {
  235. await closeServer(server);
  236. await relay.close();
  237. }
  238. });
  239. test('SMTP stores each inbound recipient without exposing other envelope recipients', async () => {
  240. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-multi-')), 'inbound-secret');
  241. const supportUser = createUser({ username: 'support-user', email: 'support@example.com', password: 'password123' });
  242. const privateUser = createUser({ username: 'private-user', email: 'private@example.com', password: 'password123' });
  243. createDomain(supportUser.id, {
  244. domain: 'support.example',
  245. selector: 'mh',
  246. verificationToken: 'verify',
  247. dkimPublic: 'public',
  248. dkimPrivate: 'private',
  249. senderHost: 'mail.support.example',
  250. sendingIp: '192.0.2.12',
  251. spfExtra: '',
  252. dmarcPolicy: 'none',
  253. dmarcRua: ''
  254. });
  255. createDomain(privateUser.id, {
  256. domain: 'private.example',
  257. selector: 'mh',
  258. verificationToken: 'verify',
  259. dkimPublic: 'public',
  260. dkimPrivate: 'private',
  261. senderHost: 'mail.private.example',
  262. sendingIp: '192.0.2.13',
  263. spfExtra: '',
  264. dmarcPolicy: 'none',
  265. dmarcRua: ''
  266. });
  267. createInboundMailbox(supportUser.id, { address: 'support@support.example' });
  268. createInboundMailbox(privateUser.id, { address: 'private@private.example' });
  269. const [server] = startSubmissionServer({
  270. enabled: true,
  271. listeners: [{ port: 0, protocol: 'smtp' }],
  272. hostname: 'mx.inbound.example',
  273. allowInsecureAuth: true,
  274. inboundEnabled: true
  275. });
  276. await waitForListening(server);
  277. try {
  278. const rawMessage = [
  279. 'From: Alice <alice@example.net>',
  280. 'To: Support <support@support.example>',
  281. 'Subject: Multi recipient',
  282. '',
  283. 'Hello both.',
  284. ''
  285. ].join('\r\n');
  286. await sendViaSmtp({
  287. host: '127.0.0.1',
  288. port: server.address().port,
  289. secure: false,
  290. username: '',
  291. password: '',
  292. helo: 'sender.example.net',
  293. mailFrom: 'alice@example.net',
  294. recipients: ['support@support.example', 'private@private.example'],
  295. rawMessage
  296. });
  297. assert.deepEqual(listInboundMessages(supportUser.id)[0].recipients, ['support@support.example']);
  298. assert.deepEqual(listInboundMessages(privateUser.id)[0].recipients, ['private@private.example']);
  299. } finally {
  300. await closeServer(server);
  301. }
  302. });
  303. test('SMTP accepts unauthenticated inbound bounces with an empty envelope sender', async () => {
  304. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-bounce-')), 'inbound-secret');
  305. const user = createUser({ username: 'bounce-user', email: 'bounce@example.com', password: 'password123' });
  306. createDomain(user.id, {
  307. domain: 'bounce.example',
  308. selector: 'mh',
  309. verificationToken: 'verify',
  310. dkimPublic: 'public',
  311. dkimPrivate: 'private',
  312. senderHost: 'mail.bounce.example',
  313. sendingIp: '192.0.2.14',
  314. spfExtra: '',
  315. dmarcPolicy: 'none',
  316. dmarcRua: ''
  317. });
  318. createInboundMailbox(user.id, { address: 'postmaster@bounce.example' });
  319. const [server] = startSubmissionServer({
  320. enabled: true,
  321. listeners: [{ port: 0, protocol: 'smtp' }],
  322. hostname: 'mx.bounce.example',
  323. allowInsecureAuth: true,
  324. inboundEnabled: true
  325. });
  326. await waitForListening(server);
  327. try {
  328. await sendViaSmtp({
  329. host: '127.0.0.1',
  330. port: server.address().port,
  331. secure: false,
  332. username: '',
  333. password: '',
  334. helo: 'sender.example.net',
  335. mailFrom: '',
  336. recipients: ['postmaster@bounce.example'],
  337. rawMessage: 'From: MAILER-DAEMON <>\r\nSubject: Delivery status\r\n\r\nBounced.'
  338. });
  339. const [message] = listInboundMessages(user.id);
  340. assert.equal(message.sender, '');
  341. assert.deepEqual(message.recipients, ['postmaster@bounce.example']);
  342. } finally {
  343. await closeServer(server);
  344. }
  345. });
  346. test('SMTP rejects oversized unauthenticated inbound messages without storing them', async () => {
  347. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-size-')), 'inbound-secret');
  348. const user = createUser({ username: 'size-user', email: 'size@example.com', password: 'password123' });
  349. createDomain(user.id, {
  350. domain: 'size.example',
  351. selector: 'mh',
  352. verificationToken: 'verify',
  353. dkimPublic: 'public',
  354. dkimPrivate: 'private',
  355. senderHost: 'mail.size.example',
  356. sendingIp: '192.0.2.15',
  357. spfExtra: '',
  358. dmarcPolicy: 'none',
  359. dmarcRua: ''
  360. });
  361. createInboundMailbox(user.id, { address: 'support@size.example' });
  362. const [server] = startSubmissionServer({
  363. enabled: true,
  364. listeners: [{ port: 0, protocol: 'smtp' }],
  365. hostname: 'mx.size.example',
  366. allowInsecureAuth: true,
  367. inboundEnabled: true,
  368. maxMessageBytes: 64
  369. });
  370. await waitForListening(server);
  371. try {
  372. const transcript = await smtpTranscript(server.address().port, [
  373. 'EHLO sender.example.net',
  374. 'MAIL FROM:<alice@example.net>',
  375. 'RCPT TO:<support@size.example>',
  376. 'DATA',
  377. [
  378. 'Subject: Oversized inbound',
  379. '',
  380. 'This body is intentionally longer than the configured inbound message size limit.',
  381. '.'
  382. ].join('\r\n')
  383. ]);
  384. assert.match(transcript.at(-1), /^552 /);
  385. assert.equal(listInboundMessages(user.id).length, 0);
  386. } finally {
  387. await closeServer(server);
  388. }
  389. });
  390. function waitForListening(server) {
  391. if (server.listening) return Promise.resolve();
  392. return new Promise((resolve) => server.once('listening', resolve));
  393. }
  394. function closeServer(server) {
  395. return new Promise((resolve, reject) => {
  396. server.close((error) => error ? reject(error) : resolve());
  397. });
  398. }
  399. async function smtpTranscript(port, commands) {
  400. return await new Promise((resolve, reject) => {
  401. const socket = net.createConnection({ host: '127.0.0.1', port });
  402. socket.setEncoding('utf8');
  403. socket.setTimeout(3000);
  404. const responses = [];
  405. let buffer = '';
  406. let index = -1;
  407. socket.on('data', (chunk) => {
  408. buffer += chunk;
  409. let lineEnd;
  410. while ((lineEnd = buffer.indexOf('\n')) !== -1) {
  411. const line = buffer.slice(0, lineEnd).replace(/\r$/, '');
  412. buffer = buffer.slice(lineEnd + 1);
  413. if (!/^\d{3}[ -]/.test(line)) continue;
  414. responses.push(line);
  415. if (/^\d{3} /.test(line)) {
  416. index += 1;
  417. if (index >= commands.length) {
  418. socket.end('QUIT\r\n');
  419. resolve(responses);
  420. return;
  421. }
  422. socket.write(`${commands[index]}\r\n`);
  423. }
  424. }
  425. });
  426. socket.once('error', reject);
  427. socket.once('timeout', () => reject(new Error('SMTP transcript timed out')));
  428. });
  429. }
  430. function startFakeSmtpServer() {
  431. const commands = [];
  432. const messages = [];
  433. const server = net.createServer((socket) => {
  434. socket.setEncoding('utf8');
  435. socket.write('220 relay.test ESMTP ready\r\n');
  436. let buffer = '';
  437. let dataMode = false;
  438. let messageLines = [];
  439. socket.on('data', (chunk) => {
  440. buffer += chunk;
  441. let index;
  442. while ((index = buffer.indexOf('\n')) !== -1) {
  443. const line = buffer.slice(0, index).replace(/\r$/, '');
  444. buffer = buffer.slice(index + 1);
  445. if (dataMode) {
  446. if (line === '.') {
  447. dataMode = false;
  448. messages.push(messageLines.join('\n'));
  449. messageLines = [];
  450. socket.write('250 2.0.0 queued as FORWARD123\r\n');
  451. } else {
  452. messageLines.push(line);
  453. }
  454. continue;
  455. }
  456. commands.push(line);
  457. if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
  458. else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
  459. else if (line === 'DATA') {
  460. dataMode = true;
  461. socket.write('354 end with dot\r\n');
  462. } else if (line === 'QUIT') {
  463. socket.write('221 bye\r\n');
  464. socket.end();
  465. }
  466. }
  467. });
  468. });
  469. return new Promise((resolve, reject) => {
  470. server.once('error', reject);
  471. server.listen(0, '127.0.0.1', () => resolve({
  472. port: server.address().port,
  473. commands,
  474. messages,
  475. close: () => closeServer(server)
  476. }));
  477. });
  478. }