server-mailbox-access.test.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  1. import assert from 'node:assert/strict';
  2. import { spawn, spawnSync } from 'node:child_process';
  3. import { mkdtempSync } from 'node:fs';
  4. import net from 'node:net';
  5. import { tmpdir } from 'node:os';
  6. import path from 'node:path';
  7. import process from 'node:process';
  8. import { test } from 'node:test';
  9. test('mailbox grants constrain admin, session, token, and send API access', async (t) => {
  10. const smtp = await startFakeSmtpServer();
  11. const readerSmtp = await startFakeSmtpServer();
  12. const fixture = await startTestServer(smtp.port);
  13. try {
  14. const seeded = seedMailboxAccessFixtures(fixture.dataDir, fixture.sessionSecret, readerSmtp.port);
  15. const adminCookie = await login(fixture.baseUrl, 'admin', 'password123');
  16. const ownerCookie = await login(fixture.baseUrl, 'access-owner', 'password123');
  17. const readerCookie = await login(fixture.baseUrl, 'access-reader', 'password123');
  18. const viewerCookie = await login(fixture.baseUrl, 'access-viewer', 'password123');
  19. await t.test('only administrators can list and replace mailbox grants', async () => {
  20. const ordinaryList = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
  21. cookie: readerCookie
  22. });
  23. assert.equal(ordinaryList.status, 403);
  24. const ordinaryReplace = await requestJson(
  25. fixture.baseUrl,
  26. `/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`,
  27. {
  28. method: 'PUT',
  29. cookie: readerCookie,
  30. body: { grants: [] }
  31. }
  32. );
  33. assert.equal(ordinaryReplace.status, 403);
  34. const malformed = await fetch(
  35. `${fixture.baseUrl}/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`,
  36. {
  37. method: 'PUT',
  38. headers: {
  39. 'Content-Type': 'application/json',
  40. Cookie: adminCookie
  41. },
  42. body: '{"grants":'
  43. }
  44. );
  45. assert.equal(malformed.status, 400);
  46. assert.equal(typeof (await malformed.json()).error, 'string');
  47. const initial = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
  48. cookie: adminCookie
  49. });
  50. assert.equal(initial.status, 200);
  51. const initialShared = findAdminMailbox(initial.body.mailboxes, seeded.sharedMailboxId);
  52. assert.equal(initialShared.owner.id, seeded.ownerUserId);
  53. assert.deepEqual(initialShared.grants, []);
  54. const replaced = await replaceGrants(
  55. fixture.baseUrl,
  56. adminCookie,
  57. seeded.sharedMailboxId,
  58. [
  59. { userId: seeded.readerUserId, permissions: ['receive', 'send'] },
  60. { userId: seeded.viewerUserId, permissions: ['view'] }
  61. ]
  62. );
  63. assert.equal(replaced.mailbox.id, seeded.sharedMailboxId);
  64. assert.equal(replaced.owner.id, seeded.ownerUserId);
  65. assert.deepEqual(grantPermissions(replaced, seeded.readerUserId), {
  66. view: true,
  67. receive: true,
  68. send: true
  69. });
  70. assert.deepEqual(grantPermissions(replaced, seeded.viewerUserId), {
  71. view: true,
  72. receive: false,
  73. send: false
  74. });
  75. const listed = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
  76. cookie: adminCookie
  77. });
  78. const listedShared = findAdminMailbox(listed.body.mailboxes, seeded.sharedMailboxId);
  79. assert.deepEqual(grantPermissions(listedShared, seeded.readerUserId), {
  80. view: true,
  81. receive: true,
  82. send: true
  83. });
  84. });
  85. await t.test('owned, effective, and all mailbox scopes stay distinct', async () => {
  86. assert.deepEqual(
  87. await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', ownerCookie),
  88. ['other@access-owner.example', 'shared@access-owner.example']
  89. );
  90. assert.deepEqual(
  91. await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', readerCookie),
  92. ['own@access-reader.example']
  93. );
  94. assert.deepEqual(
  95. await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', readerCookie),
  96. ['own@access-reader.example', 'shared@access-owner.example']
  97. );
  98. assert.deepEqual(
  99. await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', viewerCookie),
  100. ['shared@access-owner.example']
  101. );
  102. const ordinaryAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', {
  103. cookie: readerCookie
  104. });
  105. assert.equal(ordinaryAll.status, 403);
  106. const adminAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', {
  107. cookie: adminCookie
  108. });
  109. assert.equal(adminAll.status, 200);
  110. assert.deepEqual(
  111. adminAll.body.mailboxes.map((mailbox) => mailbox.address).sort(),
  112. [
  113. 'other@access-owner.example',
  114. 'own@access-reader.example',
  115. 'shared@access-owner.example'
  116. ]
  117. );
  118. const invalid = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=unknown', {
  119. cookie: adminCookie
  120. });
  121. assert.equal(invalid.status, 400);
  122. });
  123. await t.test('receive grants allow message reads while view-only grants do not', async () => {
  124. const list = await requestJson(
  125. fixture.baseUrl,
  126. `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}&q=Shared%20grant`,
  127. { cookie: readerCookie }
  128. );
  129. assert.equal(list.status, 200);
  130. assert.equal(list.body.total, 1);
  131. assert.deepEqual(list.body.messages.map((message) => message.id), [seeded.sharedMessageId]);
  132. const detail = await requestJson(
  133. fixture.baseUrl,
  134. `/api/inbound-messages/${seeded.sharedMessageId}`,
  135. { cookie: readerCookie }
  136. );
  137. assert.equal(detail.status, 200);
  138. assert.equal(detail.body.message.subject, 'Shared grant message');
  139. const marked = await requestJson(
  140. fixture.baseUrl,
  141. `/api/inbound-messages/${seeded.sharedMessageId}`,
  142. {
  143. method: 'PATCH',
  144. cookie: readerCookie,
  145. body: { read: true }
  146. }
  147. );
  148. assert.equal(marked.status, 200);
  149. assert.equal(marked.body.message.read, true);
  150. const viewerList = await requestJson(
  151. fixture.baseUrl,
  152. `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
  153. { cookie: viewerCookie }
  154. );
  155. assert.equal(viewerList.status, 200);
  156. assert.equal(viewerList.body.total, 0);
  157. const viewerDetail = await requestJson(
  158. fixture.baseUrl,
  159. `/api/inbound-messages/${seeded.sharedMessageId}`,
  160. { cookie: viewerCookie }
  161. );
  162. assert.equal(viewerDetail.status, 404);
  163. const viewerMark = await requestJson(
  164. fixture.baseUrl,
  165. `/api/inbound-messages/${seeded.sharedMessageId}`,
  166. {
  167. method: 'PATCH',
  168. cookie: viewerCookie,
  169. body: { read: false }
  170. }
  171. );
  172. assert.equal(viewerMark.status, 404);
  173. });
  174. await t.test('Bearer owner scope cannot expand into grants and selected scope is revoked immediately', async () => {
  175. const ownerToken = await createToken(fixture.baseUrl, readerCookie, {
  176. name: 'grant owner scope',
  177. scopes: ['mailboxes:read', 'messages:read'],
  178. mailboxAccess: 'owner'
  179. });
  180. const ownerMessages = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
  181. bearer: ownerToken.token
  182. });
  183. assert.equal(ownerMessages.status, 200);
  184. assert.deepEqual(ownerMessages.body.messages.map((message) => message.id), [seeded.readerMessageId]);
  185. const ownerMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', {
  186. bearer: ownerToken.token
  187. });
  188. assert.deepEqual(ownerMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.readerMailboxId]);
  189. const selectedToken = await createToken(fixture.baseUrl, readerCookie, {
  190. name: 'grant selected scope',
  191. scopes: ['mailboxes:read', 'messages:read'],
  192. mailboxAccess: 'selected',
  193. mailboxIds: [seeded.sharedMailboxId]
  194. });
  195. const selectedMessages = await requestJson(
  196. fixture.baseUrl,
  197. `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
  198. { bearer: selectedToken.token }
  199. );
  200. assert.equal(selectedMessages.status, 200);
  201. assert.deepEqual(selectedMessages.body.messages.map((message) => message.id), [seeded.sharedMessageId]);
  202. const selectedDetail = await requestJson(
  203. fixture.baseUrl,
  204. `/api/inbound-messages/${seeded.sharedMessageId}`,
  205. { bearer: selectedToken.token }
  206. );
  207. assert.equal(selectedDetail.status, 200);
  208. await replaceGrants(
  209. fixture.baseUrl,
  210. adminCookie,
  211. seeded.sharedMailboxId,
  212. [{ userId: seeded.viewerUserId, permissions: ['view'] }]
  213. );
  214. const afterRevoke = await requestJson(
  215. fixture.baseUrl,
  216. `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
  217. { bearer: selectedToken.token }
  218. );
  219. assert.equal(afterRevoke.status, 200);
  220. assert.equal(afterRevoke.body.total, 0);
  221. const detailAfterRevoke = await requestJson(
  222. fixture.baseUrl,
  223. `/api/inbound-messages/${seeded.sharedMessageId}`,
  224. { bearer: selectedToken.token }
  225. );
  226. assert.equal(detailAfterRevoke.status, 404);
  227. });
  228. await t.test('Bearer send scope is intersected with token mailbox selection and live send grants', async () => {
  229. await replaceGrants(
  230. fixture.baseUrl,
  231. adminCookie,
  232. seeded.sharedMailboxId,
  233. [{ userId: seeded.readerUserId, permissions: ['send'] }]
  234. );
  235. const ownerToken = await createToken(fixture.baseUrl, readerCookie, {
  236. name: 'send owner scope',
  237. scopes: ['send'],
  238. mailboxAccess: 'owner'
  239. });
  240. const selectedSharedToken = await createToken(fixture.baseUrl, readerCookie, {
  241. name: 'send selected shared mailbox',
  242. scopes: ['send'],
  243. mailboxAccess: 'selected',
  244. mailboxIds: [seeded.sharedMailboxId]
  245. });
  246. const selectedOwnToken = await createToken(fixture.baseUrl, readerCookie, {
  247. name: 'send selected owned mailbox',
  248. scopes: ['send'],
  249. mailboxAccess: 'selected',
  250. mailboxIds: [seeded.readerMailboxId]
  251. });
  252. const mailFromCount = smtpMailFromCommands(smtp).length;
  253. await assertSendDenied(fixture.baseUrl, {
  254. bearer: ownerToken.token,
  255. from: 'shared@access-owner.example'
  256. });
  257. await assertSendDenied(fixture.baseUrl, {
  258. bearer: selectedOwnToken.token,
  259. from: 'shared@access-owner.example'
  260. });
  261. const selectedSend = await sendApiMessage(fixture.baseUrl, {
  262. bearer: selectedSharedToken.token,
  263. from: 'shared@access-owner.example',
  264. subject: 'Selected token mailbox grant send'
  265. });
  266. assert.equal(selectedSend.status, 202, JSON.stringify(selectedSend.body));
  267. assert.equal(selectedSend.body.queued, true);
  268. assert.deepEqual(
  269. smtpMailFromCommands(smtp).slice(mailFromCount),
  270. ['MAIL FROM:<shared@access-owner.example>']
  271. );
  272. await replaceGrants(fixture.baseUrl, adminCookie, seeded.sharedMailboxId, []);
  273. await assertSendDenied(fixture.baseUrl, {
  274. bearer: selectedSharedToken.token,
  275. from: 'shared@access-owner.example'
  276. });
  277. assert.equal(smtpMailFromCommands(smtp).length, mailFromCount + 1);
  278. });
  279. await t.test('send grants authorize only the exact mailbox address and aliases', async () => {
  280. await replaceGrants(
  281. fixture.baseUrl,
  282. adminCookie,
  283. seeded.sharedMailboxId,
  284. [
  285. { userId: seeded.readerUserId, permissions: ['send'] },
  286. { userId: seeded.viewerUserId, permissions: ['view'] }
  287. ]
  288. );
  289. const mailFromCount = smtpMailFromCommands(smtp).length;
  290. const readerRelayMailFromCount = smtpMailFromCommands(readerSmtp).length;
  291. const forbiddenRelay = await sendApiMessage(fixture.baseUrl, {
  292. cookie: readerCookie,
  293. from: 'shared@access-owner.example',
  294. subject: 'Shared mailbox cannot use grantee relay',
  295. smtpRelayId: seeded.readerRelayId
  296. });
  297. assert.equal(forbiddenRelay.status, 403, JSON.stringify(forbiddenRelay.body));
  298. assert.match(forbiddenRelay.body.error, /域名所有者.*SMTP 出口/);
  299. assert.equal(smtpMailFromCommands(smtp).length, mailFromCount);
  300. assert.equal(smtpMailFromCommands(readerSmtp).length, readerRelayMailFromCount);
  301. for (const from of ['shared@access-owner.example', 'shared-alias@access-owner.example']) {
  302. const sent = await sendApiMessage(fixture.baseUrl, {
  303. cookie: readerCookie,
  304. from,
  305. subject: `Mailbox grant send from ${from}`
  306. });
  307. assert.equal(sent.status, 202, JSON.stringify(sent.body));
  308. assert.equal(sent.body.queued, true);
  309. }
  310. await assertSendDenied(fixture.baseUrl, {
  311. cookie: readerCookie,
  312. from: 'other@access-owner.example'
  313. });
  314. assert.deepEqual(
  315. smtpMailFromCommands(smtp).slice(mailFromCount),
  316. [
  317. 'MAIL FROM:<shared@access-owner.example>',
  318. 'MAIL FROM:<shared-alias@access-owner.example>'
  319. ]
  320. );
  321. });
  322. } finally {
  323. fixture.child.kill('SIGTERM');
  324. await waitForExit(fixture.child, 1000);
  325. await smtp.close();
  326. await readerSmtp.close();
  327. }
  328. });
  329. function seedMailboxAccessFixtures(dataDir, sessionSecret, readerRelayPort) {
  330. const script = `
  331. import {
  332. createDomain,
  333. createInboundMailbox,
  334. createInboundMessage,
  335. createUser,
  336. initDatabase,
  337. saveSmtpRelay
  338. } from './src/db.js';
  339. import { createDkimKeyPair } from './src/dkim.js';
  340. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  341. const owner = createUser({
  342. username: 'access-owner',
  343. email: 'access-owner@example.test',
  344. password: 'password123',
  345. status: 'active'
  346. });
  347. const reader = createUser({
  348. username: 'access-reader',
  349. email: 'access-reader@example.test',
  350. password: 'password123',
  351. status: 'active'
  352. });
  353. const viewer = createUser({
  354. username: 'access-viewer',
  355. email: 'access-viewer@example.test',
  356. password: 'password123',
  357. status: 'active'
  358. });
  359. const readerRelay = saveSmtpRelay(reader.id, {
  360. name: 'Reader private relay',
  361. host: '127.0.0.1',
  362. port: Number(process.env.READER_RELAY_PORT),
  363. secure: false,
  364. username: '',
  365. password: '',
  366. helo: 'mail.access-reader.example'
  367. });
  368. const keys = createDkimKeyPair();
  369. const createUserDomain = (user, name) => createDomain(user.id, {
  370. domain: name,
  371. selector: 'mh',
  372. verificationToken: 'verify-' + name,
  373. dkimPublic: keys.publicKey,
  374. dkimPrivate: keys.privateKey,
  375. senderHost: 'mail.' + name,
  376. sendingIp: '127.0.0.1',
  377. spfExtra: '',
  378. dmarcPolicy: 'none',
  379. dmarcRua: ''
  380. });
  381. createUserDomain(owner, 'access-owner.example');
  382. createUserDomain(reader, 'access-reader.example');
  383. const sharedMailbox = createInboundMailbox(owner.id, {
  384. address: 'shared@access-owner.example',
  385. password: 'mailbox-password',
  386. aliases: ['shared-alias']
  387. });
  388. const otherMailbox = createInboundMailbox(owner.id, {
  389. address: 'other@access-owner.example',
  390. password: 'mailbox-password'
  391. });
  392. const readerMailbox = createInboundMailbox(reader.id, {
  393. address: 'own@access-reader.example',
  394. password: 'mailbox-password'
  395. });
  396. const createMessage = (mailbox, subject, sequence) => createInboundMessage(mailbox, {
  397. sender: 'sender@example.net',
  398. recipients: [mailbox.address],
  399. subject,
  400. messageId: '<mailbox-access-' + sequence + '@example.net>',
  401. rawMessage: 'Subject: ' + subject + '\\r\\n\\r\\n' + subject,
  402. textBody: subject,
  403. receivedAt: '2026-07-18T0' + sequence + ':00:00.000Z'
  404. });
  405. const sharedMessage = createMessage(sharedMailbox, 'Shared grant message', 1);
  406. createMessage(otherMailbox, 'Other owner message', 2);
  407. const readerMessage = createMessage(readerMailbox, 'Reader owned message', 3);
  408. console.log(JSON.stringify({
  409. ownerUserId: owner.id,
  410. readerUserId: reader.id,
  411. viewerUserId: viewer.id,
  412. sharedMailboxId: sharedMailbox.id,
  413. otherMailboxId: otherMailbox.id,
  414. readerMailboxId: readerMailbox.id,
  415. readerRelayId: readerRelay.id,
  416. sharedMessageId: sharedMessage.id,
  417. readerMessageId: readerMessage.id
  418. }));
  419. `;
  420. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  421. cwd: process.cwd(),
  422. env: {
  423. ...process.env,
  424. DATA_DIR: dataDir,
  425. SESSION_SECRET: sessionSecret,
  426. READER_RELAY_PORT: String(readerRelayPort)
  427. },
  428. encoding: 'utf8'
  429. });
  430. assert.equal(result.status, 0, result.stderr || result.stdout);
  431. return JSON.parse(result.stdout);
  432. }
  433. async function replaceGrants(baseUrl, adminCookie, mailboxId, grants) {
  434. const response = await requestJson(
  435. baseUrl,
  436. `/api/admin/inbound-mailboxes/${mailboxId}/access`,
  437. {
  438. method: 'PUT',
  439. cookie: adminCookie,
  440. body: { grants }
  441. }
  442. );
  443. assert.equal(response.status, 200, JSON.stringify(response.body));
  444. return response.body.mailbox;
  445. }
  446. function findAdminMailbox(mailboxes, mailboxId) {
  447. const mailbox = mailboxes.find((entry) => entry.mailbox.id === mailboxId);
  448. assert.ok(mailbox, `expected admin mailbox ${mailboxId}`);
  449. return mailbox;
  450. }
  451. function grantPermissions(mailboxAccess, userId) {
  452. const grant = mailboxAccess.grants.find((entry) => entry.user.id === userId);
  453. assert.ok(grant, `expected grant for user ${userId}`);
  454. return grant.permissions;
  455. }
  456. async function mailboxAddresses(baseUrl, pathname, cookie) {
  457. const response = await requestJson(baseUrl, pathname, { cookie });
  458. assert.equal(response.status, 200, JSON.stringify(response.body));
  459. return response.body.mailboxes.map((mailbox) => mailbox.address).sort();
  460. }
  461. async function createToken(baseUrl, cookie, input) {
  462. const response = await requestJson(baseUrl, '/api/api-tokens', {
  463. method: 'POST',
  464. cookie,
  465. body: input
  466. });
  467. assert.equal(response.status, 201, JSON.stringify(response.body));
  468. assert.ok(response.body.token.token);
  469. return response.body.token;
  470. }
  471. function sendApiMessage(baseUrl, {
  472. cookie = '',
  473. bearer = '',
  474. from,
  475. subject = 'Mailbox grant send',
  476. smtpRelayId = null
  477. }) {
  478. return requestJson(baseUrl, '/api/send', {
  479. method: 'POST',
  480. cookie,
  481. bearer,
  482. body: {
  483. from,
  484. to: 'recipient@example.net',
  485. subject,
  486. text: 'mailbox grant send test',
  487. ...(smtpRelayId ? { smtpRelayId } : {})
  488. }
  489. });
  490. }
  491. async function assertSendDenied(baseUrl, input) {
  492. const response = await sendApiMessage(baseUrl, {
  493. ...input,
  494. subject: 'Mailbox grant denial'
  495. });
  496. assert.equal(response.status, 403, JSON.stringify(response.body));
  497. assert.match(response.body.error, /未获得发信权限/);
  498. }
  499. function smtpMailFromCommands(smtp) {
  500. return smtp.commands.filter((command) => command.startsWith('MAIL FROM:'));
  501. }
  502. async function requestJson(baseUrl, pathname, {
  503. method = 'GET',
  504. cookie = '',
  505. bearer = '',
  506. body
  507. } = {}) {
  508. const headers = {};
  509. if (cookie) headers.Cookie = cookie;
  510. if (bearer) headers.Authorization = `Bearer ${bearer}`;
  511. if (body !== undefined) headers['Content-Type'] = 'application/json';
  512. const response = await fetch(`${baseUrl}${pathname}`, {
  513. method,
  514. headers,
  515. body: body === undefined ? undefined : JSON.stringify(body),
  516. redirect: 'manual'
  517. });
  518. const text = await response.text();
  519. return {
  520. status: response.status,
  521. body: text ? JSON.parse(text) : null
  522. };
  523. }
  524. async function login(baseUrl, username, password) {
  525. const response = await fetch(`${baseUrl}/api/login`, {
  526. method: 'POST',
  527. headers: { 'Content-Type': 'application/json' },
  528. body: JSON.stringify({ username, password })
  529. });
  530. assert.equal(response.status, 200);
  531. const cookie = response.headers.get('set-cookie')?.split(';')[0] || '';
  532. assert.ok(cookie);
  533. return cookie;
  534. }
  535. async function startTestServer(smtpPort) {
  536. const port = await freePort();
  537. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-access-'));
  538. const sessionSecret = 'mailbox-access-session-secret';
  539. const child = spawn(process.execPath, ['src/server.js'], {
  540. cwd: process.cwd(),
  541. env: {
  542. ...process.env,
  543. PORT: String(port),
  544. DATA_DIR: dataDir,
  545. SESSION_SECRET: sessionSecret,
  546. ADMIN_USER: 'admin',
  547. ADMIN_EMAIL: 'admin@example.test',
  548. ADMIN_PASSWORD: 'password123',
  549. DNS_AUTO_CHECK_ENABLED: 'false',
  550. DELIVERY_TRACKING_ENABLED: 'false',
  551. WEBHOOK_WORKER_ENABLED: 'false',
  552. SUBMISSION_ENABLED: 'false',
  553. IMAP_ENABLED: 'false',
  554. POP3_ENABLED: 'false',
  555. SMTP_HOST: '127.0.0.1',
  556. SMTP_PORT: String(smtpPort),
  557. SMTP_SECURE: 'false',
  558. SMTP_HELO: 'mailhub-access.test'
  559. },
  560. stdio: ['ignore', 'pipe', 'pipe']
  561. });
  562. await waitForOutput(child, 'MailHub listening');
  563. return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
  564. }
  565. function startFakeSmtpServer() {
  566. const commands = [];
  567. const server = net.createServer((socket) => {
  568. socket.setEncoding('utf8');
  569. socket.write('220 relay.test ESMTP ready\r\n');
  570. let buffer = '';
  571. let dataMode = false;
  572. socket.on('data', (chunk) => {
  573. buffer += chunk;
  574. let index;
  575. while ((index = buffer.indexOf('\n')) !== -1) {
  576. const line = buffer.slice(0, index).replace(/\r$/, '');
  577. buffer = buffer.slice(index + 1);
  578. if (dataMode) {
  579. if (line === '.') {
  580. dataMode = false;
  581. socket.write('250 2.0.0 queued as ACCESS123\r\n');
  582. }
  583. continue;
  584. }
  585. commands.push(line);
  586. if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
  587. else if (line.startsWith('MAIL FROM:')) socket.write('250 2.1.0 ok\r\n');
  588. else if (line.startsWith('RCPT TO:')) socket.write('250 2.1.5 ok\r\n');
  589. else if (line === 'DATA') {
  590. dataMode = true;
  591. socket.write('354 end data\r\n');
  592. } else if (line === 'QUIT') {
  593. socket.write('221 bye\r\n');
  594. socket.end();
  595. }
  596. }
  597. });
  598. });
  599. return new Promise((resolve, reject) => {
  600. server.once('error', reject);
  601. server.listen(0, '127.0.0.1', () => {
  602. server.off('error', reject);
  603. resolve({
  604. port: server.address().port,
  605. commands,
  606. close: () => new Promise((closeResolve) => server.close(closeResolve))
  607. });
  608. });
  609. });
  610. }
  611. function freePort() {
  612. return new Promise((resolve, reject) => {
  613. const server = net.createServer();
  614. server.listen(0, '127.0.0.1', () => {
  615. const address = server.address();
  616. server.close(() => {
  617. if (address && typeof address === 'object') resolve(address.port);
  618. else reject(new Error('Unable to allocate a test port.'));
  619. });
  620. });
  621. server.on('error', reject);
  622. });
  623. }
  624. function waitForOutput(child, text, timeoutMs = 8000) {
  625. return new Promise((resolve, reject) => {
  626. let output = '';
  627. const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}\n${output}`)), timeoutMs);
  628. const onData = (chunk) => {
  629. output += chunk.toString();
  630. if (!output.includes(text)) return;
  631. clearTimeout(timeout);
  632. child.stdout.off('data', onData);
  633. child.stderr.off('data', onData);
  634. resolve();
  635. };
  636. child.stdout.on('data', onData);
  637. child.stderr.on('data', onData);
  638. child.once('exit', (code) => {
  639. clearTimeout(timeout);
  640. reject(new Error(`Server exited before startup with code ${code}\n${output}`));
  641. });
  642. });
  643. }
  644. function waitForExit(child, timeoutMs) {
  645. if (child.exitCode !== null) return Promise.resolve(child.exitCode);
  646. return new Promise((resolve) => {
  647. const timeout = setTimeout(() => resolve(null), timeoutMs);
  648. child.once('exit', (code) => {
  649. clearTimeout(timeout);
  650. resolve(code);
  651. });
  652. });
  653. }