submission-inbound.test.js 31 KB

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