submission-inbound.test.js 18 KB

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