submission-inbound.test.js 20 KB

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