inbound-db.test.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync } from 'node:fs';
  3. import { tmpdir } from 'node:os';
  4. import path from 'node:path';
  5. import { test } from 'node:test';
  6. import {
  7. createDomain,
  8. createApiToken,
  9. createInboundMailbox,
  10. createInboundMessage,
  11. createUser,
  12. deleteDomain,
  13. getInboundMailboxByAddress,
  14. getInboundMailboxForSender,
  15. getInboundMessageMaildirStorage,
  16. getInboundMessage,
  17. initDatabase,
  18. listAdminInboundMailboxAccess,
  19. listDomains,
  20. listInboundMailboxFolders,
  21. listInboundMailboxDomains,
  22. listInboundMailboxes,
  23. listInboundMessages,
  24. markInboundMessageRead,
  25. replaceInboundMailboxGrants,
  26. resolveInboundRecipient,
  27. searchInboundMessages,
  28. transferDomain,
  29. updateDomain,
  30. updateInboundMailbox,
  31. upsertImportedInboundMailbox,
  32. verifySmtpCredential
  33. } from '../src/db.js';
  34. test('users can create inbound mailboxes and read received messages', () => {
  35. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-db-')), 'inbound-secret');
  36. const user = createUser({ username: 'inbound-user', email: 'inbound-user@example.com', password: 'password123' });
  37. createDomain(user.id, {
  38. domain: 'inbound.example',
  39. selector: 'mh',
  40. verificationToken: 'verify',
  41. dkimPublic: 'public',
  42. dkimPrivate: 'private',
  43. senderHost: 'mail.inbound.example',
  44. sendingIp: '192.0.2.10',
  45. spfExtra: '',
  46. dmarcPolicy: 'none',
  47. dmarcRua: ''
  48. });
  49. const mailbox = createInboundMailbox(user.id, {
  50. address: 'Support@Inbound.Example',
  51. displayName: 'Support',
  52. password: 'mailbox-pass-123',
  53. aliases: 'help desk',
  54. forwardTo: 'archive@example.net, ops@example.net',
  55. keepForwarded: false,
  56. quotaMb: 512
  57. });
  58. assert.equal(mailbox.address, 'support@inbound.example');
  59. assert.equal(mailbox.displayName, 'Support');
  60. assert.equal(mailbox.passwordSet, true);
  61. assert.deepEqual(mailbox.aliases, ['help', 'desk']);
  62. assert.deepEqual(mailbox.forwardTo, ['archive@example.net', 'ops@example.net']);
  63. assert.equal(mailbox.keepForwarded, false);
  64. assert.equal(mailbox.quotaMb, 512);
  65. assert.equal(mailbox.messageCount, 0);
  66. assert.equal(mailbox.unreadCount, 0);
  67. const resolved = getInboundMailboxByAddress('SUPPORT@INBOUND.EXAMPLE');
  68. assert.equal(resolved.id, mailbox.id);
  69. assert.equal(resolved.userId, user.id);
  70. assert.equal(verifySmtpCredential('support@inbound.example', 'mailbox-pass-123').user.id, user.id);
  71. assert.equal(verifySmtpCredential('support@inbound.example', 'wrong-password'), null);
  72. assert.equal(resolveInboundRecipient('help@inbound.example').mailbox.id, mailbox.id);
  73. const message = createInboundMessage(resolved, {
  74. sender: 'alice@example.net',
  75. recipients: ['support@inbound.example'],
  76. subject: 'Hello inbound',
  77. messageId: '<hello@example.net>',
  78. rawMessage: 'From: alice@example.net\r\nTo: support@inbound.example\r\nSubject: Hello inbound\r\n\r\nHello MailHub',
  79. textBody: 'Hello MailHub',
  80. htmlBody: ''
  81. });
  82. assert.equal(message.mailboxId, mailbox.id);
  83. assert.equal(message.read, false);
  84. const mailboxes = listInboundMailboxes(user.id);
  85. assert.equal(mailboxes.length, 1);
  86. assert.equal(mailboxes[0].messageCount, 1);
  87. assert.equal(mailboxes[0].unreadCount, 1);
  88. const messages = listInboundMessages(user.id);
  89. assert.equal(messages.length, 1);
  90. assert.equal(messages[0].subject, 'Hello inbound');
  91. assert.equal(messages[0].preview, 'Hello MailHub');
  92. assert.equal('rawMessage' in messages[0], false);
  93. const detail = getInboundMessage(user.id, message.id);
  94. assert.equal(detail.rawMessage.includes('Hello MailHub'), true);
  95. assert.equal(detail.textBody, 'Hello MailHub');
  96. const readMessage = markInboundMessageRead(user.id, message.id, true);
  97. assert.equal(readMessage.read, true);
  98. assert.equal(listInboundMailboxes(user.id)[0].unreadCount, 0);
  99. assert.equal(markInboundMessageRead(999999, message.id, true), null);
  100. });
  101. test('mailbox grants isolate owner, view, receive, send, token selection, and transfer access', () => {
  102. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-grants-')), 'inbound-secret');
  103. const owner = createUser({ username: 'grant-owner', email: 'grant-owner@example.com', password: 'password123' });
  104. const viewer = createUser({ username: 'grant-viewer', email: 'grant-viewer@example.com', password: 'password123' });
  105. const receiver = createUser({ username: 'grant-receiver', email: 'grant-receiver@example.com', password: 'password123' });
  106. const sender = createUser({ username: 'grant-sender', email: 'grant-sender@example.com', password: 'password123' });
  107. const domain = createDomain(owner.id, {
  108. domain: 'grants.example',
  109. selector: 'mh',
  110. verificationToken: 'verify-grants',
  111. dkimPublic: 'public',
  112. dkimPrivate: 'private',
  113. senderHost: 'mail.grants.example',
  114. sendingIp: '192.0.2.31',
  115. spfExtra: '',
  116. dmarcPolicy: 'none',
  117. dmarcRua: ''
  118. });
  119. const mailbox = createInboundMailbox(owner.id, {
  120. address: 'support@grants.example',
  121. aliases: ['help']
  122. });
  123. const message = createInboundMessage(mailbox, {
  124. sender: 'alice@example.net',
  125. recipients: [mailbox.address],
  126. subject: 'Mailbox grant message',
  127. rawMessage: 'Subject: Mailbox grant message\r\n\r\nShared body',
  128. textBody: 'Shared body'
  129. });
  130. const accessEntry = replaceInboundMailboxGrants(mailbox.id, [
  131. { userId: viewer.id, view: true },
  132. { userId: receiver.id, permissions: ['receive'] },
  133. { userId: sender.id, permissions: { send: true } }
  134. ]);
  135. assert.equal(accessEntry.mailbox.id, mailbox.id);
  136. assert.equal(accessEntry.owner.id, owner.id);
  137. assert.deepEqual(
  138. new Map(accessEntry.grants.map((grant) => [grant.user.id, grant.permissions])),
  139. new Map([
  140. [viewer.id, { view: true, receive: false, send: false }],
  141. [receiver.id, { view: true, receive: true, send: false }],
  142. [sender.id, { view: true, receive: false, send: true }]
  143. ])
  144. );
  145. assert.equal(listAdminInboundMailboxAccess().find((entry) => entry.mailbox.id === mailbox.id).owner.id, owner.id);
  146. assert.deepEqual(listInboundMailboxes(owner.id, { permission: 'view' })[0].access, {
  147. type: 'owner',
  148. permissions: { view: true, receive: true, send: true }
  149. });
  150. assert.deepEqual(listInboundMailboxes(viewer.id), []);
  151. const [viewOnlyMailbox] = listInboundMailboxes(viewer.id, { permission: 'view' });
  152. assert.deepEqual(viewOnlyMailbox.access, {
  153. type: 'assigned',
  154. permissions: { view: true, receive: false, send: false }
  155. });
  156. assert.equal(viewOnlyMailbox.ownerUserId, owner.id);
  157. assert.equal(viewOnlyMailbox.messageCount, null);
  158. assert.equal(viewOnlyMailbox.unreadCount, null);
  159. assert.equal(viewOnlyMailbox.lastMessageAt, null);
  160. assert.equal(getInboundMessage(viewer.id, message.id, { permission: 'receive' }), null);
  161. assert.equal(listInboundMailboxFolders(viewer.id, mailbox.id, { permission: 'receive' }), null);
  162. assert.equal(getInboundMessageMaildirStorage(viewer.id, message.id, { permission: 'receive' }), null);
  163. assert.equal(markInboundMessageRead(viewer.id, message.id, true, { permission: 'receive' }), null);
  164. assert.equal(searchInboundMessages(receiver.id, { pageSize: 100 }).total, 0);
  165. const [receivedMailbox] = listInboundMailboxes(receiver.id, { permission: 'view' });
  166. assert.equal(receivedMailbox.access.permissions.receive, true);
  167. assert.equal(receivedMailbox.messageCount, 1);
  168. assert.equal(receivedMailbox.unreadCount, 1);
  169. assert.equal(searchInboundMessages(receiver.id, { pageSize: 100 }, { permission: 'receive' }).total, 1);
  170. assert.equal(getInboundMessage(receiver.id, message.id, { permission: 'receive' }).subject, 'Mailbox grant message');
  171. assert.ok(listInboundMailboxFolders(receiver.id, mailbox.id, { permission: 'receive' }));
  172. assert.equal(getInboundMessageMaildirStorage(receiver.id, message.id, { permission: 'receive' }).id, message.id);
  173. assert.equal(markInboundMessageRead(receiver.id, message.id, true, { permission: 'receive' }).read, true);
  174. assert.equal(getInboundMailboxForSender(receiver.id, mailbox.address), null);
  175. assert.equal(getInboundMailboxForSender(sender.id, mailbox.address).id, mailbox.id);
  176. assert.equal(getInboundMailboxForSender(sender.id, 'help@grants.example').id, mailbox.id);
  177. assert.equal(getInboundMailboxForSender(sender.id, mailbox.address, { ownerOnly: true }), null);
  178. assert.equal(getInboundMailboxForSender(sender.id, mailbox.address, { mailboxIds: [] }), null);
  179. assert.equal(getInboundMailboxForSender(sender.id, mailbox.address, { mailboxIds: [mailbox.id] }).id, mailbox.id);
  180. const selectedToken = createApiToken(viewer.id, 'granted mailbox', {
  181. scopes: ['mailboxes:read'],
  182. mailboxAccess: 'selected',
  183. mailboxIds: [mailbox.id]
  184. });
  185. assert.equal(selectedToken.mailboxAccess, 'selected');
  186. assert.deepEqual(selectedToken.mailboxIds, [mailbox.id]);
  187. assert.equal(listInboundMailboxes(viewer.id, {
  188. permission: 'view',
  189. mailboxIds: selectedToken.mailboxIds
  190. }).length, 1);
  191. assert.equal(searchInboundMessages(viewer.id, { pageSize: 100 }, {
  192. permission: 'receive',
  193. mailboxIds: selectedToken.mailboxIds
  194. }).total, 0);
  195. const receiveToken = createApiToken(receiver.id, 'received mailbox', {
  196. scopes: ['messages:read'],
  197. mailboxAccess: 'selected',
  198. mailboxIds: [mailbox.id]
  199. });
  200. assert.equal(searchInboundMessages(receiver.id, { pageSize: 100 }, {
  201. permission: 'receive',
  202. mailboxIds: receiveToken.mailboxIds
  203. }).total, 1);
  204. assert.throws(
  205. () => replaceInboundMailboxGrants(mailbox.id, [{ userId: viewer.id, view: false, receive: false, send: false }]),
  206. /至少需要一项权限/
  207. );
  208. assert.throws(
  209. () => replaceInboundMailboxGrants(mailbox.id, [{ userId: owner.id, view: true }]),
  210. /所有者无需额外授权/
  211. );
  212. assert.throws(
  213. () => replaceInboundMailboxGrants(mailbox.id, [{ userId: 999999, view: true }]),
  214. /用户不存在/
  215. );
  216. assert.throws(
  217. () => replaceInboundMailboxGrants(mailbox.id, [
  218. { userId: viewer.id, view: true },
  219. { userId: viewer.id, receive: true }
  220. ]),
  221. /不能重复/
  222. );
  223. assert.equal(listAdminInboundMailboxAccess().find((entry) => entry.mailbox.id === mailbox.id).grants.length, 3);
  224. replaceInboundMailboxGrants(mailbox.id, []);
  225. assert.equal(listInboundMailboxes(viewer.id, {
  226. permission: 'view',
  227. mailboxIds: selectedToken.mailboxIds
  228. }).length, 0);
  229. assert.equal(getInboundMailboxForSender(sender.id, mailbox.address), null);
  230. replaceInboundMailboxGrants(mailbox.id, [{ userId: sender.id, send: true }]);
  231. transferDomain({ actorUserId: owner.id, domainId: domain.id, targetUserId: sender.id });
  232. const transferred = listAdminInboundMailboxAccess().find((entry) => entry.mailbox.id === mailbox.id);
  233. assert.equal(transferred.owner.id, sender.id);
  234. assert.deepEqual(transferred.grants, []);
  235. });
  236. test('domains can route unknown inbound recipients to catch-all targets', () => {
  237. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-catchall-')), 'inbound-secret');
  238. const user = createUser({ username: 'catch-user', email: 'catch@example.com', password: 'password123' });
  239. const domain = createDomain(user.id, {
  240. domain: 'catch.example',
  241. selector: 'mh',
  242. verificationToken: 'verify',
  243. dkimPublic: 'public',
  244. dkimPrivate: 'private',
  245. senderHost: 'mail.catch.example',
  246. sendingIp: '192.0.2.20',
  247. spfExtra: '',
  248. dmarcPolicy: 'none',
  249. dmarcRua: ''
  250. });
  251. const mailbox = createInboundMailbox(user.id, { address: 'share@catch.example' });
  252. const updated = updateDomain(domain.id, user.id, { catchAllAddress: 'share@catch.example' });
  253. assert.equal(updated.catchAllAddress, 'share@catch.example');
  254. const localRoute = resolveInboundRecipient('missing@catch.example');
  255. assert.equal(localRoute.catchAll, true);
  256. assert.equal(localRoute.mailbox.id, mailbox.id);
  257. assert.equal(localRoute.recipient, 'missing@catch.example');
  258. updateDomain(domain.id, user.id, { catchAllAddress: '/dev/null' });
  259. const dropRoute = resolveInboundRecipient('drop@catch.example');
  260. assert.equal(dropRoute.drop, true);
  261. assert.equal(dropRoute.mailbox, null);
  262. updateDomain(domain.id, user.id, { catchAllAddress: 'external@example.net' });
  263. const forwardRoute = resolveInboundRecipient('forward@catch.example');
  264. assert.deepEqual(forwardRoute.forwardTo, ['external@example.net']);
  265. assert.equal(forwardRoute.mailbox, null);
  266. });
  267. test('domain owners can share mailbox creation without sharing domain management', () => {
  268. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-db-scope-')), 'inbound-secret');
  269. const owner = createUser({ username: 'owner-user', email: 'owner@example.com', password: 'password123' });
  270. const other = createUser({ username: 'other-user', email: 'other@example.com', password: 'password123' });
  271. const domain = createDomain(owner.id, {
  272. domain: 'owned.example',
  273. selector: 'mh',
  274. verificationToken: 'verify',
  275. dkimPublic: 'public',
  276. dkimPrivate: 'private',
  277. senderHost: 'mail.owned.example',
  278. sendingIp: '192.0.2.11',
  279. spfExtra: '',
  280. dmarcPolicy: 'none',
  281. dmarcRua: ''
  282. });
  283. assert.throws(
  284. () => createInboundMailbox(other.id, { address: 'support@owned.example' }),
  285. /收信域名不存在/
  286. );
  287. assert.equal(listInboundMailboxDomains(other.id).some((item) => item.domain === 'owned.example'), false);
  288. const shared = updateDomain(domain.id, owner.id, { mailboxSignupEnabled: true });
  289. assert.equal(shared.mailboxSignupEnabled, true);
  290. assert.equal(listDomains(other.id).some((item) => item.domain === 'owned.example'), false);
  291. const sharedDomains = listInboundMailboxDomains(other.id);
  292. assert.deepEqual(
  293. sharedDomains.map((item) => item.domain),
  294. ['owned.example']
  295. );
  296. assert.deepEqual(Object.keys(sharedDomains[0]).sort(), ['domain', 'id', 'mailboxSignupEnabled', 'userId']);
  297. const mailbox = createInboundMailbox(other.id, {
  298. address: 'support@owned.example',
  299. password: 'mailbox-pass-123'
  300. });
  301. assert.equal(mailbox.userId, other.id);
  302. assert.equal(mailbox.domainId, domain.id);
  303. assert.equal(verifySmtpCredential('support@owned.example', 'mailbox-pass-123').user.id, other.id);
  304. const message = createInboundMessage(mailbox, {
  305. sender: 'sender@example.net',
  306. recipients: ['support@owned.example'],
  307. subject: 'Shared domain',
  308. rawMessage: 'From: sender@example.net\r\nTo: support@owned.example\r\nSubject: Shared domain\r\n\r\nHello',
  309. textBody: 'Hello'
  310. });
  311. assert.equal(listInboundMailboxes(other.id).length, 1);
  312. assert.equal(listInboundMessages(other.id)[0].id, message.id);
  313. assert.equal(listInboundMessages(owner.id).length, 0);
  314. assert.throws(
  315. () => deleteDomain(domain.id, owner.id),
  316. /该域名仍有关联收信邮箱/
  317. );
  318. assert.equal(listDomains(owner.id)[0].id, domain.id);
  319. assert.equal(getInboundMailboxByAddress(mailbox.address).id, mailbox.id);
  320. assert.equal(getInboundMessage(other.id, message.id).id, message.id);
  321. });
  322. test('mailbox addresses and aliases share one namespace per domain', () => {
  323. initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-namespace-')), 'inbound-secret');
  324. const owner = createUser({ username: 'namespace-owner', email: 'namespace-owner@example.com', password: 'password123' });
  325. const other = createUser({ username: 'namespace-user', email: 'namespace-user@example.com', password: 'password123' });
  326. const domain = createDomain(owner.id, {
  327. domain: 'namespace.example',
  328. selector: 'mh',
  329. verificationToken: 'verify',
  330. dkimPublic: 'public',
  331. dkimPrivate: 'private',
  332. senderHost: 'mail.namespace.example',
  333. sendingIp: '192.0.2.12',
  334. spfExtra: '',
  335. dmarcPolicy: 'none',
  336. dmarcRua: ''
  337. });
  338. updateDomain(domain.id, owner.id, { mailboxSignupEnabled: true });
  339. const primary = createInboundMailbox(owner.id, {
  340. address: 'primary@namespace.example',
  341. aliases: ['billing', 'legacy']
  342. });
  343. assert.throws(
  344. () => createInboundMailbox(other.id, { address: 'billing@namespace.example' }),
  345. /billing@namespace\.example 已被其他邮箱占用/
  346. );
  347. assert.throws(
  348. () => createInboundMailbox(other.id, {
  349. address: 'second@namespace.example',
  350. aliases: ['primary']
  351. }),
  352. /primary@namespace\.example 已被其他邮箱占用/
  353. );
  354. const second = createInboundMailbox(other.id, {
  355. address: 'second@namespace.example',
  356. aliases: ['support']
  357. });
  358. assert.deepEqual(updateInboundMailbox(other.id, second.id, { aliases: ['support'] }).aliases, ['support']);
  359. assert.throws(
  360. () => updateInboundMailbox(other.id, second.id, { aliases: ['legacy'] }),
  361. /legacy@namespace\.example 已被其他邮箱占用/
  362. );
  363. assert.throws(
  364. () => upsertImportedInboundMailbox(owner.id, { address: 'legacy@namespace.example' }),
  365. /legacy@namespace\.example 已被其他邮箱占用/
  366. );
  367. const imported = upsertImportedInboundMailbox(owner.id, {
  368. address: 'archive@namespace.example',
  369. aliases: ['old-archive']
  370. });
  371. assert.throws(
  372. () => upsertImportedInboundMailbox(owner.id, {
  373. address: imported.address,
  374. aliases: ['support']
  375. }),
  376. /support@namespace\.example 已被其他邮箱占用/
  377. );
  378. assert.equal(resolveInboundRecipient('billing@namespace.example').mailbox.id, primary.id);
  379. assert.equal(resolveInboundRecipient('support@namespace.example').mailbox.id, second.id);
  380. });