Kaynağa Gözat

feat: add mailbox access control and update SPF policy

AI-Co-Authored-By: Codex
chendeben 1 ay önce
ebeveyn
işleme
1e2b6249ff
83 değiştirilmiş dosya ile 2967 ekleme ve 151 silme
  1. 2 1
      .env.example
  2. 0 0
      public/assets/Account-nhuoQQlz.js
  3. 0 0
      public/assets/Admin-DUVnon0q.js
  4. 0 0
      public/assets/Admin-xFOA6-bp.js
  5. 0 0
      public/assets/ApiTokens-CsrrFg6_.js
  6. 0 0
      public/assets/ApiTokens-yNoe0ogC.js
  7. 1 1
      public/assets/CodeBlock-BB4pQul-.js
  8. 1 1
      public/assets/CopyOutlined-C9O1hBIm.js
  9. 1 0
      public/assets/Dashboard-BENr2dgX.js
  10. 0 1
      public/assets/Dashboard-Byy8J9_C.js
  11. 2 2
      public/assets/DashboardCharts-DJfbdwi-.js
  12. 0 0
      public/assets/DnsApi-BQ3vGiSA.js
  13. 0 0
      public/assets/DnsApi-Bros5Dbx.js
  14. 0 0
      public/assets/DomainDetail-DXZVCUbT.js
  15. 0 0
      public/assets/DomainDetail-XYsUb3nP.js
  16. 0 0
      public/assets/Domains-BJSbG9ca.js
  17. 0 0
      public/assets/Domains-CRnd-K9E.js
  18. 1 1
      public/assets/EditOutlined-C5punjK1.js
  19. 1 1
      public/assets/EllipsisOutlined-twSxuZli.js
  20. 0 0
      public/assets/Inbox-7UH19KPt.js
  21. 0 0
      public/assets/Inbox-CTE3UnSi.js
  22. 1 0
      public/assets/MailboxPermissionTags-dGhSGuw4.js
  23. 1 1
      public/assets/PlusOutlined-aj3TL3Tz.js
  24. 1 1
      public/assets/ReloadOutlined-Cmwy5NFg.js
  25. 1 1
      public/assets/SearchOutlined-B6SLAKJm.js
  26. 0 0
      public/assets/SectionCard--RQCBj1c.js
  27. 0 0
      public/assets/SendingLogs-CLQnGbZh.js
  28. 0 0
      public/assets/SendingLogs-DSu5v-Zl.js
  29. 0 0
      public/assets/Settings-CEV9En54.js
  30. 0 0
      public/assets/Settings-v55Om0XG.js
  31. 0 0
      public/assets/SmtpCredentials-COhLie7_.js
  32. 0 0
      public/assets/SmtpCredentials-a0UgeAH-.js
  33. 1 0
      public/assets/StatusPill-BM21x0ED.js
  34. 0 0
      public/assets/StatusPill-DYejnVUU.js
  35. 1 1
      public/assets/StatusTag-BOdwUJ1y.js
  36. 1 1
      public/assets/ThunderboltOutlined-CAOlQtzj.js
  37. 0 0
      public/assets/Webhooks-BfQuuVxm.js
  38. 0 0
      public/assets/Webhooks-pSWRIh7Y.js
  39. 1 1
      public/assets/form-GQckeBSC.js
  40. 1 1
      public/assets/grid-DaQoG6GE.js
  41. 0 1
      public/assets/index-Dztone_T.js
  42. 0 0
      public/assets/landing-CEGWIr1r.js
  43. 0 0
      public/assets/list-BBhECcFB.js
  44. 0 0
      public/assets/list-Cfo2HgHN.js
  45. 0 0
      public/assets/login-D7D2knjl.js
  46. 1 1
      public/assets/modal-Ba-m1ypG.js
  47. 0 0
      public/assets/popconfirm-BvP3t7-7.js
  48. 0 1
      public/assets/row-CYL70Wz3.js
  49. 1 0
      public/assets/row-Cd6f9U9h.js
  50. 0 0
      public/assets/table-7bnHOUOM.js
  51. 0 0
      public/assets/theme-B5baUfEh.js
  52. 0 0
      public/assets/theme-QBN1gbX1.css
  53. 3 3
      public/index.html
  54. 1 1
      public/landing.html
  55. 5 5
      public/login.html
  56. 19 0
      src/components/mailbox/MailboxPermissionTags.tsx
  57. 392 27
      src/db.js
  58. 33 5
      src/dns-guide.js
  59. 2 0
      src/frontend/App.tsx
  60. 101 17
      src/frontend/i18n/index.js
  61. 2 2
      src/frontend/landing/i18n.js
  62. 22 1
      src/frontend/services/api.ts
  63. 48 0
      src/frontend/styles.css
  64. 29 2
      src/frontend/types.ts
  65. 3 1
      src/layouts/AdminLayout.tsx
  66. 181 0
      src/pages/Account.tsx
  67. 387 0
      src/pages/Admin/MailboxAccess.tsx
  68. 10 4
      src/pages/Admin/index.tsx
  69. 18 10
      src/pages/ApiTokens.tsx
  70. 65 23
      src/pages/Inbox.tsx
  71. 104 20
      src/server.js
  72. 43 6
      src/submission.js
  73. 19 0
      test/db.test.js
  74. 126 0
      test/dns-guide.test.js
  75. 152 0
      test/inbound-db.test.js
  76. 703 0
      test/server-mailbox-access.test.js
  77. 114 0
      test/submission-inbound.test.js
  78. 11 0
      test/ui/admin-layout.test.tsx
  79. 112 4
      test/ui/api-token-secret.test.tsx
  80. 28 0
      test/ui/inbox-navigation.test.tsx
  81. 2 2
      test/ui/integration-resource-resilience.test.tsx
  82. 210 0
      test/ui/mailbox-access.test.tsx
  83. 2 0
      test/ui/operations-navigation.test.tsx

+ 2 - 1
.env.example

@@ -47,7 +47,8 @@ UV_THREADPOOL_SIZE=2
 MAILDIR_ROOT=./data/maildir
 MAILDIR_SYNC_INTERVAL_MS=300000
 
-# Default outbound identity used in SPF, HELO, and Postfix myhostname.
+# Outbound hostname is authorized through the SPF a: mechanism and used for HELO/Postfix.
+# The sending IP drives the managed A record and PTR health check, not a direct SPF ip4 entry.
 MAIL_HOSTNAME=smtp.mailhub.example.com
 SENDING_IP=203.0.113.10
 

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/Account-nhuoQQlz.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/Admin-DUVnon0q.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/Admin-xFOA6-bp.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/ApiTokens-CsrrFg6_.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/ApiTokens-yNoe0ogC.js


+ 1 - 1
public/assets/CodeBlock-B8oVHT9c.js → public/assets/CodeBlock-BB4pQul-.js

@@ -1 +1 @@
-import{kt as e,n as t,t as n}from"./jsx-runtime-dm82oUI8.js";import{t as r}from"./CopyOutlined-DIsSRLKB.js";var i=n();function a({value:n,onCopy:a}){return(0,i.jsxs)(`div`,{className:`code-block`,children:[(0,i.jsx)(t.Paragraph,{code:!0,className:`code-block__value`,children:n}),a?(0,i.jsx)(e,{type:`text`,size:`small`,icon:(0,i.jsx)(r,{}),className:`code-block__copy`,onClick:()=>a(n),"aria-label":`Copy`}):null]})}export{a as t};
+import{kt as e,n as t,t as n}from"./jsx-runtime-dm82oUI8.js";import{t as r}from"./CopyOutlined-C9O1hBIm.js";var i=n();function a({value:n,onCopy:a}){return(0,i.jsxs)(`div`,{className:`code-block`,children:[(0,i.jsx)(t.Paragraph,{code:!0,className:`code-block__value`,children:n}),a?(0,i.jsx)(e,{type:`text`,size:`small`,icon:(0,i.jsx)(r,{}),className:`code-block__copy`,onClick:()=>a(n),"aria-label":`Copy`}):null]})}export{a as t};

+ 1 - 1
public/assets/CopyOutlined-DIsSRLKB.js → public/assets/CopyOutlined-C9O1hBIm.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v688c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V96c0-17.7-14.3-32-32-32zM704 192H192c-17.7 0-32 14.3-32 32v530.7c0 8.5 3.4 16.6 9.4 22.6l173.3 173.3c2.2 2.2 4.7 4 7.4 5.5v1.9h4.2c3.5 1.3 7.2 2 11 2H704c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32zM350 856.2L263.9 770H350v86.2zM664 888H414V746c0-22.1-17.9-40-40-40H232V264h432v624z`}}]},name:`copy`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-B5baUfEh.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M832 64H296c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496v688c0 4.4 3.6 8 8 8h56c4.4 0 8-3.6 8-8V96c0-17.7-14.3-32-32-32zM704 192H192c-17.7 0-32 14.3-32 32v530.7c0 8.5 3.4 16.6 9.4 22.6l173.3 173.3c2.2 2.2 4.7 4 7.4 5.5v1.9h4.2c3.5 1.3 7.2 2 11 2H704c17.7 0 32-14.3 32-32V224c0-17.7-14.3-32-32-32zM350 856.2L263.9 770H350v86.2zM664 888H414V746c0-22.1-17.9-40-40-40H232V264h432v624z`}}]},name:`copy`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

Dosya farkı çok büyük olduğundan ihmal edildi
+ 1 - 0
public/assets/Dashboard-BENr2dgX.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 1
public/assets/Dashboard-Byy8J9_C.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 2 - 2
public/assets/DashboardCharts-DJfbdwi-.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/DnsApi-BQ3vGiSA.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/DnsApi-Bros5Dbx.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/DomainDetail-DXZVCUbT.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/DomainDetail-XYsUb3nP.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/Domains-BJSbG9ca.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/Domains-CRnd-K9E.js


+ 1 - 1
public/assets/EditOutlined-D5870S0F.js → public/assets/EditOutlined-C5punjK1.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default}))),l=e(n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`}}))());function u(){return u=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},u.apply(this,arguments)}var d=a.forwardRef((e,t)=>a.createElement(r,u({},e,{ref:t,icon:l.default})));export{c as n,d as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-B5baUfEh.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default}))),l=e(n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`}}))());function u(){return u=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},u.apply(this,arguments)}var d=a.forwardRef((e,t)=>a.createElement(r,u({},e,{ref:t,icon:l.default})));export{c as n,d as t};

+ 1 - 1
public/assets/EllipsisOutlined-B_1Q6yFN.js → public/assets/EllipsisOutlined-twSxuZli.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M176 511a56 56 0 10112 0 56 56 0 10-112 0zm280 0a56 56 0 10112 0 56 56 0 10-112 0zm280 0a56 56 0 10112 0 56 56 0 10-112 0z`}}]},name:`ellipsis`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-B5baUfEh.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M176 511a56 56 0 10112 0 56 56 0 10-112 0zm280 0a56 56 0 10112 0 56 56 0 10-112 0zm280 0a56 56 0 10112 0 56 56 0 10-112 0z`}}]},name:`ellipsis`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/Inbox-7UH19KPt.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/Inbox-CTE3UnSi.js


+ 1 - 0
public/assets/MailboxPermissionTags-dGhSGuw4.js

@@ -0,0 +1 @@
+import{a as e}from"./theme-B5baUfEh.js";import{i as t,t as n}from"./jsx-runtime-dm82oUI8.js";import{_ as r}from"./index-Dztone_T.js";var i=n();function a({permissions:n}){let{t:a}=e(),o=[n.view?[`view`,a(`mailboxPermission.view`)]:null,n.receive?[`receive`,a(`mailboxPermission.receive`)]:null,n.send?[`send`,a(`mailboxPermission.send`)]:null].filter(Boolean);return o.length?(0,i.jsx)(t,{size:[4,4],wrap:!0,children:o.map(([e,t])=>(0,i.jsx)(r,{children:t},e))}):(0,i.jsx)(r,{children:a(`mailboxPermission.none`)})}export{a as t};

+ 1 - 1
public/assets/PlusOutlined-BsubesQg.js → public/assets/PlusOutlined-aj3TL3Tz.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-B5baUfEh.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z`}},{tag:`path`,attrs:{d:`M192 474h672q8 0 8 8v60q0 8-8 8H160q-8 0-8-8v-60q0-8 8-8z`}}]},name:`plus`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

+ 1 - 1
public/assets/ReloadOutlined-CymPmaTf.js → public/assets/ReloadOutlined-Cmwy5NFg.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.1 209.3l-56.4 44.1C775.8 155.1 656.2 92 521.9 92 290 92 102.3 279.5 102 511.5 101.7 743.7 289.8 932 521.9 932c181.3 0 335.8-115 394.6-276.1 1.5-4.2-.7-8.9-4.9-10.3l-56.7-19.5a8 8 0 00-10.1 4.8c-1.8 5-3.8 10-5.9 14.9-17.3 41-42.1 77.8-73.7 109.4A344.77 344.77 0 01655.9 829c-42.3 17.9-87.4 27-133.8 27-46.5 0-91.5-9.1-133.8-27A341.5 341.5 0 01279 755.2a342.16 342.16 0 01-73.7-109.4c-17.9-42.4-27-87.4-27-133.9s9.1-91.5 27-133.9c17.3-41 42.1-77.8 73.7-109.4 31.6-31.6 68.4-56.4 109.3-73.8 42.3-17.9 87.4-27 133.8-27 46.5 0 91.5 9.1 133.8 27a341.5 341.5 0 01109.3 73.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.6 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c-.1-6.6-7.8-10.3-13-6.2z`}}]},name:`reload`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-B5baUfEh.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.1 209.3l-56.4 44.1C775.8 155.1 656.2 92 521.9 92 290 92 102.3 279.5 102 511.5 101.7 743.7 289.8 932 521.9 932c181.3 0 335.8-115 394.6-276.1 1.5-4.2-.7-8.9-4.9-10.3l-56.7-19.5a8 8 0 00-10.1 4.8c-1.8 5-3.8 10-5.9 14.9-17.3 41-42.1 77.8-73.7 109.4A344.77 344.77 0 01655.9 829c-42.3 17.9-87.4 27-133.8 27-46.5 0-91.5-9.1-133.8-27A341.5 341.5 0 01279 755.2a342.16 342.16 0 01-73.7-109.4c-17.9-42.4-27-87.4-27-133.9s9.1-91.5 27-133.9c17.3-41 42.1-77.8 73.7-109.4 31.6-31.6 68.4-56.4 109.3-73.8 42.3-17.9 87.4-27 133.8-27 46.5 0 91.5 9.1 133.8 27a341.5 341.5 0 01109.3 73.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 003 14.1l175.6 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c-.1-6.6-7.8-10.3-13-6.2z`}}]},name:`reload`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

+ 1 - 1
public/assets/SearchOutlined-D3DEMDXm.js → public/assets/SearchOutlined-B6SLAKJm.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z`}}]},name:`search`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-B5baUfEh.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z`}}]},name:`search`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/SectionCard--RQCBj1c.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/SendingLogs-CLQnGbZh.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/SendingLogs-DSu5v-Zl.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/Settings-CEV9En54.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/Settings-v55Om0XG.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/SmtpCredentials-COhLie7_.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/SmtpCredentials-a0UgeAH-.js


+ 1 - 0
public/assets/StatusPill-BM21x0ED.js

@@ -0,0 +1 @@
+import{t as e}from"./jsx-runtime-dm82oUI8.js";var t=e();function n({tone:e=`neutral`,icon:n,children:r}){return(0,t.jsxs)(`span`,{className:`status-pill status-pill--${e}`,children:[n,(0,t.jsx)(`span`,{children:r})]})}export{n as t};

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/StatusPill-DYejnVUU.js


+ 1 - 1
public/assets/StatusTag-5kTuQ3q1.js → public/assets/StatusTag-BOdwUJ1y.js

@@ -1 +1 @@
-import{a as e}from"./theme-DI8XQWlV.js";import{t}from"./jsx-runtime-dm82oUI8.js";import{t as n}from"./badge-CT2dDAGA.js";import{t as r}from"./StatusPill-DYejnVUU.js";import{r as i}from"./domain-model-BLOrIAIx.js";var a=t();function o(e){switch(e){case`success`:return`success`;case`warning`:case`processing`:return`warning`;case`error`:return`error`;default:return`neutral`}}function s({status:t,record:s,label:c,mode:l=`tag`}){let{t:u}=e(),d=i(s||{status:t}),f=c||u(`status.${d.key}`);return l===`badge`?(0,a.jsx)(n,{status:d.color==="default"?`default`:d.color,text:f}):(0,a.jsx)(r,{tone:o(d.color),children:f})}export{s as t};
+import{a as e}from"./theme-B5baUfEh.js";import{t}from"./jsx-runtime-dm82oUI8.js";import{t as n}from"./badge-CT2dDAGA.js";import{t as r}from"./StatusPill-BM21x0ED.js";import{r as i}from"./domain-model-BLOrIAIx.js";var a=t();function o(e){switch(e){case`success`:return`success`;case`warning`:case`processing`:return`warning`;case`error`:return`error`;default:return`neutral`}}function s({status:t,record:s,label:c,mode:l=`tag`}){let{t:u}=e(),d=i(s||{status:t}),f=c||u(`status.${d.key}`);return l===`badge`?(0,a.jsx)(n,{status:d.color==="default"?`default`:d.color,text:f}):(0,a.jsx)(r,{tone:o(d.color),children:f})}export{s as t};

+ 1 - 1
public/assets/ThunderboltOutlined-Bnk5EjOs.js → public/assets/ThunderboltOutlined-CAOlQtzj.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DI8XQWlV.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M848 359.3H627.7L825.8 109c4.1-5.3.4-13-6.3-13H436c-2.8 0-5.5 1.5-6.9 4L170 547.5c-3.1 5.3.7 12 6.9 12h174.4l-89.4 357.6c-1.9 7.8 7.5 13.3 13.3 7.7L853.5 373c5.2-4.9 1.7-13.7-5.5-13.7zM378.2 732.5l60.3-241H281.1l189.6-327.4h224.6L487 427.4h211L378.2 732.5z`}}]},name:`thunderbolt`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-B5baUfEh.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M848 359.3H627.7L825.8 109c4.1-5.3.4-13-6.3-13H436c-2.8 0-5.5 1.5-6.9 4L170 547.5c-3.1 5.3.7 12 6.9 12h174.4l-89.4 357.6c-1.9 7.8 7.5 13.3 13.3 7.7L853.5 373c5.2-4.9 1.7-13.7-5.5-13.7zM378.2 732.5l60.3-241H281.1l189.6-327.4h224.6L487 427.4h211L378.2 732.5z`}}]},name:`thunderbolt`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/Webhooks-BfQuuVxm.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/Webhooks-pSWRIh7Y.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 1 - 1
public/assets/form-GQckeBSC.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 1 - 1
public/assets/grid-DaQoG6GE.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 1
public/assets/index-Dztone_T.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/landing-CEGWIr1r.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/list-BBhECcFB.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/list-Cfo2HgHN.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/login-D7D2knjl.js


+ 1 - 1
public/assets/modal-tQWt4gNJ.js → public/assets/modal-Ba-m1ypG.js

@@ -1 +1 @@
-import{a as e,n as t}from"./react-dom-stZKkA6x.js";import{C as n,D as r,S as i,T as a,_ as o,b as s,f as c,g as l,h as u,m as d,p as f,v as p,w as m,x as h,y as g}from"./theme-DI8XQWlV.js";import{Mr as _,Rn as v,cn as y,rt as b}from"./jsx-runtime-dm82oUI8.js";var x=e(t()),S=e(_()),C=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(e!=null&&typeof Object.getOwnPropertySymbols==`function`)for(var i=0,r=Object.getOwnPropertySymbols(e);i<r.length;i++)t.indexOf(r[i])<0&&Object.prototype.propertyIsEnumerable.call(e,r[i])&&(n[r[i]]=e[r[i]]);return n},w=b(e=>{let{prefixCls:t,className:i,closeIcon:o,closable:s,type:c,title:l,children:u,footer:d}=e,f=C(e,[`prefixCls`,`className`,`closeIcon`,`closable`,`type`,`title`,`children`,`footer`]),{getPrefixCls:p}=x.useContext(v),g=p(),_=t||p(`modal`),b=y(g),[w,T,E]=n(_,b),D=`${_}-confirm`,O={};return O=c?{closable:s??!1,title:``,footer:``,children:x.createElement(h,Object.assign({},e,{prefixCls:_,confirmPrefixCls:D,rootPrefixCls:g,content:u}))}:{closable:s??!0,title:l,footer:d!==null&&x.createElement(m,Object.assign({},e)),children:u},w(x.createElement(r,Object.assign({prefixCls:_,className:(0,S.default)(T,`${_}-pure-panel`,c&&D,c&&`${D}-${c}`,i,E,b)},f,{closeIcon:a(_,o),closable:s},O)))});function T(e){return f(g(e))}var E=i;E.useModal=c,E.info=function(e){return f(o(e))},E.success=function(e){return f(p(e))},E.error=function(e){return f(l(e))},E.warning=T,E.warn=T,E.confirm=function(e){return f(u(e))},E.destroyAll=function(){for(;s.length;){let e=s.pop();e&&e()}},E.config=d,E._InternalPanelDoNotUseOrYouWillBeFired=w;export{E as t};
+import{a as e,n as t}from"./react-dom-stZKkA6x.js";import{C as n,D as r,S as i,T as a,_ as o,b as s,f as c,g as l,h as u,m as d,p as f,v as p,w as m,x as h,y as g}from"./theme-B5baUfEh.js";import{Mr as _,Rn as v,cn as y,rt as b}from"./jsx-runtime-dm82oUI8.js";var x=e(t()),S=e(_()),C=function(e,t){var n={};for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&t.indexOf(r)<0&&(n[r]=e[r]);if(e!=null&&typeof Object.getOwnPropertySymbols==`function`)for(var i=0,r=Object.getOwnPropertySymbols(e);i<r.length;i++)t.indexOf(r[i])<0&&Object.prototype.propertyIsEnumerable.call(e,r[i])&&(n[r[i]]=e[r[i]]);return n},w=b(e=>{let{prefixCls:t,className:i,closeIcon:o,closable:s,type:c,title:l,children:u,footer:d}=e,f=C(e,[`prefixCls`,`className`,`closeIcon`,`closable`,`type`,`title`,`children`,`footer`]),{getPrefixCls:p}=x.useContext(v),g=p(),_=t||p(`modal`),b=y(g),[w,T,E]=n(_,b),D=`${_}-confirm`,O={};return O=c?{closable:s??!1,title:``,footer:``,children:x.createElement(h,Object.assign({},e,{prefixCls:_,confirmPrefixCls:D,rootPrefixCls:g,content:u}))}:{closable:s??!0,title:l,footer:d!==null&&x.createElement(m,Object.assign({},e)),children:u},w(x.createElement(r,Object.assign({prefixCls:_,className:(0,S.default)(T,`${_}-pure-panel`,c&&D,c&&`${D}-${c}`,i,E,b)},f,{closeIcon:a(_,o),closable:s},O)))});function T(e){return f(g(e))}var E=i;E.useModal=c,E.info=function(e){return f(o(e))},E.success=function(e){return f(p(e))},E.error=function(e){return f(l(e))},E.warning=T,E.warn=T,E.confirm=function(e){return f(u(e))},E.destroyAll=function(){for(;s.length;){let e=s.pop();e&&e()}},E.config=d,E._InternalPanelDoNotUseOrYouWillBeFired=w;export{E as t};

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/popconfirm-BvP3t7-7.js


+ 0 - 1
public/assets/row-CYL70Wz3.js

@@ -1 +0,0 @@
-import{n as e,r as t}from"./grid-CbrYQCAq.js";var n=t,r=e;export{n,r as t};

+ 1 - 0
public/assets/row-Cd6f9U9h.js

@@ -0,0 +1 @@
+import{n as e,r as t}from"./grid-DaQoG6GE.js";var n=t,r=e;export{n,r as t};

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/table-7bnHOUOM.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/theme-B5baUfEh.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
public/assets/theme-QBN1gbX1.css


+ 3 - 3
public/index.html

@@ -4,12 +4,12 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-8y_yP12y.js"></script>
+    <script type="module" crossorigin src="/assets/index-Dztone_T.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/react-dom-stZKkA6x.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-dm82oUI8.js">
-    <link rel="modulepreload" crossorigin href="/assets/theme-DI8XQWlV.js">
-    <link rel="stylesheet" crossorigin href="/assets/theme-e1juCvr1.css">
+    <link rel="modulepreload" crossorigin href="/assets/theme-B5baUfEh.js">
+    <link rel="stylesheet" crossorigin href="/assets/theme-QBN1gbX1.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>
   <body>

+ 1 - 1
public/landing.html

@@ -5,7 +5,7 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="description" content="MailHub — self-hosted transactional email with API, SMTP, DNS and webhooks." />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/landing-BUb9icyI.js"></script>
+    <script type="module" crossorigin src="/assets/landing-CEGWIr1r.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="stylesheet" crossorigin href="/assets/landing-BkPrzdt8.css">
   </head>

+ 5 - 5
public/login.html

@@ -4,17 +4,17 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub Auth</title>
-    <script type="module" crossorigin src="/assets/login-Cu9iKgzv.js"></script>
+    <script type="module" crossorigin src="/assets/login-D7D2knjl.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/react-dom-stZKkA6x.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-dm82oUI8.js">
-    <link rel="modulepreload" crossorigin href="/assets/theme-DI8XQWlV.js">
-    <link rel="modulepreload" crossorigin href="/assets/grid-CbrYQCAq.js">
+    <link rel="modulepreload" crossorigin href="/assets/theme-B5baUfEh.js">
+    <link rel="modulepreload" crossorigin href="/assets/grid-DaQoG6GE.js">
     <link rel="modulepreload" crossorigin href="/assets/segmented-DUpU-ING.js">
     <link rel="modulepreload" crossorigin href="/assets/useForm-sdDvuRHy.js">
-    <link rel="modulepreload" crossorigin href="/assets/form-DcO_GhUr.js">
+    <link rel="modulepreload" crossorigin href="/assets/form-GQckeBSC.js">
     <link rel="modulepreload" crossorigin href="/assets/input-0_Xo22Fl.js">
-    <link rel="stylesheet" crossorigin href="/assets/theme-e1juCvr1.css">
+    <link rel="stylesheet" crossorigin href="/assets/theme-QBN1gbX1.css">
   </head>
   <body>
     <div id="auth-root"></div>

+ 19 - 0
src/components/mailbox/MailboxPermissionTags.tsx

@@ -0,0 +1,19 @@
+import { Space, Tag } from 'antd';
+
+import { useI18n } from '../../frontend/i18n/react';
+import type { MailboxPermissions } from '../../frontend/types';
+
+export function MailboxPermissionTags({ permissions }: { permissions: MailboxPermissions }) {
+  const { t } = useI18n();
+  const values = [
+    permissions.view ? ['view', t('mailboxPermission.view')] : null,
+    permissions.receive ? ['receive', t('mailboxPermission.receive')] : null,
+    permissions.send ? ['send', t('mailboxPermission.send')] : null
+  ].filter(Boolean) as string[][];
+
+  return values.length ? (
+    <Space size={[4, 4]} wrap>
+      {values.map(([key, label]) => <Tag key={key}>{label}</Tag>)}
+    </Space>
+  ) : <Tag>{t('mailboxPermission.none')}</Tag>;
+}

+ 392 - 27
src/db.js

@@ -190,6 +190,19 @@ export function initDatabase(dataDir, secret = '') {
       FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
     );
 
+    CREATE TABLE IF NOT EXISTS inbound_mailbox_grants (
+      mailbox_id INTEGER NOT NULL,
+      user_id INTEGER NOT NULL,
+      can_view TEXT NOT NULL DEFAULT 'false',
+      can_receive TEXT NOT NULL DEFAULT 'false',
+      can_send TEXT NOT NULL DEFAULT 'false',
+      created_at TEXT NOT NULL,
+      updated_at TEXT NOT NULL,
+      PRIMARY KEY (mailbox_id, user_id),
+      FOREIGN KEY(mailbox_id) REFERENCES inbound_mailboxes(id) ON DELETE CASCADE,
+      FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+    );
+
     CREATE TABLE IF NOT EXISTS inbound_messages (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       mailbox_id INTEGER NOT NULL,
@@ -358,6 +371,7 @@ export function initDatabase(dataDir, secret = '') {
     CREATE INDEX IF NOT EXISTS idx_tracking_events_type_time ON tracking_events(event_type, occurred_at);
     CREATE INDEX IF NOT EXISTS idx_inbound_mailboxes_user_id ON inbound_mailboxes(user_id);
     CREATE INDEX IF NOT EXISTS idx_inbound_mailboxes_domain_id ON inbound_mailboxes(domain_id);
+    CREATE INDEX IF NOT EXISTS idx_inbound_mailbox_grants_user_mailbox ON inbound_mailbox_grants(user_id, mailbox_id);
     CREATE INDEX IF NOT EXISTS idx_inbound_messages_user_received ON inbound_messages(user_id, received_at);
     CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_received ON inbound_messages(mailbox_id, received_at);
     CREATE INDEX IF NOT EXISTS idx_inbound_folders_mailbox ON inbound_folders(mailbox_id, deleted_at);
@@ -643,6 +657,119 @@ export function getAdminResourceInventory() {
   };
 }
 
+export function listAdminInboundMailboxAccess() {
+  const ownersById = new Map(listUsers().map((user) => [user.id, user]));
+  const grantsByMailboxId = new Map();
+  const grantRows = requireDb()
+    .prepare(`
+      SELECT
+        g.mailbox_id,
+        g.can_view,
+        g.can_receive,
+        g.can_send,
+        g.created_at AS grant_created_at,
+        g.updated_at AS grant_updated_at,
+        u.id,
+        u.username,
+        u.email,
+        u.role,
+        u.status,
+        u.created_at,
+        u.updated_at
+      FROM inbound_mailbox_grants g
+      JOIN inbound_mailboxes m ON m.id = g.mailbox_id AND m.deleted_at IS NULL
+      JOIN users u ON u.id = g.user_id
+      ORDER BY g.mailbox_id, u.username COLLATE NOCASE, u.id
+    `)
+    .all();
+  for (const row of grantRows) {
+    const grants = grantsByMailboxId.get(row.mailbox_id) || [];
+    grants.push({
+      user: publicUser(row),
+      permissions: storedInboundMailboxGrantPermissions(row),
+      createdAt: row.grant_created_at,
+      updatedAt: row.grant_updated_at
+    });
+    grantsByMailboxId.set(row.mailbox_id, grants);
+  }
+
+  return requireDb()
+    .prepare(`
+      SELECT
+        m.*,
+        d.domain,
+        COUNT(msg.id) AS message_count,
+        COALESCE(SUM(CASE WHEN msg.read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count,
+        MAX(msg.received_at) AS last_message_at
+      FROM inbound_mailboxes m
+      JOIN domains d ON d.id = m.domain_id
+      LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
+      WHERE m.deleted_at IS NULL
+      GROUP BY m.id
+      ORDER BY COALESCE(last_message_at, m.created_at) DESC, m.id DESC
+    `)
+    .all()
+    .map((row) => ({
+      mailbox: publicInboundMailbox(row),
+      owner: ownersById.get(row.user_id) || null,
+      grants: grantsByMailboxId.get(row.id) || []
+    }));
+}
+
+export function replaceInboundMailboxGrants(mailboxId, grants) {
+  return withTransaction(() => {
+    const mailbox = requireDb()
+      .prepare('SELECT id, user_id FROM inbound_mailboxes WHERE id = ? AND deleted_at IS NULL')
+      .get(Number(mailboxId));
+    if (!mailbox) throw new Error('收信邮箱不存在。');
+    if (!Array.isArray(grants)) throw new Error('邮箱授权列表格式不正确。');
+
+    const normalized = [];
+    const userIds = new Set();
+    for (const grant of grants) {
+      const userId = Number(grant?.userId);
+      if (!Number.isSafeInteger(userId) || userId <= 0) throw new Error('授权用户不正确。');
+      if (userId === Number(mailbox.user_id)) throw new Error('邮箱所有者无需额外授权。');
+      if (userIds.has(userId)) throw new Error('邮箱授权用户不能重复。');
+      userIds.add(userId);
+      const permissions = normalizeInboundMailboxGrantPermissions(grant?.permissions || grant);
+      if (!permissions.view && !permissions.receive && !permissions.send) {
+        throw new Error('邮箱授权至少需要一项权限。');
+      }
+      normalized.push({ userId, permissions });
+    }
+
+    if (normalized.length) {
+      const placeholders = normalized.map(() => '?').join(', ');
+      const users = requireDb()
+        .prepare(`SELECT id FROM users WHERE id IN (${placeholders})`)
+        .all(...normalized.map((grant) => grant.userId));
+      if (users.length !== normalized.length) throw new Error('用户不存在。');
+    }
+
+    requireDb().prepare('DELETE FROM inbound_mailbox_grants WHERE mailbox_id = ?').run(mailbox.id);
+    const timestamp = now();
+    const insert = requireDb().prepare(`
+      INSERT INTO inbound_mailbox_grants (
+        mailbox_id, user_id, can_view, can_receive, can_send, created_at, updated_at
+      ) VALUES (?, ?, ?, ?, ?, ?, ?)
+    `);
+    for (const grant of normalized) {
+      insert.run(
+        mailbox.id,
+        grant.userId,
+        boolString(grant.permissions.view),
+        boolString(grant.permissions.receive),
+        boolString(grant.permissions.send),
+        timestamp,
+        timestamp
+      );
+    }
+
+    return listAdminInboundMailboxAccess().find((entry) => entry.mailbox.id === mailbox.id) || null;
+  });
+}
+
 export function transferDomain({ actorUserId, domainId, targetUserId, dnsCredentialMode = 'domain_only' }) {
   return withTransaction(() => {
     const target = requireTransferTargetUser(targetUserId);
@@ -653,6 +780,7 @@ export function transferDomain({ actorUserId, domainId, targetUserId, dnsCredent
       .prepare('UPDATE domains SET user_id = ?, dns_credential_id = ?, updated_at = ? WHERE id = ?')
       .run(target.id, nextDnsCredentialId, now(), domain.id);
     const inboundCounts = moveInboundDomainResources(domain.id, target.id);
+    deleteSelfInboundMailboxGrants({ domainId: domain.id });
     if (mode === 'with_dns_credential' && domain.dns_credential_id) {
       const credential = requireDnsCredentialRow(domain.dns_credential_id);
       if (credential.user_id !== domain.user_id) throw new Error('DNS 凭据归属不一致。');
@@ -813,6 +941,8 @@ export function executeUserMerge({ actorUserId, sourceUserId, targetUserId, opti
     if (options.transferSmtpCredential !== false && preview.counts.smtpCredential > 0) {
       counts.smtpCredential = moveRows('smtp_credentials', sourceId, targetId);
     }
+    mergeInboundMailboxGrantsForUsers(sourceId, targetId);
+    deleteSelfInboundMailboxGrants({ ownerUserId: targetId });
     if (options.disableSource !== false) {
       requireDb()
         .prepare("UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?")
@@ -1182,48 +1312,67 @@ export function updateInboundMailbox(userId, id, patch = {}) {
 
 export function listInboundMailboxes(userId, access = {}) {
   const accessFilter = inboundAccessFilter('m', userId, access);
+  const exposeAccess = accessFilter.permission !== 'owner' || Boolean(access.includeAllUsers);
   return requireDb()
     .prepare(`
       SELECT
         m.*,
         d.domain,
+        access_grant.can_view AS access_can_view,
+        access_grant.can_receive AS access_can_receive,
+        access_grant.can_send AS access_can_send,
         COUNT(msg.id) AS message_count,
         COALESCE(SUM(CASE WHEN msg.read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count,
         MAX(msg.received_at) AS last_message_at
       FROM inbound_mailboxes m
       JOIN domains d ON d.id = m.domain_id
+      LEFT JOIN inbound_mailbox_grants access_grant
+        ON access_grant.mailbox_id = m.id AND access_grant.user_id = ?
       LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
       WHERE ${accessFilter.clause} AND m.deleted_at IS NULL
       GROUP BY m.id
       ORDER BY COALESCE(last_message_at, m.created_at) DESC, m.id DESC
     `)
-    .all(...accessFilter.params)
-    .map(publicInboundMailbox);
+    .all(Number(userId), ...accessFilter.params)
+    .map((row) => publicInboundMailbox(row, {
+      accessContext: exposeAccess ? { userId, includeAllUsers: access.includeAllUsers } : null
+    }));
 }
 
 export function getInboundMailbox(id, userId, {
   includeHash = false,
   includeSecret = false,
   includeAllUsers = false,
-  mailboxIds = null
+  mailboxIds = null,
+  permission = 'owner'
 } = {}) {
-  const accessFilter = inboundAccessFilter('m', userId, { includeAllUsers, mailboxIds });
+  const accessFilter = inboundAccessFilter('m', userId, { includeAllUsers, mailboxIds, permission });
+  const exposeAccess = accessFilter.permission !== 'owner' || includeAllUsers;
   const row = requireDb()
     .prepare(`
       SELECT
         m.*,
         d.domain,
+        access_grant.can_view AS access_can_view,
+        access_grant.can_receive AS access_can_receive,
+        access_grant.can_send AS access_can_send,
         COUNT(msg.id) AS message_count,
         COALESCE(SUM(CASE WHEN msg.read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count,
         MAX(msg.received_at) AS last_message_at
       FROM inbound_mailboxes m
       JOIN domains d ON d.id = m.domain_id
+      LEFT JOIN inbound_mailbox_grants access_grant
+        ON access_grant.mailbox_id = m.id AND access_grant.user_id = ?
       LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
       WHERE m.id = ? AND ${accessFilter.clause} AND m.deleted_at IS NULL
       GROUP BY m.id
     `)
-    .get(Number(id), ...accessFilter.params);
-  return publicInboundMailbox(row, { includeHash, includeSecret });
+    .get(Number(userId), Number(id), ...accessFilter.params);
+  return publicInboundMailbox(row, {
+    includeHash,
+    includeSecret,
+    accessContext: exposeAccess ? { userId, includeAllUsers } : null
+  });
 }
 
 export function getInboundMailboxByAddress(address, { includeHash = false, includeSecret = false } = {}) {
@@ -1246,11 +1395,23 @@ export function getInboundMailboxByAddress(address, { includeHash = false, inclu
   return publicInboundMailbox(row, { includeHash, includeSecret });
 }
 
-export function getInboundMailboxForSender(userId, address) {
+export function getInboundMailboxForSender(userId, address, {
+  ownerOnly = false,
+  mailboxIds = null
+} = {}) {
   const cleanAddress = normalizeInboundAddress(address);
   if (!cleanAddress) return null;
   const mailbox = getInboundMailboxByAddress(cleanAddress) || getInboundMailboxByAliasAddress(cleanAddress);
-  return mailbox?.userId === Number(userId) ? mailbox : null;
+  if (!mailbox) return null;
+  if (Array.isArray(mailboxIds)) {
+    const allowedMailboxIds = new Set(mailboxIds
+      .map((id) => Number(id))
+      .filter((id) => Number.isSafeInteger(id) && id > 0));
+    if (!allowedMailboxIds.has(Number(mailbox.id))) return null;
+  }
+  if (mailbox.userId === Number(userId)) return mailbox;
+  if (ownerOnly) return null;
+  return hasInboundMailboxGrantPermission(mailbox.id, userId, 'send') ? mailbox : null;
 }
 
 export function verifyInboundMailboxCredential(username, password) {
@@ -1901,17 +2062,18 @@ export function markMissingInboundMaildirMessages(mailboxId, presentStorageKeys)
     .run(updatedAt, updatedAt, updatedAt, Number(mailboxId), ...missing).changes || 0);
 }
 
-export function getInboundMessageMaildirStorage(userId, id) {
+export function getInboundMessageMaildirStorage(userId, id, access = {}) {
+  const accessFilter = inboundAccessFilter('msg', userId, access);
   const row = requireDb()
     .prepare(`
       SELECT msg.id, msg.storage_backend, msg.storage_key, msg.storage_relpath,
              msg.flags_json, msg.read_state, m.address AS mailbox_address
       FROM inbound_messages msg
       JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
-      WHERE msg.id = ? AND msg.user_id = ? AND msg.deleted_at IS NULL
+      WHERE msg.id = ? AND ${accessFilter.clause} AND msg.deleted_at IS NULL
       LIMIT 1
     `)
-    .get(Number(id), Number(userId));
+    .get(Number(id), ...accessFilter.params);
   if (!row) return null;
   return {
     id: Number(row.id),
@@ -2241,20 +2403,25 @@ export function getInboundMailboxProtocolMessage(mailbox, messageId, { folder =
   return publicInboundMessage(row, { includeRawBytes: true });
 }
 
-export function markInboundMessageRead(userId, id, read = true) {
+export function markInboundMessageRead(userId, id, read = true, access = {}) {
+  const accessFilter = inboundAccessFilter('inbound_messages', userId, access);
   const current = requireDb()
-    .prepare('SELECT flags_json FROM inbound_messages WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
-    .get(Number(id), userId);
+    .prepare(`SELECT flags_json FROM inbound_messages WHERE id = ? AND ${accessFilter.clause} AND deleted_at IS NULL`)
+    .get(Number(id), ...accessFilter.params);
   if (!current) return null;
   const flags = normalizeImportedStringList(safeJson(current.flags_json, []))
     .filter((flag) => flag.toLowerCase() !== '\\seen');
   if (read) flags.push('\\Seen');
   const updatedAt = now();
   const result = requireDb()
-    .prepare('UPDATE inbound_messages SET read_state = ?, flags_json = ?, updated_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
-    .run(read ? 'true' : 'false', JSON.stringify(flags), updatedAt, Number(id), userId);
+    .prepare(`
+      UPDATE inbound_messages
+      SET read_state = ?, flags_json = ?, updated_at = ?
+      WHERE id = ? AND ${accessFilter.clause} AND deleted_at IS NULL
+    `)
+    .run(read ? 'true' : 'false', JSON.stringify(flags), updatedAt, Number(id), ...accessFilter.params);
   if (!result.changes) return null;
-  return getInboundMessage(userId, id);
+  return getInboundMessage(userId, id, access);
 }
 
 export function softDeleteInboundMessages(userId, mailboxId, ids, { folder = null } = {}) {
@@ -4488,6 +4655,83 @@ function moveInboundResourcesForUserDomains(sourceUserId, targetUserId) {
   };
 }
 
+function deleteSelfInboundMailboxGrants({ domainId = null, ownerUserId = null } = {}) {
+  const filters = [];
+  const params = [];
+  if (domainId !== null) {
+    filters.push('m.domain_id = ?');
+    params.push(Number(domainId));
+  }
+  if (ownerUserId !== null) {
+    filters.push('m.user_id = ?');
+    params.push(Number(ownerUserId));
+  }
+  const extraWhere = filters.length ? `AND ${filters.join(' AND ')}` : '';
+  return requireDb()
+    .prepare(`
+      DELETE FROM inbound_mailbox_grants
+      WHERE EXISTS (
+        SELECT 1
+        FROM inbound_mailboxes m
+        WHERE m.id = inbound_mailbox_grants.mailbox_id
+          AND m.user_id = inbound_mailbox_grants.user_id
+          ${extraWhere}
+      )
+    `)
+    .run(...params).changes;
+}
+
+function mergeInboundMailboxGrantsForUsers(sourceUserId, targetUserId) {
+  const sourceGrants = requireDb()
+    .prepare(`
+      SELECT g.*, m.user_id AS mailbox_owner_user_id
+      FROM inbound_mailbox_grants g
+      JOIN inbound_mailboxes m ON m.id = g.mailbox_id
+      WHERE g.user_id = ?
+    `)
+    .all(Number(sourceUserId));
+  if (!sourceGrants.length) return 0;
+
+  const getTargetGrant = requireDb().prepare(`
+    SELECT * FROM inbound_mailbox_grants WHERE mailbox_id = ? AND user_id = ?
+  `);
+  const upsert = requireDb().prepare(`
+    INSERT INTO inbound_mailbox_grants (
+      mailbox_id, user_id, can_view, can_receive, can_send, created_at, updated_at
+    ) VALUES (?, ?, ?, ?, ?, ?, ?)
+    ON CONFLICT(mailbox_id, user_id) DO UPDATE SET
+      can_view = excluded.can_view,
+      can_receive = excluded.can_receive,
+      can_send = excluded.can_send,
+      updated_at = excluded.updated_at
+  `);
+  const timestamp = now();
+  let moved = 0;
+  for (const sourceGrant of sourceGrants) {
+    if (Number(sourceGrant.mailbox_owner_user_id) === Number(targetUserId)) continue;
+    const targetGrant = getTargetGrant.get(sourceGrant.mailbox_id, Number(targetUserId));
+    const sourcePermissions = storedInboundMailboxGrantPermissions(sourceGrant);
+    const targetPermissions = storedInboundMailboxGrantPermissions(targetGrant);
+    const permissions = normalizeInboundMailboxGrantPermissions({
+      view: sourcePermissions.view || targetPermissions.view,
+      receive: sourcePermissions.receive || targetPermissions.receive,
+      send: sourcePermissions.send || targetPermissions.send
+    });
+    upsert.run(
+      sourceGrant.mailbox_id,
+      Number(targetUserId),
+      boolString(permissions.view),
+      boolString(permissions.receive),
+      boolString(permissions.send),
+      targetGrant?.created_at || sourceGrant.created_at || timestamp,
+      timestamp
+    );
+    moved += 1;
+  }
+  requireDb().prepare('DELETE FROM inbound_mailbox_grants WHERE user_id = ?').run(Number(sourceUserId));
+  return moved;
+}
+
 function requireDomainRow(domainId) {
   const domain = requireDb().prepare('SELECT * FROM domains WHERE id = ?').get(Number(domainId));
   if (!domain) throw new Error('域名不存在。');
@@ -4738,13 +4982,20 @@ function privateDomainRow(row) {
   return publicRow ? { ...publicRow, dkimPrivate: row.dkim_private } : null;
 }
 
-function publicInboundMailbox(row, { includeHash = false, includeSecret = false } = {}) {
+function publicInboundMailbox(row, {
+  includeHash = false,
+  includeSecret = false,
+  accessContext = null
+} = {}) {
   if (!row) return null;
   const passwordRecoverable = Boolean(row.password_secret && decryptSecret(row.password_secret));
   const expiresAt = row.expires_at || null;
+  const access = inboundMailboxAccessFromRow(row, accessContext);
+  const hideMessageMetadata = access?.type === 'assigned' && !access.permissions.receive;
   return {
     id: row.id,
     userId: row.user_id,
+    ownerUserId: row.user_id,
     domainId: row.domain_id,
     domain: row.domain || '',
     address: row.address,
@@ -4759,9 +5010,10 @@ function publicInboundMailbox(row, { includeHash = false, includeSecret = false
     status: inboundMailboxStatus(row),
     expiresAt,
     temporary: Boolean(expiresAt),
-    messageCount: Number(row.message_count || 0),
-    unreadCount: Number(row.unread_count || 0),
-    lastMessageAt: row.last_message_at || null,
+    messageCount: hideMessageMetadata ? null : Number(row.message_count || 0),
+    unreadCount: hideMessageMetadata ? null : Number(row.unread_count || 0),
+    lastMessageAt: hideMessageMetadata ? null : (row.last_message_at || null),
+    ...(access ? { access } : {}),
     ...(includeHash ? { passwordHash: row.password_hash } : {}),
     ...(includeSecret ? { passwordSecret: row.password_secret } : {}),
     createdAt: row.created_at,
@@ -4769,6 +5021,27 @@ function publicInboundMailbox(row, { includeHash = false, includeSecret = false
   };
 }
 
+function inboundMailboxAccessFromRow(row, context) {
+  if (!row || !context) return null;
+  if (Number(row.user_id) === Number(context.userId)) {
+    return {
+      type: 'owner',
+      permissions: { view: true, receive: true, send: true }
+    };
+  }
+  const permissions = storedInboundMailboxGrantPermissions(row, 'access_');
+  if (permissions.view || permissions.receive || permissions.send) {
+    return { type: 'assigned', permissions };
+  }
+  if (context.includeAllUsers) {
+    return {
+      type: 'admin',
+      permissions: { view: true, receive: true, send: false }
+    };
+  }
+  return null;
+}
+
 function publicInboundMessage(row, { includeBody = false, includeRawBytes = false } = {}) {
   if (!row) return null;
   const rawMessageSize = row.raw_message_size === undefined
@@ -5450,13 +5723,35 @@ function normalizeUserRolePatch(value, fallback) {
   throw new Error('用户角色不正确。');
 }
 
-function inboundAccessFilter(alias, userId, { includeAllUsers = false, mailboxIds = null } = {}) {
+function inboundAccessFilter(alias, userId, {
+  includeAllUsers = false,
+  mailboxIds = null,
+  permission = 'owner'
+} = {}) {
   const clauses = [];
   const params = [];
   const mailboxColumn = alias === 'm' ? 'id' : 'mailbox_id';
+  const cleanPermission = normalizeInboundMailboxAccessPermission(permission);
   if (!includeAllUsers) {
-    clauses.push(`${alias}.user_id = ?`);
-    params.push(Number(userId));
+    if (cleanPermission === 'owner') {
+      clauses.push(`${alias}.user_id = ?`);
+      params.push(Number(userId));
+    } else {
+      const grantPermission = cleanPermission === 'receive'
+        ? "grant_access.can_receive = 'true'"
+        : "(grant_access.can_view = 'true' OR grant_access.can_receive = 'true' OR grant_access.can_send = 'true')";
+      clauses.push(`(
+        ${alias}.user_id = ?
+        OR EXISTS (
+          SELECT 1
+          FROM inbound_mailbox_grants grant_access
+          WHERE grant_access.mailbox_id = ${alias}.${mailboxColumn}
+            AND grant_access.user_id = ?
+            AND ${grantPermission}
+        )
+      )`);
+      params.push(Number(userId), Number(userId));
+    }
   }
   if (Array.isArray(mailboxIds)) {
     const ids = [...new Set(mailboxIds
@@ -5471,10 +5766,67 @@ function inboundAccessFilter(alias, userId, { includeAllUsers = false, mailboxId
   }
   return {
     clause: clauses.length ? clauses.join(' AND ') : '1 = 1',
-    params
+    params,
+    permission: cleanPermission
   };
 }
 
+function normalizeInboundMailboxAccessPermission(value) {
+  const permission = String(value || 'owner').trim().toLowerCase();
+  if (!['owner', 'view', 'receive'].includes(permission)) {
+    throw new Error('邮箱访问权限不正确。');
+  }
+  return permission;
+}
+
+function normalizeInboundMailboxGrantPermissions(value = {}) {
+  const permissions = Array.isArray(value)
+    ? {
+        view: value.includes('view'),
+        receive: value.includes('receive'),
+        send: value.includes('send')
+      }
+    : value;
+  if (!permissions || typeof permissions !== 'object') throw new Error('邮箱授权权限格式不正确。');
+  for (const key of ['view', 'receive', 'send']) {
+    if (permissions[key] !== undefined && typeof permissions[key] !== 'boolean') {
+      throw new Error('邮箱授权权限格式不正确。');
+    }
+  }
+  const receive = permissions.receive === true;
+  const send = permissions.send === true;
+  return {
+    view: permissions.view === true || receive || send,
+    receive,
+    send
+  };
+}
+
+function storedInboundMailboxGrantPermissions(row, prefix = '') {
+  return {
+    view: row?.[`${prefix}can_view`] === 'true',
+    receive: row?.[`${prefix}can_receive`] === 'true',
+    send: row?.[`${prefix}can_send`] === 'true'
+  };
+}
+
+function hasInboundMailboxGrantPermission(mailboxId, userId, permission) {
+  const column = {
+    view: 'can_view',
+    receive: 'can_receive',
+    send: 'can_send'
+  }[String(permission || '').trim().toLowerCase()];
+  if (!column) throw new Error('邮箱授权权限不正确。');
+  return Boolean(requireDb()
+    .prepare(`
+      SELECT 1
+      FROM inbound_mailbox_grants
+      WHERE mailbox_id = ? AND user_id = ? AND ${column} = 'true'
+      LIMIT 1
+    `)
+    .get(Number(mailboxId), Number(userId)));
+}
+
 function normalizeApiTokenScopes(value) {
   const candidates = value === undefined ? defaultApiTokenScopes : (Array.isArray(value) ? value : [value]);
   const scopes = [...new Set(candidates.map((item) => String(item || '').trim()).filter(Boolean))];
@@ -5508,8 +5860,21 @@ function normalizeApiTokenMailboxAccess(userId, { mailboxAccess = 'owner', mailb
   const params = [...ids];
   const where = [`id IN (${placeholders})`, 'deleted_at IS NULL'];
   if (user.role !== 'admin') {
-    where.push('user_id = ?');
-    params.push(Number(userId));
+    where.push(`(
+      user_id = ?
+      OR EXISTS (
+        SELECT 1
+        FROM inbound_mailbox_grants grant_access
+        WHERE grant_access.mailbox_id = inbound_mailboxes.id
+          AND grant_access.user_id = ?
+          AND (
+            grant_access.can_view = 'true'
+            OR grant_access.can_receive = 'true'
+            OR grant_access.can_send = 'true'
+          )
+      )
+    )`);
+    params.push(Number(userId), Number(userId));
   }
   const rows = requireDb()
     .prepare(`SELECT id FROM inbound_mailboxes WHERE ${where.join(' AND ')}`)

+ 33 - 5
src/dns-guide.js

@@ -12,7 +12,10 @@ resolver.setServers(
 export async function buildDnsGuide(domain) {
   const live = await readLiveDns(domain);
   const requiredSpf = buildRequiredSpfMechanisms(domain);
-  const spf = mergeSpfRecords(live.rootTxt.filter(isSpfRecord), requiredSpf);
+  const spf = mergeSpfRecords(live.rootTxt.filter(isSpfRecord), requiredSpf, {
+    pruneMechanisms: domain.sendingIp ? [`ip4:${domain.sendingIp}`] : [],
+    pruneUnconfiguredDirectIps: Boolean(domain.senderHost)
+  });
   const dmarc = mergeDmarcRecord(live.dmarcTxt.find(isDmarcRecord), domain);
   const verificationValue = `mailhub-verification=${domain.verificationToken}`;
   const dkimValue = buildDkimRecord(domain.dkimPublic);
@@ -179,9 +182,11 @@ function isDmarcRecord(value) {
 
 function buildRequiredSpfMechanisms(domain) {
   const mechanisms = [];
-  if (domain.sendingIp) mechanisms.push(`ip4:${domain.sendingIp}`);
   if (domain.senderHost) mechanisms.push(`a:${domain.senderHost}`);
-  mechanisms.push(...splitMechanisms(domain.spfExtra));
+  const managedDirectIp = domain.sendingIp ? normalizeMechanism(`ip4:${domain.sendingIp}`) : '';
+  mechanisms.push(...splitMechanisms(domain.spfExtra).filter((mechanism) => (
+    !managedDirectIp || normalizeMechanism(mechanism) !== managedDirectIp
+  )));
   return uniqueMechanisms(mechanisms);
 }
 
@@ -192,7 +197,10 @@ function splitMechanisms(value) {
     .filter(Boolean);
 }
 
-export function mergeSpfRecords(existingRecords, requiredMechanisms) {
+export function mergeSpfRecords(existingRecords, requiredMechanisms, {
+  pruneMechanisms = [],
+  pruneUnconfiguredDirectIps = false
+} = {}) {
   const warnings = [];
   const current = existingRecords.map(normalizeTxt);
   if (current.length > 1) {
@@ -211,8 +219,28 @@ export function mergeSpfRecords(existingRecords, requiredMechanisms) {
 
   const parsed = current.map(parseSpf);
   const mechanisms = [];
-  for (const record of parsed) mechanisms.push(...record.mechanisms);
+  const required = new Set(requiredMechanisms.map(normalizeMechanism));
+  const pruned = new Set(pruneMechanisms.map(normalizeMechanism));
+  let prunedManagedMechanism = false;
+  for (const record of parsed) {
+    for (const mechanism of record.mechanisms) {
+      const normalized = normalizeMechanism(mechanism);
+      if (
+        (pruned.has(normalized) || (
+          pruneUnconfiguredDirectIps && /^(?:ip4|ip6):/.test(normalized)
+        )) &&
+        !required.has(normalized)
+      ) {
+        prunedManagedMechanism = true;
+        continue;
+      }
+      mechanisms.push(mechanism);
+    }
+  }
   mechanisms.push(...requiredMechanisms);
+  if (prunedManagedMechanism) {
+    warnings.push('已移除未配置的直接 IP 授权;平台发信地址由发信主机 A 记录授权。');
+  }
 
   const all = parsed.find((record) => record.all === '-all')?.all
     || parsed.find((record) => record.all === '~all')?.all

+ 2 - 0
src/frontend/App.tsx

@@ -30,6 +30,7 @@ const Webhooks = lazy(() => import('../pages/Webhooks'));
 const DnsApi = lazy(() => import('../pages/DnsApi'));
 const Admin = lazy(() => import('../pages/Admin'));
 const Settings = lazy(() => import('../pages/Settings'));
+const Account = lazy(() => import('../pages/Account'));
 
 const router = createBrowserRouter(createRoutesFromElements(
   <Route element={<ConsoleBootstrap />}>
@@ -42,6 +43,7 @@ const router = createBrowserRouter(createRoutesFromElements(
       <Route path="domains/:id/:section" element={<RoutePage><DomainDetail /></RoutePage>} />
       <Route path="inbox" element={<RoutePage><Inbox /></RoutePage>} />
       <Route path="inbox/messages/:messageId" element={<RoutePage><Inbox /></RoutePage>} />
+      <Route path="account" element={<RoutePage><Account /></RoutePage>} />
       <Route path="integrations/smtp" element={<RoutePage><SmtpCredentials /></RoutePage>} />
       <Route path="integrations/api-keys" element={<RoutePage><ApiTokens /></RoutePage>} />
       <Route path="integrations/webhooks" element={<RoutePage><Webhooks /></RoutePage>} />

+ 101 - 17
src/frontend/i18n/index.js

@@ -331,6 +331,27 @@ const messages = {
     'inbox.noRawMessage': '暂无原始 MIME',
     'inbox.messageNotFound': '邮件不存在或已被删除。',
     'inbox.detailLoadFailed': '邮件详情加载失败。',
+    'inbox.noReceivingAccess': '当前账号没有可收取邮件的邮箱权限。',
+    'inbox.ownerManaged': '由邮箱所有者管理',
+    'account.title': '账号与邮箱权限',
+    'account.subtitle': '查看账号信息,以及自己拥有或由管理员分配的邮箱权限。',
+    'account.summary': '账号信息',
+    'account.role': '角色',
+    'account.mailboxAccess': '我的邮箱权限',
+    'account.accessSource': '权限来源',
+    'account.permissions': '权限',
+    'account.owned': '拥有',
+    'account.assigned': '管理员分配',
+    'account.ownedMailbox': '我拥有的邮箱',
+    'account.adminAssigned': '管理员分配',
+    'account.adminAccess': '管理员查看',
+    'account.openInbox': '打开收件箱',
+    'account.manageMailbox': '管理邮箱',
+    'account.noMailboxes': '当前账号还没有拥有或被分配的邮箱。',
+    'mailboxPermission.view': '查看配置',
+    'mailboxPermission.receive': '收取邮件',
+    'mailboxPermission.send': '发送邮件',
+    'mailboxPermission.none': '无权限',
     'logs.trackingScope': '统计范围',
     'logs.messageLevel': '消息级(多收件人)',
     'logs.recipientLevel': '单收件人',
@@ -407,10 +428,31 @@ const messages = {
     'settings.approvalNotRequired': '自动启用',
     'admin.title': '管理中心',
     'admin.users': '用户',
+    'admin.mailboxAccess': '邮箱授权',
     'admin.resources': '资源',
     'admin.migration': '合并迁移',
     'admin.systemEmail': '系统邮件',
     'admin.auditLogs': '审计日志',
+    'adminMailboxAccess.title': '邮箱权限管理',
+    'adminMailboxAccess.drawerTitle': '配置邮箱权限',
+    'adminMailboxAccess.search': '搜索邮箱、所有者或邮箱地址',
+    'adminMailboxAccess.owner': '邮箱所有者',
+    'adminMailboxAccess.ownerPermissions': '所有者权限',
+    'adminMailboxAccess.assignedUsers': '已分配用户',
+    'adminMailboxAccess.configure': '配置权限',
+    'adminMailboxAccess.permissions': '邮箱权限',
+    'adminMailboxAccess.permissionHint': '收取邮件或发送邮件会自动包含查看配置权限;邮箱所有者始终拥有完整权限且不能移除。',
+    'adminMailboxAccess.permissionRequired': '每个用户至少选择一项权限,或移除该授权行。',
+    'adminMailboxAccess.user': '授权用户',
+    'adminMailboxAccess.addUser': '添加授权用户',
+    'adminMailboxAccess.remove': '移除授权用户',
+    'adminMailboxAccess.noAssignments': '该邮箱尚未分配给其他用户。',
+    'adminMailboxAccess.noSearchResult': '没有符合搜索条件的邮箱。',
+    'adminMailboxAccess.empty': '暂无可配置的邮箱。',
+    'adminMailboxAccess.notFound': '邮箱不存在、已删除或无权访问。',
+    'adminMailboxAccess.duplicateUser': '同一用户不能重复分配。',
+    'adminMailboxAccess.disabledUser': '已禁用',
+    'adminMailboxAccess.saved': '邮箱权限已保存',
     'metrics.accepted': '已接收',
     'metrics.failed': '失败',
     'metrics.recipients': '收件人',
@@ -432,14 +474,14 @@ const messages = {
     'tokens.scopes': '权限范围',
     'tokens.scopesRequired': '请至少选择一项权限',
     'tokens.scopesExtra': '发信使用 send;邮箱资源读取/创建使用 mailboxes:read、mailboxes:write;读取邮件内容使用 messages:read。',
-    'tokens.mailboxAccess': '邮件读取范围',
-    'tokens.mailboxAccessOwner': '当前账号的全部邮箱',
+    'tokens.mailboxAccess': '邮箱访问范围',
+    'tokens.mailboxAccessOwner': '账号拥有的邮箱',
     'tokens.mailboxAccessSelected': '指定邮箱',
     'tokens.mailboxAccessAll': '全系统邮箱(管理员)',
-    'tokens.mailboxAccessExtra': '启用 messages:read 后配置;同时约束该 Token 的邮箱列表。指定邮箱按资源 ID 授权;全系统范围只对管理员开放。',
+    'tokens.mailboxAccessExtra': 'Token 包含 send、mailboxes:read 或 messages:read 时生效。指定邮箱可包含管理员分配的邮箱;实际查看、读取邮件和发信能力仍与账号当前邮箱权限取交集。',
     'tokens.mailboxIds': '授权邮箱',
     'tokens.mailboxIdsRequired': '请至少选择一个邮箱',
-    'tokens.mailboxIdsPlaceholder': '选择可读取邮件的邮箱',
+    'tokens.mailboxIdsPlaceholder': '选择该 Token 可访问的邮箱',
     'tokens.status': '状态',
     'tokens.statusActive': '生效中',
     'tokens.statusExpired': '已过期',
@@ -481,7 +523,7 @@ const messages = {
     'tokens.authHeaderValue': 'Authorization: Bearer <USER_API_TOKEN>',
     'tokens.contentType': 'Content-Type',
     'tokens.requestFields': '请求字段',
-    'tokens.fieldFrom': 'from:发件地址,域名必须已添加并通过验证。',
+    'tokens.fieldFrom': 'from:可使用账号可发信域名下的地址,或管理员分配且具发送权限、并已被“指定邮箱”范围选中的邮箱/别名;发件域名仍须通过验证。',
     'tokens.fieldTo': 'to:收件人地址,支持单个邮箱或邮箱数组。',
     'tokens.fieldSubject': 'subject:邮件主题。',
     'tokens.fieldText': 'text:纯文本正文。',
@@ -497,18 +539,18 @@ const messages = {
     'tokens.temporaryMailbox': '创建临时邮箱',
     'tokens.mailboxResponse': '创建结果与客户端配置',
     'tokens.messagesApi': '收信 API',
-    'tokens.messagesApiHint': '需要 messages:read 权限,并受 Token 的邮件读取范围限制。列表支持分页与邮箱、文件夹、已读状态、关键词筛选。',
+    'tokens.messagesApiHint': '需要 messages:read 权限,并受 Token 的邮箱访问范围及账号收信权限共同限制。列表支持分页与邮箱、文件夹、已读状态、关键词筛选。',
     'tokens.messagesList': '查询邮件列表',
     'tokens.messageDetail': '读取邮件详情',
     'tokens.mailboxFolders': '查询邮箱文件夹',
     'tokens.messagesFilters': '筛选参数:page/pageSize 控制分页;mailboxId 限定邮箱;folder 限定文件夹;read 使用 true/false;q 搜索发件人、收件人、主题、Message ID 与摘要。',
-    'tokens.messagesAccess': '范围说明:当前账号覆盖 Token 所有者的邮箱;指定邮箱仅覆盖所选 ID;全系统邮箱只允许管理员显式选择。',
+    'tokens.messagesAccess': '范围说明:账号范围只覆盖 Token 所有者的邮箱;指定邮箱仅覆盖所选 ID;全系统邮箱只允许管理员显式选择。邮件读取和发信均与账号当前权限取交集。',
     'tokens.securityTips': '安全建议',
     'tokens.securityTipStore': '只在服务端环境保存 Token,不要放进浏览器前端代码或公开仓库。',
     'tokens.securityTipRotate': '不同环境使用不同 Token,泄露后立即重新生成并更新所有调用方。',
     'tokens.securityTipScopes': '只授予实际需要的权限;给自动化邮箱创建任务时无需授予 send。',
-    'tokens.securityTipDomain': 'From 域名必须属于当前账号,建议先完成 DNS 验证再接入生产发送。',
-    'tokens.noTokenHint': '创建 API 密钥后可在列表和详情中复制完整 Token,并按最小权限限制邮件读取范围。',
+    'tokens.securityTipDomain': 'From 必须属于账号可发信域名,或使用已分配且具发送权限的邮箱/别名;发件域名应先完成 DNS 验证。',
+    'tokens.noTokenHint': '创建 API 密钥后可在列表和详情中复制完整 Token,并按最小权限限制邮箱访问范围。',
     'webhooks.title': 'Webhooks',
     'webhooks.subtitle': '在投递状态变为已送达、退信或失败时,向你的 HTTPS 端点推送通知。',
     'webhooks.create': '新建 Webhook',
@@ -974,6 +1016,27 @@ const messages = {
     'inbox.noRawMessage': 'No raw MIME',
     'inbox.messageNotFound': 'Message not found or deleted.',
     'inbox.detailLoadFailed': 'Failed to load message detail.',
+    'inbox.noReceivingAccess': 'This account does not have permission to receive mail for any mailbox.',
+    'inbox.ownerManaged': 'Managed by the mailbox owner',
+    'account.title': 'Account & mailbox access',
+    'account.subtitle': 'Review your account and mailboxes you own or an administrator assigned.',
+    'account.summary': 'Account information',
+    'account.role': 'Role',
+    'account.mailboxAccess': 'My mailbox access',
+    'account.accessSource': 'Access source',
+    'account.permissions': 'Permissions',
+    'account.owned': 'Owned',
+    'account.assigned': 'Assigned',
+    'account.ownedMailbox': 'Owned mailbox',
+    'account.adminAssigned': 'Admin assigned',
+    'account.adminAccess': 'Administrator access',
+    'account.openInbox': 'Open inbox',
+    'account.manageMailbox': 'Manage mailbox',
+    'account.noMailboxes': 'This account does not own or have access to any mailboxes yet.',
+    'mailboxPermission.view': 'View configuration',
+    'mailboxPermission.receive': 'Receive mail',
+    'mailboxPermission.send': 'Send mail',
+    'mailboxPermission.none': 'No access',
     'smtp.connectionTitle': 'SMTP connection',
     'smtp.loginCredentialsTitle': 'SMTP credentials',
     'smtp.username': 'Username',
@@ -1036,10 +1099,31 @@ const messages = {
     'settings.approvalNotRequired': 'Auto-activate',
     'admin.title': 'Admin center',
     'admin.users': 'Users',
+    'admin.mailboxAccess': 'Mailbox access',
     'admin.resources': 'Resources',
     'admin.migration': 'Merge',
     'admin.systemEmail': 'System Email',
     'admin.auditLogs': 'Audit Logs',
+    'adminMailboxAccess.title': 'Mailbox access management',
+    'adminMailboxAccess.drawerTitle': 'Configure mailbox access',
+    'adminMailboxAccess.search': 'Search mailbox, owner, or email address',
+    'adminMailboxAccess.owner': 'Mailbox owner',
+    'adminMailboxAccess.ownerPermissions': 'Owner permissions',
+    'adminMailboxAccess.assignedUsers': 'Assigned users',
+    'adminMailboxAccess.configure': 'Configure access',
+    'adminMailboxAccess.permissions': 'Mailbox permissions',
+    'adminMailboxAccess.permissionHint': 'Receive or send automatically includes view access. The mailbox owner always has full access and cannot be removed.',
+    'adminMailboxAccess.permissionRequired': 'Select at least one permission for each user, or remove that grant row.',
+    'adminMailboxAccess.user': 'Authorized user',
+    'adminMailboxAccess.addUser': 'Add authorized user',
+    'adminMailboxAccess.remove': 'Remove authorized user',
+    'adminMailboxAccess.noAssignments': 'This mailbox has not been assigned to another user.',
+    'adminMailboxAccess.noSearchResult': 'No mailboxes match this search.',
+    'adminMailboxAccess.empty': 'There are no mailboxes to configure.',
+    'adminMailboxAccess.notFound': 'The mailbox does not exist, was deleted, or is not accessible.',
+    'adminMailboxAccess.duplicateUser': 'A user cannot be assigned more than once.',
+    'adminMailboxAccess.disabledUser': 'Disabled',
+    'adminMailboxAccess.saved': 'Mailbox access saved',
     'metrics.accepted': 'Accepted',
     'metrics.failed': 'Failed',
     'metrics.recipients': 'Recipients',
@@ -1061,14 +1145,14 @@ const messages = {
     'tokens.scopes': 'Scopes',
     'tokens.scopesRequired': 'Select at least one scope',
     'tokens.scopesExtra': 'Use send for delivery, mailboxes:read/mailboxes:write for mailbox resources, and messages:read for inbound message content.',
-    'tokens.mailboxAccess': 'Message access',
+    'tokens.mailboxAccess': 'Mailbox access',
     'tokens.mailboxAccessOwner': 'All mailboxes owned by this account',
     'tokens.mailboxAccessSelected': 'Selected mailboxes',
     'tokens.mailboxAccessAll': 'All system mailboxes (admin)',
-    'tokens.mailboxAccessExtra': 'Configured with messages:read and also applied to this token\'s mailbox list. Selected access is bound to mailbox IDs. All-system access is available to administrators only.',
+    'tokens.mailboxAccessExtra': 'Applies when the token includes send, mailboxes:read, or messages:read. Selected access may include assigned mailboxes; listing, message reads, and sending are still intersected with the account\'s current mailbox permissions.',
     'tokens.mailboxIds': 'Authorized mailboxes',
     'tokens.mailboxIdsRequired': 'Select at least one mailbox',
-    'tokens.mailboxIdsPlaceholder': 'Select mailboxes this token can read',
+    'tokens.mailboxIdsPlaceholder': 'Select mailboxes this token can access',
     'tokens.status': 'Status',
     'tokens.statusActive': 'Active',
     'tokens.statusExpired': 'Expired',
@@ -1110,7 +1194,7 @@ const messages = {
     'tokens.authHeaderValue': 'Authorization: Bearer <USER_API_TOKEN>',
     'tokens.contentType': 'Content-Type',
     'tokens.requestFields': 'Request fields',
-    'tokens.fieldFrom': 'from: sender address. The domain must be added and verified.',
+    'tokens.fieldFrom': 'from: an address on a domain this account can send from, or an assigned mailbox/alias with send permission that is included in the token\'s selected-mailbox scope. The sender domain must still be verified.',
     'tokens.fieldTo': 'to: recipient address. Single email or an array of emails.',
     'tokens.fieldSubject': 'subject: email subject.',
     'tokens.fieldText': 'text: plain text body.',
@@ -1126,18 +1210,18 @@ const messages = {
     'tokens.temporaryMailbox': 'Create a temporary mailbox',
     'tokens.mailboxResponse': 'Creation result and client configuration',
     'tokens.messagesApi': 'Inbound message API',
-    'tokens.messagesApiHint': 'Requires messages:read and is limited by the token message-access setting. Lists support pagination plus mailbox, folder, read-state, and keyword filters.',
+    'tokens.messagesApiHint': 'Requires messages:read and is limited by both the token mailbox-access setting and the account\'s receive permission. Lists support pagination plus mailbox, folder, read-state, and keyword filters.',
     'tokens.messagesList': 'List inbound messages',
     'tokens.messageDetail': 'Read message detail',
     'tokens.mailboxFolders': 'List mailbox folders',
     'tokens.messagesFilters': 'Filters: page/pageSize control pagination; mailboxId limits one mailbox; folder limits one folder; read accepts true/false; q searches sender, recipients, subject, Message ID, and preview.',
-    'tokens.messagesAccess': 'Access: account scope covers mailboxes owned by the token owner; selected scope covers only chosen IDs; all-system scope must be explicitly selected by an administrator.',
+    'tokens.messagesAccess': 'Access: account scope covers only mailboxes owned by the token owner; selected scope covers chosen IDs; all-system scope must be explicitly selected by an administrator. Message reads and sending are intersected with the account\'s current permissions.',
     'tokens.securityTips': 'Security tips',
     'tokens.securityTipStore': 'Store tokens only on the server side. Do not put them in browser code or public repositories.',
     'tokens.securityTipRotate': 'Use separate tokens per environment. Regenerate immediately after a leak and update every caller.',
     'tokens.securityTipScopes': 'Grant only the scopes your integration needs. Mailbox automation does not need send.',
-    'tokens.securityTipDomain': 'The From domain must belong to this account. Verify DNS before production sending.',
-    'tokens.noTokenHint': 'Create a token, copy its full secret from the list or detail view, and limit inbound message access to the minimum required scope.',
+    'tokens.securityTipDomain': 'From must use a domain this account can send from, or an assigned mailbox/alias with send permission. Verify the sender domain before production use.',
+    'tokens.noTokenHint': 'Create a token, copy its full secret from the list or detail view, and limit mailbox access to the minimum required scope.',
     'webhooks.title': 'Webhooks',
     'webhooks.subtitle': 'Push delivery updates to your HTTPS endpoint when mail is sent, bounced, or failed.',
     'webhooks.create': 'Create webhook',

+ 2 - 2
src/frontend/landing/i18n.js

@@ -193,7 +193,7 @@ const messages = {
     'domainsDoc.row.verify': '域名归属验证',
     'domainsDoc.row.verifyValue': '发布控制台生成的验证值。',
     'domainsDoc.row.dkim': '发布该域名生成的公钥。',
-    'domainsDoc.row.spf': '仅保留一条 SPF,并包含 MailHub 要求的发信 IP/主机。',
+    'domainsDoc.row.spf': '仅保留一条 SPF,并通过 MailHub 发信主机的 A 记录授权;第三方中继需保留对应 include。',
     'domainsDoc.row.dmarc': '先以监控策略开始,确认对齐后再逐步增强策略。',
     'domainsDoc.row.mx': '收信',
     'domainsDoc.row.mxValue': 'MX 指向 MailHub 收信主机,并确保该主机存在 A/AAAA 记录。',
@@ -422,7 +422,7 @@ const messages = {
     'domainsDoc.row.verify': 'Domain ownership',
     'domainsDoc.row.verifyValue': 'Publish the verification value generated in the console.',
     'domainsDoc.row.dkim': 'Publish the public key generated for this domain.',
-    'domainsDoc.row.spf': 'Keep one SPF record and include the sender IP/host required by MailHub.',
+    'domainsDoc.row.spf': 'Keep one SPF record, authorize MailHub through its sending-host A record, and retain includes for third-party relays.',
     'domainsDoc.row.dmarc': 'Start with monitoring, then strengthen the policy after alignment is verified.',
     'domainsDoc.row.mx': 'Inbound mail',
     'domainsDoc.row.mxValue': 'Point MX to the MailHub receiving host and ensure that host has an A/AAAA record.',

+ 22 - 1
src/frontend/services/api.ts

@@ -1,5 +1,6 @@
 import type {
   AddDomainPayload,
+  AdminMailboxAccessEntry,
   AdminResourceInventory,
   AdminUser,
   ApiToken,
@@ -14,6 +15,7 @@ import type {
   InboundFolder,
   InboundMessage,
   InboundMessageFilters,
+  MailboxPermissions,
   MailboxClientConfig,
   RuntimeConfig,
   SendEvent,
@@ -109,7 +111,12 @@ export const api = {
     );
   },
   event: (id: number) => request<{ event: SendEvent | null }>(`/api/events/${id}`),
-  inboundMailboxes: (all = false) => request<{ mailboxes: InboundMailbox[] }>(`/api/inbound-mailboxes${all ? '?all=true' : ''}`),
+  inboundMailboxes: (scope: boolean | 'owned' | 'effective' | 'all' = false) => {
+    const query = typeof scope === 'boolean'
+      ? (scope ? '?all=true' : '')
+      : `?scope=${encodeURIComponent(scope)}`;
+    return request<{ mailboxes: InboundMailbox[] }>(`/api/inbound-mailboxes${query}`);
+  },
   inboundMailboxDomains: () => request<{ domains: InboundMailboxDomain[] }>('/api/inbound-mailbox-domains'),
   createInboundMailbox: (data: {
     address: string;
@@ -214,6 +221,20 @@ export const api = {
   saveAdminSettings: (data: Partial<RuntimeConfig>) =>
     request<{ settings: RuntimeConfig }>('/api/admin/settings', { method: 'PATCH', data }),
   adminUsers: () => request<{ users: AdminUser[] }>('/api/admin/users'),
+  adminMailboxAccess: async () => {
+    const result = await request<{
+      mailboxes?: AdminMailboxAccessEntry[];
+      entries?: AdminMailboxAccessEntry[];
+    }>('/api/admin/mailbox-access');
+    return { mailboxes: result.mailboxes || result.entries || [] };
+  },
+  saveAdminMailboxAccess: (
+    id: number,
+    grants: Array<{ userId: number } & MailboxPermissions>
+  ) => request<{ mailbox: AdminMailboxAccessEntry }>(`/api/admin/inbound-mailboxes/${id}/access`, {
+    method: 'PUT',
+    data: { grants }
+  }),
   updateAdminUser: (id: number, data: { role?: UserRole; status?: UserStatus; password?: string }) =>
     request<{ user: AdminUser }>(`/api/admin/users/${id}`, { method: 'PATCH', data }),
   approveAdminUser: (id: number) =>

+ 48 - 0
src/frontend/styles.css

@@ -922,6 +922,43 @@ code {
   word-break: break-word;
 }
 
+.admin-mailbox-access-toolbar {
+  display: flex;
+  width: 100%;
+}
+
+.admin-mailbox-access-toolbar .ant-input-search {
+  flex: 1 1 320px;
+  max-width: 560px;
+}
+
+.admin-mailbox-access-card,
+.account-mailbox-card {
+  min-width: 0;
+}
+
+.admin-mailbox-access-footer {
+  display: flex;
+  justify-content: flex-end;
+  width: 100%;
+}
+
+.admin-mailbox-permissions {
+  display: grid;
+  gap: 8px;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.admin-mailbox-permissions .ant-checkbox-wrapper {
+  align-items: center;
+  min-height: 44px;
+}
+
+.account-page .ant-descriptions-item-content,
+.account-page .ant-descriptions-item-label {
+  overflow-wrap: anywhere;
+}
+
 .auth-page {
   background:
     radial-gradient(circle at 12% 10%, rgba(79, 70, 229, 0.14), transparent 30%),
@@ -1411,6 +1448,17 @@ code {
   .admin-user-card__fields {
     grid-template-columns: minmax(0, 1fr);
   }
+
+  .admin-mailbox-access-toolbar,
+  .admin-mailbox-access-toolbar .ant-input-search,
+  .admin-mailbox-access-toolbar .ant-btn {
+    max-width: none;
+    width: 100%;
+  }
+
+  .admin-mailbox-permissions {
+    grid-template-columns: minmax(0, 1fr);
+  }
 }
 
 @media (max-width: 374px) {

+ 29 - 2
src/frontend/types.ts

@@ -13,6 +13,18 @@ export type ViewKey =
 export type DomainMode = 'list' | 'detail';
 export type UserStatus = 'pending_email' | 'pending_review' | 'active' | 'disabled';
 export type UserRole = 'admin' | 'user';
+export interface MailboxPermissions {
+  view: boolean;
+  receive: boolean;
+  send: boolean;
+}
+
+export type MailboxAccessType = 'owner' | 'assigned' | 'admin';
+
+export interface MailboxAccess {
+  type: MailboxAccessType;
+  permissions: MailboxPermissions;
+}
 
 export interface UserResourceCounts {
   domains: number;
@@ -325,6 +337,7 @@ export interface ApiToken {
 export interface InboundMailbox {
   id: number;
   userId: number;
+  ownerUserId?: number;
   domainId: number;
   domain: string;
   address: string;
@@ -339,13 +352,27 @@ export interface InboundMailbox {
   status: string;
   expiresAt?: string | null;
   temporary?: boolean;
-  messageCount: number;
-  unreadCount: number;
+  messageCount: number | null;
+  unreadCount: number | null;
   lastMessageAt?: string | null;
+  access?: MailboxAccess;
   createdAt: string;
   updatedAt: string;
 }
 
+export interface AdminMailboxAccessGrant {
+  user: User;
+  permissions: MailboxPermissions;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export interface AdminMailboxAccessEntry {
+  mailbox: InboundMailbox;
+  owner: User;
+  grants: AdminMailboxAccessGrant[];
+}
+
 export interface MailboxClientConfig {
   username: string;
   password?: string;

+ 3 - 1
src/layouts/AdminLayout.tsx

@@ -94,6 +94,7 @@ export function navigationSelection(pathname: string) {
   if (pathname.startsWith('/activity')) return '/activity';
   if (pathname.startsWith('/domains')) return '/domains';
   if (pathname.startsWith('/inbox')) return '/inbox';
+  if (pathname.startsWith('/account')) return '/account';
   if (pathname.startsWith('/integrations/smtp')) return '/integrations/smtp';
   if (pathname.startsWith('/integrations/api-keys')) return '/integrations/api-keys';
   if (pathname.startsWith('/integrations/webhooks')) return '/integrations/webhooks';
@@ -213,7 +214,7 @@ export function AdminLayout() {
               menu={{
                 items: [
                   {
-                    key: 'account',
+                    key: 'account-summary',
                     label: (
                       <div className="account-menu-summary">
                         <Typography.Text strong>{user?.username || t('common.user')}</Typography.Text>
@@ -222,6 +223,7 @@ export function AdminLayout() {
                     ),
                     disabled: true
                   },
+                  { key: 'account', label: t('account.title'), onClick: () => navigate('/account') },
                   ...(isAdmin ? [{ key: 'settings', label: t('nav.settings'), onClick: () => navigate('/settings') }] : []),
                   { type: 'divider' as const },
                   { key: 'logout', label: t('common.logout'), onClick: () => void logout() }

+ 181 - 0
src/pages/Account.tsx

@@ -0,0 +1,181 @@
+import {
+  InboxOutlined,
+  ReloadOutlined,
+  SettingOutlined
+} from '@ant-design/icons';
+import {
+  Alert,
+  Button,
+  Card,
+  Descriptions,
+  List,
+  Skeleton,
+  Space,
+  Table,
+  Tag,
+  Typography
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { EmptyState } from '../components/common/EmptyState';
+import { PageHeader } from '../components/common/PageHeader';
+import { SectionCard } from '../components/common/SectionCard';
+import { MailboxPermissionTags } from '../components/mailbox/MailboxPermissionTags';
+import { useAppContext } from '../frontend/app-context';
+import { useI18n } from '../frontend/i18n/react';
+import { api } from '../frontend/services/api';
+import type { InboundMailbox, MailboxAccess } from '../frontend/types';
+import { useMediaQuery } from '../frontend/use-media-query';
+
+export default function Account() {
+  const { user } = useAppContext();
+  const { t } = useI18n();
+  const navigate = useNavigate();
+  const isTableView = useMediaQuery('(min-width: 768px)');
+  const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [loadError, setLoadError] = useState('');
+
+  const loadMailboxes = useCallback(async () => {
+    setLoading(true);
+    setLoadError('');
+    try {
+      const result = await api.inboundMailboxes('effective');
+      setMailboxes(result.mailboxes || []);
+    } catch (error) {
+      setLoadError(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setLoading(false);
+    }
+  }, [t]);
+
+  useEffect(() => {
+    void loadMailboxes();
+  }, [loadMailboxes]);
+
+  const counts = useMemo(() => ({
+    owned: mailboxes.filter((mailbox) => mailboxAccess(mailbox).type === 'owner').length,
+    assigned: mailboxes.filter((mailbox) => mailboxAccess(mailbox).type === 'assigned').length
+  }), [mailboxes]);
+
+  function openInbox(mailbox: InboundMailbox) {
+    navigate(`/inbox?mailboxId=${mailbox.id}&folder=INBOX`);
+  }
+
+  function manageMailbox(mailbox: InboundMailbox) {
+    navigate(`/inbox?workspace=routing&mailboxId=${mailbox.id}`);
+  }
+
+  const columns: ColumnsType<InboundMailbox> = [
+    {
+      title: t('inbox.mailboxAddress'),
+      render: (_, mailbox) => (
+        <Space direction="vertical" size={0}>
+          <Typography.Text strong>{mailbox.address}</Typography.Text>
+          {mailbox.displayName ? <Typography.Text type="secondary">{mailbox.displayName}</Typography.Text> : null}
+        </Space>
+      )
+    },
+    {
+      title: t('account.accessSource'),
+      width: 160,
+      render: (_, mailbox) => <AccessSource mailbox={mailbox} />
+    },
+    {
+      title: t('account.permissions'),
+      width: 280,
+      render: (_, mailbox) => <MailboxPermissionTags permissions={mailboxAccess(mailbox).permissions} />
+    },
+    {
+      title: t('common.actions'),
+      width: 250,
+      render: (_, mailbox) => (
+        <Space wrap>
+          {mailboxAccess(mailbox).permissions.receive ? (
+            <Button aria-label={`${t('account.openInbox')} · ${mailbox.address}`} icon={<InboxOutlined />} style={{ minHeight: 44 }} onClick={() => openInbox(mailbox)}>
+              {t('account.openInbox')}
+            </Button>
+          ) : null}
+          {mailboxAccess(mailbox).type === 'owner' ? (
+            <Button aria-label={`${t('account.manageMailbox')} · ${mailbox.address}`} icon={<SettingOutlined />} style={{ minHeight: 44 }} onClick={() => manageMailbox(mailbox)}>
+              {t('account.manageMailbox')}
+            </Button>
+          ) : null}
+        </Space>
+      )
+    }
+  ];
+
+  return (
+    <Space direction="vertical" size={20} className="full-width account-page">
+      <PageHeader title={t('account.title')} subtitle={t('account.subtitle')} />
+      <SectionCard title={t('account.summary')}>
+        <Descriptions column={isTableView ? 2 : 1} bordered size="small">
+          <Descriptions.Item label={t('auth.username')}>{user?.username || '—'}</Descriptions.Item>
+          <Descriptions.Item label={t('auth.email')}>{user?.email || '—'}</Descriptions.Item>
+          <Descriptions.Item label={t('account.role')}>{user?.role || '—'}</Descriptions.Item>
+          <Descriptions.Item label={t('common.status')}>{user?.status || '—'}</Descriptions.Item>
+        </Descriptions>
+      </SectionCard>
+      {loadError ? (
+        <Alert
+          type="error"
+          showIcon
+          message={loadError}
+          action={<Button icon={<ReloadOutlined />} onClick={() => void loadMailboxes()}>{t('common.refresh')}</Button>}
+        />
+      ) : null}
+      <SectionCard
+        title={t('account.mailboxAccess')}
+        extra={<Space wrap><Tag>{t('account.owned')} {counts.owned}</Tag><Tag>{t('account.assigned')} {counts.assigned}</Tag></Space>}
+      >
+        {loading && !mailboxes.length ? <Skeleton active paragraph={{ rows: 7 }} /> : mailboxes.length ? (
+          isTableView ? (
+            <Table rowKey="id" columns={columns} dataSource={mailboxes} scroll={{ x: 900 }} />
+          ) : (
+            <List
+              dataSource={mailboxes}
+              renderItem={(mailbox) => (
+                <List.Item>
+                  <Card size="small" title={mailbox.address} className="full-width account-mailbox-card">
+                    <Space direction="vertical" size={12} className="full-width">
+                      <AccessSource mailbox={mailbox} />
+                      <MailboxPermissionTags permissions={mailboxAccess(mailbox).permissions} />
+                      {mailboxAccess(mailbox).permissions.receive ? (
+                        <Button block aria-label={`${t('account.openInbox')} · ${mailbox.address}`} icon={<InboxOutlined />} style={{ minHeight: 44 }} onClick={() => openInbox(mailbox)}>
+                          {t('account.openInbox')}
+                        </Button>
+                      ) : null}
+                      {mailboxAccess(mailbox).type === 'owner' ? (
+                        <Button block aria-label={`${t('account.manageMailbox')} · ${mailbox.address}`} icon={<SettingOutlined />} style={{ minHeight: 44 }} onClick={() => manageMailbox(mailbox)}>
+                          {t('account.manageMailbox')}
+                        </Button>
+                      ) : null}
+                    </Space>
+                  </Card>
+                </List.Item>
+              )}
+            />
+          )
+        ) : <EmptyState description={t('account.noMailboxes')} action={<Button onClick={() => navigate('/inbox?workspace=routing')}>{t('inbox.createMailbox')}</Button>} />}
+      </SectionCard>
+    </Space>
+  );
+}
+
+function AccessSource({ mailbox }: { mailbox: InboundMailbox }) {
+  const { t } = useI18n();
+  const access = mailboxAccess(mailbox);
+  if (access.type === 'owner') return <Tag color="blue">{t('account.ownedMailbox')}</Tag>;
+  if (access.type === 'admin') return <Tag color="gold">{t('account.adminAccess')}</Tag>;
+  return <Tag color="purple">{t('account.adminAssigned')}</Tag>;
+}
+
+function mailboxAccess(mailbox: InboundMailbox): MailboxAccess {
+  return mailbox.access || {
+    type: 'owner',
+    permissions: { view: true, receive: true, send: true }
+  };
+}

+ 387 - 0
src/pages/Admin/MailboxAccess.tsx

@@ -0,0 +1,387 @@
+import {
+  DeleteOutlined,
+  EditOutlined,
+  PlusOutlined,
+  ReloadOutlined
+} from '@ant-design/icons';
+import {
+  Alert,
+  App as AntApp,
+  Button,
+  Card,
+  Checkbox,
+  Descriptions,
+  Drawer,
+  Input,
+  List,
+  Select,
+  Skeleton,
+  Space,
+  Table,
+  Tag,
+  Typography
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+
+import { EmptyState } from '../../components/common/EmptyState';
+import { SectionCard } from '../../components/common/SectionCard';
+import { MailboxPermissionTags } from '../../components/mailbox/MailboxPermissionTags';
+import { useI18n } from '../../frontend/i18n/react';
+import { api } from '../../frontend/services/api';
+import type {
+  AdminMailboxAccessEntry,
+  AdminUser,
+  MailboxPermissions
+} from '../../frontend/types';
+import { useMediaQuery } from '../../frontend/use-media-query';
+
+type DraftGrant = { userId: number } & MailboxPermissions;
+
+export function AdminMailboxAccess({ users }: { users: AdminUser[] }) {
+  const { message } = AntApp.useApp();
+  const { t } = useI18n();
+  const isTableView = useMediaQuery('(min-width: 768px)');
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [entries, setEntries] = useState<AdminMailboxAccessEntry[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [loadError, setLoadError] = useState('');
+  const [query, setQuery] = useState('');
+  const [draftGrants, setDraftGrants] = useState<DraftGrant[]>([]);
+  const [actionKey, setActionKey] = useState('');
+  const drawerTitleRef = useRef<HTMLSpanElement>(null);
+  const mailboxId = positiveInteger(searchParams.get('mailboxId'));
+  const selected = mailboxId ? entries.find((entry) => entry.mailbox.id === mailboxId) || null : null;
+
+  const loadEntries = useCallback(async () => {
+    setLoading(true);
+    setLoadError('');
+    try {
+      const result = await api.adminMailboxAccess();
+      setEntries(result.mailboxes || []);
+    } catch (error) {
+      setLoadError(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setLoading(false);
+    }
+  }, [t]);
+
+  useEffect(() => {
+    void loadEntries();
+  }, [loadEntries]);
+
+  useEffect(() => {
+    if (!selected) {
+      setDraftGrants([]);
+      return;
+    }
+    setDraftGrants(selected.grants.map((grant) => ({
+      userId: grant.user.id,
+      ...normalizePermissions(grant.permissions)
+    })));
+  }, [selected]);
+
+  const visibleEntries = useMemo(() => {
+    const needle = query.trim().toLowerCase();
+    if (!needle) return entries;
+    return entries.filter((entry) => [
+      entry.mailbox.address,
+      entry.mailbox.displayName,
+      entry.owner.username,
+      entry.owner.email
+    ].some((value) => String(value || '').toLowerCase().includes(needle)));
+  }, [entries, query]);
+
+  function openDrawer(entry: AdminMailboxAccessEntry) {
+    const next = new URLSearchParams(searchParams);
+    next.set('mailboxId', String(entry.mailbox.id));
+    setSearchParams(next);
+  }
+
+  function closeDrawer() {
+    const closingId = mailboxId;
+    const next = new URLSearchParams(searchParams);
+    next.delete('mailboxId');
+    setSearchParams(next);
+    if (closingId) {
+      window.requestAnimationFrame(() => {
+        document.querySelector<HTMLElement>(`[data-mailbox-access-trigger="${closingId}"]`)?.focus();
+      });
+    }
+  }
+
+  function addGrant() {
+    const used = new Set(draftGrants.map((grant) => grant.userId));
+    const ownerId = selected ? ownerIdForEntry(selected) : 0;
+    const candidate = users.find((user) => user.id !== ownerId && user.status !== 'disabled' && !used.has(user.id));
+    if (!candidate) return;
+    setDraftGrants((current) => [...current, { userId: candidate.id, view: true, receive: false, send: false }]);
+  }
+
+  function updateGrant(index: number, patch: Partial<DraftGrant>) {
+    setDraftGrants((current) => current.map((grant, currentIndex) => {
+      if (currentIndex !== index) return grant;
+      const next = { ...grant, ...patch };
+      if (next.receive || next.send) next.view = true;
+      return next;
+    }));
+  }
+
+  async function saveGrants() {
+    if (!selected) return;
+    const duplicate = draftGrants.find((grant, index) => draftGrants.some((other, otherIndex) => (
+      otherIndex !== index && other.userId === grant.userId
+    )));
+    if (duplicate) {
+      message.error(t('adminMailboxAccess.duplicateUser'));
+      return;
+    }
+    if (draftGrants.some((grant) => !grant.view && !grant.receive && !grant.send)) {
+      message.error(t('adminMailboxAccess.permissionRequired'));
+      return;
+    }
+    const key = `mailbox-access:${selected.mailbox.id}`;
+    setActionKey(key);
+    try {
+      const result = await api.saveAdminMailboxAccess(selected.mailbox.id, draftGrants.map((grant) => ({
+        userId: grant.userId,
+        ...normalizePermissions(grant)
+      })));
+      setEntries((current) => current.map((entry) => (
+        entry.mailbox.id === result.mailbox.mailbox.id ? result.mailbox : entry
+      )));
+      message.success(t('adminMailboxAccess.saved'));
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setActionKey('');
+    }
+  }
+
+  function availableUsers(index: number) {
+    const usedByOtherRows = new Set(draftGrants
+      .filter((_grant, currentIndex) => currentIndex !== index)
+      .map((grant) => grant.userId));
+    const ownerId = selected ? ownerIdForEntry(selected) : 0;
+    return users
+      .filter((user) => user.id !== ownerId && !usedByOtherRows.has(user.id))
+      .map((user) => ({
+        value: user.id,
+        label: `${user.username} · ${user.email}${user.status === 'disabled' ? ` · ${t('adminMailboxAccess.disabledUser')}` : ''}`,
+        disabled: user.status === 'disabled'
+      }));
+  }
+
+  const columns: ColumnsType<AdminMailboxAccessEntry> = [
+    {
+      title: t('inbox.mailboxAddress'),
+      render: (_, entry) => (
+        <Space direction="vertical" size={0}>
+          <Typography.Text strong>{entry.mailbox.address}</Typography.Text>
+          {entry.mailbox.displayName ? <Typography.Text type="secondary">{entry.mailbox.displayName}</Typography.Text> : null}
+        </Space>
+      )
+    },
+    {
+      title: t('adminMailboxAccess.owner'),
+      width: 220,
+      render: (_, entry) => (
+        <Space direction="vertical" size={0}>
+          <Typography.Text>{entry.owner.username}</Typography.Text>
+          <Typography.Text type="secondary">{entry.owner.email}</Typography.Text>
+        </Space>
+      )
+    },
+    {
+      title: t('adminMailboxAccess.assignedUsers'),
+      width: 170,
+      render: (_, entry) => <Tag>{entry.grants.length}</Tag>
+    },
+    {
+      title: t('common.actions'),
+      align: 'right',
+      width: 150,
+      render: (_, entry) => (
+        <Button
+          data-mailbox-access-trigger={entry.mailbox.id}
+          aria-label={`${t('adminMailboxAccess.configure')} · ${entry.mailbox.address}`}
+          icon={<EditOutlined />}
+          style={{ minHeight: 44 }}
+          onClick={() => openDrawer(entry)}
+        >
+          {t('adminMailboxAccess.configure')}
+        </Button>
+      )
+    }
+  ];
+
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <Space wrap className="admin-mailbox-access-toolbar">
+        <Input.Search
+          allowClear
+          aria-label={t('adminMailboxAccess.search')}
+          placeholder={t('adminMailboxAccess.search')}
+          value={query}
+          onChange={(event) => setQuery(event.target.value)}
+        />
+        <Button icon={<ReloadOutlined />} loading={loading} style={{ minHeight: 44 }} onClick={() => void loadEntries()}>
+          {t('common.refresh')}
+        </Button>
+      </Space>
+      {loadError ? <Alert type="error" showIcon message={loadError} action={<Button onClick={() => void loadEntries()}>{t('common.refresh')}</Button>} /> : null}
+      <SectionCard title={t('adminMailboxAccess.title')} extra={<Tag>{entries.length}</Tag>}>
+        {loading && !entries.length ? <Skeleton active paragraph={{ rows: 8 }} /> : visibleEntries.length ? (
+          isTableView ? (
+            <Table rowKey={(entry) => entry.mailbox.id} columns={columns} dataSource={visibleEntries} scroll={{ x: 760 }} />
+          ) : (
+            <List
+              dataSource={visibleEntries}
+              renderItem={(entry) => (
+                <List.Item>
+                  <Card size="small" className="full-width admin-mailbox-access-card" title={entry.mailbox.address}>
+                    <Space direction="vertical" size={12} className="full-width">
+                      <Typography.Text type="secondary">{t('adminMailboxAccess.owner')} · {entry.owner.username}</Typography.Text>
+                      <Tag>{t('adminMailboxAccess.assignedUsers')} {entry.grants.length}</Tag>
+                      <Button
+                        block
+                        data-mailbox-access-trigger={entry.mailbox.id}
+                        aria-label={`${t('adminMailboxAccess.configure')} · ${entry.mailbox.address}`}
+                        icon={<EditOutlined />}
+                        style={{ minHeight: 44 }}
+                        onClick={() => openDrawer(entry)}
+                      >
+                        {t('adminMailboxAccess.configure')}
+                      </Button>
+                    </Space>
+                  </Card>
+                </List.Item>
+              )}
+            />
+          )
+        ) : <EmptyState description={query ? t('adminMailboxAccess.noSearchResult') : t('adminMailboxAccess.empty')} />}
+      </SectionCard>
+
+      <Drawer
+        title={<span ref={drawerTitleRef} tabIndex={-1}>{selected?.mailbox.address || t('adminMailboxAccess.drawerTitle')}</span>}
+        width={isTableView ? 720 : '100%'}
+        open={Boolean(mailboxId)}
+        onClose={closeDrawer}
+        destroyOnHidden
+        afterOpenChange={(open) => {
+          if (open) window.requestAnimationFrame(() => drawerTitleRef.current?.focus({ preventScroll: true }));
+        }}
+        footer={selected ? (
+          <Space className="admin-mailbox-access-footer">
+            <Button style={{ minHeight: 44 }} onClick={closeDrawer}>{t('common.cancel')}</Button>
+            <Button
+              type="primary"
+              style={{ minHeight: 44 }}
+              loading={actionKey === `mailbox-access:${selected.mailbox.id}`}
+              onClick={() => void saveGrants()}
+            >
+              {t('common.save')}
+            </Button>
+          </Space>
+        ) : null}
+      >
+        {loading && !selected ? <Skeleton active paragraph={{ rows: 8 }} /> : selected ? (
+          <Space direction="vertical" size={20} className="full-width">
+            <Descriptions bordered size="small" column={1}>
+              <Descriptions.Item label={t('inbox.mailboxAddress')}>{selected.mailbox.address}</Descriptions.Item>
+              <Descriptions.Item label={t('adminMailboxAccess.owner')}>
+                {selected.owner.username} · {selected.owner.email}
+              </Descriptions.Item>
+              <Descriptions.Item label={t('adminMailboxAccess.ownerPermissions')}>
+                <MailboxPermissionTags permissions={{ view: true, receive: true, send: true }} />
+              </Descriptions.Item>
+            </Descriptions>
+            <Alert type="info" showIcon message={t('adminMailboxAccess.permissionHint')} />
+            <Space direction="vertical" size={12} className="full-width">
+              {draftGrants.map((grant, index) => {
+                const selectedUser = users.find((user) => user.id === grant.userId);
+                return (
+                  <Card
+                    key={`${grant.userId}:${index}`}
+                    size="small"
+                    title={selectedUser?.username || t('common.user')}
+                    extra={(
+                      <Button
+                        danger
+                        type="text"
+                        icon={<DeleteOutlined />}
+                        aria-label={`${t('adminMailboxAccess.remove')} ${selectedUser?.username || grant.userId}`}
+                        style={{ minHeight: 44, minWidth: 44 }}
+                        onClick={() => setDraftGrants((current) => current.filter((_item, currentIndex) => currentIndex !== index))}
+                      />
+                    )}
+                  >
+                    <Space direction="vertical" size={12} className="full-width">
+                      <Select
+                        aria-label={`${t('adminMailboxAccess.user')} ${index + 1}`}
+                        showSearch
+                        optionFilterProp="label"
+                        value={grant.userId}
+                        options={availableUsers(index)}
+                        className="full-width"
+                        onChange={(userId) => updateGrant(index, { userId })}
+                      />
+                      <div role="group" aria-label={`${t('adminMailboxAccess.permissions')} · ${selectedUser?.username || grant.userId}`} className="admin-mailbox-permissions">
+                        <Checkbox
+                          checked={grant.view}
+                          disabled={grant.receive || grant.send}
+                          onChange={(event) => updateGrant(index, { view: event.target.checked })}
+                        >
+                          {t('mailboxPermission.view')}
+                        </Checkbox>
+                        <Checkbox checked={grant.receive} onChange={(event) => updateGrant(index, { receive: event.target.checked })}>
+                          {t('mailboxPermission.receive')}
+                        </Checkbox>
+                        <Checkbox checked={grant.send} onChange={(event) => updateGrant(index, { send: event.target.checked })}>
+                          {t('mailboxPermission.send')}
+                        </Checkbox>
+                      </div>
+                    </Space>
+                  </Card>
+                );
+              })}
+              {!draftGrants.length ? <EmptyState description={t('adminMailboxAccess.noAssignments')} /> : null}
+              <Button
+                type="dashed"
+                block
+                icon={<PlusOutlined />}
+                style={{ minHeight: 44 }}
+                disabled={!users.some((user) => user.status !== 'disabled' && user.id !== ownerIdForEntry(selected) && !draftGrants.some((grant) => grant.userId === user.id))}
+                onClick={addGrant}
+              >
+                {t('adminMailboxAccess.addUser')}
+              </Button>
+            </Space>
+          </Space>
+        ) : (
+          <Alert type="error" showIcon message={loadError || t('adminMailboxAccess.notFound')} action={<Button onClick={closeDrawer}>{t('common.confirm')}</Button>} />
+        )}
+      </Drawer>
+    </Space>
+  );
+}
+
+function ownerIdForEntry(entry: AdminMailboxAccessEntry) {
+  return entry.mailbox.ownerUserId || entry.mailbox.userId || entry.owner.id;
+}
+
+function normalizePermissions(permissions: Partial<MailboxPermissions>) {
+  const receive = Boolean(permissions.receive);
+  const send = Boolean(permissions.send);
+  return {
+    view: Boolean(permissions.view) || receive || send,
+    receive,
+    send
+  };
+}
+
+function positiveInteger(value: string | null) {
+  const number = Number(value);
+  return Number.isInteger(number) && number > 0 ? number : null;
+}

+ 10 - 4
src/pages/Admin/index.tsx

@@ -48,6 +48,7 @@ import {
 } from './admin-model.js';
 import { useI18n } from '../../frontend/i18n/react';
 import { api } from '../../frontend/services/api';
+import { AdminMailboxAccess } from './MailboxAccess';
 import type {
   AdminResourceInventory,
   AdminUser,
@@ -61,7 +62,7 @@ import type {
 
 const statusValues: UserStatus[] = ['pending_email', 'pending_review', 'active', 'disabled'];
 const roleValues: UserRole[] = ['user', 'admin'];
-const adminSections = ['users', 'resources', 'migration', 'system-email', 'audit-logs'] as const;
+const adminSections = ['users', 'mailbox-access', 'resources', 'migration', 'system-email', 'audit-logs'] as const;
 type AdminSection = typeof adminSections[number];
 
 const mergeOptionLabels: Array<[keyof UserMergeOptions, string, string]> = [
@@ -125,7 +126,7 @@ export default function AdminPage() {
     let currentAuditRequestId: number | null = null;
     if (showLoading) setLoading(true);
     try {
-      if (section === 'users' || section === 'migration') {
+      if (section === 'users' || section === 'mailbox-access' || section === 'migration') {
         const result = await api.adminUsers();
         setUsers(result.users || []);
       } else if (section === 'resources') {
@@ -212,6 +213,11 @@ export default function AdminPage() {
         />
       )
     },
+    {
+      key: 'mailbox-access',
+      label: t('admin.mailboxAccess'),
+      children: <AdminMailboxAccess users={users} />
+    },
     {
       key: 'resources',
       label: t('admin.resources'),
@@ -278,11 +284,11 @@ export default function AdminPage() {
     <Space direction="vertical" size={20} className="full-width admin-page">
       <PageHeader
         title={t('nav.adminCenter')}
-        extra={
+        extra={activeSection === 'mailbox-access' ? undefined : (
           <Button icon={<ReloadOutlined />} loading={loading} onClick={() => void loadSection(activeSection, true)}>
             {t('common.refresh')}
           </Button>
-        }
+        )}
       />
       <SectionCard className="admin-center-card">
         <Tabs

+ 18 - 10
src/pages/ApiTokens.tsx

@@ -95,7 +95,7 @@ export default function ApiTokens() {
   const pendingDirectGuideClose = useRef<string | null>(null);
   const selectedScopes = Form.useWatch('scopes', form) || [];
   const selectedMailboxAccess = Form.useWatch('mailboxAccess', form) || 'owner';
-  const messagesReadEnabled = selectedScopes.includes('messages:read');
+  const mailboxAccessEnabled = tokenUsesMailboxAccess(selectedScopes);
   const isAdmin = user?.role === 'admin';
   const requestedTokenId = positiveInteger(searchParams.get('tokenId'));
   const guideParam = searchParams.get('guide');
@@ -112,7 +112,7 @@ export default function ApiTokens() {
     setMailboxError('');
     const [tokenResult, mailboxResult] = await Promise.allSettled([
       api.apiTokens(),
-      api.inboundMailboxes(isAdmin)
+      api.inboundMailboxes(isAdmin ? 'all' : 'effective')
     ]);
 
     if (tokenResult.status === 'fulfilled') {
@@ -357,9 +357,11 @@ export default function ApiTokens() {
     }
   ];
 
-  const mailboxOptions = mailboxes.map((mailbox) => ({
+  const mailboxOptions = mailboxes
+    .filter((mailbox) => isAdmin || mailbox.access?.permissions.view !== false)
+    .map((mailbox) => ({
     value: mailbox.id,
-    label: `${mailbox.address}${isAdmin && mailbox.userId !== user?.id ? ` · UID ${mailbox.userId}` : ''}`
+    label: `${mailbox.address}${isAdmin && mailbox.userId !== user?.id ? ` · UID ${mailbox.userId}` : ''}${!isAdmin && mailbox.access?.type === 'assigned' ? ` · ${t('account.adminAssigned')}` : ''}`
   }));
   const mailboxAccessOptions = [
     { label: t('tokens.mailboxAccessOwner'), value: 'owner' },
@@ -419,7 +421,7 @@ export default function ApiTokens() {
           <Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}><Input placeholder={t('tokens.namePlaceholder')} autoComplete="off" /></Form.Item>
           <Form.Item name="expiresAt" label={t('tokens.expiresAt')} extra={t('tokens.expiresAtExtra')}><Input type="datetime-local" /></Form.Item>
           <Form.Item name="scopes" label={t('tokens.scopes')} rules={[{ required: true, type: 'array', min: 1, message: t('tokens.scopesRequired') }]} extra={t('tokens.scopesExtra')}><Checkbox.Group options={scopeOptions} /></Form.Item>
-          {messagesReadEnabled ? (
+          {mailboxAccessEnabled ? (
             <>
               <Form.Item name="mailboxAccess" label={t('tokens.mailboxAccess')} extra={t('tokens.mailboxAccessExtra')} rules={[{ required: true }]}>
                 <Radio.Group className="full-width" style={{ display: 'grid', gap: 4 }}>
@@ -462,7 +464,7 @@ export default function ApiTokens() {
               <Descriptions.Item label={t('tokens.status')}><StatusPill tone={tokenStatusTone(selectedToken.status)}>{tokenStatusText(selectedToken.status, t)}</StatusPill></Descriptions.Item>
               <Descriptions.Item label={t('tokens.scopes')}><Space wrap>{selectedToken.scopes.map((scope) => <Tag key={scope}>{scope}</Tag>)}</Space></Descriptions.Item>
               <Descriptions.Item label={t('tokens.mailboxAccess')}>{mailboxAccessSummary(selectedToken, t)}</Descriptions.Item>
-              {selectedToken.scopes.includes('messages:read') && selectedToken.mailboxAccess === 'selected' ? (
+              {tokenUsesMailboxAccess(selectedToken.scopes) && selectedToken.mailboxAccess === 'selected' ? (
                 <Descriptions.Item label={t('tokens.mailboxIds')}>
                   <Space size={[4, 4]} wrap>{selectedToken.mailboxIds.map((id) => <Tag key={id}>{mailboxes.find((mailbox) => mailbox.id === id)?.address || `#${id}`}</Tag>)}</Space>
                 </Descriptions.Item>
@@ -613,24 +615,30 @@ function ApiGuideDrawer({ open, section, config, onClose, onSectionChange, onCop
 }
 
 function toTokenPayload(values: TokenFormValues): ApiTokenInput {
-  const messagesRead = values.scopes.includes('messages:read');
-  const mailboxAccess = messagesRead ? values.mailboxAccess || 'owner' : 'owner';
+  const mailboxAccessEnabled = tokenUsesMailboxAccess(values.scopes);
+  const mailboxAccess = mailboxAccessEnabled ? values.mailboxAccess || 'owner' : 'owner';
   return {
     name: values.name.trim(),
     scopes: values.scopes,
     expiresAt: values.expiresAt ? new Date(values.expiresAt).toISOString() : null,
     mailboxAccess,
-    mailboxIds: messagesRead && mailboxAccess === 'selected' ? values.mailboxIds || [] : []
+    mailboxIds: mailboxAccessEnabled && mailboxAccess === 'selected' ? values.mailboxIds || [] : []
   };
 }
 
 function mailboxAccessSummary(token: ApiToken, t: (key: string) => string) {
-  if (!token.scopes.includes('messages:read')) return '—';
+  if (!tokenUsesMailboxAccess(token.scopes)) return '—';
   if (token.mailboxAccess === 'all') return <StatusPill tone="warning">{t('tokens.mailboxAccessAll')}</StatusPill>;
   if (token.mailboxAccess === 'selected') return <StatusPill tone="info">{t('tokens.mailboxAccessSelected')} · {token.mailboxIds.length}</StatusPill>;
   return <StatusPill tone="neutral">{t('tokens.mailboxAccessOwner')}</StatusPill>;
 }
 
+function tokenUsesMailboxAccess(scopes: readonly string[]) {
+  return scopes.includes('send')
+    || scopes.includes('mailboxes:read')
+    || scopes.includes('messages:read');
+}
+
 function toDatetimeLocal(value?: string | null) {
   if (!value) return '';
   const date = new Date(value);

+ 65 - 23
src/pages/Inbox.tsx

@@ -47,6 +47,7 @@ import { EmptyState } from '../components/common/EmptyState';
 import { PageHeader } from '../components/common/PageHeader';
 import { SectionCard } from '../components/common/SectionCard';
 import { StatusPill } from '../components/common/StatusPill';
+import { MailboxPermissionTags } from '../components/mailbox/MailboxPermissionTags';
 import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
 import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
@@ -134,12 +135,16 @@ export default function Inbox() {
     setSearchDraft(query);
   }, [query]);
 
-  const mailboxOptions = useMemo(() => mailboxes.map((item) => ({
+  const receivingMailboxes = useMemo(
+    () => mailboxes.filter((item) => mailboxPermissions(item).receive),
+    [mailboxes]
+  );
+  const mailboxOptions = useMemo(() => receivingMailboxes.map((item) => ({
     value: item.id,
     label: locale.startsWith('en')
-      ? `${item.address} · ${item.unreadCount} unread / ${item.messageCount} total`
-      : `${item.address} · ${item.unreadCount} 未读 / ${item.messageCount} 封`
-  })), [locale, mailboxes]);
+      ? `${item.address} · ${countLabel(item.unreadCount)} unread / ${countLabel(item.messageCount)} total${item.access?.type === 'assigned' ? ' · Admin assigned' : ''}`
+      : `${item.address} · ${countLabel(item.unreadCount)} 未读 / ${countLabel(item.messageCount)} 封${item.access?.type === 'assigned' ? ' · 管理员分配' : ''}`
+  })), [locale, receivingMailboxes]);
   const mailboxDomainOptions = useMemo(() => mailboxDomains.map((domain) => {
     const owned = user?.id === domain.userId;
     return {
@@ -167,7 +172,7 @@ export default function Inbox() {
     setLoadError('');
     setDomainsError('');
     const [domainResult, mailboxDomainResult, mailboxResult] = await Promise.allSettled([
-      api.domains(), api.inboundMailboxDomains(), api.inboundMailboxes()
+      api.domains(), api.inboundMailboxDomains(), api.inboundMailboxes('effective')
     ]);
     if (requestId !== baseRequestId.current) return;
     if (domainResult.status === 'fulfilled') setDomains(domainResult.value.domains || []);
@@ -185,21 +190,21 @@ export default function Inbox() {
   }, [loadBase]);
 
   useEffect(() => {
-    if (!mailboxes.length) return;
+    if (!receivingMailboxes.length) return;
     if (routeMessageId && (!searchParams.has('mailboxId') || !searchParams.has('folder'))) return;
-    if (selectedMailboxId && mailboxes.some((item) => item.id === selectedMailboxId)) return;
-    const preferredId = preferredMailboxId(mailboxes, selectedMailboxId);
+    if (selectedMailboxId && receivingMailboxes.some((item) => item.id === selectedMailboxId)) return;
+    const preferredId = preferredMailboxId(receivingMailboxes, selectedMailboxId);
     if (!preferredId) return;
     const next = new URLSearchParams(searchParams);
     next.set('mailboxId', String(preferredId));
     next.set('folder', 'INBOX');
     next.set('page', '1');
     setSearchParams(next, { replace: true });
-  }, [mailboxes, routeMessageId, searchParams, selectedMailboxId, setSearchParams]);
+  }, [receivingMailboxes, routeMessageId, searchParams, selectedMailboxId, setSearchParams]);
 
   const loadFolders = useCallback(async () => {
     const requestId = ++foldersRequestId.current;
-    if (!selectedMailboxId) {
+    if (!selectedMailboxId || !receivingMailboxes.some((mailbox) => mailbox.id === selectedMailboxId)) {
       setFolders(fallbackFolders());
       setFoldersError('');
       setFoldersLoading(false);
@@ -220,7 +225,7 @@ export default function Inbox() {
     } finally {
       if (requestId === foldersRequestId.current) setFoldersLoading(false);
     }
-  }, [locale, selectedMailboxId]);
+  }, [locale, receivingMailboxes, selectedMailboxId]);
 
   useEffect(() => {
     void loadFolders();
@@ -229,7 +234,7 @@ export default function Inbox() {
 
   const loadMessages = useCallback(async () => {
     const requestId = ++messagesRequestId.current;
-    if (!selectedMailboxId || workspace !== 'messages') {
+    if (!selectedMailboxId || workspace !== 'messages' || !receivingMailboxes.some((mailbox) => mailbox.id === selectedMailboxId)) {
       setMessages([]);
       setTotal(0);
       setMessagesError('');
@@ -257,7 +262,7 @@ export default function Inbox() {
     } finally {
       if (requestId === messagesRequestId.current) setMessagesLoading(false);
     }
-  }, [folder, page, query, readFilter, selectedMailboxId, t, workspace]);
+  }, [folder, page, query, readFilter, receivingMailboxes, selectedMailboxId, t, workspace]);
 
   useEffect(() => {
     void loadMessages();
@@ -293,7 +298,7 @@ export default function Inbox() {
               setSelectedMessage((current) => current?.id === detail.id ? { ...current, read: true } : current);
               setMessages((items) => items.map((item) => item.id === detail.id ? { ...item, read: true } : item));
               setMailboxes((items) => items.map((item) => item.id === detail.mailboxId
-                ? { ...item, unreadCount: Math.max(0, item.unreadCount - 1) }
+                ? { ...item, unreadCount: Math.max(0, (item.unreadCount ?? 0) - 1) }
                 : item));
               void loadFolders();
             })
@@ -523,12 +528,22 @@ export default function Inbox() {
     {
       title: t('inbox.mailboxAddress'), dataIndex: 'address', render: (value: string, item) => <Space direction="vertical" size={0}><Typography.Text strong>{value}</Typography.Text>{item.displayName ? <Typography.Text type="secondary">{item.displayName}</Typography.Text> : null}</Space>
     },
+    {
+      title: t('account.accessSource'),
+      width: 150,
+      render: (_, item) => <MailboxAccessSource mailbox={item} t={t} />
+    },
+    {
+      title: t('account.permissions'),
+      width: 240,
+      render: (_, item) => <MailboxPermissionTags permissions={mailboxPermissions(item)} />
+    },
     { title: t('common.status'), dataIndex: 'status', width: 120, render: (value: string) => <StatusPill tone={value === 'active' ? 'success' : 'warning'}>{value}</StatusPill> },
     { title: t('inbox.forwardTo'), dataIndex: 'forwardTo', render: (value: string[], item) => value?.length ? <Space direction="vertical" size={2}><Typography.Text>{value.join(', ')}</Typography.Text><Tag>{item.keepForwarded ? t('inbox.keepForwarded') : t('inbox.forwardOnly')}</Tag></Space> : '—' },
-    { title: t('inbox.unread'), dataIndex: 'unreadCount', width: 90 },
-    { title: t('inbox.messageCount'), dataIndex: 'messageCount', width: 100 },
+    { title: t('inbox.unread'), dataIndex: 'unreadCount', width: 90, render: countLabel },
+    { title: t('inbox.messageCount'), dataIndex: 'messageCount', width: 100, render: countLabel },
     {
-      title: t('common.actions'), width: 330, render: (_, item) => <Space wrap><Button icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button><Button icon={<SettingOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button icon={<MailOutlined />} onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space>
+      title: t('common.actions'), width: 330, render: (_, item) => isMailboxOwner(item, user?.id) ? <Space wrap><Button icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button><Button icon={<SettingOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button icon={<MailOutlined />} onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space> : <Typography.Text type="secondary">{t('inbox.ownerManaged')}</Typography.Text>
     }
   ];
 
@@ -559,7 +574,7 @@ export default function Inbox() {
         ]}
       />
       {workspace === 'messages' ? (
-        loadError && !mailboxes.length ? null : mailboxes.length ? (
+        loadError && !mailboxes.length ? null : receivingMailboxes.length ? (
           <div style={{ display: 'grid', gridTemplateColumns: isDesktop ? '220px minmax(320px, 380px) minmax(0, 1fr)' : screens.md ? '220px minmax(0, 1fr)' : 'minmax(0, 1fr)', gap: 16, minWidth: 0 }}>
             {screens.md ? (
               <FolderPane
@@ -604,6 +619,8 @@ export default function Inbox() {
             </Card>
             {isDesktop ? <Card styles={{ body: { padding: 20, minWidth: 0 } }}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} mutationError={readMutationError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Card> : null}
           </div>
+        ) : mailboxes.length ? (
+          <EmptyState description={t('inbox.noReceivingAccess')} action={<Button onClick={() => navigate('/account')}>{t('account.title')}</Button>} />
         ) : <EmptyState description={mailboxDomains.length ? (locale.startsWith('en') ? 'No receiving mailbox has been created yet.' : '尚未创建收信邮箱。') : t('inbox.noMailboxDomain')} action={<Button icon={<PlusOutlined />} disabled={!mailboxDomains.length} onClick={() => { switchWorkspace('routing'); openCreateMailbox(); }}>{t('inbox.createMailbox')}</Button>} />
       ) : (
         <Space direction="vertical" size={20} className="full-width">
@@ -629,9 +646,9 @@ export default function Inbox() {
                   />
                 </Space>
                 {screens.md ? (
-                  <Table rowKey="id" columns={mailboxColumns} dataSource={visibleMailboxes} scroll={{ x: 1040 }} />
+                  <Table rowKey="id" columns={mailboxColumns} dataSource={visibleMailboxes} scroll={{ x: 1420 }} />
                 ) : (
-                  <List dataSource={visibleMailboxes} renderItem={(item) => <List.Item><Card size="small" className="full-width" title={item.address}><Space direction="vertical" className="full-width"><Typography.Text type="secondary">{item.messageCount} {t('inbox.messageCount')} · {item.unreadCount} {t('inbox.unread')}</Typography.Text><Space wrap><Button icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button><Button onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space></Space></Card></List.Item>} />
+                  <List dataSource={visibleMailboxes} renderItem={(item) => <List.Item><Card size="small" className="full-width" title={item.address}><Space direction="vertical" className="full-width"><MailboxAccessSource mailbox={item} t={t} /><MailboxPermissionTags permissions={mailboxPermissions(item)} /><Typography.Text type="secondary">{countLabel(item.messageCount)} {t('inbox.messageCount')} · {countLabel(item.unreadCount)} {t('inbox.unread')}</Typography.Text>{isMailboxOwner(item, user?.id) ? <Space wrap><Button icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button><Button onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space> : <Typography.Text type="secondary">{t('inbox.ownerManaged')}</Typography.Text>}</Space></Card></List.Item>} />
                 )}
               </Space>
             ) : loadError ? null : <EmptyState description={mailboxDomains.length ? (locale.startsWith('en') ? 'No receiving mailbox has been created yet.' : '尚未创建收信邮箱。') : t('inbox.noMailboxDomain')} action={<Button icon={<PlusOutlined />} disabled={!mailboxDomains.length} onClick={openCreateMailbox}>{t('inbox.createMailbox')}</Button>} />}
@@ -875,7 +892,7 @@ function preferredMailboxId(mailboxes: InboundMailbox[], requestedId: number | n
   const lastActive = [...mailboxes]
     .filter((item) => item.lastMessageAt)
     .sort((left, right) => Date.parse(right.lastMessageAt || '') - Date.parse(left.lastMessageAt || ''))[0];
-  return lastActive?.id || mailboxes.find((item) => item.messageCount > 0)?.id || mailboxes[0]?.id || null;
+  return lastActive?.id || mailboxes.find((item) => (item.messageCount ?? 0) > 0)?.id || mailboxes[0]?.id || null;
 }
 
 function filterMailboxes(mailboxes: InboundMailbox[], query: string) {
@@ -888,8 +905,8 @@ function filterMailboxes(mailboxes: InboundMailbox[], query: string) {
 function sortMailboxes(mailboxes: InboundMailbox[], sort: MailboxSort) {
   return [...mailboxes].sort((left, right) => {
     if (sort === 'address') return left.address.localeCompare(right.address);
-    if (sort === 'unread') return right.unreadCount - left.unreadCount || left.address.localeCompare(right.address);
-    if (sort === 'messages') return right.messageCount - left.messageCount || left.address.localeCompare(right.address);
+    if (sort === 'unread') return (right.unreadCount ?? -1) - (left.unreadCount ?? -1) || left.address.localeCompare(right.address);
+    if (sort === 'messages') return (right.messageCount ?? -1) - (left.messageCount ?? -1) || left.address.localeCompare(right.address);
     const leftActivity = Date.parse(left.lastMessageAt || '') || 0;
     const rightActivity = Date.parse(right.lastMessageAt || '') || 0;
     return rightActivity - leftActivity || left.address.localeCompare(right.address);
@@ -989,6 +1006,31 @@ function generateMailboxPassword() {
   return Array.from(bytes, (value) => (value % 36).toString(36)).join('');
 }
 
+function mailboxPermissions(mailbox: InboundMailbox) {
+  const permissions = mailbox.access?.permissions;
+  const owner = mailbox.access ? mailbox.access.type === 'owner' : true;
+  return {
+    view: permissions?.view ?? owner,
+    receive: permissions?.receive ?? owner,
+    send: permissions?.send ?? owner
+  };
+}
+
+function isMailboxOwner(mailbox: InboundMailbox, userId?: number) {
+  if (!userId) return false;
+  return mailbox.access?.type === 'owner' || (mailbox.ownerUserId || mailbox.userId) === userId;
+}
+
+function MailboxAccessSource({ mailbox, t }: { mailbox: InboundMailbox; t: (key: string) => string }) {
+  if (mailbox.access?.type === 'assigned') return <Tag color="purple">{t('account.adminAssigned')}</Tag>;
+  if (mailbox.access?.type === 'admin') return <Tag color="gold">{t('account.adminAccess')}</Tag>;
+  return <Tag color="blue">{t('account.ownedMailbox')}</Tag>;
+}
+
+function countLabel(value: number | null | undefined) {
+  return value == null ? '—' : value;
+}
+
 function formatCompactTime(value: string) {
   const date = new Date(value);
   const now = new Date();

+ 104 - 20
src/server.js

@@ -31,6 +31,7 @@ import {
   getApiToken,
   getInboundMessage,
   getInboundMessageMaildirStorage,
+  getInboundMailboxForSender,
   getSendEvent,
   getSendAnalytics,
   getSettings,
@@ -44,6 +45,7 @@ import {
   invalidateAccountTokens,
   listApiTokens,
   listAuditLogs,
+  listAdminInboundMailboxAccess,
   listDnsCredentials,
   listDomains,
   listInboundMailboxDomains,
@@ -63,6 +65,7 @@ import {
   markUserEmailVerified,
   previewUserMerge,
   replayWebhookDelivery,
+  replaceInboundMailboxGrants,
   rotateApiToken,
   rotateWebhookSecret,
   saveDnsCredential,
@@ -451,12 +454,20 @@ async function handleApi(req, res, url, user) {
     }
   }
   if (method === 'GET' && pathname === '/api/inbound-mailboxes') {
-    const includeAllUsers = url.searchParams.get('all') === 'true';
+    const requestedScope = url.searchParams.get('scope')
+      || (url.searchParams.get('all') === 'true' ? 'all' : 'owned');
+    if (!['owned', 'effective', 'all'].includes(requestedScope)) {
+      return sendJson(res, 400, { error: '邮箱范围参数不正确。' });
+    }
+    const includeAllUsers = requestedScope === 'all';
     if (includeAllUsers && (user.role !== 'admin' || req.mailhubAuthType !== 'session')) {
       return sendJson(res, 403, { error: '只有管理员可以查看全部邮箱。' });
     }
     return sendJson(res, 200, {
-      mailboxes: listInboundMailboxes(user.id, { includeAllUsers })
+      mailboxes: listInboundMailboxes(user.id, {
+        includeAllUsers,
+        permission: requestedScope === 'effective' ? 'view' : 'owner'
+      })
     });
   }
   if (method === 'GET' && pathname === '/api/inbound-mailbox-domains') {
@@ -508,7 +519,7 @@ async function handleApi(req, res, url, user) {
       return sendJson(res, 200, searchInboundMessages(
         user.id,
         inboundMessageListFilters(url.searchParams),
-        apiTokenMailboxAccess(req, user)
+        apiTokenMailboxAccess(req, user, 'receive')
       ));
     } catch (error) {
       return sendJson(res, 400, { error: error.message || '收件查询参数不正确。' });
@@ -520,7 +531,7 @@ async function handleApi(req, res, url, user) {
     const folders = listInboundMailboxFolders(
       user.id,
       Number(inboundMailboxFoldersMatch[1]),
-      apiTokenMailboxAccess(req, user)
+      apiTokenMailboxAccess(req, user, 'receive')
     );
     return sendJson(res, folders ? 200 : 404, { folders: folders || [] });
   }
@@ -529,12 +540,13 @@ async function handleApi(req, res, url, user) {
     const id = Number(inboundMessageMatch[1]);
     if (method === 'GET') {
       if (!requireApiTokenScope(req, res, 'messages:read')) return;
-      const message = getInboundMessage(user.id, id, apiTokenMailboxAccess(req, user));
+      const message = getInboundMessage(user.id, id, apiTokenMailboxAccess(req, user, 'receive'));
       return sendJson(res, message ? 200 : 404, { message });
     }
     if (method === 'PATCH') {
       const body = await readJson(req);
-      const storage = getInboundMessageMaildirStorage(user.id, id);
+      const access = apiTokenMailboxAccess(req, user, 'receive');
+      const storage = getInboundMessageMaildirStorage(user.id, id, access);
       if (storage?.backend === 'maildir') {
         const updatedStorage = await setMaildirMessageSeen({
           root: envConfig.maildirRoot,
@@ -546,7 +558,7 @@ async function handleApi(req, res, url, user) {
         if (!updatedStorage) return sendJson(res, 409, { error: '邮件存储已发生变化,请刷新后重试。' });
         recordInboundMessageMaildirStorage(id, updatedStorage);
       }
-      const message = markInboundMessageRead(user.id, id, body.read !== false);
+      const message = markInboundMessageRead(user.id, id, body.read !== false, access);
       return sendJson(res, message ? 200 : 404, { message });
     }
   }
@@ -657,14 +669,23 @@ async function handleApi(req, res, url, user) {
     if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
       return sendJson(res, 400, { error: 'SMTP 出口不存在。' });
     }
-    const result = await sendMailFromBody(body, user);
-    return sendJson(res, 202, result);
+    try {
+      const result = await sendMailFromBody(body, user, {
+        senderAccess: apiTokenSenderAccess(req)
+      });
+      return sendJson(res, 202, result);
+    } catch (error) {
+      if (error?.code === 'SENDER_NOT_AUTHORIZED' || error?.code === 'RELAY_NOT_AUTHORIZED') {
+        return sendJson(res, 403, { error: error.message });
+      }
+      throw error;
+    }
   }
 
   if (method === 'GET' && pathname === '/api/mailboxes') {
     if (!requireApiTokenScope(req, res, 'mailboxes:read')) return;
     return sendJson(res, 200, {
-      mailboxes: listInboundMailboxes(user.id, apiTokenMailboxAccess(req, user))
+      mailboxes: listInboundMailboxes(user.id, apiTokenMailboxAccess(req, user, 'view'))
     });
   }
   if (method === 'POST' && pathname === '/api/mailboxes') {
@@ -1012,6 +1033,40 @@ async function handleAdminApi(req, res, url, user) {
   if (method === 'GET' && pathname === '/api/admin/resources') {
     return sendJson(res, 200, { inventory: getAdminResourceInventory() });
   }
+  if (method === 'GET' && pathname === '/api/admin/mailbox-access') {
+    return sendJson(res, 200, { mailboxes: listAdminInboundMailboxAccess() });
+  }
+  const mailboxAccessMatch = pathname.match(/^\/api\/admin\/inbound-mailboxes\/(\d+)\/access$/);
+  if (mailboxAccessMatch && (method === 'PUT' || method === 'PATCH')) {
+    const mailboxId = Number(mailboxAccessMatch[1]);
+    try {
+      const body = await readJson(req);
+      const mailbox = replaceInboundMailboxGrants(mailboxId, body.grants);
+      logAudit({
+        actorUserId: user.id,
+        action: 'admin.update_mailbox_access',
+        targetType: 'inbound_mailbox',
+        targetId: String(mailboxId),
+        targetUserId: mailbox.owner.id,
+        summary: {
+          address: mailbox.mailbox.address,
+          grants: mailbox.grants.map((grant) => ({
+            userId: grant.user.id,
+            permissions: grant.permissions
+          }))
+        }
+      });
+      return sendJson(res, 200, { mailbox });
+    } catch (error) {
+      if (error?.message === '收信邮箱不存在。') {
+        return sendJson(res, 404, { error: error.message });
+      }
+      if (error?.message === '用户不存在。') {
+        return sendJson(res, 404, { error: error.message });
+      }
+      return sendJson(res, 400, { error: error?.message || '邮箱权限保存失败。' });
+    }
+  }
   const transferDomainMatch = pathname.match(/^\/api\/admin\/resources\/domains\/(\d+)\/transfer$/);
   if (transferDomainMatch && method === 'POST') {
     const body = await readJson(req);
@@ -1411,15 +1466,26 @@ function auditDateParam(value) {
   return !Number.isNaN(date.getTime()) && date.toISOString() === text ? text : undefined;
 }
 
-async function sendMailFromBody(body, user) {
+async function sendMailFromBody(body, user, { senderAccess = {} } = {}) {
   const settings = runtimeSettings();
   const from = extractAddress(body.from);
   if (!from) throw new Error('发件人地址格式不正确。');
   const recipients = parseAddressList(body.to);
   if (!recipients.length) throw new Error('收件人地址格式不正确。');
   const fromDomain = domainFromAddress(from);
-  const domain = getDomainByName(fromDomain, { userId: user.id, includePrivate: true });
-  if (!domain) throw new Error(`发件域名 ${fromDomain} 不属于当前用户或尚未添加。`);
+  const requiresSelectedMailbox = Array.isArray(senderAccess.mailboxIds);
+  let domain = requiresSelectedMailbox
+    ? null
+    : getDomainByName(fromDomain, { userId: user.id, includePrivate: true });
+  if (!domain) {
+    const mailbox = getInboundMailboxForSender(user.id, from, senderAccess);
+    if (mailbox?.domainId) domain = getDomain(mailbox.domainId, { includePrivate: true });
+  }
+  if (!domain || domain.domain !== fromDomain) {
+    const error = new Error(`发件地址 ${from} 不属于当前用户,也未获得发信权限。`);
+    error.code = 'SENDER_NOT_AUTHORIZED';
+    throw error;
+  }
   if (settings.sendRequiresVerified && !domain.status?.verified) {
     throw new Error(`发件域名 ${fromDomain} 尚未完成验证。`);
   }
@@ -1597,16 +1663,21 @@ function deliveryLogFromError(error) {
 function smtpTransportForSend(body, domain, user) {
   const requestedRelayId = Number(body.smtpRelayId || 0) || null;
   if (requestedRelayId) {
+    if (Number(domain.userId) !== Number(user.id)) {
+      const error = new Error('共享域名邮箱必须使用域名所有者配置的 SMTP 出口。');
+      error.code = 'RELAY_NOT_AUTHORIZED';
+      throw error;
+    }
     const relay = getSmtpRelay(requestedRelayId, user.id, { includePassword: true });
     if (!relay) throw new Error('SMTP 出口不存在。');
     return smtpTransportFromRelay(relay);
   }
   if (domain.smtpRelayId) {
-    const relay = getSmtpRelay(domain.smtpRelayId, user.id, { includePassword: true });
+    const relay = getSmtpRelay(domain.smtpRelayId, domain.userId, { includePassword: true });
     if (!relay) throw new Error('SMTP 出口不存在。');
     return smtpTransportFromRelay(relay);
   }
-  const defaultRelay = getDefaultSmtpRelay(user.id, { includePassword: true });
+  const defaultRelay = getDefaultSmtpRelay(domain.userId, { includePassword: true });
   if (defaultRelay) return smtpTransportFromRelay(defaultRelay);
   return {
     smtpRelayId: null,
@@ -1885,20 +1956,33 @@ function requireApiTokenScope(req, res, scope) {
   return false;
 }
 
-function apiTokenMailboxAccess(req, user) {
+function apiTokenMailboxAccess(req, user, permission = 'owner') {
   const token = req.mailhubApiToken;
-  if (!token) return {};
-  if (token.mailboxAccess === 'owner') return {};
+  if (!token) return { permission };
+  if (token.mailboxAccess === 'owner') return { permission: 'owner' };
   if (token.mailboxAccess === 'selected') {
     return {
       includeAllUsers: user.role === 'admin',
-      mailboxIds: Array.isArray(token.mailboxIds) ? token.mailboxIds : []
+      mailboxIds: Array.isArray(token.mailboxIds) ? token.mailboxIds : [],
+      permission
     };
   }
   if (token.mailboxAccess === 'all' && user.role === 'admin') {
     return { includeAllUsers: true };
   }
-  return { mailboxIds: [] };
+  return { mailboxIds: [], permission };
+}
+
+function apiTokenSenderAccess(req) {
+  const token = req.mailhubApiToken;
+  if (!token) return {};
+  if (token.mailboxAccess === 'owner') return { ownerOnly: true };
+  if (token.mailboxAccess === 'selected') {
+    return {
+      mailboxIds: Array.isArray(token.mailboxIds) ? token.mailboxIds : []
+    };
+  }
+  return {};
 }
 
 function getSessionUser(req) {

+ 43 - 6
src/submission.js

@@ -6,9 +6,11 @@ import {
   createInboundMessageWithWebhook,
   createTrackingLink,
   finalizeSendEvent,
+  getDefaultSmtpRelay,
   getDomain,
   getDomainByName,
   getInboundMailboxForSender,
+  getSmtpRelay,
   logSendEvent,
   resolveInboundRecipient,
   verifySmtpCredential
@@ -504,7 +506,10 @@ class SubmissionSession {
       trackingClicks: requestedClicks
     });
 
+    let smtpRelayId = null;
     try {
+      const smtpTransport = submissionSmtpTransport(domain, this.config);
+      smtpRelayId = smtpTransport.smtpRelayId;
       let preparedMessage = rawMessage;
       let trackingOpens = false;
       let trackingClicks = false;
@@ -554,17 +559,18 @@ class SubmissionSession {
       }));
       const signed = signMessageForDomain(preparedMessage, domain);
       const smtpResult = await sendViaSmtp({
-        host: this.config.relayHost,
-        port: this.config.relayPort,
-        secure: this.config.relaySecure,
-        username: this.config.relayUsername,
-        password: this.config.relayPassword,
-        helo: this.config.relayHelo,
+        host: smtpTransport.host,
+        port: smtpTransport.port,
+        secure: smtpTransport.secure,
+        username: smtpTransport.username,
+        password: smtpTransport.password,
+        helo: smtpTransport.helo,
         mailFrom: resolveEnvelopeSender(deliverabilitySettings, this.mailFrom),
         recipients: this.recipients,
         rawMessage: signed
       });
       finalizeSendEvent(eventId, this.user.id, {
+        smtpRelayId,
         status: 'queued',
         detail: `submission ${this.remoteAddress}; ${smtpResult.message}`,
         queueId: smtpResult.queueId,
@@ -576,6 +582,7 @@ class SubmissionSession {
       return this.write(250, 'Message queued');
     } catch (error) {
       finalizeSendEvent(eventId, this.user.id, {
+        smtpRelayId,
         status: 'failed',
         detail: `submission ${this.remoteAddress}; ${error.message}`,
         deliveryLog: deliveryLogFromError(error)
@@ -742,6 +749,36 @@ function resolveAccountSenderDomain(userId, address, { includePrivate = false }
   return { configured: true, domain };
 }
 
+function submissionSmtpTransport(domain, config) {
+  let relay = null;
+  if (domain.smtpRelayId) {
+    relay = getSmtpRelay(domain.smtpRelayId, domain.userId, { includePassword: true });
+    if (!relay) throw new Error('SMTP 出口不存在。');
+  } else {
+    relay = getDefaultSmtpRelay(domain.userId, { includePassword: true });
+  }
+  if (relay) {
+    return {
+      smtpRelayId: relay.id,
+      host: relay.host,
+      port: relay.port,
+      secure: relay.secure,
+      username: relay.username,
+      password: relay.password || '',
+      helo: relay.helo || config.relayHelo
+    };
+  }
+  return {
+    smtpRelayId: null,
+    host: config.relayHost,
+    port: config.relayPort,
+    secure: config.relaySecure,
+    username: config.relayUsername,
+    password: config.relayPassword,
+    helo: config.relayHelo
+  };
+}
+
 function extractHeader(rawMessage, name) {
   const head = rawMessage.split(/\r?\n\r?\n/, 1)[0] || '';
   const lines = head.split(/\r?\n/);

+ 19 - 0
test/db.test.js

@@ -34,6 +34,7 @@ import {
   getUser,
   initDatabase,
   listAuditLogs,
+  listAdminInboundMailboxAccess,
   listApiTokens,
   listDomains,
   listInboundMailboxes,
@@ -50,6 +51,7 @@ import {
   previewUserMerge,
   pruneTrackingEvents,
   recordTrackingEvent,
+  replaceInboundMailboxGrants,
   saveDnsCredential,
   saveSmtpRelay,
   saveSmtpCredential,
@@ -1376,6 +1378,7 @@ test('previews and executes user merge with resource counts and multiple smtp cr
   const admin = createUser({ username: 'admin3', email: 'admin3@example.com', password: 'password123', role: 'admin' });
   const source = createUser({ username: 'source', email: 'source@example.com', password: 'password123' });
   const target = createUser({ username: 'target', email: 'target@example.com', password: 'password123' });
+  const externalOwner = createUser({ username: 'external-owner', email: 'external-owner@example.com', password: 'password123' });
   const credential = saveDnsCredential(source.id, {
     name: 'Source DNS',
     provider: 'cloudflare',
@@ -1384,6 +1387,13 @@ test('previews and executes user merge with resource counts and multiple smtp cr
   });
   const domain = createDomain(source.id, { ...domainFixture('source.example'), dnsCredentialId: credential.id });
   const inboundMailbox = createInboundMailbox(source.id, { address: 'support@source.example' });
+  replaceInboundMailboxGrants(inboundMailbox.id, [{ userId: target.id, view: true }]);
+  createDomain(externalOwner.id, domainFixture('external.example'));
+  const externalMailbox = createInboundMailbox(externalOwner.id, { address: 'shared@external.example' });
+  replaceInboundMailboxGrants(externalMailbox.id, [
+    { userId: source.id, receive: true },
+    { userId: target.id, send: true }
+  ]);
   createInboundMessage(inboundMailbox, {
     sender: 'sender@example.net',
     recipients: ['support@source.example'],
@@ -1462,6 +1472,15 @@ test('previews and executes user merge with resource counts and multiple smtp cr
   });
   assert.equal(getDomain(domain.id).userId, target.id);
   assert.equal(listInboundMailboxes(target.id)[0].address, 'support@source.example');
+  assert.deepEqual(
+    listAdminInboundMailboxAccess().find((entry) => entry.mailbox.id === inboundMailbox.id).grants,
+    []
+  );
+  const mergedExternalGrant = listAdminInboundMailboxAccess()
+    .find((entry) => entry.mailbox.id === externalMailbox.id).grants;
+  assert.equal(mergedExternalGrant.length, 1);
+  assert.equal(mergedExternalGrant[0].user.id, target.id);
+  assert.deepEqual(mergedExternalGrant[0].permissions, { view: true, receive: true, send: true });
   assert.equal(listInboundMessages(target.id)[0].subject, 'Merge inbound');
   assert.deepEqual(listInboundMailboxes(source.id), []);
   assert.equal(getDnsCredential(credential.id, target.id).id, credential.id);

+ 126 - 0
test/dns-guide.test.js

@@ -49,10 +49,136 @@ test('includes live current values for verification and dkim records', async ()
 
     const verification = guide.records.find((record) => record.key === 'verification');
     const dkim = guide.records.find((record) => record.key === 'dkim');
+    const spf = guide.records.find((record) => record.key === 'spf');
 
     assert.equal(guide.records.some((record) => record.key === 'ptr'), false);
     assert.deepEqual(verification.current, ['mailhub-verification=verify-token']);
     assert.deepEqual(dkim.current, ['v=DKIM1; k=rsa; p=dkim-public']);
+    assert.deepEqual(guide.requiredSpf, ['a:mail.example.com']);
+    assert.equal(spf.value, 'v=spf1 a:mail.example.com ~all');
+    assert.equal(spf.value.includes('ip4:'), false);
+  } finally {
+    dns.promises.Resolver = originalResolver;
+  }
+});
+
+test('removes the managed direct IP while preserving configured SPF extensions', async () => {
+  const originalResolver = dns.promises.Resolver;
+
+  dns.promises.Resolver = class FakeResolver {
+    setServers() {}
+    async resolveTxt(name) {
+      if (name === 'example.com') {
+        return [['v=spf1 ip4:192.0.2.10 a:mail.example.com include:spf.mailjet.com ~all']];
+      }
+      const error = new Error('not found');
+      error.code = 'ENODATA';
+      throw error;
+    }
+    async resolve4() {
+      return ['192.0.2.20'];
+    }
+  };
+
+  try {
+    const { buildDnsGuide } = await import(`../src/dns-guide.js?test=${Date.now()}-spf-policy`);
+    const guide = await buildDnsGuide({
+      domain: 'example.com',
+      selector: 'mailhub',
+      verificationToken: 'verify-token',
+      dkimPublic: 'dkim-public',
+      senderHost: 'mail.example.com',
+      sendingIp: '192.0.2.10',
+      spfExtra: 'ip4:192.0.2.10 include:spf.mailjet.com',
+      dmarcPolicy: 'none',
+      dmarcRua: ''
+    });
+    const spf = guide.records.find((record) => record.key === 'spf');
+
+    assert.equal(spf.value, 'v=spf1 a:mail.example.com include:spf.mailjet.com ~all');
+    assert.match(spf.warnings.join('\n'), /直接 IP 授权/);
+  } finally {
+    dns.promises.Resolver = originalResolver;
+  }
+});
+
+test('removes stale direct IP mechanisms after the managed sending IP changes', async () => {
+  const originalResolver = dns.promises.Resolver;
+
+  dns.promises.Resolver = class FakeResolver {
+    setServers() {}
+    async resolveTxt(name) {
+      if (name === 'example.com') {
+        return [['v=spf1 ip4:192.0.2.10 ip4:198.51.100.25 a:mail.example.com ~all']];
+      }
+      const error = new Error('not found');
+      error.code = 'ENODATA';
+      throw error;
+    }
+    async resolve4() {
+      return ['192.0.2.10'];
+    }
+  };
+
+  try {
+    const { buildDnsGuide } = await import(`../src/dns-guide.js?test=${Date.now()}-spf-preserve`);
+    const guide = await buildDnsGuide({
+      domain: 'example.com',
+      selector: 'mailhub',
+      verificationToken: 'verify-token',
+      dkimPublic: 'dkim-public',
+      senderHost: 'mail.example.com',
+      sendingIp: '192.0.2.20',
+      spfExtra: '',
+      dmarcPolicy: 'none',
+      dmarcRua: ''
+    });
+    const spf = guide.records.find((record) => record.key === 'spf');
+
+    assert.equal(spf.value, 'v=spf1 a:mail.example.com ~all');
+    assert.match(spf.warnings.join('\n'), /直接 IP 授权/);
+  } finally {
+    dns.promises.Resolver = originalResolver;
+  }
+});
+
+test('preserves direct IP mechanisms explicitly configured as SPF extensions', async () => {
+  const originalResolver = dns.promises.Resolver;
+
+  dns.promises.Resolver = class FakeResolver {
+    setServers() {}
+    async resolveTxt(name) {
+      if (name === 'example.com') {
+        return [['v=spf1 ip4:192.0.2.10 ip4:198.51.100.25 include:spf.mailjet.com ~all']];
+      }
+      const error = new Error('not found');
+      error.code = 'ENODATA';
+      throw error;
+    }
+    async resolve4() {
+      return ['192.0.2.20'];
+    }
+  };
+
+  try {
+    const { buildDnsGuide } = await import(`../src/dns-guide.js?test=${Date.now()}-spf-explicit-ip`);
+    const guide = await buildDnsGuide({
+      domain: 'example.com',
+      selector: 'mailhub',
+      verificationToken: 'verify-token',
+      dkimPublic: 'dkim-public',
+      senderHost: 'mail.example.com',
+      sendingIp: '192.0.2.20',
+      spfExtra: 'ip4:198.51.100.25 include:spf.mailjet.com',
+      dmarcPolicy: 'none',
+      dmarcRua: ''
+    });
+    const spf = guide.records.find((record) => record.key === 'spf');
+
+    assert.equal(
+      spf.value,
+      'v=spf1 ip4:198.51.100.25 include:spf.mailjet.com a:mail.example.com ~all'
+    );
   } finally {
     dns.promises.Resolver = originalResolver;
   }

+ 152 - 0
test/inbound-db.test.js

@@ -6,19 +6,27 @@ import { test } from 'node:test';
 
 import {
   createDomain,
+  createApiToken,
   createInboundMailbox,
   createInboundMessage,
   createUser,
   deleteDomain,
   getInboundMailboxByAddress,
+  getInboundMailboxForSender,
+  getInboundMessageMaildirStorage,
   getInboundMessage,
   initDatabase,
+  listAdminInboundMailboxAccess,
   listDomains,
+  listInboundMailboxFolders,
   listInboundMailboxDomains,
   listInboundMailboxes,
   listInboundMessages,
   markInboundMessageRead,
+  replaceInboundMailboxGrants,
   resolveInboundRecipient,
+  searchInboundMessages,
+  transferDomain,
   updateDomain,
   updateInboundMailbox,
   upsertImportedInboundMailbox,
@@ -100,6 +108,150 @@ test('users can create inbound mailboxes and read received messages', () => {
   assert.equal(markInboundMessageRead(999999, message.id, true), null);
 });
 
+test('mailbox grants isolate owner, view, receive, send, token selection, and transfer access', () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-grants-')), 'inbound-secret');
+  const owner = createUser({ username: 'grant-owner', email: 'grant-owner@example.com', password: 'password123' });
+  const viewer = createUser({ username: 'grant-viewer', email: 'grant-viewer@example.com', password: 'password123' });
+  const receiver = createUser({ username: 'grant-receiver', email: 'grant-receiver@example.com', password: 'password123' });
+  const sender = createUser({ username: 'grant-sender', email: 'grant-sender@example.com', password: 'password123' });
+  const domain = createDomain(owner.id, {
+    domain: 'grants.example',
+    selector: 'mh',
+    verificationToken: 'verify-grants',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.grants.example',
+    sendingIp: '192.0.2.31',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(owner.id, {
+    address: 'support@grants.example',
+    aliases: ['help']
+  });
+  const message = createInboundMessage(mailbox, {
+    sender: 'alice@example.net',
+    recipients: [mailbox.address],
+    subject: 'Mailbox grant message',
+    rawMessage: 'Subject: Mailbox grant message\r\n\r\nShared body',
+    textBody: 'Shared body'
+  });
+
+  const accessEntry = replaceInboundMailboxGrants(mailbox.id, [
+    { userId: viewer.id, view: true },
+    { userId: receiver.id, permissions: ['receive'] },
+    { userId: sender.id, permissions: { send: true } }
+  ]);
+  assert.equal(accessEntry.mailbox.id, mailbox.id);
+  assert.equal(accessEntry.owner.id, owner.id);
+  assert.deepEqual(
+    new Map(accessEntry.grants.map((grant) => [grant.user.id, grant.permissions])),
+    new Map([
+      [viewer.id, { view: true, receive: false, send: false }],
+      [receiver.id, { view: true, receive: true, send: false }],
+      [sender.id, { view: true, receive: false, send: true }]
+    ])
+  );
+  assert.equal(listAdminInboundMailboxAccess().find((entry) => entry.mailbox.id === mailbox.id).owner.id, owner.id);
+  assert.deepEqual(listInboundMailboxes(owner.id, { permission: 'view' })[0].access, {
+    type: 'owner',
+    permissions: { view: true, receive: true, send: true }
+  });
+
+  assert.deepEqual(listInboundMailboxes(viewer.id), []);
+  const [viewOnlyMailbox] = listInboundMailboxes(viewer.id, { permission: 'view' });
+  assert.deepEqual(viewOnlyMailbox.access, {
+    type: 'assigned',
+    permissions: { view: true, receive: false, send: false }
+  });
+  assert.equal(viewOnlyMailbox.ownerUserId, owner.id);
+  assert.equal(viewOnlyMailbox.messageCount, null);
+  assert.equal(viewOnlyMailbox.unreadCount, null);
+  assert.equal(viewOnlyMailbox.lastMessageAt, null);
+  assert.equal(getInboundMessage(viewer.id, message.id, { permission: 'receive' }), null);
+  assert.equal(listInboundMailboxFolders(viewer.id, mailbox.id, { permission: 'receive' }), null);
+  assert.equal(getInboundMessageMaildirStorage(viewer.id, message.id, { permission: 'receive' }), null);
+  assert.equal(markInboundMessageRead(viewer.id, message.id, true, { permission: 'receive' }), null);
+
+  assert.equal(searchInboundMessages(receiver.id, { pageSize: 100 }).total, 0);
+  const [receivedMailbox] = listInboundMailboxes(receiver.id, { permission: 'view' });
+  assert.equal(receivedMailbox.access.permissions.receive, true);
+  assert.equal(receivedMailbox.messageCount, 1);
+  assert.equal(receivedMailbox.unreadCount, 1);
+  assert.equal(searchInboundMessages(receiver.id, { pageSize: 100 }, { permission: 'receive' }).total, 1);
+  assert.equal(getInboundMessage(receiver.id, message.id, { permission: 'receive' }).subject, 'Mailbox grant message');
+  assert.ok(listInboundMailboxFolders(receiver.id, mailbox.id, { permission: 'receive' }));
+  assert.equal(getInboundMessageMaildirStorage(receiver.id, message.id, { permission: 'receive' }).id, message.id);
+  assert.equal(markInboundMessageRead(receiver.id, message.id, true, { permission: 'receive' }).read, true);
+
+  assert.equal(getInboundMailboxForSender(receiver.id, mailbox.address), null);
+  assert.equal(getInboundMailboxForSender(sender.id, mailbox.address).id, mailbox.id);
+  assert.equal(getInboundMailboxForSender(sender.id, 'help@grants.example').id, mailbox.id);
+  assert.equal(getInboundMailboxForSender(sender.id, mailbox.address, { ownerOnly: true }), null);
+  assert.equal(getInboundMailboxForSender(sender.id, mailbox.address, { mailboxIds: [] }), null);
+  assert.equal(getInboundMailboxForSender(sender.id, mailbox.address, { mailboxIds: [mailbox.id] }).id, mailbox.id);
+
+  const selectedToken = createApiToken(viewer.id, 'granted mailbox', {
+    scopes: ['mailboxes:read'],
+    mailboxAccess: 'selected',
+    mailboxIds: [mailbox.id]
+  });
+  assert.equal(selectedToken.mailboxAccess, 'selected');
+  assert.deepEqual(selectedToken.mailboxIds, [mailbox.id]);
+  assert.equal(listInboundMailboxes(viewer.id, {
+    permission: 'view',
+    mailboxIds: selectedToken.mailboxIds
+  }).length, 1);
+  assert.equal(searchInboundMessages(viewer.id, { pageSize: 100 }, {
+    permission: 'receive',
+    mailboxIds: selectedToken.mailboxIds
+  }).total, 0);
+  const receiveToken = createApiToken(receiver.id, 'received mailbox', {
+    scopes: ['messages:read'],
+    mailboxAccess: 'selected',
+    mailboxIds: [mailbox.id]
+  });
+  assert.equal(searchInboundMessages(receiver.id, { pageSize: 100 }, {
+    permission: 'receive',
+    mailboxIds: receiveToken.mailboxIds
+  }).total, 1);
+
+  assert.throws(
+    () => replaceInboundMailboxGrants(mailbox.id, [{ userId: viewer.id, view: false, receive: false, send: false }]),
+    /至少需要一项权限/
+  );
+  assert.throws(
+    () => replaceInboundMailboxGrants(mailbox.id, [{ userId: owner.id, view: true }]),
+    /所有者无需额外授权/
+  );
+  assert.throws(
+    () => replaceInboundMailboxGrants(mailbox.id, [{ userId: 999999, view: true }]),
+    /用户不存在/
+  );
+  assert.throws(
+    () => replaceInboundMailboxGrants(mailbox.id, [
+      { userId: viewer.id, view: true },
+      { userId: viewer.id, receive: true }
+    ]),
+    /不能重复/
+  );
+  assert.equal(listAdminInboundMailboxAccess().find((entry) => entry.mailbox.id === mailbox.id).grants.length, 3);
+
+  replaceInboundMailboxGrants(mailbox.id, []);
+  assert.equal(listInboundMailboxes(viewer.id, {
+    permission: 'view',
+    mailboxIds: selectedToken.mailboxIds
+  }).length, 0);
+  assert.equal(getInboundMailboxForSender(sender.id, mailbox.address), null);
+
+  replaceInboundMailboxGrants(mailbox.id, [{ userId: sender.id, send: true }]);
+  transferDomain({ actorUserId: owner.id, domainId: domain.id, targetUserId: sender.id });
+  const transferred = listAdminInboundMailboxAccess().find((entry) => entry.mailbox.id === mailbox.id);
+  assert.equal(transferred.owner.id, sender.id);
+  assert.deepEqual(transferred.grants, []);
+});
+
 test('domains can route unknown inbound recipients to catch-all targets', () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-catchall-')), 'inbound-secret');
   const user = createUser({ username: 'catch-user', email: 'catch@example.com', password: 'password123' });

+ 703 - 0
test/server-mailbox-access.test.js

@@ -0,0 +1,703 @@
+import assert from 'node:assert/strict';
+import { spawn, spawnSync } from 'node:child_process';
+import { mkdtempSync } from 'node:fs';
+import net from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import process from 'node:process';
+import { test } from 'node:test';
+
+test('mailbox grants constrain admin, session, token, and send API access', async (t) => {
+  const smtp = await startFakeSmtpServer();
+  const readerSmtp = await startFakeSmtpServer();
+  const fixture = await startTestServer(smtp.port);
+
+  try {
+    const seeded = seedMailboxAccessFixtures(fixture.dataDir, fixture.sessionSecret, readerSmtp.port);
+    const adminCookie = await login(fixture.baseUrl, 'admin', 'password123');
+    const ownerCookie = await login(fixture.baseUrl, 'access-owner', 'password123');
+    const readerCookie = await login(fixture.baseUrl, 'access-reader', 'password123');
+    const viewerCookie = await login(fixture.baseUrl, 'access-viewer', 'password123');
+
+    await t.test('only administrators can list and replace mailbox grants', async () => {
+      const ordinaryList = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
+        cookie: readerCookie
+      });
+      assert.equal(ordinaryList.status, 403);
+
+      const ordinaryReplace = await requestJson(
+        fixture.baseUrl,
+        `/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`,
+        {
+          method: 'PUT',
+          cookie: readerCookie,
+          body: { grants: [] }
+        }
+      );
+      assert.equal(ordinaryReplace.status, 403);
+
+      const malformed = await fetch(
+        `${fixture.baseUrl}/api/admin/inbound-mailboxes/${seeded.sharedMailboxId}/access`,
+        {
+          method: 'PUT',
+          headers: {
+            'Content-Type': 'application/json',
+            Cookie: adminCookie
+          },
+          body: '{"grants":'
+        }
+      );
+      assert.equal(malformed.status, 400);
+      assert.equal(typeof (await malformed.json()).error, 'string');
+
+      const initial = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
+        cookie: adminCookie
+      });
+      assert.equal(initial.status, 200);
+      const initialShared = findAdminMailbox(initial.body.mailboxes, seeded.sharedMailboxId);
+      assert.equal(initialShared.owner.id, seeded.ownerUserId);
+      assert.deepEqual(initialShared.grants, []);
+
+      const replaced = await replaceGrants(
+        fixture.baseUrl,
+        adminCookie,
+        seeded.sharedMailboxId,
+        [
+          { userId: seeded.readerUserId, permissions: ['receive', 'send'] },
+          { userId: seeded.viewerUserId, permissions: ['view'] }
+        ]
+      );
+      assert.equal(replaced.mailbox.id, seeded.sharedMailboxId);
+      assert.equal(replaced.owner.id, seeded.ownerUserId);
+      assert.deepEqual(grantPermissions(replaced, seeded.readerUserId), {
+        view: true,
+        receive: true,
+        send: true
+      });
+      assert.deepEqual(grantPermissions(replaced, seeded.viewerUserId), {
+        view: true,
+        receive: false,
+        send: false
+      });
+
+      const listed = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
+        cookie: adminCookie
+      });
+      const listedShared = findAdminMailbox(listed.body.mailboxes, seeded.sharedMailboxId);
+      assert.deepEqual(grantPermissions(listedShared, seeded.readerUserId), {
+        view: true,
+        receive: true,
+        send: true
+      });
+    });
+
+    await t.test('owned, effective, and all mailbox scopes stay distinct', async () => {
+      assert.deepEqual(
+        await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', ownerCookie),
+        ['other@access-owner.example', 'shared@access-owner.example']
+      );
+      assert.deepEqual(
+        await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', readerCookie),
+        ['own@access-reader.example']
+      );
+      assert.deepEqual(
+        await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', readerCookie),
+        ['own@access-reader.example', 'shared@access-owner.example']
+      );
+      assert.deepEqual(
+        await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=effective', viewerCookie),
+        ['shared@access-owner.example']
+      );
+
+      const ordinaryAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', {
+        cookie: readerCookie
+      });
+      assert.equal(ordinaryAll.status, 403);
+
+      const adminAll = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=all', {
+        cookie: adminCookie
+      });
+      assert.equal(adminAll.status, 200);
+      assert.deepEqual(
+        adminAll.body.mailboxes.map((mailbox) => mailbox.address).sort(),
+        [
+          'other@access-owner.example',
+          'own@access-reader.example',
+          'shared@access-owner.example'
+        ]
+      );
+
+      const invalid = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?scope=unknown', {
+        cookie: adminCookie
+      });
+      assert.equal(invalid.status, 400);
+    });
+
+    await t.test('receive grants allow message reads while view-only grants do not', async () => {
+      const list = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}&q=Shared%20grant`,
+        { cookie: readerCookie }
+      );
+      assert.equal(list.status, 200);
+      assert.equal(list.body.total, 1);
+      assert.deepEqual(list.body.messages.map((message) => message.id), [seeded.sharedMessageId]);
+
+      const detail = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages/${seeded.sharedMessageId}`,
+        { cookie: readerCookie }
+      );
+      assert.equal(detail.status, 200);
+      assert.equal(detail.body.message.subject, 'Shared grant message');
+
+      const marked = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages/${seeded.sharedMessageId}`,
+        {
+          method: 'PATCH',
+          cookie: readerCookie,
+          body: { read: true }
+        }
+      );
+      assert.equal(marked.status, 200);
+      assert.equal(marked.body.message.read, true);
+
+      const viewerList = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
+        { cookie: viewerCookie }
+      );
+      assert.equal(viewerList.status, 200);
+      assert.equal(viewerList.body.total, 0);
+
+      const viewerDetail = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages/${seeded.sharedMessageId}`,
+        { cookie: viewerCookie }
+      );
+      assert.equal(viewerDetail.status, 404);
+
+      const viewerMark = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages/${seeded.sharedMessageId}`,
+        {
+          method: 'PATCH',
+          cookie: viewerCookie,
+          body: { read: false }
+        }
+      );
+      assert.equal(viewerMark.status, 404);
+    });
+
+    await t.test('Bearer owner scope cannot expand into grants and selected scope is revoked immediately', async () => {
+      const ownerToken = await createToken(fixture.baseUrl, readerCookie, {
+        name: 'grant owner scope',
+        scopes: ['mailboxes:read', 'messages:read'],
+        mailboxAccess: 'owner'
+      });
+      const ownerMessages = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
+        bearer: ownerToken.token
+      });
+      assert.equal(ownerMessages.status, 200);
+      assert.deepEqual(ownerMessages.body.messages.map((message) => message.id), [seeded.readerMessageId]);
+      const ownerMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', {
+        bearer: ownerToken.token
+      });
+      assert.deepEqual(ownerMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.readerMailboxId]);
+
+      const selectedToken = await createToken(fixture.baseUrl, readerCookie, {
+        name: 'grant selected scope',
+        scopes: ['mailboxes:read', 'messages:read'],
+        mailboxAccess: 'selected',
+        mailboxIds: [seeded.sharedMailboxId]
+      });
+      const selectedMessages = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
+        { bearer: selectedToken.token }
+      );
+      assert.equal(selectedMessages.status, 200);
+      assert.deepEqual(selectedMessages.body.messages.map((message) => message.id), [seeded.sharedMessageId]);
+      const selectedDetail = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages/${seeded.sharedMessageId}`,
+        { bearer: selectedToken.token }
+      );
+      assert.equal(selectedDetail.status, 200);
+
+      await replaceGrants(
+        fixture.baseUrl,
+        adminCookie,
+        seeded.sharedMailboxId,
+        [{ userId: seeded.viewerUserId, permissions: ['view'] }]
+      );
+
+      const afterRevoke = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages?mailboxId=${seeded.sharedMailboxId}`,
+        { bearer: selectedToken.token }
+      );
+      assert.equal(afterRevoke.status, 200);
+      assert.equal(afterRevoke.body.total, 0);
+      const detailAfterRevoke = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages/${seeded.sharedMessageId}`,
+        { bearer: selectedToken.token }
+      );
+      assert.equal(detailAfterRevoke.status, 404);
+    });
+
+    await t.test('Bearer send scope is intersected with token mailbox selection and live send grants', async () => {
+      await replaceGrants(
+        fixture.baseUrl,
+        adminCookie,
+        seeded.sharedMailboxId,
+        [{ userId: seeded.readerUserId, permissions: ['send'] }]
+      );
+      const ownerToken = await createToken(fixture.baseUrl, readerCookie, {
+        name: 'send owner scope',
+        scopes: ['send'],
+        mailboxAccess: 'owner'
+      });
+      const selectedSharedToken = await createToken(fixture.baseUrl, readerCookie, {
+        name: 'send selected shared mailbox',
+        scopes: ['send'],
+        mailboxAccess: 'selected',
+        mailboxIds: [seeded.sharedMailboxId]
+      });
+      const selectedOwnToken = await createToken(fixture.baseUrl, readerCookie, {
+        name: 'send selected owned mailbox',
+        scopes: ['send'],
+        mailboxAccess: 'selected',
+        mailboxIds: [seeded.readerMailboxId]
+      });
+      const mailFromCount = smtpMailFromCommands(smtp).length;
+
+      await assertSendDenied(fixture.baseUrl, {
+        bearer: ownerToken.token,
+        from: 'shared@access-owner.example'
+      });
+      await assertSendDenied(fixture.baseUrl, {
+        bearer: selectedOwnToken.token,
+        from: 'shared@access-owner.example'
+      });
+
+      const selectedSend = await sendApiMessage(fixture.baseUrl, {
+        bearer: selectedSharedToken.token,
+        from: 'shared@access-owner.example',
+        subject: 'Selected token mailbox grant send'
+      });
+      assert.equal(selectedSend.status, 202, JSON.stringify(selectedSend.body));
+      assert.equal(selectedSend.body.queued, true);
+      assert.deepEqual(
+        smtpMailFromCommands(smtp).slice(mailFromCount),
+        ['MAIL FROM:<shared@access-owner.example>']
+      );
+
+      await replaceGrants(fixture.baseUrl, adminCookie, seeded.sharedMailboxId, []);
+      await assertSendDenied(fixture.baseUrl, {
+        bearer: selectedSharedToken.token,
+        from: 'shared@access-owner.example'
+      });
+      assert.equal(smtpMailFromCommands(smtp).length, mailFromCount + 1);
+    });
+
+    await t.test('send grants authorize only the exact mailbox address and aliases', async () => {
+      await replaceGrants(
+        fixture.baseUrl,
+        adminCookie,
+        seeded.sharedMailboxId,
+        [
+          { userId: seeded.readerUserId, permissions: ['send'] },
+          { userId: seeded.viewerUserId, permissions: ['view'] }
+        ]
+      );
+      const mailFromCount = smtpMailFromCommands(smtp).length;
+      const readerRelayMailFromCount = smtpMailFromCommands(readerSmtp).length;
+
+      const forbiddenRelay = await sendApiMessage(fixture.baseUrl, {
+        cookie: readerCookie,
+        from: 'shared@access-owner.example',
+        subject: 'Shared mailbox cannot use grantee relay',
+        smtpRelayId: seeded.readerRelayId
+      });
+      assert.equal(forbiddenRelay.status, 403, JSON.stringify(forbiddenRelay.body));
+      assert.match(forbiddenRelay.body.error, /域名所有者.*SMTP 出口/);
+      assert.equal(smtpMailFromCommands(smtp).length, mailFromCount);
+      assert.equal(smtpMailFromCommands(readerSmtp).length, readerRelayMailFromCount);
+
+      for (const from of ['shared@access-owner.example', 'shared-alias@access-owner.example']) {
+        const sent = await sendApiMessage(fixture.baseUrl, {
+          cookie: readerCookie,
+          from,
+          subject: `Mailbox grant send from ${from}`
+        });
+        assert.equal(sent.status, 202, JSON.stringify(sent.body));
+        assert.equal(sent.body.queued, true);
+      }
+
+      await assertSendDenied(fixture.baseUrl, {
+        cookie: readerCookie,
+        from: 'other@access-owner.example'
+      });
+
+      assert.deepEqual(
+        smtpMailFromCommands(smtp).slice(mailFromCount),
+        [
+          'MAIL FROM:<shared@access-owner.example>',
+          'MAIL FROM:<shared-alias@access-owner.example>'
+        ]
+      );
+    });
+  } finally {
+    fixture.child.kill('SIGTERM');
+    await waitForExit(fixture.child, 1000);
+    await smtp.close();
+    await readerSmtp.close();
+  }
+});
+
+function seedMailboxAccessFixtures(dataDir, sessionSecret, readerRelayPort) {
+  const script = `
+    import {
+      createDomain,
+      createInboundMailbox,
+      createInboundMessage,
+      createUser,
+      initDatabase,
+      saveSmtpRelay
+    } from './src/db.js';
+    import { createDkimKeyPair } from './src/dkim.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    const owner = createUser({
+      username: 'access-owner',
+      email: 'access-owner@example.test',
+      password: 'password123',
+      status: 'active'
+    });
+    const reader = createUser({
+      username: 'access-reader',
+      email: 'access-reader@example.test',
+      password: 'password123',
+      status: 'active'
+    });
+    const viewer = createUser({
+      username: 'access-viewer',
+      email: 'access-viewer@example.test',
+      password: 'password123',
+      status: 'active'
+    });
+    const readerRelay = saveSmtpRelay(reader.id, {
+      name: 'Reader private relay',
+      host: '127.0.0.1',
+      port: Number(process.env.READER_RELAY_PORT),
+      secure: false,
+      username: '',
+      password: '',
+      helo: 'mail.access-reader.example'
+    });
+    const keys = createDkimKeyPair();
+    const createUserDomain = (user, name) => createDomain(user.id, {
+      domain: name,
+      selector: 'mh',
+      verificationToken: 'verify-' + name,
+      dkimPublic: keys.publicKey,
+      dkimPrivate: keys.privateKey,
+      senderHost: 'mail.' + name,
+      sendingIp: '127.0.0.1',
+      spfExtra: '',
+      dmarcPolicy: 'none',
+      dmarcRua: ''
+    });
+    createUserDomain(owner, 'access-owner.example');
+    createUserDomain(reader, 'access-reader.example');
+
+    const sharedMailbox = createInboundMailbox(owner.id, {
+      address: 'shared@access-owner.example',
+      password: 'mailbox-password',
+      aliases: ['shared-alias']
+    });
+    const otherMailbox = createInboundMailbox(owner.id, {
+      address: 'other@access-owner.example',
+      password: 'mailbox-password'
+    });
+    const readerMailbox = createInboundMailbox(reader.id, {
+      address: 'own@access-reader.example',
+      password: 'mailbox-password'
+    });
+    const createMessage = (mailbox, subject, sequence) => createInboundMessage(mailbox, {
+      sender: 'sender@example.net',
+      recipients: [mailbox.address],
+      subject,
+      messageId: '<mailbox-access-' + sequence + '@example.net>',
+      rawMessage: 'Subject: ' + subject + '\\r\\n\\r\\n' + subject,
+      textBody: subject,
+      receivedAt: '2026-07-18T0' + sequence + ':00:00.000Z'
+    });
+    const sharedMessage = createMessage(sharedMailbox, 'Shared grant message', 1);
+    createMessage(otherMailbox, 'Other owner message', 2);
+    const readerMessage = createMessage(readerMailbox, 'Reader owned message', 3);
+
+    console.log(JSON.stringify({
+      ownerUserId: owner.id,
+      readerUserId: reader.id,
+      viewerUserId: viewer.id,
+      sharedMailboxId: sharedMailbox.id,
+      otherMailboxId: otherMailbox.id,
+      readerMailboxId: readerMailbox.id,
+      readerRelayId: readerRelay.id,
+      sharedMessageId: sharedMessage.id,
+      readerMessageId: readerMessage.id
+    }));
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret,
+      READER_RELAY_PORT: String(readerRelayPort)
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  return JSON.parse(result.stdout);
+}
+
+async function replaceGrants(baseUrl, adminCookie, mailboxId, grants) {
+  const response = await requestJson(
+    baseUrl,
+    `/api/admin/inbound-mailboxes/${mailboxId}/access`,
+    {
+      method: 'PUT',
+      cookie: adminCookie,
+      body: { grants }
+    }
+  );
+  assert.equal(response.status, 200, JSON.stringify(response.body));
+  return response.body.mailbox;
+}
+
+function findAdminMailbox(mailboxes, mailboxId) {
+  const mailbox = mailboxes.find((entry) => entry.mailbox.id === mailboxId);
+  assert.ok(mailbox, `expected admin mailbox ${mailboxId}`);
+  return mailbox;
+}
+
+function grantPermissions(mailboxAccess, userId) {
+  const grant = mailboxAccess.grants.find((entry) => entry.user.id === userId);
+  assert.ok(grant, `expected grant for user ${userId}`);
+  return grant.permissions;
+}
+
+async function mailboxAddresses(baseUrl, pathname, cookie) {
+  const response = await requestJson(baseUrl, pathname, { cookie });
+  assert.equal(response.status, 200, JSON.stringify(response.body));
+  return response.body.mailboxes.map((mailbox) => mailbox.address).sort();
+}
+
+async function createToken(baseUrl, cookie, input) {
+  const response = await requestJson(baseUrl, '/api/api-tokens', {
+    method: 'POST',
+    cookie,
+    body: input
+  });
+  assert.equal(response.status, 201, JSON.stringify(response.body));
+  assert.ok(response.body.token.token);
+  return response.body.token;
+}
+
+function sendApiMessage(baseUrl, {
+  cookie = '',
+  bearer = '',
+  from,
+  subject = 'Mailbox grant send',
+  smtpRelayId = null
+}) {
+  return requestJson(baseUrl, '/api/send', {
+    method: 'POST',
+    cookie,
+    bearer,
+    body: {
+      from,
+      to: 'recipient@example.net',
+      subject,
+      text: 'mailbox grant send test',
+      ...(smtpRelayId ? { smtpRelayId } : {})
+    }
+  });
+}
+
+async function assertSendDenied(baseUrl, input) {
+  const response = await sendApiMessage(baseUrl, {
+    ...input,
+    subject: 'Mailbox grant denial'
+  });
+  assert.equal(response.status, 403, JSON.stringify(response.body));
+  assert.match(response.body.error, /未获得发信权限/);
+}
+
+function smtpMailFromCommands(smtp) {
+  return smtp.commands.filter((command) => command.startsWith('MAIL FROM:'));
+}
+
+async function requestJson(baseUrl, pathname, {
+  method = 'GET',
+  cookie = '',
+  bearer = '',
+  body
+} = {}) {
+  const headers = {};
+  if (cookie) headers.Cookie = cookie;
+  if (bearer) headers.Authorization = `Bearer ${bearer}`;
+  if (body !== undefined) headers['Content-Type'] = 'application/json';
+  const response = await fetch(`${baseUrl}${pathname}`, {
+    method,
+    headers,
+    body: body === undefined ? undefined : JSON.stringify(body),
+    redirect: 'manual'
+  });
+  const text = await response.text();
+  return {
+    status: response.status,
+    body: text ? JSON.parse(text) : null
+  };
+}
+
+async function login(baseUrl, username, password) {
+  const response = await fetch(`${baseUrl}/api/login`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ username, password })
+  });
+  assert.equal(response.status, 200);
+  const cookie = response.headers.get('set-cookie')?.split(';')[0] || '';
+  assert.ok(cookie);
+  return cookie;
+}
+
+async function startTestServer(smtpPort) {
+  const port = await freePort();
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-access-'));
+  const sessionSecret = 'mailbox-access-session-secret';
+  const child = spawn(process.execPath, ['src/server.js'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      PORT: String(port),
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret,
+      ADMIN_USER: 'admin',
+      ADMIN_EMAIL: 'admin@example.test',
+      ADMIN_PASSWORD: 'password123',
+      DNS_AUTO_CHECK_ENABLED: 'false',
+      DELIVERY_TRACKING_ENABLED: 'false',
+      WEBHOOK_WORKER_ENABLED: 'false',
+      SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false',
+      SMTP_HOST: '127.0.0.1',
+      SMTP_PORT: String(smtpPort),
+      SMTP_SECURE: 'false',
+      SMTP_HELO: 'mailhub-access.test'
+    },
+    stdio: ['ignore', 'pipe', 'pipe']
+  });
+  await waitForOutput(child, 'MailHub listening');
+  return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
+}
+
+function startFakeSmtpServer() {
+  const commands = [];
+  const server = net.createServer((socket) => {
+    socket.setEncoding('utf8');
+    socket.write('220 relay.test ESMTP ready\r\n');
+    let buffer = '';
+    let dataMode = false;
+    socket.on('data', (chunk) => {
+      buffer += chunk;
+      let index;
+      while ((index = buffer.indexOf('\n')) !== -1) {
+        const line = buffer.slice(0, index).replace(/\r$/, '');
+        buffer = buffer.slice(index + 1);
+        if (dataMode) {
+          if (line === '.') {
+            dataMode = false;
+            socket.write('250 2.0.0 queued as ACCESS123\r\n');
+          }
+          continue;
+        }
+        commands.push(line);
+        if (line.startsWith('EHLO')) socket.write('250 relay.test\r\n');
+        else if (line.startsWith('MAIL FROM:')) socket.write('250 2.1.0 ok\r\n');
+        else if (line.startsWith('RCPT TO:')) socket.write('250 2.1.5 ok\r\n');
+        else if (line === 'DATA') {
+          dataMode = true;
+          socket.write('354 end data\r\n');
+        } else if (line === 'QUIT') {
+          socket.write('221 bye\r\n');
+          socket.end();
+        }
+      }
+    });
+  });
+  return new Promise((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => {
+      server.off('error', reject);
+      resolve({
+        port: server.address().port,
+        commands,
+        close: () => new Promise((closeResolve) => server.close(closeResolve))
+      });
+    });
+  });
+}
+
+function freePort() {
+  return new Promise((resolve, reject) => {
+    const server = net.createServer();
+    server.listen(0, '127.0.0.1', () => {
+      const address = server.address();
+      server.close(() => {
+        if (address && typeof address === 'object') resolve(address.port);
+        else reject(new Error('Unable to allocate a test port.'));
+      });
+    });
+    server.on('error', reject);
+  });
+}
+
+function waitForOutput(child, text, timeoutMs = 8000) {
+  return new Promise((resolve, reject) => {
+    let output = '';
+    const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}\n${output}`)), timeoutMs);
+    const onData = (chunk) => {
+      output += chunk.toString();
+      if (!output.includes(text)) return;
+      clearTimeout(timeout);
+      child.stdout.off('data', onData);
+      child.stderr.off('data', onData);
+      resolve();
+    };
+    child.stdout.on('data', onData);
+    child.stderr.on('data', onData);
+    child.once('exit', (code) => {
+      clearTimeout(timeout);
+      reject(new Error(`Server exited before startup with code ${code}\n${output}`));
+    });
+  });
+}
+
+function waitForExit(child, timeoutMs) {
+  if (child.exitCode !== null) return Promise.resolve(child.exitCode);
+  return new Promise((resolve) => {
+    const timeout = setTimeout(() => resolve(null), timeoutMs);
+    child.once('exit', (code) => {
+      clearTimeout(timeout);
+      resolve(code);
+    });
+  });
+}

+ 114 - 0
test/submission-inbound.test.js

@@ -13,6 +13,8 @@ import {
   initDatabase,
   listInboundMessages,
   listWebhookDeliveries,
+  replaceInboundMailboxGrants,
+  saveSmtpRelay,
   saveSmtpCredential,
   updateDomain
 } from '../src/db.js';
@@ -456,6 +458,118 @@ test('SMTP relays only the authenticated mailbox and aliases from a shared domai
   }
 });
 
+test('account SMTP credentials honor shared mailbox send grants and revocation', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-account-grant-')), 'inbound-secret');
+  const owner = createUser({ username: 'account-grant-owner', email: 'grant-owner@example.com', password: 'password123' });
+  const member = createUser({ username: 'account-grant-member', email: 'grant-member@example.com', password: 'password123' });
+  saveSmtpCredential(member.id, { username: 'account-grant-member-smtp', password: 'member-smtp-pass' });
+  const ownerRelay = await startFakeSmtpServer();
+  const fallbackRelay = await startFakeSmtpServer();
+  const ownerRelayConfig = saveSmtpRelay(owner.id, {
+    name: 'Owner shared mailbox relay',
+    host: '127.0.0.1',
+    port: ownerRelay.port,
+    secure: false,
+    username: '',
+    password: '',
+    helo: 'mail.account-grant.example'
+  });
+  const keys = createDkimKeyPair();
+  createDomain(owner.id, {
+    domain: 'account-grant.example',
+    selector: 'mh',
+    smtpRelayId: ownerRelayConfig.id,
+    verificationToken: 'verify-account-grant',
+    dkimPublic: keys.publicKey,
+    dkimPrivate: keys.privateKey,
+    senderHost: 'mail.account-grant.example',
+    sendingIp: '192.0.2.21',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const sharedMailbox = createInboundMailbox(owner.id, {
+    address: 'shared@account-grant.example',
+    password: 'shared-mailbox-pass',
+    aliases: ['shared-alias']
+  });
+  createInboundMailbox(owner.id, {
+    address: 'private@account-grant.example',
+    password: 'private-mailbox-pass'
+  });
+  replaceInboundMailboxGrants(sharedMailbox.id, [
+    { userId: member.id, permissions: ['send'] }
+  ]);
+
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.account-grant.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true,
+    relayHost: '127.0.0.1',
+    relayPort: fallbackRelay.port,
+    relaySecure: false,
+    relayUsername: '',
+    relayPassword: '',
+    relayHelo: 'mail.account-grant.example'
+  });
+  await waitForListening(server);
+
+  try {
+    for (const sender of ['shared@account-grant.example', 'shared-alias@account-grant.example']) {
+      const response = await sendViaSmtp({
+        host: '127.0.0.1',
+        port: server.address().port,
+        secure: false,
+        username: 'account-grant-member-smtp',
+        password: 'member-smtp-pass',
+        helo: 'client.example.net',
+        mailFrom: sender,
+        recipients: ['recipient@example.net'],
+        rawMessage: [
+          `From: ${sender}`,
+          'To: recipient@example.net',
+          'Subject: Account credential shared mailbox grant',
+          '',
+          'Shared mailbox grant body'
+        ].join('\r\n')
+      });
+      assert.match(response.message, /Message queued/i);
+    }
+
+    assert.equal(ownerRelay.messages.length, 2);
+    assert.equal(fallbackRelay.messages.length, 0);
+    assert.ok(ownerRelay.messages.every((message) => /^DKIM-Signature:/m.test(message)));
+    assert.ok(ownerRelay.commands.includes('MAIL FROM:<shared@account-grant.example>'));
+    assert.ok(ownerRelay.commands.includes('MAIL FROM:<shared-alias@account-grant.example>'));
+
+    const auth = Buffer.from('\u0000account-grant-member-smtp\u0000member-smtp-pass').toString('base64');
+    const unauthorized = await smtpTranscript(server.address().port, [
+      'EHLO client.example.net',
+      `AUTH PLAIN ${auth}`,
+      'MAIL FROM:<private@account-grant.example>'
+    ]);
+    assert.match(unauthorized.at(-1), /^553 /);
+
+    replaceInboundMailboxGrants(sharedMailbox.id, []);
+    for (const sender of ['shared@account-grant.example', 'shared-alias@account-grant.example']) {
+      const revoked = await smtpTranscript(server.address().port, [
+        'EHLO client.example.net',
+        `AUTH PLAIN ${auth}`,
+        `MAIL FROM:<${sender}>`
+      ]);
+      assert.match(revoked.at(-1), /^553 /);
+    }
+    assert.equal(ownerRelay.messages.length, 2);
+    assert.equal(fallbackRelay.messages.length, 0);
+  } finally {
+    await closeServer(server);
+    await ownerRelay.close();
+    await fallbackRelay.close();
+  }
+});
+
 test('SMTP routes unknown inbound recipients to the domain catch-all mailbox', async () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-catchall-')), 'inbound-secret');
   const user = createUser({ username: 'catchall-smtp', email: 'catchall-smtp@example.com', password: 'password123' });

+ 11 - 0
test/ui/admin-layout.test.tsx

@@ -21,6 +21,17 @@ describe('AdminLayout navigation', () => {
     expect(navigationSelection('/activity/42')).toBe('/activity');
     expect(navigationSelection('/domains/7/dns')).toBe('/domains');
     expect(navigationSelection('/inbox/messages/9')).toBe('/inbox');
+    expect(navigationSelection('/account')).toBe('/account');
+  });
+
+  it('opens the account center for normal users without adding it to the main sidebar', async () => {
+    const user = userEvent.setup();
+    renderShell('/overview', { role: 'user' });
+
+    expect(screen.queryByText('账号与邮箱权限')).toBeNull();
+    await user.click(screen.getByRole('button', { name: '账户菜单' }));
+    await user.click(await screen.findByRole('menuitem', { name: '账号与邮箱权限' }));
+    expect(screen.getByTestId('location').textContent).toBe('/account');
   });
 
   it('navigates with semantic menu items and exposes a skip link', async () => {

+ 112 - 4
test/ui/api-token-secret.test.tsx

@@ -51,7 +51,7 @@ describe('API token secrets and message access', () => {
     expect(screen.queryByRole('dialog', { name: 'CI sender' })).toBeNull();
   });
 
-  it('progressively requires selected mailboxes for messages:read and loads all choices for admins', async () => {
+  it('requires selected mailboxes for send or messages:read and loads all choices for admins', async () => {
     const user = userEvent.setup();
     const mailbox = mailboxFixture();
     const created = tokenFixture({
@@ -66,14 +66,14 @@ describe('API token secrets and message access', () => {
     const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({ token: created });
 
     renderPage('admin');
-    await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith(true));
+    await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith('all'));
     await user.click(screen.getAllByRole('button', { name: /创建密钥/ })[0]);
     const editor = await screen.findByRole('dialog');
-    expect(within(editor).queryByText('邮件读取范围')).toBeNull();
+    expect(within(editor).getByText('邮箱访问范围')).toBeTruthy();
 
     await user.type(within(editor).getByLabelText('名称'), 'Message reader');
     await user.click(within(editor).getByRole('checkbox', { name: 'messages:read' }));
-    expect(await within(editor).findByText('邮件读取范围')).not.toBeNull();
+    expect(await within(editor).findByText('邮箱访问范围')).not.toBeNull();
     await user.click(within(editor).getByRole('radio', { name: '指定邮箱' }));
     await user.click(within(editor).getByLabelText('授权邮箱'));
     await user.click(await screen.findByText(new RegExp(mailbox.address)));
@@ -88,6 +88,112 @@ describe('API token secrets and message access', () => {
     }));
   });
 
+  it('allows a send-only token to select an assigned mailbox with send access', async () => {
+    const browser = userEvent.setup();
+    const base = mailboxFixture();
+    const owned = {
+      ...base,
+      id: 1,
+      userId: 1,
+      ownerUserId: 1,
+      address: 'owned@example.test',
+      access: { type: 'owner' as const, permissions: { view: true, receive: true, send: true } }
+    };
+    const assigned = {
+      ...base,
+      id: 2,
+      address: 'assigned@example.test',
+      access: { type: 'assigned' as const, permissions: { view: true, receive: true, send: false } }
+    };
+    const sendOnly = {
+      ...base,
+      id: 3,
+      address: 'send-only@example.test',
+      access: { type: 'assigned' as const, permissions: { view: true, receive: false, send: true } }
+    };
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
+    const loadMailboxes = vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owned, assigned, sendOnly] });
+    const created = tokenFixture({
+      name: 'Shared sender',
+      token: 'mh_shared.full-secret',
+      tokenRecoverable: true,
+      scopes: ['send'],
+      mailboxAccess: 'selected',
+      mailboxIds: [sendOnly.id]
+    });
+    const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({ token: created });
+
+    renderPage('user');
+    await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith('effective'));
+    await browser.click(screen.getAllByRole('button', { name: /创建密钥/ })[0]);
+    const editor = await screen.findByRole('dialog');
+    await browser.type(within(editor).getByLabelText('名称'), 'Shared sender');
+    await browser.click(within(editor).getByRole('radio', { name: '指定邮箱' }));
+    await browser.click(within(editor).getByLabelText('授权邮箱'));
+
+    expect(await screen.findByText(owned.address)).toBeTruthy();
+    expect(screen.getByText(new RegExp(assigned.address))).toBeTruthy();
+    expect(screen.getByText(new RegExp(sendOnly.address))).toBeTruthy();
+    await browser.click(screen.getByText(new RegExp(sendOnly.address)));
+    await browser.click(within(editor).getByRole('button', { name: /创建密钥/ }));
+
+    await waitFor(() => expect(createToken).toHaveBeenCalledWith({
+      name: 'Shared sender',
+      scopes: ['send'],
+      expiresAt: null,
+      mailboxAccess: 'selected',
+      mailboxIds: [sendOnly.id]
+    }));
+  });
+
+  it('shows selected mailbox access in the detail drawer for a send-only token', async () => {
+    const browser = userEvent.setup();
+    const mailbox = mailboxFixture();
+    const token = tokenFixture({
+      scopes: ['send'],
+      mailboxAccess: 'selected',
+      mailboxIds: [mailbox.id]
+    });
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [token] });
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
+
+    renderPage('user');
+    await browser.click(await screen.findByText(token.name));
+
+    const detail = await screen.findByRole('dialog');
+    expect(within(detail).getByText('指定邮箱 · 1')).toBeTruthy();
+    expect(within(detail).getByText(mailbox.address)).toBeTruthy();
+  });
+
+  it('preserves selected mailbox access when editing a mailboxes:read token', async () => {
+    const browser = userEvent.setup();
+    const mailbox = mailboxFixture();
+    const token = tokenFixture({
+      name: 'Mailbox reader',
+      scopes: ['mailboxes:read'],
+      mailboxAccess: 'selected',
+      mailboxIds: [mailbox.id]
+    });
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [token] });
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
+    const updateToken = vi.spyOn(api, 'updateApiToken').mockResolvedValue({ token });
+
+    renderPage('user');
+    await browser.click(await screen.findByRole('button', { name: /编辑.*Mailbox reader/ }));
+    const editor = await screen.findByRole('dialog');
+    expect(within(editor).getByText('邮箱访问范围')).toBeTruthy();
+    expect(within(editor).getByText(mailbox.address)).toBeTruthy();
+    await browser.click(within(editor).getByRole('button', { name: /保\s*存/ }));
+
+    await waitFor(() => expect(updateToken).toHaveBeenCalledWith(token.id, {
+      name: token.name,
+      scopes: ['mailboxes:read'],
+      expiresAt: null,
+      mailboxAccess: 'selected',
+      mailboxIds: [mailbox.id]
+    }));
+  });
+
   it('regenerates an unrecoverable legacy token only after destructive confirmation', async () => {
     const user = userEvent.setup();
     const legacy = tokenFixture({ tokenRecoverable: false, token: undefined, name: 'Legacy worker' });
@@ -149,6 +255,7 @@ function mailboxFixture(): InboundMailbox {
   return {
     id: 42,
     userId: 2,
+    ownerUserId: 2,
     domainId: 5,
     domain: 'example.test',
     address: 'billing@example.test',
@@ -163,6 +270,7 @@ function mailboxFixture(): InboundMailbox {
     status: 'active',
     messageCount: 1,
     unreadCount: 1,
+    access: { type: 'admin', permissions: { view: true, receive: true, send: true } },
     createdAt: '2026-07-14T00:00:00.000Z',
     updatedAt: '2026-07-14T00:00:00.000Z'
   };

+ 28 - 0
test/ui/inbox-navigation.test.tsx

@@ -228,6 +228,32 @@ describe('Inbox request and mailbox behavior', () => {
     expect(new URLSearchParams(router.state.location.search).get('mailboxId')).toBe('2');
   });
 
+  it('uses effective mailbox access and never loads messages for view-only assignments', async () => {
+    const viewOnly = mailboxFixture(1, {
+      userId: 2,
+      ownerUserId: 2,
+      access: { type: 'assigned', permissions: { view: true, receive: false, send: false } },
+      messageCount: null,
+      unreadCount: null,
+      lastMessageAt: null
+    });
+    const receiving = mailboxFixture(2, {
+      userId: 2,
+      ownerUserId: 2,
+      access: { type: 'assigned', permissions: { view: true, receive: true, send: false } }
+    });
+    mockInboxApis([viewOnly, receiving], []);
+    const loadMailboxes = vi.spyOn(api, 'inboundMailboxes');
+    const listMessages = vi.spyOn(api, 'inboundMessages');
+    const router = createInboxRouter(['/inbox?mailboxId=1&folder=INBOX'], 0);
+    renderRouter(router);
+
+    await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith('effective'));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('mailboxId')).toBe('2'));
+    await waitFor(() => expect(listMessages).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 2 })));
+    expect(listMessages).not.toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 1 }));
+  });
+
   it('updates mailbox settings from the routing workspace edit drawer', async () => {
     const user = userEvent.setup();
     const mailbox = mailboxFixture(1);
@@ -342,6 +368,7 @@ function domainFixture(id: number): Domain {
   return {
     id,
     userId: 1,
+    ownerUserId: 1,
     dnsCredentialId: null,
     smtpRelayId: null,
     domain: `example-${id}.test`,
@@ -379,6 +406,7 @@ function mailboxFixture(id: number, overrides: Partial<InboundMailbox> = {}): In
     status: 'active',
     messageCount: 2,
     unreadCount: 0,
+    access: { type: 'owner', permissions: { view: true, receive: true, send: true } },
     createdAt: '2026-07-14T00:00:00.000Z',
     updatedAt: '2026-07-14T00:00:00.000Z',
     ...overrides

+ 2 - 2
test/ui/integration-resource-resilience.test.tsx

@@ -57,7 +57,7 @@ describe('Integration resource resilience and deep links', () => {
     const alert = error.closest('.ant-alert');
     expect(alert).not.toBeNull();
     expect(document.querySelector('.empty-state')).toBeNull();
-    expect(screen.queryByText('创建 API 密钥后可在列表和详情中复制完整 Token,并按最小权限限制邮件读取范围。')).toBeNull();
+    expect(screen.queryByText('创建 API 密钥后可在列表和详情中复制完整 Token,并按最小权限限制邮箱访问范围。')).toBeNull();
 
     await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
     expect(await screen.findByText(apiToken.name)).toBeTruthy();
@@ -247,7 +247,7 @@ describe('Integration resource resilience and deep links', () => {
 
     expect(await findDrawer(apiToken.name)).toBeTruthy();
     const guide = await findDrawer('API 使用文档');
-    expect(within(guide).getByText('需要 messages:read 权限,并受 Token 的邮件读取范围限制。列表支持分页与邮箱、文件夹、已读状态、关键词筛选。')).toBeTruthy();
+    expect(within(guide).getByText('需要 messages:read 权限,并受 Token 的邮箱访问范围及账号收信权限共同限制。列表支持分页与邮箱、文件夹、已读状态、关键词筛选。')).toBeTruthy();
     expect(within(guide).getAllByText('https://mail-send.ss5.xyz/api/inbound-messages').length).toBeGreaterThan(0);
     expect(guide.textContent).not.toContain('http://mail-send.ss5.xyz');
 

+ 210 - 0
test/ui/mailbox-access.test.tsx

@@ -0,0 +1,210 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
+import { I18nProvider } from '../../src/frontend/i18n/react';
+import { api } from '../../src/frontend/services/api';
+import { mailhubTheme } from '../../src/frontend/theme';
+import type {
+  AdminMailboxAccessEntry,
+  AdminUser,
+  InboundMailbox,
+  MailboxAccessType,
+  MailboxPermissions,
+  RuntimeConfig,
+  User
+} from '../../src/frontend/types';
+import Account from '../../src/pages/Account';
+import AdminPage from '../../src/pages/Admin';
+
+describe('Mailbox access UI', () => {
+  afterEach(() => vi.restoreAllMocks());
+
+  it('keeps the admin mailbox editor in the URL and saves normalized grants', async () => {
+    const browser = userEvent.setup();
+    const entry = accessEntry();
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
+    vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: [entry] });
+    const save = vi.spyOn(api, 'saveAdminMailboxAccess').mockImplementation(async (_id, grants) => ({
+      mailbox: {
+        ...entry,
+        grants: grants.map((grant) => ({
+          user: adminUsers.find((user) => user.id === grant.userId)!,
+          permissions: grant,
+          createdAt: '2026-07-18T00:00:00.000Z',
+          updatedAt: '2026-07-18T00:00:00.000Z'
+        }))
+      }
+    }));
+    const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
+      initialEntries: ['/admin/mailbox-access']
+    });
+    renderWithRouter(router, adminContext);
+
+    const configure = await screen.findByRole('button', { name: /配置权限/ });
+    expect(configure.style.minHeight).toBe('44px');
+    await browser.click(configure);
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('mailboxId')).toBe('10'));
+
+    const drawer = await screen.findByRole('dialog');
+    expect(within(drawer).getByText(/owner@example\.test/)).toBeTruthy();
+    expect(within(drawer).getByText('所有者权限')).toBeTruthy();
+    await browser.click(within(drawer).getByRole('checkbox', { name: '收取邮件' }));
+    await browser.click(within(drawer).getByRole('checkbox', { name: '查看配置' }));
+    await browser.click(within(drawer).getByRole('button', { name: /保\s*存/ }));
+    expect(await screen.findByText('每个用户至少选择一项权限,或移除该授权行。')).toBeTruthy();
+    expect(save).not.toHaveBeenCalled();
+    await browser.click(within(drawer).getByRole('checkbox', { name: '发送邮件' }));
+    expect((within(drawer).getByRole('checkbox', { name: '查看配置' }) as HTMLInputElement).checked).toBe(true);
+    await browser.click(within(drawer).getByRole('button', { name: /保\s*存/ }));
+
+    await waitFor(() => expect(save).toHaveBeenCalledWith(10, [{
+      userId: 2,
+      view: true,
+      receive: false,
+      send: true
+    }]));
+    expect(await screen.findByText('邮箱权限已保存')).toBeTruthy();
+
+    await browser.click(within(drawer).getByRole('button', { name: 'Close' }));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).has('mailboxId')).toBe(false));
+    await waitFor(() => expect(document.activeElement).toBe(configure));
+  });
+
+  it('shows owned and assigned mailboxes in account center with permission-based actions', async () => {
+    const browser = userEvent.setup();
+    const owned = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
+    const assigned = mailboxFixture(2, 'assigned@example.test', 'assigned', { view: true, receive: true, send: false }, 1);
+    const viewOnly = mailboxFixture(3, 'view-only@example.test', 'assigned', { view: true, receive: false, send: false }, 1);
+    const list = vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owned, assigned, viewOnly] });
+    const router = createMemoryRouter([
+      { path: '/account', element: <Account /> },
+      { path: '/inbox', element: <LocationProbe /> }
+    ], { initialEntries: ['/account'] });
+    renderWithRouter(router, userContext);
+
+    expect(await screen.findByRole('heading', { name: '账号与邮箱权限' })).toBeTruthy();
+    expect(list).toHaveBeenCalledWith('effective');
+    expect(screen.getAllByText('管理员分配').length).toBeGreaterThan(0);
+
+    const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card');
+    expect(viewOnlyCard).toBeTruthy();
+    expect(within(viewOnlyCard as HTMLElement).queryByRole('button', { name: /打开收件箱/ })).toBeNull();
+    expect(screen.getAllByRole('button', { name: /管理邮箱/ })).toHaveLength(1);
+
+    const assignedCard = screen.getByText(assigned.address).closest('.ant-card');
+    expect(assignedCard).toBeTruthy();
+    await browser.click(within(assignedCard as HTMLElement).getByRole('button', { name: /打开收件箱/ }));
+    expect(screen.getByTestId('location').textContent).toBe('/inbox?mailboxId=2&folder=INBOX');
+  });
+});
+
+function renderWithRouter(router: ReturnType<typeof createMemoryRouter>, context: AppContextValue) {
+  return render(
+    <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
+      <AntApp>
+        <I18nProvider>
+          <AppContext.Provider value={context}>
+            <RouterProvider router={router} />
+          </AppContext.Provider>
+        </I18nProvider>
+      </AntApp>
+    </ConfigProvider>
+  );
+}
+
+function LocationProbe() {
+  const location = useLocation();
+  return <div data-testid="location">{location.pathname}{location.search}</div>;
+}
+
+function accessEntry(): AdminMailboxAccessEntry {
+  const mailbox = mailboxFixture(10, 'support@example.test', 'owner', { view: true, receive: true, send: true }, 1);
+  return {
+    mailbox,
+    owner: adminUsers[0],
+    grants: [{
+      user: adminUsers[1],
+      permissions: { view: true, receive: true, send: false },
+      createdAt: '2026-07-18T00:00:00.000Z',
+      updatedAt: '2026-07-18T00:00:00.000Z'
+    }]
+  };
+}
+
+function mailboxFixture(
+  id: number,
+  address: string,
+  type: MailboxAccessType,
+  permissions: MailboxPermissions,
+  ownerUserId: number
+): InboundMailbox {
+  const [, domain] = address.split('@');
+  return {
+    id,
+    userId: ownerUserId,
+    ownerUserId,
+    domainId: id,
+    domain,
+    address,
+    localPart: address.split('@')[0],
+    displayName: '',
+    aliases: [],
+    forwardTo: [],
+    keepForwarded: true,
+    quotaMb: 1024,
+    passwordSet: true,
+    passwordRecoverable: false,
+    status: 'active',
+    messageCount: permissions.receive ? 3 : null,
+    unreadCount: permissions.receive ? 1 : null,
+    lastMessageAt: permissions.receive ? '2026-07-18T00:00:00.000Z' : null,
+    access: { type, permissions },
+    createdAt: '2026-07-18T00:00:00.000Z',
+    updatedAt: '2026-07-18T00:00:00.000Z'
+  };
+}
+
+const adminUsers: AdminUser[] = [
+  { id: 1, username: 'owner', email: 'owner@example.test', role: 'admin', status: 'active', resourceCounts: resourceCounts() },
+  { id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active', resourceCounts: resourceCounts() }
+];
+
+function contextFor(user: User): AppContextValue {
+  return {
+    user,
+    config,
+    refreshBootstrap: vi.fn(async () => undefined),
+    logout: vi.fn(async () => undefined)
+  };
+}
+
+function resourceCounts() {
+  return { domains: 0, dnsCredentials: 0, apiTokens: 0, inboundMailboxes: 0, inboundMessages: 0, sendEvents: 0, smtpCredential: 0 };
+}
+
+const config: RuntimeConfig = {
+  appBaseUrl: 'https://mail.example.test',
+  mailHostname: 'mail.example.test',
+  sendingIp: '192.0.2.10',
+  defaultSpfMechanisms: '',
+  dmarcPolicy: 'none',
+  dmarcRua: '',
+  registrationRequiresApproval: false,
+  sendRequiresVerified: true,
+  engagementTrackingEnabled: true,
+  listUnsubscribeMailto: '',
+  listUnsubscribeUrl: '',
+  listUnsubscribePostEnabled: false,
+  feedbackIdEnabled: false,
+  reportAbuseTo: '',
+  csaComplaintsTo: '',
+  bounceAddress: '',
+  bounceEnvelopeEnabled: false
+};
+
+const adminContext: AppContextValue = contextFor(adminUsers[0]);
+const userContext: AppContextValue = contextFor({ id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active' });

+ 2 - 0
test/ui/operations-navigation.test.tsx

@@ -270,6 +270,7 @@ const appContext: AppContextValue = {
 const mailbox: InboundMailbox = {
   id: 1,
   userId: 1,
+  ownerUserId: 1,
   domainId: 1,
   domain: 'example.test',
   address: 'inbox@example.test',
@@ -284,6 +285,7 @@ const mailbox: InboundMailbox = {
   status: 'active',
   messageCount: 1,
   unreadCount: 0,
+  access: { type: 'owner', permissions: { view: true, receive: true, send: true } },
   createdAt: '2026-07-14T00:00:00.000Z',
   updatedAt: '2026-07-14T00:00:00.000Z'
 };

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor