server-listing-api.test.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. import assert from 'node:assert/strict';
  2. import { spawn, spawnSync } from 'node:child_process';
  3. import { mkdtempSync } from 'node:fs';
  4. import { tmpdir } from 'node:os';
  5. import net from 'node:net';
  6. import path from 'node:path';
  7. import { test } from 'node:test';
  8. test('listing APIs paginate, filter, isolate users, and expose folder counts', async () => {
  9. const fixture = await startTestServer();
  10. try {
  11. const seeded = seedListingFixtures(fixture.dataDir, fixture.sessionSecret);
  12. const aliceCookie = await login(fixture.baseUrl, 'list-alice', 'password123');
  13. const bobCookie = await login(fixture.baseUrl, 'list-bob', 'password123');
  14. const defaultEvents = await getJson(fixture.baseUrl, '/api/events', aliceCookie);
  15. assert.equal(defaultEvents.status, 200);
  16. assert.equal(defaultEvents.body.total, 5);
  17. assert.equal(defaultEvents.body.page, 1);
  18. assert.equal(defaultEvents.body.pageSize, 30);
  19. assert.equal(defaultEvents.body.events.length, 5);
  20. const firstPage = await getJson(fixture.baseUrl, '/api/events?page=1&pageSize=2', aliceCookie);
  21. const secondPage = await getJson(fixture.baseUrl, '/api/events?page=2&pageSize=2', aliceCookie);
  22. assert.equal(firstPage.body.total, 5);
  23. assert.equal(firstPage.body.events.length, 2);
  24. assert.equal(secondPage.body.events.length, 2);
  25. assert.equal(new Set([...firstPage.body.events, ...secondPage.body.events].map((event) => event.id)).size, 4);
  26. assert.equal((await getJson(fixture.baseUrl, '/api/events?status=failed', aliceCookie)).body.total, 1);
  27. assert.equal(
  28. (await getJson(fixture.baseUrl, `/api/events?domainId=${seeded.aliceSecondaryDomainId}`, aliceCookie)).body.total,
  29. 1
  30. );
  31. assert.equal(
  32. (await getJson(fixture.baseUrl, '/api/events?recipient=target%2Bfilter%40example.net', aliceCookie)).body.total,
  33. 1
  34. );
  35. const byQueue = await getJson(fixture.baseUrl, '/api/events?q=QUEUE-ALICE-FAILED', aliceCookie);
  36. assert.equal(byQueue.body.total, 1);
  37. assert.equal(byQueue.body.events[0].status, 'failed');
  38. const byMessageId = await getJson(fixture.baseUrl, `/api/events?q=mh-${seeded.specialEventId}`, aliceCookie);
  39. assert.equal(byMessageId.body.total, 1);
  40. assert.equal(byMessageId.body.events[0].messageId, `mh-${seeded.specialEventId}`);
  41. const byNumericId = await getJson(fixture.baseUrl, `/api/events?q=${seeded.specialEventId}`, aliceCookie);
  42. assert.equal(byNumericId.body.total, 1);
  43. assert.equal(byNumericId.body.events[0].id, seeded.specialEventId);
  44. const bySubject = await getJson(fixture.baseUrl, '/api/events?q=Message%20identifier%20event', aliceCookie);
  45. assert.equal(bySubject.body.total, 1);
  46. assert.equal(bySubject.body.events[0].id, seeded.specialEventId);
  47. assert.equal((await getJson(fixture.baseUrl, '/api/events?from=2000-01-01', aliceCookie)).body.total, 5);
  48. assert.equal((await getJson(fixture.baseUrl, '/api/events?to=2000-01-01', aliceCookie)).body.total, 0);
  49. const bobEvents = await getJson(fixture.baseUrl, '/api/events?q=QUEUE-BOB-ONLY', bobCookie);
  50. assert.equal(bobEvents.body.total, 1);
  51. assert.equal((await getJson(fixture.baseUrl, '/api/events?q=QUEUE-BOB-ONLY', aliceCookie)).body.total, 0);
  52. const inboxPage = await getJson(
  53. fixture.baseUrl,
  54. `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&page=1&pageSize=1`,
  55. aliceCookie
  56. );
  57. assert.equal(inboxPage.status, 200);
  58. assert.equal(inboxPage.body.total, 2);
  59. assert.equal(inboxPage.body.messages.length, 1);
  60. assert.equal(inboxPage.body.page, 1);
  61. assert.equal(inboxPage.body.pageSize, 1);
  62. const unread = await getJson(
  63. fixture.baseUrl,
  64. `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=INBOX&read=false`,
  65. aliceCookie
  66. );
  67. assert.equal(unread.body.total, 1);
  68. assert.equal(unread.body.messages[0].read, false);
  69. const sent = await getJson(
  70. fixture.baseUrl,
  71. `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=Sent`,
  72. aliceCookie
  73. );
  74. assert.equal(sent.body.total, 1);
  75. assert.equal(sent.body.messages[0].folder, 'Sent');
  76. const custom = await getJson(
  77. fixture.baseUrl,
  78. `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=Projects`,
  79. aliceCookie
  80. );
  81. assert.equal(custom.body.total, 1);
  82. assert.equal(custom.body.messages[0].folder, 'Projects');
  83. const byInboundMessageId = await getJson(
  84. fixture.baseUrl,
  85. '/api/inbound-messages?folder=Projects&q=inbound-project-special',
  86. aliceCookie
  87. );
  88. assert.equal(byInboundMessageId.body.total, 1);
  89. assert.equal(byInboundMessageId.body.messages[0].messageId, '<inbound-project-special@example.net>');
  90. const isolatedMessages = await getJson(
  91. fixture.baseUrl,
  92. `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}`,
  93. bobCookie
  94. );
  95. assert.equal(isolatedMessages.body.total, 0);
  96. const folders = await getJson(
  97. fixture.baseUrl,
  98. `/api/inbound-mailboxes/${seeded.aliceMailboxId}/folders`,
  99. aliceCookie
  100. );
  101. assert.equal(folders.status, 200);
  102. assert.deepEqual(folderSummary(folders.body.folders, 'INBOX'), {
  103. name: 'INBOX',
  104. specialUse: null,
  105. messageCount: 2,
  106. unreadCount: 1
  107. });
  108. assert.deepEqual(folderSummary(folders.body.folders, 'Sent'), {
  109. name: 'Sent',
  110. specialUse: '\\Sent',
  111. messageCount: 1,
  112. unreadCount: 0
  113. });
  114. assert.deepEqual(folderSummary(folders.body.folders, 'Projects'), {
  115. name: 'Projects',
  116. specialUse: null,
  117. messageCount: 1,
  118. unreadCount: 1
  119. });
  120. assert.ok(folders.body.folders.some((folder) => folder.name === 'Drafts' && folder.messageCount === 0));
  121. const isolatedFolders = await getJson(
  122. fixture.baseUrl,
  123. `/api/inbound-mailboxes/${seeded.aliceMailboxId}/folders`,
  124. bobCookie
  125. );
  126. assert.equal(isolatedFolders.status, 404);
  127. assert.deepEqual(isolatedFolders.body.folders, []);
  128. for (const pathName of [
  129. '/api/events?page=0',
  130. '/api/events?page=abc',
  131. '/api/events?pageSize=101',
  132. '/api/events?domainId=-1',
  133. '/api/events?status=sent%20OR%201%3D1',
  134. '/api/events?from=not-a-date',
  135. '/api/events?from=2026-07-02&to=2026-07-01',
  136. '/api/inbound-messages?page=0',
  137. '/api/inbound-messages?pageSize=101',
  138. '/api/inbound-messages?mailboxId=nope',
  139. '/api/inbound-messages?folder=',
  140. '/api/inbound-messages?read=1'
  141. ]) {
  142. const response = await getJson(fixture.baseUrl, pathName, aliceCookie);
  143. assert.equal(response.status, 400, pathName);
  144. assert.equal(typeof response.body.error, 'string');
  145. }
  146. } finally {
  147. fixture.child.kill('SIGTERM');
  148. await waitForExit(fixture.child, 1000);
  149. }
  150. });
  151. test('SMTP API responses keep passwords write-only while internal relay auth still works', async () => {
  152. const relayServer = await startFakeSmtpServer();
  153. const fixture = await startTestServer();
  154. try {
  155. const cookie = await login(fixture.baseUrl, 'admin', 'password123');
  156. const singularSave = await requestJson(fixture.baseUrl, '/api/smtp-credential', cookie, {
  157. method: 'PUT',
  158. body: { username: 'legacy-smtp', password: 'legacy-secret' }
  159. });
  160. assertSecretSummary(singularSave.body.credential);
  161. const singularGet = await getJson(fixture.baseUrl, '/api/smtp-credential', cookie);
  162. assertSecretSummary(singularGet.body.credential);
  163. const credentialCreate = await requestJson(fixture.baseUrl, '/api/smtp-credentials', cookie, {
  164. method: 'POST',
  165. body: { username: 'app-smtp', password: 'app-secret' }
  166. });
  167. assert.equal(credentialCreate.status, 201);
  168. assertSecretSummary(credentialCreate.body.credential);
  169. const credentialId = credentialCreate.body.credential.id;
  170. const credentialList = await getJson(fixture.baseUrl, '/api/smtp-credentials', cookie);
  171. credentialList.body.credentials.forEach(assertSecretSummary);
  172. assertSecretSummary((await getJson(fixture.baseUrl, `/api/smtp-credentials/${credentialId}`, cookie)).body.credential);
  173. const credentialPatch = await requestJson(fixture.baseUrl, `/api/smtp-credentials/${credentialId}`, cookie, {
  174. method: 'PATCH',
  175. body: { username: 'app-smtp-renamed' }
  176. });
  177. assertSecretSummary(credentialPatch.body.credential);
  178. const domain = await requestJson(fixture.baseUrl, '/api/domains', cookie, {
  179. method: 'POST',
  180. body: {
  181. domain: 'write-only-relay.example',
  182. selector: 'mh',
  183. senderHost: 'mail.write-only-relay.example',
  184. sendingIp: '127.0.0.1'
  185. }
  186. });
  187. assert.equal(domain.status, 201);
  188. const relayCreate = await requestJson(fixture.baseUrl, '/api/smtp-relays', cookie, {
  189. method: 'POST',
  190. body: {
  191. name: 'Write-only relay',
  192. host: '127.0.0.1',
  193. port: relayServer.port,
  194. secure: false,
  195. username: 'relay-user',
  196. password: 'relay-secret',
  197. helo: 'mail.write-only-relay.example',
  198. isDefault: true
  199. }
  200. });
  201. assert.equal(relayCreate.status, 201);
  202. assertSecretSummary(relayCreate.body.relay);
  203. const relayId = relayCreate.body.relay.id;
  204. (await getJson(fixture.baseUrl, '/api/smtp-relays', cookie)).body.relays.forEach(assertSecretSummary);
  205. assertSecretSummary((await getJson(fixture.baseUrl, `/api/smtp-relays/${relayId}`, cookie)).body.relay);
  206. const relayPatch = await requestJson(fixture.baseUrl, `/api/smtp-relays/${relayId}`, cookie, {
  207. method: 'PATCH',
  208. body: { name: 'Write-only relay renamed' }
  209. });
  210. assertSecretSummary(relayPatch.body.relay);
  211. const send = await requestJson(fixture.baseUrl, '/api/send', cookie, {
  212. method: 'POST',
  213. body: {
  214. from: 'noreply@write-only-relay.example',
  215. to: 'recipient@example.net',
  216. subject: 'write-only credential test',
  217. text: 'hello',
  218. smtpRelayId: relayId
  219. }
  220. });
  221. assert.equal(send.status, 202);
  222. const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
  223. assert.ok(authCommand);
  224. assert.equal(
  225. Buffer.from(authCommand.slice('AUTH PLAIN '.length), 'base64').toString('utf8'),
  226. '\0relay-user\0relay-secret'
  227. );
  228. } finally {
  229. fixture.child.kill('SIGTERM');
  230. await waitForExit(fixture.child, 1000);
  231. await relayServer.close();
  232. }
  233. });
  234. test('authentication next paths preserve safe deep links and reject open redirects', async () => {
  235. const fixture = await startTestServer();
  236. try {
  237. const target = '/domains/42?tab=dns&q=pending';
  238. const anonymous = await fetch(`${fixture.baseUrl}${target}`, { redirect: 'manual' });
  239. assert.equal(anonymous.status, 302);
  240. const loginLocation = new URL(anonymous.headers.get('location'), fixture.baseUrl);
  241. assert.equal(loginLocation.pathname, '/login');
  242. assert.equal(loginLocation.searchParams.get('next'), target);
  243. const successfulLogin = await loginResponse(fixture.baseUrl, 'admin', 'password123', target);
  244. assert.equal(successfulLogin.status, 200);
  245. const loginBody = await successfulLogin.json();
  246. assert.equal(loginBody.redirectTo, target);
  247. const cookie = sessionCookieFrom(successfulLogin);
  248. assert.ok(cookie);
  249. const authenticatedLogin = await fetch(
  250. `${fixture.baseUrl}/login?next=${encodeURIComponent(target)}`,
  251. { headers: { Cookie: cookie }, redirect: 'manual' }
  252. );
  253. assert.equal(authenticatedLogin.status, 302);
  254. assert.equal(authenticatedLogin.headers.get('location'), target);
  255. const queryUrlTarget = '/activity?q=https%3A%2F%2Fexample.com%2Fmessage';
  256. const queryUrlLogin = await loginResponse(fixture.baseUrl, 'admin', 'password123', queryUrlTarget);
  257. assert.equal(queryUrlLogin.status, 200);
  258. assert.equal((await queryUrlLogin.json()).redirectTo, queryUrlTarget);
  259. for (const unsafe of [
  260. '//evil.example/path',
  261. 'https://evil.example/path',
  262. '/\\evil.example/path',
  263. '/%2F%2Fevil.example/path',
  264. '/%5C%5Cevil.example/path',
  265. '/%0Aevil',
  266. '/api/events'
  267. ]) {
  268. const response = await loginResponse(fixture.baseUrl, 'admin', 'password123', unsafe);
  269. assert.equal(response.status, 200, unsafe);
  270. assert.equal((await response.json()).redirectTo, '/', unsafe);
  271. }
  272. const invalidAuthenticatedLogin = await fetch(
  273. `${fixture.baseUrl}/login?next=${encodeURIComponent('//evil.example/path')}`,
  274. { headers: { Cookie: cookie }, redirect: 'manual' }
  275. );
  276. assert.equal(invalidAuthenticatedLogin.headers.get('location'), '/');
  277. } finally {
  278. fixture.child.kill('SIGTERM');
  279. await waitForExit(fixture.child, 1000);
  280. }
  281. });
  282. function seedListingFixtures(dataDir, sessionSecret) {
  283. const script = `
  284. import {
  285. createDomain,
  286. createInboundMailbox,
  287. createInboundMessage,
  288. createUser,
  289. initDatabase,
  290. logSendEvent,
  291. markInboundMessageRead
  292. } from './src/db.js';
  293. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  294. const alice = createUser({ username: 'list-alice', email: 'list-alice@example.com', password: 'password123', status: 'active' });
  295. const bob = createUser({ username: 'list-bob', email: 'list-bob@example.com', password: 'password123', status: 'active' });
  296. const domain = (userId, name) => createDomain(userId, {
  297. domain: name,
  298. selector: 'mh',
  299. verificationToken: 'token-' + name,
  300. dkimPublic: 'public-' + name,
  301. dkimPrivate: 'private-' + name,
  302. senderHost: 'mail.' + name,
  303. sendingIp: '127.0.0.1',
  304. spfExtra: '',
  305. dmarcPolicy: 'none',
  306. dmarcRua: ''
  307. });
  308. const alicePrimary = domain(alice.id, 'listing-alice.example');
  309. const aliceSecondary = domain(alice.id, 'listing-alice-secondary.example');
  310. const bobDomain = domain(bob.id, 'listing-bob.example');
  311. const events = [
  312. { domainId: alicePrimary.id, status: 'sent', recipient: 'one@example.net', subject: 'First event', queueId: 'QUEUE-ALICE-1' },
  313. { domainId: alicePrimary.id, status: 'failed', recipient: 'two@example.net', subject: 'Failed event', queueId: 'QUEUE-ALICE-FAILED' },
  314. { domainId: alicePrimary.id, status: 'bounced', recipient: 'target+filter@example.net', subject: 'Recipient filter event', queueId: 'QUEUE-ALICE-3' },
  315. { domainId: aliceSecondary.id, status: 'sent', recipient: 'four@example.net', subject: 'Secondary domain event', queueId: 'QUEUE-ALICE-4' },
  316. { domainId: alicePrimary.id, status: 'sent', recipient: 'five@example.net', subject: 'Message identifier event', queueId: 'QUEUE-ALICE-5' }
  317. ];
  318. let specialEventId = null;
  319. for (const event of events) {
  320. const eventId = Number(logSendEvent({
  321. userId: alice.id,
  322. domainId: event.domainId,
  323. sender: 'noreply@listing-alice.example',
  324. recipients: [event.recipient],
  325. subject: event.subject,
  326. status: event.status,
  327. detail: event.status + ' detail',
  328. queueId: event.queueId
  329. }));
  330. if (event.subject === 'Message identifier event') specialEventId = eventId;
  331. }
  332. logSendEvent({
  333. userId: bob.id,
  334. domainId: bobDomain.id,
  335. sender: 'noreply@listing-bob.example',
  336. recipients: ['bob@example.net'],
  337. subject: 'Bob only',
  338. status: 'sent',
  339. detail: 'bob detail',
  340. queueId: 'QUEUE-BOB-ONLY'
  341. });
  342. const aliceMailbox = createInboundMailbox(alice.id, { address: 'inbox@listing-alice.example', password: 'mailbox-pass-123' });
  343. const bobMailbox = createInboundMailbox(bob.id, { address: 'inbox@listing-bob.example', password: 'mailbox-pass-123' });
  344. const createMessage = (mailbox, values) => createInboundMessage(mailbox, {
  345. sender: values.sender || 'sender@example.net',
  346. recipients: [mailbox.address],
  347. subject: values.subject,
  348. messageId: values.messageId,
  349. folder: values.folder || 'INBOX',
  350. rawMessage: 'Subject: ' + values.subject + '\\r\\n\\r\\n' + values.subject,
  351. textBody: values.subject,
  352. receivedAt: values.receivedAt
  353. });
  354. createMessage(aliceMailbox, { subject: 'Inbox unread', messageId: '<inbound-unread@example.net>', receivedAt: '2026-07-14T10:00:00.000Z' });
  355. const inboxRead = createMessage(aliceMailbox, { subject: 'Inbox read', messageId: '<inbound-read@example.net>', receivedAt: '2026-07-14T11:00:00.000Z' });
  356. markInboundMessageRead(alice.id, inboxRead.id, true);
  357. const sent = createMessage(aliceMailbox, { subject: 'Sent message', messageId: '<inbound-sent@example.net>', folder: 'Sent', receivedAt: '2026-07-14T12:00:00.000Z' });
  358. markInboundMessageRead(alice.id, sent.id, true);
  359. createMessage(aliceMailbox, { subject: 'Project message', messageId: '<inbound-project-special@example.net>', folder: 'Projects', receivedAt: '2026-07-14T13:00:00.000Z' });
  360. createMessage(bobMailbox, { subject: 'Bob private', messageId: '<inbound-bob@example.net>', receivedAt: '2026-07-14T14:00:00.000Z' });
  361. console.log(JSON.stringify({
  362. aliceSecondaryDomainId: aliceSecondary.id,
  363. aliceMailboxId: aliceMailbox.id,
  364. bobMailboxId: bobMailbox.id,
  365. specialEventId
  366. }));
  367. `;
  368. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  369. cwd: process.cwd(),
  370. env: { ...process.env, DATA_DIR: dataDir, SESSION_SECRET: sessionSecret },
  371. encoding: 'utf8'
  372. });
  373. assert.equal(result.status, 0, result.stderr || result.stdout);
  374. return JSON.parse(result.stdout);
  375. }
  376. async function startTestServer() {
  377. const port = await freePort();
  378. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-listing-api-test-'));
  379. const sessionSecret = 'listing-api-session-secret';
  380. const child = spawn(process.execPath, ['src/server.js'], {
  381. cwd: process.cwd(),
  382. env: {
  383. ...process.env,
  384. PORT: String(port),
  385. DATA_DIR: dataDir,
  386. SESSION_SECRET: sessionSecret,
  387. ADMIN_USER: 'admin',
  388. ADMIN_EMAIL: 'admin@example.com',
  389. ADMIN_PASSWORD: 'password123',
  390. DNS_AUTO_CHECK_ENABLED: 'false',
  391. DELIVERY_TRACKING_ENABLED: 'false',
  392. WEBHOOK_WORKER_ENABLED: 'false',
  393. SUBMISSION_ENABLED: 'false',
  394. IMAP_ENABLED: 'false',
  395. POP3_ENABLED: 'false'
  396. },
  397. stdio: ['ignore', 'pipe', 'pipe']
  398. });
  399. await waitForOutput(child, 'MailHub listening');
  400. return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
  401. }
  402. async function login(baseUrl, username, password) {
  403. const response = await loginResponse(baseUrl, username, password);
  404. assert.equal(response.status, 200);
  405. const cookie = sessionCookieFrom(response);
  406. assert.ok(cookie);
  407. return cookie;
  408. }
  409. function loginResponse(baseUrl, username, password, next = '') {
  410. return fetch(`${baseUrl}/api/login`, {
  411. method: 'POST',
  412. headers: { 'Content-Type': 'application/json' },
  413. body: JSON.stringify({ username, password, next })
  414. });
  415. }
  416. function sessionCookieFrom(response) {
  417. return response.headers.get('set-cookie')?.split(';')[0] || '';
  418. }
  419. async function getJson(baseUrl, pathname, cookie) {
  420. const response = await fetch(`${baseUrl}${pathname}`, { headers: { Cookie: cookie }, redirect: 'manual' });
  421. return { status: response.status, body: await response.json() };
  422. }
  423. async function requestJson(baseUrl, pathname, cookie, { method, body }) {
  424. const response = await fetch(`${baseUrl}${pathname}`, {
  425. method,
  426. headers: { 'Content-Type': 'application/json', Cookie: cookie },
  427. body: JSON.stringify(body)
  428. });
  429. return { status: response.status, body: await response.json() };
  430. }
  431. function folderSummary(folders, name) {
  432. const folder = folders.find((entry) => entry.name === name);
  433. assert.ok(folder, `expected folder ${name}`);
  434. return folder;
  435. }
  436. function assertSecretSummary(value) {
  437. assert.ok(value);
  438. assert.equal(value.passwordSet, true);
  439. assert.equal('password' in value, false);
  440. assert.equal('passwordHash' in value, false);
  441. assert.equal('passwordSecret' in value, false);
  442. assert.equal('passwordRecoverable' in value, false);
  443. }
  444. function freePort() {
  445. return new Promise((resolve, reject) => {
  446. const server = net.createServer();
  447. server.listen(0, '127.0.0.1', () => {
  448. const { port } = server.address();
  449. server.close((error) => (error ? reject(error) : resolve(port)));
  450. });
  451. server.on('error', reject);
  452. });
  453. }
  454. function waitForOutput(child, text, timeoutMs = 8000) {
  455. return new Promise((resolve, reject) => {
  456. let buffer = '';
  457. const timer = setTimeout(() => reject(new Error(`Timed out waiting for: ${text}\n${buffer}`)), timeoutMs);
  458. const onData = (chunk) => {
  459. buffer += String(chunk);
  460. if (!buffer.includes(text)) return;
  461. clearTimeout(timer);
  462. child.stdout?.off('data', onData);
  463. child.stderr?.off('data', onData);
  464. resolve();
  465. };
  466. child.stdout?.on('data', onData);
  467. child.stderr?.on('data', onData);
  468. });
  469. }
  470. function waitForExit(child, timeoutMs) {
  471. return new Promise((resolve) => {
  472. if (child.exitCode != null) return resolve(true);
  473. const timer = setTimeout(() => resolve(false), timeoutMs);
  474. child.once('exit', () => {
  475. clearTimeout(timer);
  476. resolve(true);
  477. });
  478. });
  479. }
  480. function startFakeSmtpServer() {
  481. const commands = [];
  482. const server = net.createServer((socket) => {
  483. socket.setEncoding('utf8');
  484. socket.write('220 relay.test ESMTP ready\r\n');
  485. let buffer = '';
  486. let dataMode = false;
  487. socket.on('data', (chunk) => {
  488. buffer += chunk;
  489. let index;
  490. while ((index = buffer.indexOf('\n')) !== -1) {
  491. const line = buffer.slice(0, index).replace(/\r$/, '');
  492. buffer = buffer.slice(index + 1);
  493. if (dataMode) {
  494. if (line === '.') {
  495. dataMode = false;
  496. socket.write('250 2.0.0 queued as WRITEONLY123\r\n');
  497. }
  498. continue;
  499. }
  500. commands.push(line);
  501. if (line.startsWith('EHLO')) socket.write('250-relay.test\r\n250 AUTH PLAIN\r\n');
  502. else if (line.startsWith('AUTH PLAIN')) socket.write('235 2.7.0 authenticated\r\n');
  503. else if (line.startsWith('MAIL FROM')) socket.write('250 2.1.0 ok\r\n');
  504. else if (line.startsWith('RCPT TO')) socket.write('250 2.1.5 ok\r\n');
  505. else if (line === 'DATA') {
  506. dataMode = true;
  507. socket.write('354 end data\r\n');
  508. } else if (line === 'QUIT') {
  509. socket.write('221 bye\r\n');
  510. socket.end();
  511. }
  512. }
  513. });
  514. });
  515. return new Promise((resolve, reject) => {
  516. server.once('error', reject);
  517. server.listen(0, '127.0.0.1', () => {
  518. server.off('error', reject);
  519. resolve({
  520. port: server.address().port,
  521. commands,
  522. close: () => new Promise((closeResolve) => server.close(closeResolve))
  523. });
  524. });
  525. });
  526. }