submission-inbound.test.js 36 KB

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