submission-inbound.test.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816
  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. saveSmtpCredential,
  16. updateDomain
  17. } from '../src/db.js';
  18. import { createDkimKeyPair } from '../src/dkim.js';
  19. import { sendViaSmtp } from '../src/mailer.js';
  20. import { readMaildirMessage, scanMaildirMailbox } from '../src/maildir-store.js';
  21. import { startSubmissionServer } from '../src/submission.js';
  22. test('SMTP accepts unauthenticated inbound mail for local mailboxes', async () => {
  23. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-'));
  24. const maildirRoot = path.join(dataDir, 'maildir');
  25. initDatabase(dataDir, 'inbound-secret');
  26. const user = createUser({ username: 'inbound-smtp', email: 'inbound-smtp@example.com', password: 'password123' });
  27. createDomain(user.id, {
  28. domain: 'inbound.example',
  29. selector: 'mh',
  30. verificationToken: 'verify',
  31. dkimPublic: 'public',
  32. dkimPrivate: 'private',
  33. senderHost: 'mail.inbound.example',
  34. sendingIp: '192.0.2.10',
  35. spfExtra: '',
  36. dmarcPolicy: 'none',
  37. dmarcRua: ''
  38. });
  39. const mailbox = createInboundMailbox(user.id, { address: 'support@inbound.example', displayName: 'Support' });
  40. const [server] = startSubmissionServer({
  41. enabled: true,
  42. listeners: [{ port: 0, protocol: 'smtp' }],
  43. hostname: 'mx.inbound.example',
  44. allowInsecureAuth: true,
  45. inboundEnabled: true,
  46. maildirEnabled: true,
  47. maildirRoot,
  48. relayHost: '',
  49. relayPort: 25,
  50. relaySecure: false,
  51. relayUsername: '',
  52. relayPassword: '',
  53. relayHelo: 'mx.inbound.example'
  54. });
  55. await waitForListening(server);
  56. try {
  57. const rawHeaders = [
  58. 'From: Alice <alice@example.net>',
  59. 'To: Support <support@inbound.example>',
  60. 'Subject: Hello inbound SMTP',
  61. 'Message-ID: <hello-inbound@example.net>',
  62. 'Content-Type: text/plain; charset=ISO-8859-1',
  63. '',
  64. ''
  65. ].join('\r\n');
  66. const rawMessage = Buffer.concat([
  67. Buffer.from(rawHeaders, 'ascii'),
  68. Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x63, 0x61, 0x66, 0xe9, 0x2e, 0x0d, 0x0a])
  69. ]);
  70. const response = await sendViaSmtp({
  71. host: '127.0.0.1',
  72. port: server.address().port,
  73. secure: false,
  74. username: '',
  75. password: '',
  76. helo: 'sender.example.net',
  77. mailFrom: 'alice@example.net',
  78. recipients: ['support@inbound.example'],
  79. rawMessage
  80. });
  81. assert.match(response.message, /Message accepted/i);
  82. const [message] = listInboundMessages(user.id);
  83. assert.equal(message.sender, 'alice@example.net');
  84. assert.deepEqual(message.recipients, ['support@inbound.example']);
  85. assert.equal(message.subject, 'Hello inbound SMTP');
  86. assert.equal(message.preview, 'Hello café.');
  87. const [maildirMessage] = await scanMaildirMailbox({ root: maildirRoot, address: mailbox.address });
  88. assert.ok(maildirMessage.storageKey.startsWith('mhsmtp-'));
  89. assert.equal(maildirMessage.folder, 'INBOX');
  90. const maildirBytes = (await readMaildirMessage(maildirMessage)).bytes;
  91. assert.equal(maildirBytes.includes(Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x2e])), true);
  92. assert.equal(maildirBytes.includes(Buffer.from('café', 'utf8')), false);
  93. } finally {
  94. await closeServer(server);
  95. }
  96. });
  97. test('SMTP queues a mailbox receipt webhook only after the inbound message is stored', async () => {
  98. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-webhook-')), 'inbound-secret');
  99. const user = createUser({ username: 'inbound-webhook', email: 'inbound-webhook@example.com', password: 'password123' });
  100. createDomain(user.id, {
  101. domain: 'receipt-hook.example',
  102. selector: 'mh',
  103. verificationToken: 'verify',
  104. dkimPublic: 'public',
  105. dkimPrivate: 'private',
  106. senderHost: 'mail.receipt-hook.example',
  107. sendingIp: '192.0.2.12',
  108. spfExtra: '',
  109. dmarcPolicy: 'none',
  110. dmarcRua: ''
  111. });
  112. const mailbox = createInboundMailbox(user.id, { address: 'support@receipt-hook.example' });
  113. createWebhook(user.id, {
  114. name: 'Support received',
  115. url: 'https://hooks.example.com/receipt',
  116. events: ['received'],
  117. mailboxId: mailbox.id
  118. });
  119. const [server] = startSubmissionServer({
  120. enabled: true,
  121. listeners: [{ port: 0, protocol: 'smtp' }],
  122. hostname: 'mx.receipt-hook.example',
  123. allowInsecureAuth: true,
  124. inboundEnabled: true
  125. });
  126. await waitForListening(server);
  127. try {
  128. await sendViaSmtp({
  129. host: '127.0.0.1',
  130. port: server.address().port,
  131. secure: false,
  132. username: '',
  133. password: '',
  134. helo: 'sender.example.net',
  135. mailFrom: 'sender@example.net',
  136. recipients: ['support@receipt-hook.example'],
  137. rawMessage: [
  138. 'Message-ID: <smtp-receipt-hook@example.net>',
  139. 'From: sender@example.net',
  140. 'To: support@receipt-hook.example',
  141. 'Subject: Stored before callback',
  142. '',
  143. 'Inbound body'
  144. ].join('\r\n')
  145. });
  146. const [message] = listInboundMessages(user.id);
  147. assert.ok(message);
  148. const [delivery] = listWebhookDeliveries(user.id, { eventType: 'received' });
  149. assert.ok(delivery);
  150. assert.equal(delivery.inboundMessageId, message.id);
  151. const payload = JSON.parse(delivery.payloadJson);
  152. assert.equal(payload.type, 'email.received');
  153. assert.equal(payload.data.message_id, '<smtp-receipt-hook@example.net>');
  154. assert.equal(payload.data.text, 'Inbound body');
  155. } finally {
  156. await closeServer(server);
  157. }
  158. });
  159. test('SMTP rejects unauthenticated inbound mail for unknown recipients', async () => {
  160. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-reject-')), 'inbound-secret');
  161. const [server] = startSubmissionServer({
  162. enabled: true,
  163. listeners: [{ port: 0, protocol: 'smtp' }],
  164. hostname: 'mx.inbound.example',
  165. allowInsecureAuth: true,
  166. inboundEnabled: true
  167. });
  168. await waitForListening(server);
  169. try {
  170. const transcript = await smtpTranscript(server.address().port, [
  171. 'EHLO sender.example.net',
  172. 'MAIL FROM:<alice@example.net>',
  173. 'RCPT TO:<nobody@external.example>'
  174. ]);
  175. assert.match(transcript.at(-1), /^550 /);
  176. assert.equal(listInboundMessages(1).length, 0);
  177. } finally {
  178. await closeServer(server);
  179. }
  180. });
  181. test('SMTP authenticates with a mailbox account address and password', async () => {
  182. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-mailbox-auth-')), 'inbound-secret');
  183. const user = createUser({ username: 'mailbox-auth', email: 'mailbox-auth@example.com', password: 'password123' });
  184. createDomain(user.id, {
  185. domain: 'authmail.example',
  186. selector: 'mh',
  187. verificationToken: 'verify',
  188. dkimPublic: 'public',
  189. dkimPrivate: 'private',
  190. senderHost: 'mail.authmail.example',
  191. sendingIp: '192.0.2.16',
  192. spfExtra: '',
  193. dmarcPolicy: 'none',
  194. dmarcRua: ''
  195. });
  196. createDomain(user.id, {
  197. domain: 'other-authmail.example',
  198. selector: 'mh',
  199. verificationToken: 'verify-other',
  200. dkimPublic: 'public',
  201. dkimPrivate: 'private',
  202. senderHost: 'mail.other-authmail.example',
  203. sendingIp: '192.0.2.17',
  204. spfExtra: '',
  205. dmarcPolicy: 'none',
  206. dmarcRua: ''
  207. });
  208. createInboundMailbox(user.id, {
  209. address: 'admin@authmail.example',
  210. password: 'mailbox-pass-123',
  211. aliases: ['sales']
  212. });
  213. const [server] = startSubmissionServer({
  214. enabled: true,
  215. listeners: [{ port: 0, protocol: 'smtp' }],
  216. hostname: 'mx.authmail.example',
  217. allowInsecureAuth: true,
  218. inboundEnabled: true
  219. });
  220. await waitForListening(server);
  221. try {
  222. const auth = Buffer.from('\u0000admin@authmail.example\u0000mailbox-pass-123').toString('base64');
  223. const ownSender = await smtpTranscript(server.address().port, [
  224. 'EHLO sender.example.net',
  225. `AUTH PLAIN ${auth}`,
  226. 'MAIL FROM:<admin@authmail.example>'
  227. ]);
  228. assert.match(ownSender.at(-1), /^250 /);
  229. const aliasSender = await smtpTranscript(server.address().port, [
  230. 'EHLO sender.example.net',
  231. `AUTH PLAIN ${auth}`,
  232. 'MAIL FROM:<sales@authmail.example>'
  233. ]);
  234. assert.match(aliasSender.at(-1), /^250 /);
  235. const crossMailboxSender = await smtpTranscript(server.address().port, [
  236. 'EHLO sender.example.net',
  237. `AUTH PLAIN ${auth}`,
  238. 'MAIL FROM:<other@other-authmail.example>'
  239. ]);
  240. assert.match(crossMailboxSender.at(-1), /^553 /);
  241. const crossMailboxHeader = await smtpTranscript(server.address().port, [
  242. 'EHLO sender.example.net',
  243. `AUTH PLAIN ${auth}`,
  244. 'MAIL FROM:<admin@authmail.example>',
  245. 'RCPT TO:<recipient@example.net>',
  246. 'DATA',
  247. [
  248. 'From: Other <other@other-authmail.example>',
  249. 'To: recipient@example.net',
  250. 'Subject: blocked mailbox impersonation',
  251. '',
  252. 'This message must not be relayed.',
  253. '.'
  254. ].join('\r\n')
  255. ]);
  256. assert.match(crossMailboxHeader.at(-1), /^553 /);
  257. } finally {
  258. await closeServer(server);
  259. }
  260. });
  261. test('SMTP relays only the authenticated mailbox and aliases from a shared domain', async () => {
  262. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-shared-domain-')), 'inbound-secret');
  263. const owner = createUser({ username: 'shared-domain-owner', email: 'owner@example.com', password: 'password123' });
  264. const member = createUser({ username: 'shared-domain-member', email: 'member@example.com', password: 'password123' });
  265. saveSmtpCredential(member.id, { username: 'shared-domain-member-smtp', password: 'member-smtp-pass' });
  266. const keys = createDkimKeyPair();
  267. const domain = createDomain(owner.id, {
  268. domain: 'shared-authmail.example',
  269. selector: 'mh',
  270. verificationToken: 'verify-shared',
  271. dkimPublic: keys.publicKey,
  272. dkimPrivate: keys.privateKey,
  273. senderHost: 'mail.shared-authmail.example',
  274. sendingIp: '192.0.2.18',
  275. spfExtra: '',
  276. dmarcPolicy: 'none',
  277. dmarcRua: ''
  278. });
  279. const memberDomainKeys = createDkimKeyPair();
  280. createDomain(member.id, {
  281. domain: 'member-owned.example',
  282. selector: 'mh',
  283. verificationToken: 'verify-member-owned',
  284. dkimPublic: memberDomainKeys.publicKey,
  285. dkimPrivate: memberDomainKeys.privateKey,
  286. senderHost: 'mail.member-owned.example',
  287. sendingIp: '192.0.2.19',
  288. spfExtra: '',
  289. dmarcPolicy: 'none',
  290. dmarcRua: ''
  291. });
  292. createInboundMailbox(owner.id, {
  293. address: 'owner@shared-authmail.example',
  294. password: 'owner-mailbox-pass'
  295. });
  296. updateDomain(domain.id, owner.id, { mailboxSignupEnabled: true });
  297. createInboundMailbox(member.id, {
  298. address: 'member@shared-authmail.example',
  299. password: 'member-mailbox-pass',
  300. aliases: ['member-alias']
  301. });
  302. const relay = await startFakeSmtpServer();
  303. const [server] = startSubmissionServer({
  304. enabled: true,
  305. listeners: [{ port: 0, protocol: 'smtp' }],
  306. hostname: 'mx.shared-authmail.example',
  307. allowInsecureAuth: true,
  308. inboundEnabled: true,
  309. relayHost: '127.0.0.1',
  310. relayPort: relay.port,
  311. relaySecure: false,
  312. relayUsername: '',
  313. relayPassword: '',
  314. relayHelo: 'mail.shared-authmail.example'
  315. });
  316. await waitForListening(server);
  317. try {
  318. for (const sender of ['member@shared-authmail.example', 'member-alias@shared-authmail.example']) {
  319. const response = await sendViaSmtp({
  320. host: '127.0.0.1',
  321. port: server.address().port,
  322. secure: false,
  323. username: 'member@shared-authmail.example',
  324. password: 'member-mailbox-pass',
  325. helo: 'client.example.net',
  326. mailFrom: sender,
  327. recipients: ['recipient@example.net'],
  328. rawMessage: [
  329. `From: ${sender}`,
  330. 'To: recipient@example.net',
  331. 'Subject: Shared mailbox submission',
  332. '',
  333. 'Shared mailbox body'
  334. ].join('\r\n')
  335. });
  336. assert.match(response.message, /Message queued/i);
  337. }
  338. assert.equal(relay.messages.length, 2);
  339. assert.ok(relay.messages.every((message) => /^DKIM-Signature:/m.test(message)));
  340. assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<member@shared-authmail.example>'));
  341. assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<member-alias@shared-authmail.example>'));
  342. const auth = Buffer.from('\u0000member@shared-authmail.example\u0000member-mailbox-pass').toString('base64');
  343. const otherMailboxSender = await smtpTranscript(server.address().port, [
  344. 'EHLO client.example.net',
  345. `AUTH PLAIN ${auth}`,
  346. 'MAIL FROM:<owner@shared-authmail.example>'
  347. ]);
  348. assert.match(otherMailboxSender.at(-1), /^553 /);
  349. const otherMailboxHeader = await smtpTranscript(server.address().port, [
  350. 'EHLO client.example.net',
  351. `AUTH PLAIN ${auth}`,
  352. 'MAIL FROM:<member@shared-authmail.example>',
  353. 'RCPT TO:<recipient@example.net>',
  354. 'DATA',
  355. [
  356. 'From: owner@shared-authmail.example',
  357. 'To: recipient@example.net',
  358. 'Subject: Block shared-domain impersonation',
  359. '',
  360. 'This message must not be relayed.',
  361. '.'
  362. ].join('\r\n')
  363. ]);
  364. assert.match(otherMailboxHeader.at(-1), /^553 /);
  365. assert.equal(relay.messages.length, 2);
  366. for (const sender of ['member@shared-authmail.example', 'member-alias@shared-authmail.example']) {
  367. const response = await sendViaSmtp({
  368. host: '127.0.0.1',
  369. port: server.address().port,
  370. secure: false,
  371. username: 'shared-domain-member-smtp',
  372. password: 'member-smtp-pass',
  373. helo: 'client.example.net',
  374. mailFrom: sender,
  375. recipients: ['recipient@example.net'],
  376. rawMessage: [
  377. `From: ${sender}`,
  378. 'To: recipient@example.net',
  379. 'Subject: Shared mailbox account credential submission',
  380. '',
  381. 'Shared mailbox body'
  382. ].join('\r\n')
  383. });
  384. assert.match(response.message, /Message queued/i);
  385. }
  386. const ownDomainResponse = await sendViaSmtp({
  387. host: '127.0.0.1',
  388. port: server.address().port,
  389. secure: false,
  390. username: 'shared-domain-member-smtp',
  391. password: 'member-smtp-pass',
  392. helo: 'client.example.net',
  393. mailFrom: 'any-local-part@member-owned.example',
  394. recipients: ['recipient@example.net'],
  395. rawMessage: [
  396. 'From: any-local-part@member-owned.example',
  397. 'To: recipient@example.net',
  398. 'Subject: Owned domain account credential submission',
  399. '',
  400. 'Owned domain body'
  401. ].join('\r\n')
  402. });
  403. assert.match(ownDomainResponse.message, /Message queued/i);
  404. assert.equal(relay.messages.length, 5);
  405. const accountAuth = Buffer.from('\u0000shared-domain-member-smtp\u0000member-smtp-pass').toString('base64');
  406. const accountOtherMailboxSender = await smtpTranscript(server.address().port, [
  407. 'EHLO client.example.net',
  408. `AUTH PLAIN ${accountAuth}`,
  409. 'MAIL FROM:<owner@shared-authmail.example>'
  410. ]);
  411. assert.match(accountOtherMailboxSender.at(-1), /^553 /);
  412. const accountOtherMailboxHeader = await smtpTranscript(server.address().port, [
  413. 'EHLO client.example.net',
  414. `AUTH PLAIN ${accountAuth}`,
  415. 'MAIL FROM:<member@shared-authmail.example>',
  416. 'RCPT TO:<recipient@example.net>',
  417. 'DATA',
  418. [
  419. 'From: owner@shared-authmail.example',
  420. 'To: recipient@example.net',
  421. 'Subject: Block account shared-domain impersonation',
  422. '',
  423. 'This message must not be relayed.',
  424. '.'
  425. ].join('\r\n')
  426. ]);
  427. assert.match(accountOtherMailboxHeader.at(-1), /^553 /);
  428. assert.equal(relay.messages.length, 5);
  429. } finally {
  430. await closeServer(server);
  431. await relay.close();
  432. }
  433. });
  434. test('SMTP routes unknown inbound recipients to the domain catch-all mailbox', async () => {
  435. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-catchall-')), 'inbound-secret');
  436. const user = createUser({ username: 'catchall-smtp', email: 'catchall-smtp@example.com', password: 'password123' });
  437. const domain = createDomain(user.id, {
  438. domain: 'catchall.example',
  439. selector: 'mh',
  440. verificationToken: 'verify',
  441. dkimPublic: 'public',
  442. dkimPrivate: 'private',
  443. senderHost: 'mail.catchall.example',
  444. sendingIp: '192.0.2.17',
  445. spfExtra: '',
  446. dmarcPolicy: 'none',
  447. dmarcRua: ''
  448. });
  449. createInboundMailbox(user.id, { address: 'share@catchall.example' });
  450. updateDomain(domain.id, user.id, { catchAllAddress: 'share@catchall.example' });
  451. const [server] = startSubmissionServer({
  452. enabled: true,
  453. listeners: [{ port: 0, protocol: 'smtp' }],
  454. hostname: 'mx.catchall.example',
  455. allowInsecureAuth: true,
  456. inboundEnabled: true
  457. });
  458. await waitForListening(server);
  459. try {
  460. await sendViaSmtp({
  461. host: '127.0.0.1',
  462. port: server.address().port,
  463. secure: false,
  464. username: '',
  465. password: '',
  466. helo: 'sender.example.net',
  467. mailFrom: 'alice@example.net',
  468. recipients: ['missing@catchall.example'],
  469. rawMessage: 'From: alice@example.net\r\nSubject: Catch all\r\n\r\nHello catch-all.'
  470. });
  471. const [message] = listInboundMessages(user.id);
  472. assert.equal(message.mailboxAddress, 'share@catchall.example');
  473. assert.deepEqual(message.recipients, ['missing@catchall.example']);
  474. assert.equal(message.subject, 'Catch all');
  475. } finally {
  476. await closeServer(server);
  477. }
  478. });
  479. test('SMTP forwards inbound messages without storing them when keepForwarded is false', async () => {
  480. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-forward-')), 'inbound-secret');
  481. const relay = await startFakeSmtpServer();
  482. const user = createUser({ username: 'forward-smtp', email: 'forward-smtp@example.com', password: 'password123' });
  483. createDomain(user.id, {
  484. domain: 'forward.example',
  485. selector: 'mh',
  486. verificationToken: 'verify',
  487. dkimPublic: 'public',
  488. dkimPrivate: 'private',
  489. senderHost: 'mail.forward.example',
  490. sendingIp: '192.0.2.18',
  491. spfExtra: '',
  492. dmarcPolicy: 'none',
  493. dmarcRua: ''
  494. });
  495. createInboundMailbox(user.id, {
  496. address: 'ops@forward.example',
  497. forwardTo: 'archive@example.net',
  498. keepForwarded: false
  499. });
  500. const [server] = startSubmissionServer({
  501. enabled: true,
  502. listeners: [{ port: 0, protocol: 'smtp' }],
  503. hostname: 'mx.forward.example',
  504. allowInsecureAuth: true,
  505. inboundEnabled: true,
  506. relayHost: '127.0.0.1',
  507. relayPort: relay.port,
  508. relaySecure: false,
  509. relayUsername: '',
  510. relayPassword: '',
  511. relayHelo: 'mx.forward.example'
  512. });
  513. await waitForListening(server);
  514. try {
  515. await sendViaSmtp({
  516. host: '127.0.0.1',
  517. port: server.address().port,
  518. secure: false,
  519. username: '',
  520. password: '',
  521. helo: 'sender.example.net',
  522. mailFrom: 'alice@example.net',
  523. recipients: ['ops@forward.example'],
  524. rawMessage: 'From: alice@example.net\r\nSubject: Forward only\r\n\r\nForward this.'
  525. });
  526. assert.equal(listInboundMessages(user.id).length, 0);
  527. assert.ok(relay.commands.includes('RCPT TO:<archive@example.net>'));
  528. assert.match(relay.messages[0], /Subject: Forward only/);
  529. } finally {
  530. await closeServer(server);
  531. await relay.close();
  532. }
  533. });
  534. test('SMTP stores each inbound recipient without exposing other envelope recipients', async () => {
  535. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-multi-')), 'inbound-secret');
  536. const supportUser = createUser({ username: 'support-user', email: 'support@example.com', password: 'password123' });
  537. const privateUser = createUser({ username: 'private-user', email: 'private@example.com', password: 'password123' });
  538. createDomain(supportUser.id, {
  539. domain: 'support.example',
  540. selector: 'mh',
  541. verificationToken: 'verify',
  542. dkimPublic: 'public',
  543. dkimPrivate: 'private',
  544. senderHost: 'mail.support.example',
  545. sendingIp: '192.0.2.12',
  546. spfExtra: '',
  547. dmarcPolicy: 'none',
  548. dmarcRua: ''
  549. });
  550. createDomain(privateUser.id, {
  551. domain: 'private.example',
  552. selector: 'mh',
  553. verificationToken: 'verify',
  554. dkimPublic: 'public',
  555. dkimPrivate: 'private',
  556. senderHost: 'mail.private.example',
  557. sendingIp: '192.0.2.13',
  558. spfExtra: '',
  559. dmarcPolicy: 'none',
  560. dmarcRua: ''
  561. });
  562. createInboundMailbox(supportUser.id, { address: 'support@support.example' });
  563. createInboundMailbox(privateUser.id, { address: 'private@private.example' });
  564. const [server] = startSubmissionServer({
  565. enabled: true,
  566. listeners: [{ port: 0, protocol: 'smtp' }],
  567. hostname: 'mx.inbound.example',
  568. allowInsecureAuth: true,
  569. inboundEnabled: true
  570. });
  571. await waitForListening(server);
  572. try {
  573. const rawMessage = [
  574. 'From: Alice <alice@example.net>',
  575. 'To: Support <support@support.example>',
  576. 'Subject: Multi recipient',
  577. '',
  578. 'Hello both.',
  579. ''
  580. ].join('\r\n');
  581. await sendViaSmtp({
  582. host: '127.0.0.1',
  583. port: server.address().port,
  584. secure: false,
  585. username: '',
  586. password: '',
  587. helo: 'sender.example.net',
  588. mailFrom: 'alice@example.net',
  589. recipients: ['support@support.example', 'private@private.example'],
  590. rawMessage
  591. });
  592. assert.deepEqual(listInboundMessages(supportUser.id)[0].recipients, ['support@support.example']);
  593. assert.deepEqual(listInboundMessages(privateUser.id)[0].recipients, ['private@private.example']);
  594. } finally {
  595. await closeServer(server);
  596. }
  597. });
  598. test('SMTP accepts unauthenticated inbound bounces with an empty envelope sender', async () => {
  599. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-bounce-')), 'inbound-secret');
  600. const user = createUser({ username: 'bounce-user', email: 'bounce@example.com', password: 'password123' });
  601. createDomain(user.id, {
  602. domain: 'bounce.example',
  603. selector: 'mh',
  604. verificationToken: 'verify',
  605. dkimPublic: 'public',
  606. dkimPrivate: 'private',
  607. senderHost: 'mail.bounce.example',
  608. sendingIp: '192.0.2.14',
  609. spfExtra: '',
  610. dmarcPolicy: 'none',
  611. dmarcRua: ''
  612. });
  613. createInboundMailbox(user.id, { address: 'postmaster@bounce.example' });
  614. const [server] = startSubmissionServer({
  615. enabled: true,
  616. listeners: [{ port: 0, protocol: 'smtp' }],
  617. hostname: 'mx.bounce.example',
  618. allowInsecureAuth: true,
  619. inboundEnabled: true
  620. });
  621. await waitForListening(server);
  622. try {
  623. await sendViaSmtp({
  624. host: '127.0.0.1',
  625. port: server.address().port,
  626. secure: false,
  627. username: '',
  628. password: '',
  629. helo: 'sender.example.net',
  630. mailFrom: '',
  631. recipients: ['postmaster@bounce.example'],
  632. rawMessage: 'From: MAILER-DAEMON <>\r\nSubject: Delivery status\r\n\r\nBounced.'
  633. });
  634. const [message] = listInboundMessages(user.id);
  635. assert.equal(message.sender, '');
  636. assert.deepEqual(message.recipients, ['postmaster@bounce.example']);
  637. } finally {
  638. await closeServer(server);
  639. }
  640. });
  641. test('SMTP rejects oversized unauthenticated inbound messages without storing them', async () => {
  642. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-size-')), 'inbound-secret');
  643. const user = createUser({ username: 'size-user', email: 'size@example.com', password: 'password123' });
  644. createDomain(user.id, {
  645. domain: 'size.example',
  646. selector: 'mh',
  647. verificationToken: 'verify',
  648. dkimPublic: 'public',
  649. dkimPrivate: 'private',
  650. senderHost: 'mail.size.example',
  651. sendingIp: '192.0.2.15',
  652. spfExtra: '',
  653. dmarcPolicy: 'none',
  654. dmarcRua: ''
  655. });
  656. createInboundMailbox(user.id, { address: 'support@size.example' });
  657. const [server] = startSubmissionServer({
  658. enabled: true,
  659. listeners: [{ port: 0, protocol: 'smtp' }],
  660. hostname: 'mx.size.example',
  661. allowInsecureAuth: true,
  662. inboundEnabled: true,
  663. maxMessageBytes: 64
  664. });
  665. await waitForListening(server);
  666. try {
  667. const transcript = await smtpTranscript(server.address().port, [
  668. 'EHLO sender.example.net',
  669. 'MAIL FROM:<alice@example.net>',
  670. 'RCPT TO:<support@size.example>',
  671. 'DATA',
  672. [
  673. 'Subject: Oversized inbound',
  674. '',
  675. 'This body is intentionally longer than the configured inbound message size limit.',
  676. '.'
  677. ].join('\r\n')
  678. ]);
  679. assert.match(transcript.at(-1), /^552 /);
  680. assert.equal(listInboundMessages(user.id).length, 0);
  681. } finally {
  682. await closeServer(server);
  683. }
  684. });
  685. function waitForListening(server) {
  686. if (server.listening) return Promise.resolve();
  687. return new Promise((resolve) => server.once('listening', resolve));
  688. }
  689. function closeServer(server) {
  690. return new Promise((resolve, reject) => {
  691. server.close((error) => error ? reject(error) : resolve());
  692. });
  693. }
  694. async function smtpTranscript(port, commands) {
  695. return await new Promise((resolve, reject) => {
  696. const socket = net.createConnection({ host: '127.0.0.1', port });
  697. socket.setEncoding('utf8');
  698. socket.setTimeout(3000);
  699. const responses = [];
  700. let buffer = '';
  701. let index = -1;
  702. socket.on('data', (chunk) => {
  703. buffer += chunk;
  704. let lineEnd;
  705. while ((lineEnd = buffer.indexOf('\n')) !== -1) {
  706. const line = buffer.slice(0, lineEnd).replace(/\r$/, '');
  707. buffer = buffer.slice(lineEnd + 1);
  708. if (!/^\d{3}[ -]/.test(line)) continue;
  709. responses.push(line);
  710. if (/^\d{3} /.test(line)) {
  711. index += 1;
  712. if (index >= commands.length) {
  713. socket.end('QUIT\r\n');
  714. resolve(responses);
  715. return;
  716. }
  717. socket.write(`${commands[index]}\r\n`);
  718. }
  719. }
  720. });
  721. socket.once('error', reject);
  722. socket.once('timeout', () => reject(new Error('SMTP transcript timed out')));
  723. });
  724. }
  725. function startFakeSmtpServer() {
  726. const commands = [];
  727. const messages = [];
  728. const server = net.createServer((socket) => {
  729. socket.setEncoding('utf8');
  730. socket.write('220 relay.test ESMTP ready\r\n');
  731. let buffer = '';
  732. let dataMode = false;
  733. let messageLines = [];
  734. socket.on('data', (chunk) => {
  735. buffer += chunk;
  736. let index;
  737. while ((index = buffer.indexOf('\n')) !== -1) {
  738. const line = buffer.slice(0, index).replace(/\r$/, '');
  739. buffer = buffer.slice(index + 1);
  740. if (dataMode) {
  741. if (line === '.') {
  742. dataMode = false;
  743. messages.push(messageLines.join('\n'));
  744. messageLines = [];
  745. socket.write('250 2.0.0 queued as FORWARD123\r\n');
  746. } else {
  747. messageLines.push(line);
  748. }
  749. continue;
  750. }
  751. commands.push(line);
  752. if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
  753. else if (line.startsWith('MAIL FROM') || line.startsWith('RCPT TO')) socket.write('250 ok\r\n');
  754. else if (line === 'DATA') {
  755. dataMode = true;
  756. socket.write('354 end with dot\r\n');
  757. } else if (line === 'QUIT') {
  758. socket.write('221 bye\r\n');
  759. socket.end();
  760. }
  761. }
  762. });
  763. });
  764. return new Promise((resolve, reject) => {
  765. server.once('error', reject);
  766. server.listen(0, '127.0.0.1', () => resolve({
  767. port: server.address().port,
  768. commands,
  769. messages,
  770. close: () => closeServer(server)
  771. }));
  772. });
  773. }