server-mailbox-access.test.js 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995
  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 { DatabaseSync } from 'node:sqlite';
  9. import { test } from 'node:test';
  10. test('mailbox grants constrain admin, session, token, and send API access', async (t) => {
  11. const smtp = await startFakeSmtpServer();
  12. const readerSmtp = await startFakeSmtpServer();
  13. const fixture = await startTestServer(smtp.port);
  14. try {
  15. const seeded = seedMailboxAccessFixtures(fixture.dataDir, fixture.sessionSecret, readerSmtp.port);
  16. const adminCookie = await login(fixture.baseUrl, 'admin', 'password123');
  17. const ownerCookie = await login(fixture.baseUrl, 'access-owner', 'password123');
  18. const readerCookie = await login(fixture.baseUrl, 'access-reader', 'password123');
  19. const viewerCookie = await login(fixture.baseUrl, 'access-viewer', 'password123');
  20. await t.test('only administrators can list and replace mailbox grants', async () => {
  21. const ordinaryList = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
  22. cookie: readerCookie
  23. });
  24. assert.equal(ordinaryList.status, 403);
  25. const ordinaryReplace = await requestJson(
  26. fixture.baseUrl,
  27. `/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`,
  28. {
  29. method: 'PUT',
  30. cookie: readerCookie,
  31. body: { grants: [] }
  32. }
  33. );
  34. assert.equal(ordinaryReplace.status, 403);
  35. const malformed = await fetch(
  36. `${fixture.baseUrl}/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`,
  37. {
  38. method: 'PUT',
  39. headers: {
  40. 'Content-Type': 'application/json',
  41. Cookie: adminCookie
  42. },
  43. body: '{"grants":'
  44. }
  45. );
  46. assert.equal(malformed.status, 400);
  47. assert.equal(typeof (await malformed.json()).error, 'string');
  48. const initial = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
  49. cookie: adminCookie
  50. });
  51. assert.equal(initial.status, 200);
  52. const initialShared = findAdminMailbox(initial.body.mailboxes, seeded.sharedMailboxId);
  53. assert.equal(initialShared.owner.id, seeded.ownerUserId);
  54. assert.deepEqual(initialShared.grants, []);
  55. const replaced = await replaceGrants(
  56. fixture.baseUrl,
  57. adminCookie,
  58. seeded.sharedMailboxId,
  59. [
  60. { userId: seeded.readerUserId, permissions: ['receive', 'send'] },
  61. { userId: seeded.viewerUserId, permissions: ['view'] }
  62. ]
  63. );
  64. assert.equal(replaced.mailbox.id, seeded.sharedMailboxId);
  65. assert.equal(replaced.owner.id, seeded.ownerUserId);
  66. assert.deepEqual(grantPermissions(replaced, seeded.readerUserId), {
  67. view: true,
  68. receive: true,
  69. send: true
  70. });
  71. assert.deepEqual(grantPermissions(replaced, seeded.viewerUserId), {
  72. view: true,
  73. receive: false,
  74. send: false
  75. });
  76. const listed = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
  77. cookie: adminCookie
  78. });
  79. const listedShared = findAdminMailbox(listed.body.mailboxes, seeded.sharedMailboxId);
  80. assert.deepEqual(grantPermissions(listedShared, seeded.readerUserId), {
  81. view: true,
  82. receive: true,
  83. send: true
  84. });
  85. });
  86. await t.test('administrators can update selected mailbox grants in bulk without replacing unrelated access', async () => {
  87. const ordinaryBulk = await bulkUpdateGrants(fixture.baseUrl, readerCookie, {
  88. mailboxIds: [seeded.sharedMailboxId],
  89. userIds: [seeded.viewerUserId],
  90. operation: 'upsert',
  91. permissions: { view: true, receive: false, send: false }
  92. });
  93. assert.equal(ordinaryBulk.status, 403);
  94. await replaceGrants(
  95. fixture.baseUrl,
  96. adminCookie,
  97. seeded.readerMailboxId,
  98. [{ userId: seeded.ownerUserId, permissions: ['view'] }]
  99. );
  100. const updated = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
  101. mailboxIds: [seeded.sharedMailboxId, seeded.otherMailboxId, seeded.readerMailboxId],
  102. userIds: [seeded.readerUserId, seeded.viewerUserId],
  103. operation: 'upsert',
  104. permissions: { view: false, receive: true, send: false }
  105. });
  106. assert.equal(updated.status, 200, JSON.stringify(updated.body));
  107. assert.deepEqual(updated.body.summary, {
  108. mailboxCount: 3,
  109. userCount: 2,
  110. pairCount: 6,
  111. changedGrantCount: 5,
  112. unchangedGrantCount: 0,
  113. skippedOwnerCount: 1,
  114. revokedWebmailSessionCount: 0
  115. });
  116. assert.deepEqual(
  117. updated.body.mailboxes.map((entry) => entry.mailbox.id),
  118. [seeded.sharedMailboxId, seeded.otherMailboxId, seeded.readerMailboxId]
  119. );
  120. for (const mailboxId of [seeded.sharedMailboxId, seeded.otherMailboxId]) {
  121. const entry = findAdminMailbox(updated.body.mailboxes, mailboxId);
  122. for (const userId of [seeded.readerUserId, seeded.viewerUserId]) {
  123. assert.deepEqual(grantPermissions(entry, userId), {
  124. view: true,
  125. receive: true,
  126. send: false
  127. });
  128. }
  129. }
  130. const readerMailbox = findAdminMailbox(updated.body.mailboxes, seeded.readerMailboxId);
  131. assert.deepEqual(grantPermissions(readerMailbox, seeded.ownerUserId), {
  132. view: true,
  133. receive: false,
  134. send: false
  135. });
  136. assert.equal(
  137. readerMailbox.grants.some((grant) => grant.user.id === seeded.readerUserId),
  138. false
  139. );
  140. assert.deepEqual(grantPermissions(readerMailbox, seeded.viewerUserId), {
  141. view: true,
  142. receive: true,
  143. send: false
  144. });
  145. const audit = await requestJson(
  146. fixture.baseUrl,
  147. '/api/admin/audit-logs?action=admin.bulk_update_mailbox_access',
  148. { cookie: adminCookie }
  149. );
  150. assert.equal(audit.status, 200);
  151. assert.equal(audit.body.logs.length, 1);
  152. assert.equal(audit.body.logs[0].summary.operation, 'upsert');
  153. assert.deepEqual(audit.body.logs[0].summary.mailboxIds, [
  154. seeded.sharedMailboxId,
  155. seeded.otherMailboxId,
  156. seeded.readerMailboxId
  157. ]);
  158. const unchanged = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
  159. mailboxIds: [seeded.otherMailboxId],
  160. userIds: [seeded.viewerUserId],
  161. operation: 'upsert',
  162. permissions: { view: false, receive: true, send: false }
  163. });
  164. assert.equal(unchanged.status, 200, JSON.stringify(unchanged.body));
  165. assert.equal(unchanged.body.summary.changedGrantCount, 0);
  166. assert.equal(unchanged.body.summary.unchangedGrantCount, 1);
  167. const loweredSessionId = insertPendingWebmailSession(
  168. fixture.dataDir,
  169. seeded.readerUserId,
  170. seeded.sharedMailboxId
  171. );
  172. const lowered = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
  173. mailboxIds: [seeded.sharedMailboxId],
  174. userIds: [seeded.readerUserId],
  175. operation: 'upsert',
  176. permissions: { view: false, receive: false, send: true }
  177. });
  178. assert.equal(lowered.status, 200, JSON.stringify(lowered.body));
  179. assert.equal(lowered.body.summary.changedGrantCount, 1);
  180. assert.equal(lowered.body.summary.revokedWebmailSessionCount, 1);
  181. assert.ok(webmailSessionRevokedAt(fixture.dataDir, loweredSessionId));
  182. const restoredReceive = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
  183. mailboxIds: [seeded.sharedMailboxId],
  184. userIds: [seeded.readerUserId],
  185. operation: 'upsert',
  186. permissions: { view: false, receive: true, send: false }
  187. });
  188. assert.equal(restoredReceive.status, 200, JSON.stringify(restoredReceive.body));
  189. const removedSessionId = insertPendingWebmailSession(
  190. fixture.dataDir,
  191. seeded.readerUserId,
  192. seeded.sharedMailboxId
  193. );
  194. const removed = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
  195. mailboxIds: [seeded.sharedMailboxId],
  196. userIds: [seeded.readerUserId],
  197. operation: 'remove'
  198. });
  199. assert.equal(removed.status, 200, JSON.stringify(removed.body));
  200. assert.equal(removed.body.summary.changedGrantCount, 1);
  201. assert.equal(removed.body.summary.revokedWebmailSessionCount, 1);
  202. assert.ok(webmailSessionRevokedAt(fixture.dataDir, removedSessionId));
  203. assert.deepEqual(
  204. grantPermissions(findAdminMailbox(removed.body.mailboxes, seeded.sharedMailboxId), seeded.viewerUserId),
  205. { view: true, receive: true, send: false }
  206. );
  207. const beforeRejectedBatch = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
  208. cookie: adminCookie
  209. });
  210. const beforeViewerPermissions = grantPermissions(
  211. findAdminMailbox(beforeRejectedBatch.body.mailboxes, seeded.sharedMailboxId),
  212. seeded.viewerUserId
  213. );
  214. const deletedMailboxBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
  215. mailboxIds: [seeded.sharedMailboxId, seeded.deletedMailboxId],
  216. userIds: [seeded.viewerUserId],
  217. operation: 'upsert',
  218. permissions: { view: true, receive: false, send: true }
  219. });
  220. assert.equal(deletedMailboxBatch.status, 404);
  221. assert.equal(deletedMailboxBatch.body.code, 'BULK_MAILBOX_ACCESS_MAILBOX_NOT_FOUND');
  222. const disabledUserUpsert = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
  223. mailboxIds: [seeded.sharedMailboxId],
  224. userIds: [seeded.disabledUserId],
  225. operation: 'upsert',
  226. permissions: { view: true, receive: false, send: false }
  227. });
  228. assert.equal(disabledUserUpsert.status, 409);
  229. assert.equal(disabledUserUpsert.body.code, 'BULK_MAILBOX_ACCESS_USER_DISABLED');
  230. const disabledUserRemoval = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
  231. mailboxIds: [seeded.otherMailboxId],
  232. userIds: [seeded.disabledUserId],
  233. operation: 'remove'
  234. });
  235. assert.equal(disabledUserRemoval.status, 200, JSON.stringify(disabledUserRemoval.body));
  236. assert.equal(disabledUserRemoval.body.summary.changedGrantCount, 1);
  237. assert.equal(
  238. findAdminMailbox(disabledUserRemoval.body.mailboxes, seeded.otherMailboxId)
  239. .grants.some((grant) => grant.user.id === seeded.disabledUserId),
  240. false
  241. );
  242. const missingUserBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
  243. mailboxIds: [seeded.sharedMailboxId],
  244. userIds: [seeded.viewerUserId, 999_999],
  245. operation: 'remove'
  246. });
  247. assert.equal(missingUserBatch.status, 404);
  248. assert.equal(missingUserBatch.body.code, 'BULK_MAILBOX_ACCESS_USER_NOT_FOUND');
  249. const oversizedBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
  250. mailboxIds: Array.from({ length: 501 }, (_value, index) => index + 1),
  251. userIds: [seeded.viewerUserId],
  252. operation: 'remove'
  253. });
  254. assert.equal(oversizedBatch.status, 400);
  255. assert.equal(oversizedBatch.body.code, 'BULK_MAILBOX_ACCESS_LIMIT_EXCEEDED');
  256. const excessiveCombinationBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
  257. mailboxIds: Array.from({ length: 101 }, (_value, index) => index + 1),
  258. userIds: Array.from({ length: 50 }, (_value, index) => index + 1_000),
  259. operation: 'remove'
  260. });
  261. assert.equal(excessiveCombinationBatch.status, 400);
  262. assert.equal(excessiveCombinationBatch.body.code, 'BULK_MAILBOX_ACCESS_LIMIT_EXCEEDED');
  263. const afterRejectedBatch = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
  264. cookie: adminCookie
  265. });
  266. assert.deepEqual(
  267. grantPermissions(
  268. findAdminMailbox(afterRejectedBatch.body.mailboxes, seeded.sharedMailboxId),
  269. seeded.viewerUserId
  270. ),
  271. beforeViewerPermissions
  272. );
  273. await replaceGrants(
  274. fixture.baseUrl,
  275. adminCookie,
  276. seeded.sharedMailboxId,
  277. [
  278. { userId: seeded.readerUserId, permissions: ['receive', 'send'] },
  279. { userId: seeded.viewerUserId, permissions: ['view'] }
  280. ]
  281. );
  282. await replaceGrants(fixture.baseUrl, adminCookie, seeded.otherMailboxId, []);
  283. await replaceGrants(fixture.baseUrl, adminCookie, seeded.readerMailboxId, []);
  284. });
  285. await t.test('owned, effective, and all mailbox scopes stay distinct', async () => {
  286. assert.deepEqual(
  287. await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', ownerCookie),
  288. ['other@access-owner.example', 'shared@access-owner.example']
  289. );
  290. assert.deepEqual(
  291. await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', readerCookie),
  292. ['own@access-reader.example']
  293. );
  294. assert.deepEqual(
  295. await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', readerCookie),
  296. ['own@access-reader.example', 'shared@access-owner.example']
  297. );
  298. assert.deepEqual(
  299. await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', viewerCookie),
  300. ['shared@access-owner.example']
  301. );
  302. const ordinaryAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', {
  303. cookie: readerCookie
  304. });
  305. assert.equal(ordinaryAll.status, 403);
  306. const adminAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', {
  307. cookie: adminCookie
  308. });
  309. assert.equal(adminAll.status, 200);
  310. assert.deepEqual(
  311. adminAll.body.mailboxes.map((mailbox) => mailbox.address).sort(),
  312. [
  313. 'other@access-owner.example',
  314. 'own@access-reader.example',
  315. 'shared@access-owner.example'
  316. ]
  317. );
  318. const invalid = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=unknown', {
  319. cookie: adminCookie
  320. });
  321. assert.equal(invalid.status, 400);
  322. });
  323. await t.test('receive grants allow message reads while view-only grants do not', async () => {
  324. const list = await requestJson(
  325. fixture.baseUrl,
  326. `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}&q=Shared%20grant`,
  327. { cookie: readerCookie }
  328. );
  329. assert.equal(list.status, 200);
  330. assert.equal(list.body.total, 1);
  331. assert.deepEqual(list.body.messages.map((message) => message.id), [seeded.sharedMessageId]);
  332. const detail = await requestJson(
  333. fixture.baseUrl,
  334. `/api/inbound-messages/${seeded.sharedMessageId}`,
  335. { cookie: readerCookie }
  336. );
  337. assert.equal(detail.status, 200);
  338. assert.equal(detail.body.message.subject, 'Shared grant message');
  339. const marked = await requestJson(
  340. fixture.baseUrl,
  341. `/api/inbound-messages/${seeded.sharedMessageId}`,
  342. {
  343. method: 'PATCH',
  344. cookie: readerCookie,
  345. body: { read: true }
  346. }
  347. );
  348. assert.equal(marked.status, 200);
  349. assert.equal(marked.body.message.read, true);
  350. const viewerList = await requestJson(
  351. fixture.baseUrl,
  352. `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
  353. { cookie: viewerCookie }
  354. );
  355. assert.equal(viewerList.status, 200);
  356. assert.equal(viewerList.body.total, 0);
  357. const viewerDetail = await requestJson(
  358. fixture.baseUrl,
  359. `/api/inbound-messages/${seeded.sharedMessageId}`,
  360. { cookie: viewerCookie }
  361. );
  362. assert.equal(viewerDetail.status, 404);
  363. const viewerMark = await requestJson(
  364. fixture.baseUrl,
  365. `/api/inbound-messages/${seeded.sharedMessageId}`,
  366. {
  367. method: 'PATCH',
  368. cookie: viewerCookie,
  369. body: { read: false }
  370. }
  371. );
  372. assert.equal(viewerMark.status, 404);
  373. });
  374. await t.test('Bearer owner scope cannot expand into grants and selected scope is revoked immediately', async () => {
  375. const ownerToken = await createToken(fixture.baseUrl, readerCookie, {
  376. name: 'grant owner scope',
  377. scopes: ['mailboxes:read', 'messages:read'],
  378. mailboxAccess: 'owner'
  379. });
  380. const ownerMessages = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
  381. bearer: ownerToken.token
  382. });
  383. assert.equal(ownerMessages.status, 200);
  384. assert.deepEqual(ownerMessages.body.messages.map((message) => message.id), [seeded.readerMessageId]);
  385. const ownerMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', {
  386. bearer: ownerToken.token
  387. });
  388. assert.deepEqual(ownerMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.readerMailboxId]);
  389. const selectedToken = await createToken(fixture.baseUrl, readerCookie, {
  390. name: 'grant selected scope',
  391. scopes: ['mailboxes:read', 'messages:read'],
  392. mailboxAccess: 'selected',
  393. mailboxIds: [seeded.sharedMailboxId]
  394. });
  395. const selectedMessages = await requestJson(
  396. fixture.baseUrl,
  397. `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
  398. { bearer: selectedToken.token }
  399. );
  400. assert.equal(selectedMessages.status, 200);
  401. assert.deepEqual(selectedMessages.body.messages.map((message) => message.id), [seeded.sharedMessageId]);
  402. const selectedDetail = await requestJson(
  403. fixture.baseUrl,
  404. `/api/inbound-messages/${seeded.sharedMessageId}`,
  405. { bearer: selectedToken.token }
  406. );
  407. assert.equal(selectedDetail.status, 200);
  408. await replaceGrants(
  409. fixture.baseUrl,
  410. adminCookie,
  411. seeded.sharedMailboxId,
  412. [{ userId: seeded.viewerUserId, permissions: ['view'] }]
  413. );
  414. const afterRevoke = await requestJson(
  415. fixture.baseUrl,
  416. `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
  417. { bearer: selectedToken.token }
  418. );
  419. assert.equal(afterRevoke.status, 200);
  420. assert.equal(afterRevoke.body.total, 0);
  421. const detailAfterRevoke = await requestJson(
  422. fixture.baseUrl,
  423. `/api/inbound-messages/${seeded.sharedMessageId}`,
  424. { bearer: selectedToken.token }
  425. );
  426. assert.equal(detailAfterRevoke.status, 404);
  427. });
  428. await t.test('Bearer send scope is intersected with token mailbox selection and live send grants', async () => {
  429. await replaceGrants(
  430. fixture.baseUrl,
  431. adminCookie,
  432. seeded.sharedMailboxId,
  433. [{ userId: seeded.readerUserId, permissions: ['send'] }]
  434. );
  435. const ownerToken = await createToken(fixture.baseUrl, readerCookie, {
  436. name: 'send owner scope',
  437. scopes: ['send'],
  438. mailboxAccess: 'owner'
  439. });
  440. const selectedSharedToken = await createToken(fixture.baseUrl, readerCookie, {
  441. name: 'send selected shared mailbox',
  442. scopes: ['send'],
  443. mailboxAccess: 'selected',
  444. mailboxIds: [seeded.sharedMailboxId]
  445. });
  446. const selectedOwnToken = await createToken(fixture.baseUrl, readerCookie, {
  447. name: 'send selected owned mailbox',
  448. scopes: ['send'],
  449. mailboxAccess: 'selected',
  450. mailboxIds: [seeded.readerMailboxId]
  451. });
  452. const mailFromCount = smtpMailFromCommands(smtp).length;
  453. await assertSendDenied(fixture.baseUrl, {
  454. bearer: ownerToken.token,
  455. from: 'shared@access-owner.example'
  456. });
  457. await assertSendDenied(fixture.baseUrl, {
  458. bearer: selectedOwnToken.token,
  459. from: 'shared@access-owner.example'
  460. });
  461. const selectedSend = await sendApiMessage(fixture.baseUrl, {
  462. bearer: selectedSharedToken.token,
  463. from: 'shared@access-owner.example',
  464. subject: 'Selected token mailbox grant send'
  465. });
  466. assert.equal(selectedSend.status, 202, JSON.stringify(selectedSend.body));
  467. assert.equal(selectedSend.body.queued, true);
  468. assert.deepEqual(
  469. smtpMailFromCommands(smtp).slice(mailFromCount),
  470. ['MAIL FROM:<shared@access-owner.example>']
  471. );
  472. await replaceGrants(fixture.baseUrl, adminCookie, seeded.sharedMailboxId, []);
  473. await assertSendDenied(fixture.baseUrl, {
  474. bearer: selectedSharedToken.token,
  475. from: 'shared@access-owner.example'
  476. });
  477. assert.equal(smtpMailFromCommands(smtp).length, mailFromCount + 1);
  478. });
  479. await t.test('send grants authorize only the exact mailbox address and aliases', async () => {
  480. await replaceGrants(
  481. fixture.baseUrl,
  482. adminCookie,
  483. seeded.sharedMailboxId,
  484. [
  485. { userId: seeded.readerUserId, permissions: ['send'] },
  486. { userId: seeded.viewerUserId, permissions: ['view'] }
  487. ]
  488. );
  489. const mailFromCount = smtpMailFromCommands(smtp).length;
  490. const readerRelayMailFromCount = smtpMailFromCommands(readerSmtp).length;
  491. const forbiddenRelay = await sendApiMessage(fixture.baseUrl, {
  492. cookie: readerCookie,
  493. from: 'shared@access-owner.example',
  494. subject: 'Shared mailbox cannot use grantee relay',
  495. smtpRelayId: seeded.readerRelayId
  496. });
  497. assert.equal(forbiddenRelay.status, 403, JSON.stringify(forbiddenRelay.body));
  498. assert.match(forbiddenRelay.body.error, /域名所有者.*SMTP 出口/);
  499. assert.equal(smtpMailFromCommands(smtp).length, mailFromCount);
  500. assert.equal(smtpMailFromCommands(readerSmtp).length, readerRelayMailFromCount);
  501. for (const from of ['shared@access-owner.example', 'shared-alias@access-owner.example']) {
  502. const sent = await sendApiMessage(fixture.baseUrl, {
  503. cookie: readerCookie,
  504. from,
  505. subject: `Mailbox grant send from ${from}`
  506. });
  507. assert.equal(sent.status, 202, JSON.stringify(sent.body));
  508. assert.equal(sent.body.queued, true);
  509. }
  510. await assertSendDenied(fixture.baseUrl, {
  511. cookie: readerCookie,
  512. from: 'other@access-owner.example'
  513. });
  514. assert.deepEqual(
  515. smtpMailFromCommands(smtp).slice(mailFromCount),
  516. [
  517. 'MAIL FROM:<shared@access-owner.example>',
  518. 'MAIL FROM:<shared-alias@access-owner.example>'
  519. ]
  520. );
  521. });
  522. } finally {
  523. fixture.child.kill('SIGTERM');
  524. await waitForExit(fixture.child, 1000);
  525. await smtp.close();
  526. await readerSmtp.close();
  527. }
  528. });
  529. function seedMailboxAccessFixtures(dataDir, sessionSecret, readerRelayPort) {
  530. const script = `
  531. import { DatabaseSync } from 'node:sqlite';
  532. import {
  533. createDomain,
  534. createInboundMailbox,
  535. createInboundMessage,
  536. createUser,
  537. initDatabase,
  538. saveSmtpRelay
  539. } from './src/db.js';
  540. import { createDkimKeyPair } from './src/dkim.js';
  541. initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
  542. const owner = createUser({
  543. username: 'access-owner',
  544. email: 'access-owner@example.test',
  545. password: 'password123',
  546. status: 'active'
  547. });
  548. const reader = createUser({
  549. username: 'access-reader',
  550. email: 'access-reader@example.test',
  551. password: 'password123',
  552. status: 'active'
  553. });
  554. const viewer = createUser({
  555. username: 'access-viewer',
  556. email: 'access-viewer@example.test',
  557. password: 'password123',
  558. status: 'active'
  559. });
  560. const disabled = createUser({
  561. username: 'access-disabled',
  562. email: 'access-disabled@example.test',
  563. password: 'password123',
  564. status: 'disabled'
  565. });
  566. const readerRelay = saveSmtpRelay(reader.id, {
  567. name: 'Reader private relay',
  568. host: '127.0.0.1',
  569. port: Number(process.env.READER_RELAY_PORT),
  570. secure: false,
  571. username: '',
  572. password: '',
  573. helo: 'mail.access-reader.example'
  574. });
  575. const keys = createDkimKeyPair();
  576. const createUserDomain = (user, name) => createDomain(user.id, {
  577. domain: name,
  578. selector: 'mh',
  579. verificationToken: 'verify-' + name,
  580. dkimPublic: keys.publicKey,
  581. dkimPrivate: keys.privateKey,
  582. senderHost: 'mail.' + name,
  583. sendingIp: '127.0.0.1',
  584. spfExtra: '',
  585. dmarcPolicy: 'none',
  586. dmarcRua: ''
  587. });
  588. createUserDomain(owner, 'access-owner.example');
  589. createUserDomain(reader, 'access-reader.example');
  590. const sharedMailbox = createInboundMailbox(owner.id, {
  591. address: 'shared@access-owner.example',
  592. password: 'mailbox-password',
  593. aliases: ['shared-alias']
  594. });
  595. const otherMailbox = createInboundMailbox(owner.id, {
  596. address: 'other@access-owner.example',
  597. password: 'mailbox-password'
  598. });
  599. const readerMailbox = createInboundMailbox(reader.id, {
  600. address: 'own@access-reader.example',
  601. password: 'mailbox-password'
  602. });
  603. const deletedMailbox = createInboundMailbox(owner.id, {
  604. address: 'deleted@access-owner.example',
  605. password: 'mailbox-password'
  606. });
  607. const fixtureDatabase = new DatabaseSync(process.env.DATA_DIR + '/mailhub.sqlite');
  608. fixtureDatabase
  609. .prepare("UPDATE inbound_mailboxes SET status = 'disabled', deleted_at = ?, updated_at = ? WHERE id = ?")
  610. .run('2026-07-18T00:00:00.000Z', '2026-07-18T00:00:00.000Z', deletedMailbox.id);
  611. fixtureDatabase
  612. .prepare(\`
  613. INSERT INTO inbound_mailbox_grants (
  614. mailbox_id, user_id, can_view, can_receive, can_send, created_at, updated_at
  615. ) VALUES (?, ?, 'true', 'false', 'false', ?, ?)
  616. \`)
  617. .run(
  618. otherMailbox.id,
  619. disabled.id,
  620. '2026-07-18T00:00:00.000Z',
  621. '2026-07-18T00:00:00.000Z'
  622. );
  623. fixtureDatabase.close();
  624. const createMessage = (mailbox, subject, sequence) => createInboundMessage(mailbox, {
  625. sender: 'sender@example.net',
  626. recipients: [mailbox.address],
  627. subject,
  628. messageId: '<mailbox-access-' + sequence + '@example.net>',
  629. rawMessage: 'Subject: ' + subject + '\\r\\n\\r\\n' + subject,
  630. textBody: subject,
  631. receivedAt: '2026-07-18T0' + sequence + ':00:00.000Z'
  632. });
  633. const sharedMessage = createMessage(sharedMailbox, 'Shared grant message', 1);
  634. createMessage(otherMailbox, 'Other owner message', 2);
  635. const readerMessage = createMessage(readerMailbox, 'Reader owned message', 3);
  636. console.log(JSON.stringify({
  637. ownerUserId: owner.id,
  638. readerUserId: reader.id,
  639. viewerUserId: viewer.id,
  640. disabledUserId: disabled.id,
  641. sharedMailboxId: sharedMailbox.id,
  642. otherMailboxId: otherMailbox.id,
  643. readerMailboxId: readerMailbox.id,
  644. deletedMailboxId: deletedMailbox.id,
  645. readerRelayId: readerRelay.id,
  646. sharedMessageId: sharedMessage.id,
  647. readerMessageId: readerMessage.id
  648. }));
  649. `;
  650. const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
  651. cwd: process.cwd(),
  652. env: {
  653. ...process.env,
  654. DATA_DIR: dataDir,
  655. SESSION_SECRET: sessionSecret,
  656. READER_RELAY_PORT: String(readerRelayPort)
  657. },
  658. encoding: 'utf8'
  659. });
  660. assert.equal(result.status, 0, result.stderr || result.stdout);
  661. return JSON.parse(result.stdout);
  662. }
  663. async function replaceGrants(baseUrl, adminCookie, mailboxId, grants) {
  664. const response = await requestJson(
  665. baseUrl,
  666. `/api/admin/inbound-mailboxes/${mailboxId}/access`,
  667. {
  668. method: 'PUT',
  669. cookie: adminCookie,
  670. body: { grants }
  671. }
  672. );
  673. assert.equal(response.status, 200, JSON.stringify(response.body));
  674. return response.body.mailbox;
  675. }
  676. function bulkUpdateGrants(baseUrl, cookie, body) {
  677. return requestJson(baseUrl, '/api/admin/inbound-mailboxes/access/bulk', {
  678. method: 'POST',
  679. cookie,
  680. body
  681. });
  682. }
  683. let webmailSessionSequence = 0;
  684. function insertPendingWebmailSession(dataDir, actorUserId, mailboxId) {
  685. webmailSessionSequence += 1;
  686. const database = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'));
  687. try {
  688. const createdAt = new Date().toISOString();
  689. const result = database
  690. .prepare(`
  691. INSERT INTO webmail_sessions (
  692. actor_user_id, mailbox_id, audience, ticket_hash, ticket_expires_at,
  693. credential_expires_at, created_at
  694. ) VALUES (?, ?, ?, ?, ?, ?, ?)
  695. `)
  696. .run(
  697. actorUserId,
  698. mailboxId,
  699. 'https://webmail.example.test',
  700. `bulk-access-test-ticket-${process.pid}-${webmailSessionSequence}`,
  701. '2099-01-01T00:00:00.000Z',
  702. '2099-01-01T00:00:00.000Z',
  703. createdAt
  704. );
  705. return Number(result.lastInsertRowid);
  706. } finally {
  707. database.close();
  708. }
  709. }
  710. function webmailSessionRevokedAt(dataDir, sessionId) {
  711. const database = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'), { readOnly: true });
  712. try {
  713. return database
  714. .prepare('SELECT revoked_at FROM webmail_sessions WHERE id = ?')
  715. .get(sessionId)?.revoked_at || '';
  716. } finally {
  717. database.close();
  718. }
  719. }
  720. function findAdminMailbox(mailboxes, mailboxId) {
  721. const mailbox = mailboxes.find((entry) => entry.mailbox.id === mailboxId);
  722. assert.ok(mailbox, `expected admin mailbox ${mailboxId}`);
  723. return mailbox;
  724. }
  725. function grantPermissions(mailboxAccess, userId) {
  726. const grant = mailboxAccess.grants.find((entry) => entry.user.id === userId);
  727. assert.ok(grant, `expected grant for user ${userId}`);
  728. return grant.permissions;
  729. }
  730. async function mailboxAddresses(baseUrl, pathname, cookie) {
  731. const response = await requestJson(baseUrl, pathname, { cookie });
  732. assert.equal(response.status, 200, JSON.stringify(response.body));
  733. return response.body.mailboxes.map((mailbox) => mailbox.address).sort();
  734. }
  735. async function createToken(baseUrl, cookie, input) {
  736. const response = await requestJson(baseUrl, '/api/api-tokens', {
  737. method: 'POST',
  738. cookie,
  739. body: input
  740. });
  741. assert.equal(response.status, 201, JSON.stringify(response.body));
  742. assert.ok(response.body.token.token);
  743. return response.body.token;
  744. }
  745. function sendApiMessage(baseUrl, {
  746. cookie = '',
  747. bearer = '',
  748. from,
  749. subject = 'Mailbox grant send',
  750. smtpRelayId = null
  751. }) {
  752. return requestJson(baseUrl, '/api/send', {
  753. method: 'POST',
  754. cookie,
  755. bearer,
  756. body: {
  757. from,
  758. to: 'recipient@example.net',
  759. subject,
  760. text: 'mailbox grant send test',
  761. ...(smtpRelayId ? { smtpRelayId } : {})
  762. }
  763. });
  764. }
  765. async function assertSendDenied(baseUrl, input) {
  766. const response = await sendApiMessage(baseUrl, {
  767. ...input,
  768. subject: 'Mailbox grant denial'
  769. });
  770. assert.equal(response.status, 403, JSON.stringify(response.body));
  771. assert.match(response.body.error, /未获得发信权限/);
  772. }
  773. function smtpMailFromCommands(smtp) {
  774. return smtp.commands.filter((command) => command.startsWith('MAIL FROM:'));
  775. }
  776. async function requestJson(baseUrl, pathname, {
  777. method = 'GET',
  778. cookie = '',
  779. bearer = '',
  780. body
  781. } = {}) {
  782. const headers = {};
  783. if (cookie) headers.Cookie = cookie;
  784. if (bearer) headers.Authorization = `Bearer ${bearer}`;
  785. if (body !== undefined) headers['Content-Type'] = 'application/json';
  786. const response = await fetch(`${baseUrl}${pathname}`, {
  787. method,
  788. headers,
  789. body: body === undefined ? undefined : JSON.stringify(body),
  790. redirect: 'manual'
  791. });
  792. const text = await response.text();
  793. return {
  794. status: response.status,
  795. body: text ? JSON.parse(text) : null
  796. };
  797. }
  798. async function login(baseUrl, username, password) {
  799. const response = await fetch(`${baseUrl}/api/login`, {
  800. method: 'POST',
  801. headers: { 'Content-Type': 'application/json' },
  802. body: JSON.stringify({ username, password })
  803. });
  804. assert.equal(response.status, 200);
  805. const cookie = response.headers.get('set-cookie')?.split(';')[0] || '';
  806. assert.ok(cookie);
  807. return cookie;
  808. }
  809. async function startTestServer(smtpPort) {
  810. const port = await freePort();
  811. const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-access-'));
  812. const sessionSecret = 'mailbox-access-session-secret';
  813. const child = spawn(process.execPath, ['src/server.js'], {
  814. cwd: process.cwd(),
  815. env: {
  816. ...process.env,
  817. PORT: String(port),
  818. DATA_DIR: dataDir,
  819. SESSION_SECRET: sessionSecret,
  820. ADMIN_USER: 'admin',
  821. ADMIN_EMAIL: 'admin@example.test',
  822. ADMIN_PASSWORD: 'password123',
  823. DNS_AUTO_CHECK_ENABLED: 'false',
  824. DELIVERY_TRACKING_ENABLED: 'false',
  825. WEBHOOK_WORKER_ENABLED: 'false',
  826. SUBMISSION_ENABLED: 'false',
  827. IMAP_ENABLED: 'false',
  828. POP3_ENABLED: 'false',
  829. SMTP_HOST: '127.0.0.1',
  830. SMTP_PORT: String(smtpPort),
  831. SMTP_SECURE: 'false',
  832. SMTP_HELO: 'mailhub-access.test'
  833. },
  834. stdio: ['ignore', 'pipe', 'pipe']
  835. });
  836. await waitForOutput(child, 'MailHub listening');
  837. return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
  838. }
  839. function startFakeSmtpServer() {
  840. const commands = [];
  841. const server = net.createServer((socket) => {
  842. socket.setEncoding('utf8');
  843. socket.write('220 relay.test ESMTP ready\r\n');
  844. let buffer = '';
  845. let dataMode = false;
  846. socket.on('data', (chunk) => {
  847. buffer += chunk;
  848. let index;
  849. while ((index = buffer.indexOf('\n')) !== -1) {
  850. const line = buffer.slice(0, index).replace(/\r$/, '');
  851. buffer = buffer.slice(index + 1);
  852. if (dataMode) {
  853. if (line === '.') {
  854. dataMode = false;
  855. socket.write('250 2.0.0 queued as ACCESS123\r\n');
  856. }
  857. continue;
  858. }
  859. commands.push(line);
  860. if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
  861. else if (line.startsWith('MAIL FROM:')) socket.write('250 2.1.0 ok\r\n');
  862. else if (line.startsWith('RCPT TO:')) socket.write('250 2.1.5 ok\r\n');
  863. else if (line === 'DATA') {
  864. dataMode = true;
  865. socket.write('354 end data\r\n');
  866. } else if (line === 'QUIT') {
  867. socket.write('221 bye\r\n');
  868. socket.end();
  869. }
  870. }
  871. });
  872. });
  873. return new Promise((resolve, reject) => {
  874. server.once('error', reject);
  875. server.listen(0, '127.0.0.1', () => {
  876. server.off('error', reject);
  877. resolve({
  878. port: server.address().port,
  879. commands,
  880. close: () => new Promise((closeResolve) => server.close(closeResolve))
  881. });
  882. });
  883. });
  884. }
  885. function freePort() {
  886. return new Promise((resolve, reject) => {
  887. const server = net.createServer();
  888. server.listen(0, '127.0.0.1', () => {
  889. const address = server.address();
  890. server.close(() => {
  891. if (address && typeof address === 'object') resolve(address.port);
  892. else reject(new Error('Unable to allocate a test port.'));
  893. });
  894. });
  895. server.on('error', reject);
  896. });
  897. }
  898. function waitForOutput(child, text, timeoutMs = 8000) {
  899. return new Promise((resolve, reject) => {
  900. let output = '';
  901. const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}\n${output}`)), timeoutMs);
  902. const onData = (chunk) => {
  903. output += chunk.toString();
  904. if (!output.includes(text)) return;
  905. clearTimeout(timeout);
  906. child.stdout.off('data', onData);
  907. child.stderr.off('data', onData);
  908. resolve();
  909. };
  910. child.stdout.on('data', onData);
  911. child.stderr.on('data', onData);
  912. child.once('exit', (code) => {
  913. clearTimeout(timeout);
  914. reject(new Error(`Server exited before startup with code ${code}\n${output}`));
  915. });
  916. });
  917. }
  918. function waitForExit(child, timeoutMs) {
  919. if (child.exitCode !== null) return Promise.resolve(child.exitCode);
  920. return new Promise((resolve) => {
  921. const timeout = setTimeout(() => resolve(null), timeoutMs);
  922. child.once('exit', (code) => {
  923. clearTimeout(timeout);
  924. resolve(code);
  925. });
  926. });
  927. }