ソースを参照

feat: support shared-domain mailboxes

Allow users to create, receive, query, and send from mailboxes on explicitly shared domains while preserving domain ownership and sender isolation. Guard mailbox namespaces and domain deletion, improve browser verification redirects, and make wildcard certificate chain syncing configurable.

AI-Co-Authored-By: Codex
chendeben 1 ヶ月 前
コミット
e4756676f2
54 ファイル変更834 行追加65 行削除
  1. 0 0
      public/assets/Admin-BuLLDfA8.js
  2. 0 0
      public/assets/ApiTokens-DBE-drZo.js
  3. 1 1
      public/assets/CodeBlock-CkHhWvU6.js
  4. 1 1
      public/assets/CopyOutlined-CHs5xB1H.js
  5. 0 1
      public/assets/Dashboard-XVaPdxnP.js
  6. 2 2
      public/assets/DashboardCharts-BZXXYBQX.js
  7. 0 0
      public/assets/DnsApi-Ds6iMtkL.js
  8. 0 0
      public/assets/DomainDetail-C_ScJIVm.js
  9. 0 0
      public/assets/DomainDetail-D-oZH2I9.js
  10. 0 0
      public/assets/Domains-CUUYKDTN.js
  11. 1 1
      public/assets/EditOutlined-DcT9QuKO.js
  12. 1 1
      public/assets/EllipsisOutlined-1lLPAYiv.js
  13. 0 0
      public/assets/Inbox-DgC6Hs4i.js
  14. 0 0
      public/assets/Inbox-xZSCNUoB.js
  15. 1 1
      public/assets/PlusOutlined-C4jbNaBZ.js
  16. 1 1
      public/assets/ReloadOutlined-5WE6_PnE.js
  17. 1 1
      public/assets/SearchOutlined-Bq_eGz3G.js
  18. 0 0
      public/assets/SendingLogs-CaKxohJA.js
  19. 0 0
      public/assets/Settings-Dw_F8H8a.js
  20. 0 0
      public/assets/SmtpCredentials-B5Dy-Zyx.js
  21. 0 0
      public/assets/StatusPill-DNZtlEeS.js
  22. 1 1
      public/assets/StatusTag-vIbkqN7p.js
  23. 1 1
      public/assets/ThunderboltOutlined-CB3gq9nZ.js
  24. 0 0
      public/assets/Webhooks-D1r0LpNa.js
  25. 1 1
      public/assets/form-D2UigUli.js
  26. 1 1
      public/assets/grid-CgtYChfA.js
  27. 0 1
      public/assets/index-Yrdiy7jh.js
  28. 0 0
      public/assets/list-CiU3uHQT.js
  29. 0 0
      public/assets/login-BiJl_nHK.js
  30. 0 0
      public/assets/login-r6bTsl_n.js
  31. 1 1
      public/assets/modal-wyG3ngKb.js
  32. 0 0
      public/assets/popconfirm-O_m-Q9Ga.js
  33. 0 1
      public/assets/row-Cmd9RpeX.js
  34. 1 0
      public/assets/row-IaWgwDT6.js
  35. 0 0
      public/assets/table-zFpb0mtD.js
  36. 0 0
      public/assets/theme-Bw5pObCZ.js
  37. 2 2
      public/index.html
  38. 4 4
      public/login.html
  39. 28 0
      scripts/sync-tls-certificate.sh
  40. 137 4
      src/db.js
  41. 5 0
      src/frontend/auth/AuthApp.tsx
  42. 6 0
      src/frontend/i18n/index.js
  43. 2 0
      src/frontend/services/api.ts
  44. 9 0
      src/frontend/types.ts
  45. 16 12
      src/pages/Domains/DomainDetail.tsx
  46. 2 2
      src/pages/Domains/index.tsx
  47. 25 10
      src/pages/Inbox.tsx
  48. 39 8
      src/server.js
  49. 43 3
      src/submission.js
  50. 42 0
      test/cert-sync-script.test.js
  51. 110 2
      test/inbound-db.test.js
  52. 131 0
      test/server-admin-api.test.js
  53. 185 0
      test/submission-inbound.test.js
  54. 33 1
      test/ui/inbox-navigation.test.tsx

ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/Admin-BuLLDfA8.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/ApiTokens-DBE-drZo.js


+ 1 - 1
public/assets/CodeBlock-B-NIgQUI.js → public/assets/CodeBlock-CkHhWvU6.js

@@ -1 +1 @@
-import{kt as e,n as t,t as n}from"./jsx-runtime-dm82oUI8.js";import{t as r}from"./CopyOutlined-CjLq_nuP.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-CHs5xB1H.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-CjLq_nuP.js → public/assets/CopyOutlined-CHs5xB1H.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DNlqAiGO.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-Bw5pObCZ.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};

ファイルの差分が大きいため隠しています
+ 0 - 1
public/assets/Dashboard-XVaPdxnP.js


ファイルの差分が大きいため隠しています
+ 2 - 2
public/assets/DashboardCharts-BZXXYBQX.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/DnsApi-Ds6iMtkL.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/DomainDetail-C_ScJIVm.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/DomainDetail-D-oZH2I9.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/Domains-CUUYKDTN.js


+ 1 - 1
public/assets/EditOutlined-BjvG9r8B.js → public/assets/EditOutlined-DcT9QuKO.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DNlqAiGO.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-Bw5pObCZ.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-Bk3Ymyne.js → public/assets/EllipsisOutlined-1lLPAYiv.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DNlqAiGO.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-Bw5pObCZ.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};

ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/Inbox-DgC6Hs4i.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/Inbox-xZSCNUoB.js


+ 1 - 1
public/assets/PlusOutlined-CILz_LKx.js → public/assets/PlusOutlined-C4jbNaBZ.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DNlqAiGO.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-Bw5pObCZ.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-Cq8HKCaL.js → public/assets/ReloadOutlined-5WE6_PnE.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DNlqAiGO.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-Bw5pObCZ.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-Bw_KR-cI.js → public/assets/SearchOutlined-Bq_eGz3G.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DNlqAiGO.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-Bw5pObCZ.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};

ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/SendingLogs-CaKxohJA.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/Settings-Dw_F8H8a.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/SmtpCredentials-B5Dy-Zyx.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/StatusPill-DNZtlEeS.js


+ 1 - 1
public/assets/StatusTag-CBp0RCE4.js → public/assets/StatusTag-vIbkqN7p.js

@@ -1 +1 @@
-import{a as e}from"./theme-DNlqAiGO.js";import{t}from"./jsx-runtime-dm82oUI8.js";import{t as n}from"./badge-CT2dDAGA.js";import{t as r}from"./StatusPill-Bcw_ne6L.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-Bw5pObCZ.js";import{t}from"./jsx-runtime-dm82oUI8.js";import{t as n}from"./badge-CT2dDAGA.js";import{t as r}from"./StatusPill-DNZtlEeS.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-CMOzejTs.js → public/assets/ThunderboltOutlined-CB3gq9nZ.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-DNlqAiGO.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-Bw5pObCZ.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};

ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/Webhooks-D1r0LpNa.js


ファイルの差分が大きいため隠しています
+ 1 - 1
public/assets/form-D2UigUli.js


ファイルの差分が大きいため隠しています
+ 1 - 1
public/assets/grid-CgtYChfA.js


ファイルの差分が大きいため隠しています
+ 0 - 1
public/assets/index-Yrdiy7jh.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/list-CiU3uHQT.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/login-BiJl_nHK.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/login-r6bTsl_n.js


+ 1 - 1
public/assets/modal-BNBPl4Qp.js → public/assets/modal-wyG3ngKb.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-DNlqAiGO.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-Bw5pObCZ.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};

ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/popconfirm-O_m-Q9Ga.js


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

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

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

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

ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/table-zFpb0mtD.js


ファイルの差分が大きいため隠しています
+ 0 - 0
public/assets/theme-Bw5pObCZ.js


+ 2 - 2
public/index.html

@@ -4,11 +4,11 @@
     <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-CBPGvhVC.js"></script>
+    <script type="module" crossorigin src="/assets/index-Yrdiy7jh.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-DNlqAiGO.js">
+    <link rel="modulepreload" crossorigin href="/assets/theme-Bw5pObCZ.js">
     <link rel="stylesheet" crossorigin href="/assets/theme-e1juCvr1.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>

+ 4 - 4
public/login.html

@@ -4,15 +4,15 @@
     <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-r6bTsl_n.js"></script>
+    <script type="module" crossorigin src="/assets/login-BiJl_nHK.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-DNlqAiGO.js">
-    <link rel="modulepreload" crossorigin href="/assets/grid-DcIFgt5N.js">
+    <link rel="modulepreload" crossorigin href="/assets/theme-Bw5pObCZ.js">
+    <link rel="modulepreload" crossorigin href="/assets/grid-CgtYChfA.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-BRrMNJiX.js">
+    <link rel="modulepreload" crossorigin href="/assets/form-D2UigUli.js">
     <link rel="modulepreload" crossorigin href="/assets/input-0_Xo22Fl.js">
     <link rel="stylesheet" crossorigin href="/assets/theme-e1juCvr1.css">
   </head>

+ 28 - 0
scripts/sync-tls-certificate.sh

@@ -9,6 +9,7 @@ source_dir="${MAILHUB_CERT_SOURCE_DIR:-}"
 restart_app="${MAILHUB_CERT_RESTART:-0}"
 verify_host="${MAILHUB_CERT_VERIFY_HOST:-}"
 verify_endpoints="${MAILHUB_CERT_VERIFY_ENDPOINTS:-465 993}"
+max_chain_certs="${MAILHUB_CERT_MAX_CHAIN_CERTS:-0}"
 certs_dir="${project_dir}/certs"
 lock_file="${MAILHUB_CERT_LOCK_FILE:-${certs_dir}/.sync-tls-certificate.lock}"
 
@@ -109,6 +110,32 @@ set_target_metadata() {
   set_file_metadata "$2" 0640
 }
 
+compact_certificate_chain() {
+  local certificate="$1"
+  local max_certs="$2"
+  local cert_count compacted
+
+  [[ "${max_certs}" =~ ^[0-9]+$ ]] || fail "MAILHUB_CERT_MAX_CHAIN_CERTS must be numeric."
+  if (( max_certs == 0 )); then
+    return 0
+  fi
+
+  cert_count="$(grep -c -- '-----BEGIN CERTIFICATE-----' "${certificate}" || true)"
+  [[ "${cert_count}" =~ ^[0-9]+$ ]] || fail "unable to count certificates in source chain."
+  if (( cert_count <= max_certs )); then
+    return 0
+  fi
+
+  compacted="${work_dir}/compacted-cert.pem"
+  awk -v max_certs="${max_certs}" '
+    /-----BEGIN CERTIFICATE-----/ { block += 1 }
+    block <= max_certs { print }
+  ' "${certificate}" >"${compacted}"
+  [[ "$(grep -c -- '-----BEGIN CERTIFICATE-----' "${compacted}" || true)" == "${max_certs}" ]] \
+    || fail "unable to compact source certificate chain."
+  mv -f -- "${compacted}" "${certificate}"
+}
+
 wait_for_app_health() {
   local attempt
   for attempt in $(seq 1 45); do
@@ -322,6 +349,7 @@ staged_cert="${work_dir}/next-cert.pem"
 staged_key="${work_dir}/next-key.pem"
 cp -- "${source_cert}" "${staged_cert}"
 cp -- "${source_key}" "${staged_key}"
+compact_certificate_chain "${staged_cert}" "${max_chain_certs}"
 set_target_metadata "${staged_cert}" "${staged_key}"
 validate_pair "${staged_cert}" "${staged_key}" source
 

+ 137 - 4
src/db.js

@@ -78,6 +78,7 @@ export function initDatabase(dataDir, secret = '') {
       dmarc_policy TEXT NOT NULL DEFAULT 'none',
       dmarc_rua TEXT NOT NULL DEFAULT '',
       catch_all_address TEXT NOT NULL DEFAULT '',
+      mailbox_signup_enabled TEXT NOT NULL DEFAULT 'false',
       status_json TEXT NOT NULL DEFAULT '{}',
       created_at TEXT NOT NULL,
       updated_at TEXT NOT NULL
@@ -365,6 +366,7 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('domains', 'dns_credential_id', 'INTEGER');
   ensureColumn('domains', 'smtp_relay_id', 'INTEGER');
   ensureColumn('domains', 'catch_all_address', "TEXT NOT NULL DEFAULT ''");
+  ensureColumn('domains', 'mailbox_signup_enabled', "TEXT NOT NULL DEFAULT 'false'");
   ensureColumn('send_events', 'user_id', 'INTEGER');
   ensureColumn('send_events', 'smtp_relay_id', 'INTEGER');
   ensureColumn('send_events', 'queue_id', "TEXT NOT NULL DEFAULT ''");
@@ -914,6 +916,21 @@ export function listDomains(userId) {
     .map(publicDomainRow);
 }
 
+export function listInboundMailboxDomains(userId) {
+  return requireDb()
+    .prepare(`
+      SELECT id, user_id, domain, mailbox_signup_enabled, created_at
+      FROM domains
+      WHERE user_id = ? OR mailbox_signup_enabled = 'true'
+      ORDER BY
+        CASE WHEN user_id = ? THEN 0 ELSE 1 END,
+        created_at DESC,
+        domain ASC
+    `)
+    .all(userId, userId)
+    .map(publicInboundMailboxDomainRow);
+}
+
 export function listDomainsForDnsAutoCheck() {
   return requireDb()
     .prepare('SELECT * FROM domains ORDER BY updated_at ASC')
@@ -935,6 +952,20 @@ export function getDomainByName(domain, { userId, includePrivate = false } = {})
   return includePrivate ? privateDomainRow(row) : publicDomainRow(row);
 }
 
+function getDomainForInboundMailboxCreation(domain, userId) {
+  const row = requireDb()
+    .prepare(`
+      SELECT *
+      FROM domains
+      WHERE domain = ?
+        AND (user_id = ? OR mailbox_signup_enabled = 'true')
+      ORDER BY CASE WHEN user_id = ? THEN 0 ELSE 1 END
+      LIMIT 1
+    `)
+    .get(String(domain || '').toLowerCase(), Number(userId), Number(userId));
+  return publicDomainRow(row);
+}
+
 export function createDomain(userId, domain) {
   const createdAt = now();
   const result = requireDb()
@@ -969,12 +1000,18 @@ export function createInboundMailbox(userId, mailbox = {}) {
   const address = normalizeInboundAddress(mailbox.address);
   if (!address) throw new Error('收信邮箱格式不正确。');
   const [localPart, domainName] = address.split('@');
-  const domain = getDomainByName(domainName, { userId });
+  const domain = getDomainForInboundMailboxCreation(domainName, userId);
   if (!domain) throw new Error('收信域名不存在。');
   const password = String(mailbox.password || '');
   const passwordHash = password ? hashPassword(password) : '';
   const passwordSecret = password ? encryptSecret(password) : '';
   const aliases = normalizeMailboxAliases(mailbox.aliases, domain.domain, localPart);
+  assertInboundMailboxNamespaceAvailable({
+    domainId: domain.id,
+    domain: domain.domain,
+    localPart,
+    aliases
+  });
   const forwardTo = normalizeRecipientList(mailbox.forwardTo);
   const keepForwarded = boolString(mailbox.keepForwarded ?? true);
   const quotaMb = normalizeQuotaMb(mailbox.quotaMb);
@@ -1033,6 +1070,13 @@ export function upsertImportedInboundMailbox(userId, mailbox = {}) {
     const nextPasswordHash = !existing.password_hash || isLegacyPasswordHash(existing.password_hash)
       ? (passwordHash || existing.password_hash)
       : existing.password_hash;
+    assertInboundMailboxNamespaceAvailable({
+      domainId: domain.id,
+      domain: domain.domain,
+      localPart,
+      aliases,
+      excludeMailboxId: existing.id
+    });
     requireDb()
       .prepare(`
         UPDATE inbound_mailboxes
@@ -1055,6 +1099,12 @@ export function upsertImportedInboundMailbox(userId, mailbox = {}) {
     return getInboundMailbox(existing.id, userId);
   }
 
+  assertInboundMailboxNamespaceAvailable({
+    domainId: domain.id,
+    domain: domain.domain,
+    localPart,
+    aliases
+  });
   const result = requireDb()
     .prepare(`
       INSERT INTO inbound_mailboxes (
@@ -1099,6 +1149,13 @@ export function updateInboundMailbox(userId, id, patch = {}) {
     updatedAt: now()
   };
   if (!next.passwordHash) next.passwordSecret = '';
+  assertInboundMailboxNamespaceAvailable({
+    domainId: current.domainId,
+    domain: current.domain,
+    localPart: current.localPart,
+    aliases: next.aliases,
+    excludeMailboxId: current.id
+  });
   requireDb()
     .prepare(`
       UPDATE inbound_mailboxes
@@ -1189,6 +1246,13 @@ export function getInboundMailboxByAddress(address, { includeHash = false, inclu
   return publicInboundMailbox(row, { includeHash, includeSecret });
 }
 
+export function getInboundMailboxForSender(userId, address) {
+  const cleanAddress = normalizeInboundAddress(address);
+  if (!cleanAddress) return null;
+  const mailbox = getInboundMailboxByAddress(cleanAddress) || getInboundMailboxByAliasAddress(cleanAddress);
+  return mailbox?.userId === Number(userId) ? mailbox : null;
+}
+
 export function verifyInboundMailboxCredential(username, password) {
   const mailboxAddress = normalizeInboundAddress(username);
   if (!mailboxAddress) {
@@ -2229,13 +2293,16 @@ export function updateDomain(id, userId, patch) {
     catchAllAddress: patch.catchAllAddress === undefined
       ? current.catchAllAddress
       : normalizeCatchAllAddress(patch.catchAllAddress),
+    mailboxSignupEnabled: patch.mailboxSignupEnabled === undefined
+      ? current.mailboxSignupEnabled
+      : Boolean(patch.mailboxSignupEnabled),
     updatedAt: now()
   };
   requireDb()
     .prepare(`
       UPDATE domains
       SET selector = ?, dns_credential_id = ?, smtp_relay_id = ?, sender_host = ?, sending_ip = ?, spf_extra = ?,
-          dmarc_policy = ?, dmarc_rua = ?, catch_all_address = ?, updated_at = ?
+          dmarc_policy = ?, dmarc_rua = ?, catch_all_address = ?, mailbox_signup_enabled = ?, updated_at = ?
       WHERE id = ? AND user_id = ?
     `)
     .run(
@@ -2248,6 +2315,7 @@ export function updateDomain(id, userId, patch) {
       next.dmarcPolicy,
       next.dmarcRua,
       next.catchAllAddress,
+      boolString(next.mailboxSignupEnabled),
       next.updatedAt,
       id,
       userId
@@ -2269,8 +2337,24 @@ export function saveDomainStatus(id, userId, status) {
 }
 
 export function deleteDomain(id, userId) {
-  const result = requireDb().prepare('DELETE FROM domains WHERE id = ? AND user_id = ?').run(id, userId);
-  return result.changes > 0;
+  const database = requireDb();
+  const result = database
+    .prepare(`
+      DELETE FROM domains
+      WHERE id = ? AND user_id = ?
+        AND NOT EXISTS (
+          SELECT 1 FROM inbound_mailboxes WHERE domain_id = domains.id
+        )
+    `)
+    .run(id, userId);
+  if (result.changes > 0) return true;
+
+  const domain = database.prepare('SELECT id FROM domains WHERE id = ? AND user_id = ?').get(id, userId);
+  if (!domain) return false;
+
+  const error = new Error('该域名仍有关联收信邮箱,无法删除。');
+  error.code = 'DOMAIN_HAS_INBOUND_MAILBOXES';
+  throw error;
 }
 
 export function logAudit({ actorUserId, action, targetType, targetId = '', targetUserId = null, summary = {} }) {
@@ -4619,12 +4703,23 @@ function publicDomainRow(row) {
     dmarcPolicy: row.dmarc_policy,
     dmarcRua: row.dmarc_rua,
     catchAllAddress: row.catch_all_address || '',
+    mailboxSignupEnabled: row.mailbox_signup_enabled === 'true',
     status: safeJson(row.status_json, {}),
     createdAt: row.created_at,
     updatedAt: row.updated_at
   };
 }
 
+function publicInboundMailboxDomainRow(row) {
+  if (!row) return null;
+  return {
+    id: row.id,
+    userId: row.user_id,
+    domain: row.domain,
+    mailboxSignupEnabled: row.mailbox_signup_enabled === 'true'
+  };
+}
+
 function privateDomainRow(row) {
   const publicRow = publicDomainRow(row);
   return publicRow ? { ...publicRow, dkimPrivate: row.dkim_private } : null;
@@ -5470,6 +5565,44 @@ function normalizeMailboxAliases(values, domain, ownLocalPart) {
   return aliases;
 }
 
+function assertInboundMailboxNamespaceAvailable({
+  domainId,
+  domain,
+  localPart,
+  aliases,
+  excludeMailboxId = null
+}) {
+  const requestedLocalParts = [...new Set([localPart, ...aliases]
+    .map((value) => String(value || '').trim().toLowerCase())
+    .filter(Boolean))];
+  if (!requestedLocalParts.length) return;
+
+  const rows = requireDb()
+    .prepare(`
+      SELECT id, local_part, aliases_json
+      FROM inbound_mailboxes
+      WHERE domain_id = ?
+        AND deleted_at IS NULL
+        AND (? IS NULL OR id != ?)
+    `)
+    .all(Number(domainId), excludeMailboxId, excludeMailboxId);
+
+  for (const row of rows) {
+    const storedAliases = safeJson(row.aliases_json, []);
+    const existingLocalParts = new Set([
+      String(row.local_part || '').trim().toLowerCase(),
+      ...(Array.isArray(storedAliases) ? storedAliases : [])
+        .map((value) => String(value || '').trim().toLowerCase())
+    ]);
+    const conflict = requestedLocalParts.find((value) => existingLocalParts.has(value));
+    if (!conflict) continue;
+
+    const error = new Error(`邮箱地址或别名 ${conflict}@${domain} 已被其他邮箱占用。`);
+    error.code = 'INBOUND_MAILBOX_NAMESPACE_CONFLICT';
+    throw error;
+  }
+}
+
 function normalizeQuotaMb(value) {
   if (value === null || value === undefined || value === '') return null;
   const quota = Number(value);

+ 5 - 0
src/frontend/auth/AuthApp.tsx

@@ -50,10 +50,15 @@ export function AuthApp() {
   useEffect(() => {
     const params = new URLSearchParams(window.location.search);
     const error = params.get('error');
+    const successMessage = params.get('message');
     if (error) {
       setMessage(error);
       setMessageKind('error');
       window.history.replaceState(null, '', authPathWithNext(window.location.pathname, requestedNext));
+    } else if (successMessage) {
+      setMessage(successMessage);
+      setMessageKind('success');
+      window.history.replaceState(null, '', authPathWithNext(window.location.pathname, requestedNext));
     }
     if (initialAuth.mode === 'reset' && !initialAuth.token) {
       setMessage(t('auth.resetTokenMissing'));

+ 6 - 0
src/frontend/i18n/index.js

@@ -301,6 +301,9 @@ const messages = {
     'inbox.messageCount': '邮件数',
     'inbox.lastMessageAt': '最近收信',
     'inbox.noDomain': '请先添加域名,再创建该域名下的收信邮箱。',
+    'inbox.noMailboxDomain': '请先添加自己的域名,或让域名拥有者共享可创建邮箱的域名。',
+    'inbox.ownDomain': '我的域名',
+    'inbox.sharedDomain': '共享域名',
     'inbox.messages': '入站邮件',
     'inbox.mailboxFilter': '收信邮箱',
     'inbox.searchPlaceholder': '搜索发件人、主题或正文预览',
@@ -936,6 +939,9 @@ const messages = {
     'inbox.messageCount': 'Messages',
     'inbox.lastMessageAt': 'Last received',
     'inbox.noDomain': 'Add a domain before creating receiving mailboxes for it.',
+    'inbox.noMailboxDomain': 'Add your own domain, or ask a domain owner to share a domain for mailbox creation.',
+    'inbox.ownDomain': 'My domain',
+    'inbox.sharedDomain': 'Shared domain',
     'inbox.messages': 'Inbound messages',
     'inbox.mailboxFilter': 'Mailbox',
     'inbox.searchPlaceholder': 'Search sender, subject, or preview',

+ 2 - 0
src/frontend/services/api.ts

@@ -9,6 +9,7 @@ import type {
   DnsCredential,
   Domain,
   DomainPatchPayload,
+  InboundMailboxDomain,
   InboundMailbox,
   InboundFolder,
   InboundMessage,
@@ -109,6 +110,7 @@ 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' : ''}`),
+  inboundMailboxDomains: () => request<{ domains: InboundMailboxDomain[] }>('/api/inbound-mailbox-domains'),
   createInboundMailbox: (data: {
     address: string;
     displayName?: string;

+ 9 - 0
src/frontend/types.ts

@@ -137,11 +137,19 @@ export interface Domain {
   dmarcPolicy: string;
   dmarcRua: string;
   catchAllAddress: string;
+  mailboxSignupEnabled: boolean;
   status: DomainStatus;
   createdAt: string;
   updatedAt: string;
 }
 
+export interface InboundMailboxDomain {
+  id: number;
+  userId: number;
+  domain: string;
+  mailboxSignupEnabled: boolean;
+}
+
 export interface DnsCredential {
   id: number;
   userId?: number;
@@ -672,6 +680,7 @@ export interface DomainPatchPayload {
   dmarcPolicy?: string;
   dmarcRua?: string;
   catchAllAddress?: string;
+  mailboxSignupEnabled?: boolean;
 }
 
 export type WebhookEvent = 'sent' | 'bounced' | 'failed' | 'opened' | 'clicked' | 'received';

+ 16 - 12
src/pages/Domains/DomainDetail.tsx

@@ -25,6 +25,7 @@ import {
   Select,
   Skeleton,
   Space,
+  Switch,
   Table,
   Tabs,
   Typography
@@ -69,7 +70,7 @@ export default function DomainDetail() {
   const activeSection: DetailSection = isSection(section) ? section : 'overview';
   const navigate = useNavigate();
   const [searchParams, setSearchParams] = useSearchParams();
-  const { config } = useAppContext();
+  const { config, user } = useAppContext();
   const { locale } = useI18n();
   const copy = locale.startsWith('en') ? enCopy : zhCopy;
   const { message } = App.useApp();
@@ -255,7 +256,8 @@ export default function DomainDetail() {
       spfExtra: target.spfExtra,
       dmarcPolicy: target.dmarcPolicy,
       dmarcRua: target.dmarcRua,
-      catchAllAddress: target.catchAllAddress
+      catchAllAddress: target.catchAllAddress,
+      mailboxSignupEnabled: target.mailboxSignupEnabled
     });
     setEditOpen(true);
   }
@@ -305,6 +307,8 @@ export default function DomainDetail() {
 
   if (!domain || !base || !health) return null;
 
+  const canManageMailboxSignup = user?.role === 'admin' || user?.id === domain.userId;
+
   const menuItems: MenuProps['items'] = [
     { key: 'check', icon: <ReloadOutlined />, label: copy.checkDns, onClick: () => void checkDns() },
     { key: 'apply', icon: <ThunderboltOutlined />, label: copy.autoDns, disabled: !domain.dnsCredentialId, onClick: () => void applyDns() },
@@ -356,12 +360,12 @@ export default function DomainDetail() {
           { key: 'overview', label: copy.overview, children: <OverviewTab domain={domain} health={health} dnsName={base.dnsCredentials.find((item) => item.id === domain.dnsCredentialId)?.name} relayName={base.smtpRelays.find((item) => item.id === domain.smtpRelayId)?.name} copy={copy} onCheck={checkDns} onReviewDns={() => navigate(`/domains/${domain.id}/dns`)} onEdit={() => openEdit(domain)} onDelete={() => setDeleteOpen(true)} loading={actionLoading} /> },
           { key: 'dns', label: copy.dnsAndVerification, children: <DnsTab domain={domain} copy={copy} onCopy={copyValue} onCheck={checkDns} onApply={applyDns} loading={actionLoading} /> },
           { key: 'sending', label: copy.sendingConfiguration, children: <SectionContent loading={sectionLoading} error={sectionError} retry={loadSection} copy={copy}><SendingTab domain={domain} config={config} credential={smtpCredential} tokens={apiTokens || []} relayName={base.smtpRelays.find((item) => item.id === domain.smtpRelayId)?.name} copy={copy} onCopy={copyValue} onEdit={() => openEdit(domain)} /></SectionContent> },
-          { key: 'inbound', label: copy.inboundConfiguration, children: <SectionContent loading={sectionLoading} error={sectionError} retry={loadSection} copy={copy}><InboundTab domain={domain} config={config} mailboxes={mailboxes || []} copy={copy} onNavigate={() => navigate('/inbox')} onEdit={() => openEdit(domain)} /></SectionContent> },
+          { key: 'inbound', label: copy.inboundConfiguration, children: <SectionContent loading={sectionLoading} error={sectionError} retry={loadSection} copy={copy}><InboundTab domain={domain} config={config} mailboxes={mailboxes || []} copy={copy} canManageMailboxSignup={canManageMailboxSignup} onNavigate={() => navigate('/inbox')} onEdit={() => openEdit(domain)} /></SectionContent> },
           { key: 'activity', label: copy.activity, children: <SectionContent loading={sectionLoading} error={sectionError} retry={loadSection} copy={copy}><ActivityTab events={events || []} copy={copy} onView={(event) => navigate(`/activity/${event.id}?domainId=${domain.id}`)} /></SectionContent> }
         ]}
       />
 
-      <EditDomainModal open={editOpen} domain={domain} form={editForm} dnsCredentials={base.dnsCredentials} smtpRelays={base.smtpRelays} copy={copy} loading={Boolean(actionLoading.edit)} onCancel={closeEdit} onSave={saveDomain} />
+      <EditDomainModal open={editOpen} domain={domain} form={editForm} dnsCredentials={base.dnsCredentials} smtpRelays={base.smtpRelays} copy={copy} canManageMailboxSignup={canManageMailboxSignup} loading={Boolean(actionLoading.edit)} onCancel={closeEdit} onSave={saveDomain} />
 
       <Modal title={copy.deleteTitle} open={deleteOpen} okText={copy.delete} cancelText={copy.cancel} okButtonProps={{ danger: true, disabled: deleteConfirmation !== domain.domain, loading: Boolean(actionLoading.delete) }} onCancel={() => { setDeleteOpen(false); setDeleteConfirmation(''); }} onOk={() => void deleteDomain()}>
         <Space direction="vertical" size={16} className="full-width"><Alert type="error" showIcon message={copy.deleteWarning} /><Typography.Text>{copy.typeDomain} <Typography.Text code>{domain.domain}</Typography.Text></Typography.Text><Input value={deleteConfirmation} onChange={(event) => setDeleteConfirmation(event.target.value)} aria-label={copy.deleteConfirmation} style={{ minHeight: 44 }} /></Space>
@@ -441,12 +445,12 @@ function SendingTab({ domain, config, credential, tokens, relayName, copy, onCop
   );
 }
 
-function InboundTab({ domain, config, mailboxes, copy, onNavigate, onEdit }: { domain: Domain; config: ReturnType<typeof useAppContext>['config']; mailboxes: InboundMailbox[]; copy: typeof zhCopy; onNavigate: () => void; onEdit: () => void }) {
+function InboundTab({ domain, config, mailboxes, copy, canManageMailboxSignup, onNavigate, onEdit }: { domain: Domain; config: ReturnType<typeof useAppContext>['config']; mailboxes: InboundMailbox[]; copy: typeof zhCopy; canManageMailboxSignup: boolean; onNavigate: () => void; onEdit: () => void }) {
   return (
     <Row gutter={[16, 16]}>
       <Col xs={24} xl={9}><SectionCard title={copy.inboundStatus}><Space direction="vertical" size={16} className="full-width"><StatusPill tone={config?.submission?.inboundEnabled ? 'success' : 'warning'}><InboxOutlined /> {config?.submission?.inboundEnabled ? copy.inboundEnabled : copy.inboundDisabled}</StatusPill><Typography.Text type="secondary">{copy.inboundHint}</Typography.Text><Button onClick={onNavigate}>{copy.openMailboxRouting}</Button></Space></SectionCard></Col>
       <Col xs={24} xl={15}><SectionCard title={copy.mailboxes} extra={<Typography.Text type="secondary">{mailboxes.length}</Typography.Text>}>{mailboxes.length ? <List dataSource={mailboxes} renderItem={(mailbox) => <List.Item><List.Item.Meta title={mailbox.address} description={`${mailbox.unreadCount} ${copy.unread} · ${mailbox.messageCount} ${copy.messages}`} /><StatusPill tone={mailbox.status === 'active' ? 'success' : 'warning'}>{mailbox.status}</StatusPill></List.Item>} /> : <EmptyState description={copy.noMailboxes} action={<Button onClick={onNavigate}>{copy.configureMailbox}</Button>} />}</SectionCard></Col>
-      <Col span={24}><SectionCard title={copy.catchAll} extra={<Button icon={<EditOutlined />} onClick={onEdit}>{copy.edit}</Button>}><Descriptions column={1}><Descriptions.Item label={copy.catchAllAddress}>{domain.catchAllAddress || copy.notConfigured}</Descriptions.Item><Descriptions.Item label="IMAP">{config?.mailAccess?.imap.enabled ? config.mailAccess.imap.ports.map((item) => item.port).join(', ') : copy.disabled}</Descriptions.Item><Descriptions.Item label="POP3">{config?.mailAccess?.pop3.enabled ? config.mailAccess.pop3.ports.map((item) => item.port).join(', ') : copy.disabled}</Descriptions.Item></Descriptions></SectionCard></Col>
+      <Col span={24}><SectionCard title={copy.catchAll} extra={<Button icon={<EditOutlined />} onClick={onEdit}>{copy.edit}</Button>}><Descriptions column={1}><Descriptions.Item label={copy.catchAllAddress}>{domain.catchAllAddress || copy.notConfigured}</Descriptions.Item><Descriptions.Item label={copy.mailboxSignupPolicy}><Space direction="vertical" size={4}><StatusPill tone={domain.mailboxSignupEnabled ? 'success' : 'neutral'}>{domain.mailboxSignupEnabled ? copy.mailboxSignupOpen : copy.mailboxSignupOwnerOnly}</StatusPill>{canManageMailboxSignup ? <Typography.Text type="secondary">{copy.mailboxSignupHint}</Typography.Text> : null}</Space></Descriptions.Item><Descriptions.Item label="IMAP">{config?.mailAccess?.imap.enabled ? config.mailAccess.imap.ports.map((item) => item.port).join(', ') : copy.disabled}</Descriptions.Item><Descriptions.Item label="POP3">{config?.mailAccess?.pop3.enabled ? config.mailAccess.pop3.ports.map((item) => item.port).join(', ') : copy.disabled}</Descriptions.Item></Descriptions></SectionCard></Col>
     </Row>
   );
 }
@@ -467,10 +471,10 @@ function SectionContent({ loading, error, retry, copy, children }: { loading: bo
   return <>{children}</>;
 }
 
-function EditDomainModal({ open, domain, form, dnsCredentials, smtpRelays, copy, loading, onCancel, onSave }: { open: boolean; domain: Domain; form: ReturnType<typeof Form.useForm<DomainPatchPayload>>[0]; dnsCredentials: DnsCredential[]; smtpRelays: SmtpRelay[]; copy: typeof zhCopy; loading: boolean; onCancel: () => void; onSave: () => void }) {
+function EditDomainModal({ open, domain, form, dnsCredentials, smtpRelays, copy, canManageMailboxSignup, loading, onCancel, onSave }: { open: boolean; domain: Domain; form: ReturnType<typeof Form.useForm<DomainPatchPayload>>[0]; dnsCredentials: DnsCredential[]; smtpRelays: SmtpRelay[]; copy: typeof zhCopy; canManageMailboxSignup: boolean; loading: boolean; onCancel: () => void; onSave: () => void }) {
   return (
     <Modal title={copy.editTitle} open={open} width={640} confirmLoading={loading} okText={copy.save} cancelText={copy.cancel} onCancel={onCancel} onOk={onSave}>
-      <Form form={form} layout="vertical"><Row gutter={16}><Col xs={24} md={12}><Form.Item name="dnsCredentialId" label={copy.dnsIntegration}><Select allowClear options={dnsCredentials.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="smtpRelayId" label={copy.relay}><Select allowClear options={smtpRelays.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item></Col></Row><Row gutter={16}><Col xs={24} md={12}><Form.Item name="senderHost" label={copy.senderHost} rules={[{ required: true }]}><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="sendingIp" label={copy.sendingIp} rules={[{ required: true }]}><Input /></Form.Item></Col></Row><Row gutter={16}><Col xs={24} md={12}><Form.Item name="selector" label="DKIM selector" rules={[{ required: true }]}><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="dmarcPolicy" label="DMARC"><Select options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} /></Form.Item></Col></Row><Form.Item name="spfExtra" label="SPF"><Input.TextArea rows={2} /></Form.Item><Form.Item name="dmarcRua" label="DMARC rua"><Input /></Form.Item><Form.Item name="catchAllAddress" label={copy.catchAllAddress}><Input placeholder={`inbox@${domain.domain}`} /></Form.Item></Form>
+      <Form form={form} layout="vertical"><Row gutter={16}><Col xs={24} md={12}><Form.Item name="dnsCredentialId" label={copy.dnsIntegration}><Select allowClear options={dnsCredentials.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="smtpRelayId" label={copy.relay}><Select allowClear options={smtpRelays.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item></Col></Row><Row gutter={16}><Col xs={24} md={12}><Form.Item name="senderHost" label={copy.senderHost} rules={[{ required: true }]}><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="sendingIp" label={copy.sendingIp} rules={[{ required: true }]}><Input /></Form.Item></Col></Row><Row gutter={16}><Col xs={24} md={12}><Form.Item name="selector" label="DKIM selector" rules={[{ required: true }]}><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="dmarcPolicy" label="DMARC"><Select options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} /></Form.Item></Col></Row><Form.Item name="spfExtra" label="SPF"><Input.TextArea rows={2} /></Form.Item><Form.Item name="dmarcRua" label="DMARC rua"><Input /></Form.Item><Form.Item name="catchAllAddress" label={copy.catchAllAddress}><Input placeholder={`inbox@${domain.domain}`} /></Form.Item>{canManageMailboxSignup ? <Form.Item name="mailboxSignupEnabled" label={copy.mailboxSignupPolicy} valuePropName="checked" extra={copy.mailboxSignupExtra}><Switch checkedChildren={copy.enabled} unCheckedChildren={copy.disabled} /></Form.Item> : null}</Form>
     </Modal>
   );
 }
@@ -505,9 +509,9 @@ const zhCopy = {
   dnsPassed: 'DNS 已通过', needsAttention: '需要处理', testSend: '测试发送', checkDns: '检查 DNS', autoDns: '自动写入 DNS', edit: '编辑配置', delete: '删除域名', checked: 'DNS 检查已完成', dnsApplied: 'DNS 记录写入完成', dnsPartial: '部分 DNS 记录写入失败', saved: '配置已保存', testQueued: '测试邮件已加入队列', deleted: '域名已删除', actionFailed: '操作失败', copied: '已复制', copyFailed: '复制失败', setupPartialTitle: '域名已创建,但 DNS 后续操作需要处理', setupPartialDescription: '自动写入或即时检查未完全成功。请查看下方逐条结果,修正后重新执行。', setupCompleteTitle: '域名创建完成', setupAutomaticDescription: 'DNS 自动写入流程已执行,请核对逐条结果和当前验证状态。', setupManualDescription: '请按下方记录完成手动 DNS 配置,并在记录生效后重新检查。',
   domainSummary: '域名摘要', authenticationHealth: '认证健康度', recordsPassed: '项必需记录已通过', ready: '发送就绪', nextActions: '下一步操作', reviewDns: '查看 DNS 记录', dangerZone: '危险操作', dangerHint: '删除域名会停止相关发信配置,且不可撤销。', recheck: '重新检查', copyAll: '复制全部', domainVerified: '域名已验证,可用于发送', domainNotVerified: '域名尚未完成验证', lastChecked: '最近检查', never: '从未检查', noDnsRecords: '尚未生成 DNS 检查结果', checkNow: '立即检查', warnings: '需要注意', applyResults: 'DNS 写入结果', applyComplete: '全部完成', applyPartial: '部分失败', applySucceeded: '成功', applyFailed: '失败', applySkipped: '已跳过', applySucceededHint: '记录已写入或无需变更。', applyFailedHint: '未返回具体错误,请重新执行或改为手动配置。',
   host: '主机', ports: '端口', username: '用户名', password: '密码', configured: '已设置(不会返回明文)', notConfigured: '未设置', manageKeys: '管理 API 密钥', availableKeys: '可用密钥', noKeys: '尚无可用 API 密钥', senderIdentity: '发件身份', fromDomain: '发件域名', verification: '验证状态', verified: '已验证', pending: '等待验证',
-  inboundStatus: '收信服务状态', inboundEnabled: '收信服务已启用', inboundDisabled: '收信服务未启用', inboundHint: '邮箱、别名和路由在独立收件箱工作区管理。', openMailboxRouting: '打开邮箱与路由', mailboxes: '邮箱', unread: '封未读', messages: '封邮件', noMailboxes: '该域名还没有邮箱', configureMailbox: '配置邮箱', catchAll: '默认收信路由', catchAllAddress: 'Catch-all 地址', disabled: '未启用',
+  inboundStatus: '收信服务状态', inboundEnabled: '收信服务已启用', inboundDisabled: '收信服务未启用', inboundHint: '邮箱、别名和路由在独立收件箱工作区管理。', openMailboxRouting: '打开邮箱与路由', mailboxes: '邮箱', unread: '封未读', messages: '封邮件', noMailboxes: '该域名还没有邮箱', configureMailbox: '配置邮箱', catchAll: '默认收信路由', catchAllAddress: 'Catch-all 地址', mailboxSignupPolicy: '邮箱创建权限', mailboxSignupOpen: '已共享给其他用户', mailboxSignupOwnerOnly: '仅域名拥有者', mailboxSignupHint: '开启后,其他用户可以在该域名下创建自己的邮箱,但不能管理此域名配置。', mailboxSignupExtra: '共享只开放邮箱创建能力,不开放 DNS、Catch-all、删除等域名管理权限。', enabled: '开放', disabled: '未启用',
   time: '时间', recipient: '收件人', subject: '主题', body: '正文', status: '状态', noActivity: '该域名尚无发送活动', viewActivity: '查看发送活动', sent: '已发送', delivered: '已送达', queued: '已入队', deferred: '已延迟', failed: '失败', bounced: '退信',
-  editTitle: '编辑域名配置', save: '保存', cancel: '取消', deleteTitle: '删除域名', deleteWarning: '此操作不可撤销,域名相关发送配置将立即不可用。', typeDomain: '请输入域名以确认:', deleteConfirmation: '域名删除确认', queueTest: '加入发送队列', validRecipient: '请输入有效的收件邮箱', testSubject: (domain: string) => `MailHub ${domain} 测试邮件`, testBody: (domain: string) => `这是一封来自 ${domain} 的 MailHub 投递测试邮件。`
+  editTitle: '编辑域名配置', save: '保存', cancel: '取消', deleteTitle: '删除域名', deleteWarning: '此操作不可撤销,域名相关发送配置将立即不可用;如仍有关联邮箱,系统会阻止删除。', typeDomain: '请输入域名以确认:', deleteConfirmation: '域名删除确认', queueTest: '加入发送队列', validRecipient: '请输入有效的收件邮箱', testSubject: (domain: string) => `MailHub ${domain} 测试邮件`, testBody: (domain: string) => `这是一封来自 ${domain} 的 MailHub 投递测试邮件。`
 };
 
 const enCopy: typeof zhCopy = {
@@ -516,7 +520,7 @@ const enCopy: typeof zhCopy = {
   dnsPassed: 'DNS passed', needsAttention: 'Needs attention', testSend: 'Send test', checkDns: 'Check DNS', autoDns: 'Apply DNS', edit: 'Edit configuration', delete: 'Delete domain', checked: 'DNS check completed', dnsApplied: 'DNS records applied', dnsPartial: 'Some DNS records could not be applied', saved: 'Configuration saved', testQueued: 'Test email queued', deleted: 'Domain deleted', actionFailed: 'Action failed', copied: 'Copied', copyFailed: 'Unable to copy', setupPartialTitle: 'Domain created, but DNS follow-up needs attention', setupPartialDescription: 'Automatic application or the immediate check did not fully complete. Review each result below, correct it, and retry.', setupCompleteTitle: 'Domain created', setupAutomaticDescription: 'The automatic DNS flow ran. Review each result and the current verification status.', setupManualDescription: 'Add the records below at your DNS provider, then recheck after they propagate.',
   domainSummary: 'Domain summary', authenticationHealth: 'Authentication health', recordsPassed: 'required records passed', ready: 'Ready to send', nextActions: 'Next actions', reviewDns: 'Review DNS records', dangerZone: 'Danger zone', dangerHint: 'Deleting the domain stops its sending configuration and cannot be undone.', recheck: 'Recheck', copyAll: 'Copy all', domainVerified: 'Domain verified and ready to send', domainNotVerified: 'Domain verification is incomplete', lastChecked: 'Last checked', never: 'Never', noDnsRecords: 'No DNS check results yet', checkNow: 'Check now', warnings: 'Attention needed', applyResults: 'DNS apply results', applyComplete: 'Complete', applyPartial: 'Partially failed', applySucceeded: 'Succeeded', applyFailed: 'Failed', applySkipped: 'Skipped', applySucceededHint: 'The record was written or already matched.', applyFailedHint: 'No detailed error was returned. Retry or configure this record manually.',
   host: 'Host', ports: 'Ports', username: 'Username', password: 'Password', configured: 'Set (never returned in plaintext)', notConfigured: 'Not set', manageKeys: 'Manage API keys', availableKeys: 'Available keys', noKeys: 'No API keys available', senderIdentity: 'Sender identity', fromDomain: 'From domain', verification: 'Verification', verified: 'Verified', pending: 'Pending',
-  inboundStatus: 'Inbound service', inboundEnabled: 'Inbound service enabled', inboundDisabled: 'Inbound service disabled', inboundHint: 'Manage mailboxes, aliases, and routes in the dedicated Inbox workspace.', openMailboxRouting: 'Open mailboxes & routing', mailboxes: 'Mailboxes', unread: 'unread', messages: 'messages', noMailboxes: 'No mailboxes for this domain', configureMailbox: 'Configure mailbox', catchAll: 'Default inbound route', catchAllAddress: 'Catch-all address', disabled: 'Disabled',
+  inboundStatus: 'Inbound service', inboundEnabled: 'Inbound service enabled', inboundDisabled: 'Inbound service disabled', inboundHint: 'Manage mailboxes, aliases, and routes in the dedicated Inbox workspace.', openMailboxRouting: 'Open mailboxes & routing', mailboxes: 'Mailboxes', unread: 'unread', messages: 'messages', noMailboxes: 'No mailboxes for this domain', configureMailbox: 'Configure mailbox', catchAll: 'Default inbound route', catchAllAddress: 'Catch-all address', mailboxSignupPolicy: 'Mailbox creation access', mailboxSignupOpen: 'Shared with other users', mailboxSignupOwnerOnly: 'Owner only', mailboxSignupHint: 'When enabled, other users can create their own mailboxes under this domain, but cannot manage the domain configuration.', mailboxSignupExtra: 'Sharing only allows mailbox creation. DNS, catch-all, deletion, and domain administration stay private to the owner.', enabled: 'Open', disabled: 'Disabled',
   time: 'Time', recipient: 'Recipient', subject: 'Subject', body: 'Body', status: 'Status', noActivity: 'No sending activity for this domain', viewActivity: 'View sending activity', sent: 'Sent', delivered: 'Delivered', queued: 'Queued', deferred: 'Deferred', failed: 'Failed', bounced: 'Bounced',
-  editTitle: 'Edit domain configuration', save: 'Save', cancel: 'Cancel', deleteTitle: 'Delete domain', deleteWarning: 'This cannot be undone. Sending configuration will stop immediately.', typeDomain: 'Type the domain to confirm:', deleteConfirmation: 'Domain deletion confirmation', queueTest: 'Queue test', validRecipient: 'Enter a valid recipient email', testSubject: (domain: string) => `MailHub ${domain} test email`, testBody: (domain: string) => `This is a MailHub delivery test from ${domain}.`
+  editTitle: 'Edit domain configuration', save: 'Save', cancel: 'Cancel', deleteTitle: 'Delete domain', deleteWarning: 'This cannot be undone. Sending configuration will stop immediately; deletion is blocked while mailboxes remain attached.', typeDomain: 'Type the domain to confirm:', deleteConfirmation: 'Domain deletion confirmation', queueTest: 'Queue test', validRecipient: 'Enter a valid recipient email', testSubject: (domain: string) => `MailHub ${domain} test email`, testBody: (domain: string) => `This is a MailHub delivery test from ${domain}.`
 };

+ 2 - 2
src/pages/Domains/index.tsx

@@ -595,7 +595,7 @@ const zhCopy = {
   addDomain: '添加域名', addFirstDomain: '添加第一个域名', search: '搜索域名', searchPlaceholder: '搜索域名或发信主机', statusFilter: '认证状态', healthy: '健康', pending: '等待生效', needsAction: '需要处理', noMatches: '没有符合当前条件的域名', noDomains: '还没有发信域名', resultCount: (filtered: number, total: number) => `${filtered} / ${total} 个域名`,
   checkDns: '检查 DNS', autoDns: '自动写入 DNS', sendTest: '测试发送', edit: '编辑配置', delete: '删除域名', manualDns: '手动配置', credentialUnavailable: '凭据不可用', noActivity: '尚无发送活动', passed: '已通过', incomplete: '未完整', missing: '未配置', sendReady: '发送可用', sendPending: '发送待验证', receiveRouted: '接收已路由', receiveConfigurable: '接收可配置',
   created: '域名已创建,后续 DNS 操作已完成', createdPartial: '域名已创建,但后续 DNS 操作未完全成功,请在详情页继续处理', createFailed: '创建域名失败', checked: 'DNS 检查已完成', dnsApplied: 'DNS 记录写入已完成', dnsPartiallyApplied: '部分 DNS 记录写入失败,请查看详情', deleted: '域名已删除', testQueued: '测试邮件已加入队列', actionFailed: '操作失败', paginationTotal: (total: number) => `共 ${total} 个域名`,
-  deleteTitle: '删除域名', deleteWarning: '删除后,该域名的发送配置将立即不可用。此操作不可撤销。', typeDomain: '请输入域名以确认:', deleteConfirmation: '域名删除确认', cancel: '取消', testTitle: (domain: string) => `通过 ${domain} 测试发送`, queueTest: '加入发送队列', recipient: '收件人', validRecipient: '请输入有效的收件邮箱', subject: '主题', body: '正文', testSubject: (domain: string) => `MailHub ${domain} 测试邮件`, testBody: (domain: string) => `这是一封来自 ${domain} 的 MailHub 投递测试邮件。`
+  deleteTitle: '删除域名', deleteWarning: '删除后,该域名的发送配置将立即不可用。此操作不可撤销;如仍有关联邮箱,系统会阻止删除。', typeDomain: '请输入域名以确认:', deleteConfirmation: '域名删除确认', cancel: '取消', testTitle: (domain: string) => `通过 ${domain} 测试发送`, queueTest: '加入发送队列', recipient: '收件人', validRecipient: '请输入有效的收件邮箱', subject: '主题', body: '正文', testSubject: (domain: string) => `MailHub ${domain} 测试邮件`, testBody: (domain: string) => `这是一封来自 ${domain} 的 MailHub 投递测试邮件。`
 };
 
 const enCopy: typeof zhCopy = {
@@ -603,5 +603,5 @@ const enCopy: typeof zhCopy = {
   addDomain: 'Add domain', addFirstDomain: 'Add your first domain', search: 'Search domains', searchPlaceholder: 'Search domain or sending host', statusFilter: 'Authentication status', healthy: 'Healthy', pending: 'Pending', needsAction: 'Needs action', noMatches: 'No domains match these filters', noDomains: 'No sending domains yet', resultCount: (filtered: number, total: number) => `${filtered} of ${total} domains`,
   checkDns: 'Check DNS', autoDns: 'Apply DNS automatically', sendTest: 'Send test', edit: 'Edit configuration', delete: 'Delete domain', manualDns: 'Manual', credentialUnavailable: 'Credential unavailable', noActivity: 'No activity yet', passed: 'passed', incomplete: 'incomplete', missing: 'Missing', sendReady: 'Sending ready', sendPending: 'Sending pending', receiveRouted: 'Receiving routed', receiveConfigurable: 'Receiving available',
   created: 'Domain created and DNS follow-up completed', createdPartial: 'Domain created, but the DNS follow-up needs attention', createFailed: 'Unable to create domain', checked: 'DNS check completed', dnsApplied: 'DNS records applied', dnsPartiallyApplied: 'Some DNS records could not be applied', deleted: 'Domain deleted', testQueued: 'Test email queued', actionFailed: 'Action failed', paginationTotal: (total: number) => `${total} domains`,
-  deleteTitle: 'Delete domain', deleteWarning: 'Sending configuration for this domain will stop working immediately. This cannot be undone.', typeDomain: 'Type the domain to confirm:', deleteConfirmation: 'Domain deletion confirmation', cancel: 'Cancel', testTitle: (domain: string) => `Test sending from ${domain}`, queueTest: 'Queue test', recipient: 'Recipient', validRecipient: 'Enter a valid recipient email', subject: 'Subject', body: 'Body', testSubject: (domain: string) => `MailHub ${domain} test email`, testBody: (domain: string) => `This is a MailHub delivery test from ${domain}.`
+  deleteTitle: 'Delete domain', deleteWarning: 'Sending configuration for this domain will stop immediately. This cannot be undone; deletion is blocked while mailboxes remain attached.', typeDomain: 'Type the domain to confirm:', deleteConfirmation: 'Domain deletion confirmation', cancel: 'Cancel', testTitle: (domain: string) => `Test sending from ${domain}`, queueTest: 'Queue test', recipient: 'Recipient', validRecipient: 'Enter a valid recipient email', subject: 'Subject', body: 'Body', testSubject: (domain: string) => `MailHub ${domain} test email`, testBody: (domain: string) => `This is a MailHub delivery test from ${domain}.`
 };

+ 25 - 10
src/pages/Inbox.tsx

@@ -51,7 +51,7 @@ import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
 import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
 import { api } from '../frontend/services/api';
-import type { Domain, InboundFolder, InboundMailbox, InboundMessage, MailboxClientConfig, RuntimeConfig } from '../frontend/types';
+import type { Domain, InboundFolder, InboundMailbox, InboundMailboxDomain, InboundMessage, MailboxClientConfig, RuntimeConfig } from '../frontend/types';
 import { useMediaQuery } from '../frontend/use-media-query';
 
 type MailMessage = InboundMessage & { folder?: string };
@@ -79,7 +79,7 @@ export default function Inbox() {
   const screens = Grid.useBreakpoint();
   const isDesktop = useMediaQuery('(min-width: 1280px)');
   const { locale, t } = useI18n();
-  const { config } = useAppContext();
+  const { config, user } = useAppContext();
   const location = useLocation();
   const navigate = useNavigate();
   const params = useParams<{ messageId?: string }>();
@@ -87,6 +87,7 @@ export default function Inbox() {
   const [mailboxForm] = Form.useForm<MailboxFormValues>();
   const [catchAllForm] = Form.useForm<{ catchAllAddress?: string }>();
   const [domains, setDomains] = useState<Domain[]>([]);
+  const [mailboxDomains, setMailboxDomains] = useState<InboundMailboxDomain[]>([]);
   const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
   const [folders, setFolders] = useState<MailFolder[]>(fallbackFolders());
   const [messages, setMessages] = useState<MailMessage[]>([]);
@@ -139,6 +140,18 @@ export default function Inbox() {
       ? `${item.address} · ${item.unreadCount} unread / ${item.messageCount} total`
       : `${item.address} · ${item.unreadCount} 未读 / ${item.messageCount} 封`
   })), [locale, mailboxes]);
+  const mailboxDomainOptions = useMemo(() => mailboxDomains.map((domain) => {
+    const owned = user?.id === domain.userId;
+    return {
+      value: domain.domain,
+      label: (
+        <Space size={8}>
+          <Typography.Text>@{domain.domain}</Typography.Text>
+          <Tag color={owned ? 'blue' : 'purple'}>{owned ? t('inbox.ownDomain') : t('inbox.sharedDomain')}</Tag>
+        </Space>
+      )
+    };
+  }), [mailboxDomains, t, user?.id]);
   const visibleMailboxes = useMemo(
     () => sortMailboxes(filterMailboxes(mailboxes, mailboxListQuery), mailboxSort),
     [mailboxListQuery, mailboxSort, mailboxes]
@@ -153,12 +166,14 @@ export default function Inbox() {
     setLoading(true);
     setLoadError('');
     setDomainsError('');
-    const [domainResult, mailboxResult] = await Promise.allSettled([
-      api.domains(), api.inboundMailboxes()
+    const [domainResult, mailboxDomainResult, mailboxResult] = await Promise.allSettled([
+      api.domains(), api.inboundMailboxDomains(), api.inboundMailboxes()
     ]);
     if (requestId !== baseRequestId.current) return;
     if (domainResult.status === 'fulfilled') setDomains(domainResult.value.domains || []);
     else setDomainsError(domainResult.reason instanceof Error ? domainResult.reason.message : t('common.error'));
+    if (mailboxDomainResult.status === 'fulfilled') setMailboxDomains(mailboxDomainResult.value.domains || []);
+    else setDomainsError(mailboxDomainResult.reason instanceof Error ? mailboxDomainResult.reason.message : t('common.error'));
     if (mailboxResult.status === 'fulfilled') setMailboxes(mailboxResult.value.mailboxes || []);
     else setLoadError(mailboxResult.reason instanceof Error ? mailboxResult.reason.message : t('common.error'));
     setLoading(false);
@@ -414,7 +429,7 @@ export default function Inbox() {
   function openCreateMailbox() {
     setEditingMailbox(null);
     mailboxForm.resetFields();
-    mailboxForm.setFieldsValue({ domain: domains[0]?.domain, password: generateMailboxPassword(), quotaMb: 1024, keepForwarded: true, status: 'active' });
+    mailboxForm.setFieldsValue({ domain: mailboxDomains[0]?.domain, password: generateMailboxPassword(), quotaMb: 1024, keepForwarded: true, status: 'active' });
     setMailboxOpen(true);
   }
 
@@ -530,7 +545,7 @@ export default function Inbox() {
       <PageHeader
         title={t('inbox.title')}
         subtitle={t('inbox.subtitle')}
-        extra={workspace === 'routing' ? <Button type="primary" icon={<PlusOutlined />} disabled={!domains.length} onClick={openCreateMailbox} style={{ minHeight: 44 }}>{t('inbox.createMailbox')}</Button> : null}
+        extra={workspace === 'routing' ? <Button type="primary" icon={<PlusOutlined />} disabled={!mailboxDomains.length} onClick={openCreateMailbox} style={{ minHeight: 44 }}>{t('inbox.createMailbox')}</Button> : null}
       />
       {loadError ? <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadBase()}>{t('common.refresh')}</Button>} /> : null}
       {domainsError ? <Alert type="warning" showIcon message={locale.startsWith('en') ? 'Domain and routing options are temporarily unavailable.' : '域名与路由选项暂不可用。'} description={domainsError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadBase()}>{t('common.refresh')}</Button>} /> : null}
@@ -589,7 +604,7 @@ 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>
-        ) : <EmptyState description={locale.startsWith('en') ? 'No receiving mailbox has been created yet.' : '尚未创建收信邮箱。'} action={<Button icon={<PlusOutlined />} disabled={!domains.length} onClick={() => { switchWorkspace('routing'); openCreateMailbox(); }}>{t('inbox.createMailbox')}</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">
           <SectionCard title={t('inbox.mailboxes')} extra={<StatusPill tone="neutral">{mailboxes.length}</StatusPill>}>
@@ -619,7 +634,7 @@ export default function Inbox() {
                   <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>} />
                 )}
               </Space>
-            ) : loadError ? null : <EmptyState description={t('inbox.noDomain')} action={<Button icon={<PlusOutlined />} disabled={!domains.length} onClick={openCreateMailbox}>{t('inbox.createMailbox')}</Button>} />}
+            ) : 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>} />}
           </SectionCard>
           <SectionCard title={t('inbox.domainRoutes')} extra={<StatusPill tone="neutral">{domains.length}</StatusPill>}>
             {domains.length ? (
@@ -659,7 +674,7 @@ export default function Inbox() {
         destroyOnHidden
         footer={<Space style={{ display: 'flex', justifyContent: 'flex-end' }}><Button onClick={closeMailboxDrawer}>{t('common.cancel')}</Button><Button type="primary" loading={actionKey === 'mailbox:create' || actionKey === `mailbox:update:${editingMailbox?.id}`} onClick={() => void saveMailbox()}>{editingMailbox ? t('common.save') : t('inbox.createMailbox')}</Button></Space>}
       >
-        {!domains.length ? <Alert type="warning" showIcon message={t('inbox.noDomain')} /> : (
+        {!mailboxDomains.length ? <Alert type="warning" showIcon message={t('inbox.noMailboxDomain')} /> : (
           <Form form={mailboxForm} layout="vertical">
             {editingMailbox ? (
               <Descriptions bordered column={1} size="small" style={{ marginBottom: 20 }}>
@@ -668,7 +683,7 @@ export default function Inbox() {
             ) : (
               <div style={{ display: 'grid', gridTemplateColumns: screens.sm ? 'minmax(0, 1fr) minmax(220px, 0.8fr)' : 'minmax(0, 1fr)', columnGap: screens.sm ? 0 : 8 }}>
                 <Form.Item name="localPart" label={t('inbox.localPart')} rules={[{ required: true, message: t('inbox.localPartRequired') }, { pattern: /^[^@\s]+$/, message: t('inbox.localPartInvalid') }]}><Input autoComplete="off" /></Form.Item>
-                <Form.Item name="domain" label={t('domains.domain')} rules={[{ required: true, message: t('inbox.domainRequired') }]}><Select options={domains.map((domain) => ({ value: domain.domain, label: `@${domain.domain}` }))} /></Form.Item>
+                <Form.Item name="domain" label={t('domains.domain')} rules={[{ required: true, message: t('inbox.domainRequired') }]}><Select options={mailboxDomainOptions} /></Form.Item>
               </div>
             )}
             <Form.Item name="displayName" label={t('inbox.displayName')}><Input /></Form.Item>

+ 39 - 8
src/server.js

@@ -46,6 +46,7 @@ import {
   listAuditLogs,
   listDnsCredentials,
   listDomains,
+  listInboundMailboxDomains,
   listInboundMailboxes,
   listInboundMailboxFolders,
   searchInboundMessages,
@@ -456,6 +457,11 @@ async function handleApi(req, res, url, user) {
       mailboxes: listInboundMailboxes(user.id, { includeAllUsers })
     });
   }
+  if (method === 'GET' && pathname === '/api/inbound-mailbox-domains') {
+    return sendJson(res, 200, {
+      domains: listInboundMailboxDomains(user.id)
+    });
+  }
   if (method === 'POST' && pathname === '/api/inbound-mailboxes') {
     const body = await readJson(req);
     const password = String(body.password || '');
@@ -470,6 +476,9 @@ async function handleApi(req, res, url, user) {
         clientConfig: mailboxClientConfig(mailbox, { password })
       });
     } catch (error) {
+      if (error?.code === 'INBOUND_MAILBOX_NAMESPACE_CONFLICT') {
+        return sendJson(res, 409, { error: error.message });
+      }
       if (isUniqueError(error)) return sendJson(res, 409, { error: '该收信邮箱已存在。' });
       return sendJson(res, 400, { error: error.message || '收信邮箱创建失败。' });
     }
@@ -485,6 +494,9 @@ async function handleApi(req, res, url, user) {
         clientConfig: body.password ? mailboxClientConfig(mailbox, { password: String(body.password || '') }) : undefined
       });
     } catch (error) {
+      if (error?.code === 'INBOUND_MAILBOX_NAMESPACE_CONFLICT') {
+        return sendJson(res, 409, { error: error.message });
+      }
       return sendJson(res, 400, { error: error.message || '收信邮箱更新失败。' });
     }
   }
@@ -905,14 +917,24 @@ async function handleApi(req, res, url, user) {
         spfExtra: body.spfExtra !== undefined ? String(body.spfExtra).trim() : undefined,
         dmarcPolicy: body.dmarcPolicy ? normalizeDmarcPolicy(body.dmarcPolicy) : undefined,
         dmarcRua: body.dmarcRua !== undefined ? String(body.dmarcRua).trim() : undefined,
-        catchAllAddress
+        catchAllAddress,
+        mailboxSignupEnabled: body.mailboxSignupEnabled !== undefined
+          ? Boolean(body.mailboxSignupEnabled)
+          : undefined
       });
       if (!row) return sendJson(res, 404, { error: '域名不存在。' });
       return sendJson(res, 200, { domain: row });
     }
     if (method === 'DELETE' && !action) {
-      const deleted = deleteDomain(id, user.id);
-      return sendJson(res, deleted ? 200 : 404, { deleted });
+      try {
+        const deleted = deleteDomain(id, user.id);
+        return sendJson(res, deleted ? 200 : 404, { deleted });
+      } catch (error) {
+        if (error?.code === 'DOMAIN_HAS_INBOUND_MAILBOXES') {
+          return sendJson(res, 409, { error: error.message });
+        }
+        throw error;
+      }
     }
     if (method === 'POST' && action === 'check') {
       const row = getDomain(id, { userId: user.id });
@@ -1656,17 +1678,24 @@ async function handleResetPassword(req, res) {
 async function handleVerifyEmail(req, res, url) {
   if ((req.method || 'GET') !== 'GET') return sendJson(res, 404, { error: 'Not found.' });
   const token = String(url.searchParams.get('token') || '').trim();
-  if (!token) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
+  if (!token) return sendVerifyEmailError(req, res, '验证链接无效或已过期。');
   const consumed = consumeAccountToken(token, emailVerificationPurpose);
-  if (!consumed) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
+  if (!consumed) return sendVerifyEmailError(req, res, '验证链接无效或已过期。');
   const user = markUserEmailVerified(consumed.userId);
-  if (!user) return sendJson(res, 400, { error: '验证链接无效或已过期。' });
+  if (!user) return sendVerifyEmailError(req, res, '验证链接无效或已过期。');
+  const message = '邮箱验证成功,请等待管理员审核。';
+  if (wantsHtmlRedirect(req)) return redirect(res, `/login?message=${encodeURIComponent(message)}`, 303);
   return sendJson(res, 200, {
     user,
-    message: '邮箱验证成功,请等待管理员审核。'
+    message
   });
 }
 
+function sendVerifyEmailError(req, res, message) {
+  if (wantsHtmlRedirect(req)) return redirect(res, `/login?error=${encodeURIComponent(message)}`, 303);
+  return sendJson(res, 400, { error: message });
+}
+
 async function createAndSendVerificationEmail(user) {
   const settings = systemMailSettingsForSend();
   if (!systemMailConfigured(settings)) return { ok: false, message: '系统邮件未配置。' };
@@ -2147,7 +2176,9 @@ function redirect(res, location, status = 302, headers = {}) {
 function wantsHtmlRedirect(req) {
   const contentType = String(req.headers['content-type'] || '').toLowerCase();
   const accept = String(req.headers.accept || '').toLowerCase();
-  return contentType.includes('application/x-www-form-urlencoded') && accept.includes('text/html');
+  if (!accept.includes('text/html')) return false;
+  if (contentType.includes('application/x-www-form-urlencoded')) return true;
+  return String(req.method || 'GET').toUpperCase() === 'GET' && !accept.includes('application/json');
 }
 
 function setSecurityHeaders(res) {

+ 43 - 3
src/submission.js

@@ -6,7 +6,9 @@ import {
   createInboundMessageWithWebhook,
   createTrackingLink,
   finalizeSendEvent,
+  getDomain,
   getDomainByName,
+  getInboundMailboxForSender,
   logSendEvent,
   resolveInboundRecipient,
   verifySmtpCredential
@@ -402,6 +404,12 @@ class SubmissionSession {
     if (this.authMailbox && !mailboxAllowsSender(this.authMailbox, address)) {
       return this.write(553, 'Sender address is not allowed for this mailbox');
     }
+    if (!this.authMailbox && this.authenticated) {
+      const senderResolution = resolveAccountSenderDomain(this.user?.id, address);
+      if (senderResolution.configured && !senderResolution.domain) {
+        return this.write(553, 'Sender address is not allowed for this account');
+      }
+    }
     this.mailFrom = address;
     this.mailFromAccepted = true;
     this.recipients = [];
@@ -455,8 +463,18 @@ class SubmissionSession {
       return this.write(553, 'From address is not allowed for this mailbox');
     }
     const domainName = domainFromAddress(sender || this.mailFrom);
-    const domain = getDomainByName(domainName, { userId: this.user?.id, includePrivate: true });
-    if (!domain) {
+    let domain;
+    if (this.authMailbox) {
+      domain = getDomain(this.authMailbox.domainId, { includePrivate: true });
+    } else {
+      const senderResolution = resolveAccountSenderDomain(this.user?.id, sender, { includePrivate: true });
+      if (senderResolution.configured && !senderResolution.domain) {
+        this.resetEnvelope(false);
+        return this.write(553, 'From address is not allowed for this account');
+      }
+      domain = senderResolution.domain;
+    }
+    if (!domain || domain.domain !== domainName) {
       logSendEvent({
         userId: this.user?.id || null,
         domainId: null,
@@ -699,7 +717,29 @@ function mailboxAllowsSender(mailbox, address) {
     const localPart = String(alias || '').trim().toLowerCase();
     if (localPart && !localPart.includes('@')) allowed.add(`${localPart}@${domain}`);
   }
-  return allowed.has(sender);
+  if (!allowed.has(sender)) return false;
+  return getInboundMailboxForSender(mailbox.userId, sender)?.id === mailbox.id;
+}
+
+function resolveAccountSenderDomain(userId, address, { includePrivate = false } = {}) {
+  const domainName = domainFromAddress(address);
+  const cleanUserId = Number(userId);
+  if (!Number.isSafeInteger(cleanUserId) || cleanUserId <= 0) {
+    return { configured: Boolean(getDomainByName(domainName)), domain: null };
+  }
+  const ownedDomain = getDomainByName(domainName, { userId: cleanUserId, includePrivate });
+  if (ownedDomain) return { configured: true, domain: ownedDomain };
+
+  const configuredDomain = getDomainByName(domainName);
+  if (!configuredDomain) return { configured: false, domain: null };
+  const mailbox = getInboundMailboxForSender(cleanUserId, address);
+  if (!mailbox || mailbox.domainId !== configuredDomain.id) {
+    return { configured: true, domain: null };
+  }
+  const domain = includePrivate
+    ? getDomain(configuredDomain.id, { includePrivate: true })
+    : configuredDomain;
+  return { configured: true, domain };
 }
 
 function extractHeader(rawMessage, name) {

+ 42 - 0
test/cert-sync-script.test.js

@@ -82,6 +82,48 @@ test('atomically synchronizes a valid wildcard certificate and preserves safe pe
   assert.deepEqual(stagingFiles(fixture.certsDir), []);
 });
 
+test('preserves complete certificate chains by default', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  const extraOne = path.join(fixture.root, 'extra-one');
+  const extraTwo = path.join(fixture.root, 'extra-two');
+  mkdirSync(extraOne, { recursive: true });
+  mkdirSync(extraTwo, { recursive: true });
+  generateCertificate(fixture.sourceDir, 'example.test');
+  generateCertificate(extraOne, 'intermediate.example.test');
+  generateCertificate(extraTwo, 'root.example.test');
+
+  const leaf = readFileSync(fixture.sourceCert);
+  const intermediate = readFileSync(path.join(extraOne, 'fullchain.pem'));
+  const root = readFileSync(path.join(extraTwo, 'fullchain.pem'));
+  writeFileSync(fixture.sourceCert, Buffer.concat([leaf, intermediate, root]));
+
+  const result = runSync(fixture);
+  assert.equal(result.status, 0, result.stderr);
+  assert.deepEqual(readFileSync(fixture.targetCert), Buffer.concat([leaf, intermediate, root]));
+  assert.deepEqual(readFileSync(fixture.targetKey), readFileSync(fixture.sourceKey));
+});
+
+test('can compact oversized certificate chains when explicitly enabled', { skip: !canRun }, (t) => {
+  const fixture = createFixture(t);
+  const extraOne = path.join(fixture.root, 'extra-one');
+  const extraTwo = path.join(fixture.root, 'extra-two');
+  mkdirSync(extraOne, { recursive: true });
+  mkdirSync(extraTwo, { recursive: true });
+  generateCertificate(fixture.sourceDir, 'example.test');
+  generateCertificate(extraOne, 'intermediate.example.test');
+  generateCertificate(extraTwo, 'root.example.test');
+
+  const leaf = readFileSync(fixture.sourceCert);
+  const intermediate = readFileSync(path.join(extraOne, 'fullchain.pem'));
+  const root = readFileSync(path.join(extraTwo, 'fullchain.pem'));
+  writeFileSync(fixture.sourceCert, Buffer.concat([leaf, intermediate, root]));
+
+  const result = runSync(fixture, { env: { MAILHUB_CERT_MAX_CHAIN_CERTS: '2' } });
+  assert.equal(result.status, 0, result.stderr);
+  assert.deepEqual(readFileSync(fixture.targetCert), Buffer.concat([leaf, intermediate]));
+  assert.deepEqual(readFileSync(fixture.targetKey), readFileSync(fixture.sourceKey));
+});
+
 test('is idempotent when the synchronized certificate is already current', { skip: !canRun }, (t) => {
   const fixture = createFixture(t);
   generateCertificate(fixture.sourceDir, 'example.test');

+ 110 - 2
test/inbound-db.test.js

@@ -9,14 +9,19 @@ import {
   createInboundMailbox,
   createInboundMessage,
   createUser,
+  deleteDomain,
   getInboundMailboxByAddress,
   getInboundMessage,
   initDatabase,
+  listDomains,
+  listInboundMailboxDomains,
   listInboundMailboxes,
   listInboundMessages,
   markInboundMessageRead,
   resolveInboundRecipient,
   updateDomain,
+  updateInboundMailbox,
+  upsertImportedInboundMailbox,
   verifySmtpCredential
 } from '../src/db.js';
 
@@ -130,11 +135,11 @@ test('domains can route unknown inbound recipients to catch-all targets', () =>
   assert.equal(forwardRoute.mailbox, null);
 });
 
-test('inbound mailboxes must belong to a domain owned by the user', () => {
+test('domain owners can share mailbox creation without sharing domain management', () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-db-scope-')), 'inbound-secret');
   const owner = createUser({ username: 'owner-user', email: 'owner@example.com', password: 'password123' });
   const other = createUser({ username: 'other-user', email: 'other@example.com', password: 'password123' });
-  createDomain(owner.id, {
+  const domain = createDomain(owner.id, {
     domain: 'owned.example',
     selector: 'mh',
     verificationToken: 'verify',
@@ -151,4 +156,107 @@ test('inbound mailboxes must belong to a domain owned by the user', () => {
     () => createInboundMailbox(other.id, { address: 'support@owned.example' }),
     /收信域名不存在/
   );
+  assert.equal(listInboundMailboxDomains(other.id).some((item) => item.domain === 'owned.example'), false);
+
+  const shared = updateDomain(domain.id, owner.id, { mailboxSignupEnabled: true });
+  assert.equal(shared.mailboxSignupEnabled, true);
+  assert.equal(listDomains(other.id).some((item) => item.domain === 'owned.example'), false);
+
+  const sharedDomains = listInboundMailboxDomains(other.id);
+  assert.deepEqual(
+    sharedDomains.map((item) => item.domain),
+    ['owned.example']
+  );
+  assert.deepEqual(Object.keys(sharedDomains[0]).sort(), ['domain', 'id', 'mailboxSignupEnabled', 'userId']);
+
+  const mailbox = createInboundMailbox(other.id, {
+    address: 'support@owned.example',
+    password: 'mailbox-pass-123'
+  });
+  assert.equal(mailbox.userId, other.id);
+  assert.equal(mailbox.domainId, domain.id);
+  assert.equal(verifySmtpCredential('support@owned.example', 'mailbox-pass-123').user.id, other.id);
+
+  const message = createInboundMessage(mailbox, {
+    sender: 'sender@example.net',
+    recipients: ['support@owned.example'],
+    subject: 'Shared domain',
+    rawMessage: 'From: sender@example.net\r\nTo: support@owned.example\r\nSubject: Shared domain\r\n\r\nHello',
+    textBody: 'Hello'
+  });
+  assert.equal(listInboundMailboxes(other.id).length, 1);
+  assert.equal(listInboundMessages(other.id)[0].id, message.id);
+  assert.equal(listInboundMessages(owner.id).length, 0);
+
+  assert.throws(
+    () => deleteDomain(domain.id, owner.id),
+    /该域名仍有关联收信邮箱/
+  );
+  assert.equal(listDomains(owner.id)[0].id, domain.id);
+  assert.equal(getInboundMailboxByAddress(mailbox.address).id, mailbox.id);
+  assert.equal(getInboundMessage(other.id, message.id).id, message.id);
+});
+
+test('mailbox addresses and aliases share one namespace per domain', () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-inbound-namespace-')), 'inbound-secret');
+  const owner = createUser({ username: 'namespace-owner', email: 'namespace-owner@example.com', password: 'password123' });
+  const other = createUser({ username: 'namespace-user', email: 'namespace-user@example.com', password: 'password123' });
+  const domain = createDomain(owner.id, {
+    domain: 'namespace.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.namespace.example',
+    sendingIp: '192.0.2.12',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  updateDomain(domain.id, owner.id, { mailboxSignupEnabled: true });
+
+  const primary = createInboundMailbox(owner.id, {
+    address: 'primary@namespace.example',
+    aliases: ['billing', 'legacy']
+  });
+  assert.throws(
+    () => createInboundMailbox(other.id, { address: 'billing@namespace.example' }),
+    /billing@namespace\.example 已被其他邮箱占用/
+  );
+  assert.throws(
+    () => createInboundMailbox(other.id, {
+      address: 'second@namespace.example',
+      aliases: ['primary']
+    }),
+    /primary@namespace\.example 已被其他邮箱占用/
+  );
+
+  const second = createInboundMailbox(other.id, {
+    address: 'second@namespace.example',
+    aliases: ['support']
+  });
+  assert.deepEqual(updateInboundMailbox(other.id, second.id, { aliases: ['support'] }).aliases, ['support']);
+  assert.throws(
+    () => updateInboundMailbox(other.id, second.id, { aliases: ['legacy'] }),
+    /legacy@namespace\.example 已被其他邮箱占用/
+  );
+
+  assert.throws(
+    () => upsertImportedInboundMailbox(owner.id, { address: 'legacy@namespace.example' }),
+    /legacy@namespace\.example 已被其他邮箱占用/
+  );
+  const imported = upsertImportedInboundMailbox(owner.id, {
+    address: 'archive@namespace.example',
+    aliases: ['old-archive']
+  });
+  assert.throws(
+    () => upsertImportedInboundMailbox(owner.id, {
+      address: imported.address,
+      aliases: ['support']
+    }),
+    /support@namespace\.example 已被其他邮箱占用/
+  );
+
+  assert.equal(resolveInboundRecipient('billing@namespace.example').mailbox.id, primary.id);
+  assert.equal(resolveInboundRecipient('support@namespace.example').mailbox.id, second.id);
 });

+ 131 - 0
test/server-admin-api.test.js

@@ -225,6 +225,115 @@ test('users can manage inbound mailboxes and read inbound messages', async () =>
   }
 });
 
+test('shared domains are available for mailbox creation but not domain management', async () => {
+  const { child, baseUrl, dataDir, sessionSecret } = await startTestServer();
+
+  try {
+    const ownerCookie = await login(baseUrl, 'admin', 'password123');
+    const sharedDomain = await createSendingDomain(baseUrl, ownerCookie, { domain: 'shared-mailbox.example' });
+    const share = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: ownerCookie
+      },
+      body: JSON.stringify({ mailboxSignupEnabled: true })
+    });
+    assert.equal(share.status, 200);
+    assert.equal((await share.json()).domain.mailboxSignupEnabled, true);
+
+    seedUsers(dataDir, sessionSecret, [
+      { username: 'shared-user', email: 'shared-user@example.com', password: 'password123', status: 'active' }
+    ]);
+    const userCookie = await login(baseUrl, 'shared-user', 'password123');
+
+    const manageableDomains = await fetch(`${baseUrl}/api/domains`, { headers: { Cookie: userCookie } });
+    assert.equal(manageableDomains.status, 200);
+    assert.deepEqual((await manageableDomains.json()).domains, []);
+
+    const mailboxDomains = await fetch(`${baseUrl}/api/inbound-mailbox-domains`, { headers: { Cookie: userCookie } });
+    assert.equal(mailboxDomains.status, 200);
+    const mailboxDomainRows = (await mailboxDomains.json()).domains;
+    assert.deepEqual(mailboxDomainRows.map((domain) => domain.domain), ['shared-mailbox.example']);
+    assert.equal('dkimPublic' in mailboxDomainRows[0], false);
+
+    const ownerMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: ownerCookie
+      },
+      body: JSON.stringify({
+        address: 'owner@shared-mailbox.example',
+        password: 'mailbox-pass-123',
+        aliases: ['claimed']
+      })
+    });
+    assert.equal(ownerMailbox.status, 201);
+
+    const addressConflict = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: userCookie
+      },
+      body: JSON.stringify({
+        address: 'claimed@shared-mailbox.example',
+        password: 'mailbox-pass-123'
+      })
+    });
+    assert.equal(addressConflict.status, 409);
+    assert.match((await addressConflict.json()).error, /claimed@shared-mailbox\.example 已被其他邮箱占用/);
+
+    const createMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: userCookie
+      },
+      body: JSON.stringify({
+        address: 'user@shared-mailbox.example',
+        password: 'mailbox-pass-123',
+        aliases: ['user-alias']
+      })
+    });
+    assert.equal(createMailbox.status, 201);
+    const createdMailbox = (await createMailbox.json()).mailbox;
+    assert.equal(createdMailbox.address, 'user@shared-mailbox.example');
+
+    const aliasConflict = await fetch(`${baseUrl}/api/inbound-mailboxes/${createdMailbox.id}`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: userCookie
+      },
+      body: JSON.stringify({ aliases: ['owner'] })
+    });
+    assert.equal(aliasConflict.status, 409);
+    assert.match((await aliasConflict.json()).error, /owner@shared-mailbox\.example 已被其他邮箱占用/);
+
+    const blockedDelete = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, {
+      method: 'DELETE',
+      headers: { Cookie: ownerCookie }
+    });
+    assert.equal(blockedDelete.status, 409);
+    assert.match((await blockedDelete.json()).error, /仍有关联收信邮箱/);
+
+    const blockedPatch = await fetch(`${baseUrl}/api/domains/${sharedDomain.id}`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: userCookie
+      },
+      body: JSON.stringify({ catchAllAddress: 'user@shared-mailbox.example' })
+    });
+    assert.equal(blockedPatch.status, 404);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
 test('scoped API tokens create persistent and temporary mailboxes', async () => {
   const { child, baseUrl } = await startTestServer();
 
@@ -1330,6 +1439,28 @@ test('email verification route consumes token and moves user to admin review', a
     assert.equal(invalid.status, 400);
     assert.equal(sessionCookieFrom(invalid), '');
 
+    const htmlInvalid = await fetch(`${baseUrl}/api/auth/verify-email?token=not-a-real-token`, {
+      redirect: 'manual',
+      headers: { Accept: 'text/html,application/xhtml+xml' }
+    });
+    assert.equal(htmlInvalid.status, 303);
+    assert.match(htmlInvalid.headers.get('location') || '', /^\/login\?error=/);
+    assert.equal(sessionCookieFrom(htmlInvalid), '');
+
+    const browserCreated = createPendingEmailUserWithVerificationToken(dataDir, sessionSecret, {
+      username: 'verifybrowser',
+      email: 'verifybrowser@example.com',
+      password: 'password123',
+      status: 'pending_email'
+    });
+    const browserResponse = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(browserCreated.token)}`, {
+      redirect: 'manual',
+      headers: { Accept: 'text/html,application/xhtml+xml' }
+    });
+    assert.equal(browserResponse.status, 303);
+    assert.match(browserResponse.headers.get('location') || '', /^\/login\?message=/);
+    assert.equal(sessionCookieFrom(browserResponse), '');
+
     const response = await fetch(`${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(created.token)}`);
     assert.equal(response.status, 200);
     assert.equal(sessionCookieFrom(response), '');

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

@@ -13,8 +13,10 @@ import {
   initDatabase,
   listInboundMessages,
   listWebhookDeliveries,
+  saveSmtpCredential,
   updateDomain
 } from '../src/db.js';
+import { createDkimKeyPair } from '../src/dkim.js';
 import { sendViaSmtp } from '../src/mailer.js';
 import { readMaildirMessage, scanMaildirMailbox } from '../src/maildir-store.js';
 import { startSubmissionServer } from '../src/submission.js';
@@ -271,6 +273,189 @@ test('SMTP authenticates with a mailbox account address and password', async ()
   }
 });
 
+test('SMTP relays only the authenticated mailbox and aliases from a shared domain', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-shared-domain-')), 'inbound-secret');
+  const owner = createUser({ username: 'shared-domain-owner', email: 'owner@example.com', password: 'password123' });
+  const member = createUser({ username: 'shared-domain-member', email: 'member@example.com', password: 'password123' });
+  saveSmtpCredential(member.id, { username: 'shared-domain-member-smtp', password: 'member-smtp-pass' });
+  const keys = createDkimKeyPair();
+  const domain = createDomain(owner.id, {
+    domain: 'shared-authmail.example',
+    selector: 'mh',
+    verificationToken: 'verify-shared',
+    dkimPublic: keys.publicKey,
+    dkimPrivate: keys.privateKey,
+    senderHost: 'mail.shared-authmail.example',
+    sendingIp: '192.0.2.18',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const memberDomainKeys = createDkimKeyPair();
+  createDomain(member.id, {
+    domain: 'member-owned.example',
+    selector: 'mh',
+    verificationToken: 'verify-member-owned',
+    dkimPublic: memberDomainKeys.publicKey,
+    dkimPrivate: memberDomainKeys.privateKey,
+    senderHost: 'mail.member-owned.example',
+    sendingIp: '192.0.2.19',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  createInboundMailbox(owner.id, {
+    address: 'owner@shared-authmail.example',
+    password: 'owner-mailbox-pass'
+  });
+  updateDomain(domain.id, owner.id, { mailboxSignupEnabled: true });
+  createInboundMailbox(member.id, {
+    address: 'member@shared-authmail.example',
+    password: 'member-mailbox-pass',
+    aliases: ['member-alias']
+  });
+
+  const relay = await startFakeSmtpServer();
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.shared-authmail.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true,
+    relayHost: '127.0.0.1',
+    relayPort: relay.port,
+    relaySecure: false,
+    relayUsername: '',
+    relayPassword: '',
+    relayHelo: 'mail.shared-authmail.example'
+  });
+  await waitForListening(server);
+
+  try {
+    for (const sender of ['member@shared-authmail.example', 'member-alias@shared-authmail.example']) {
+      const response = await sendViaSmtp({
+        host: '127.0.0.1',
+        port: server.address().port,
+        secure: false,
+        username: 'member@shared-authmail.example',
+        password: 'member-mailbox-pass',
+        helo: 'client.example.net',
+        mailFrom: sender,
+        recipients: ['recipient@example.net'],
+        rawMessage: [
+          `From: ${sender}`,
+          'To: recipient@example.net',
+          'Subject: Shared mailbox submission',
+          '',
+          'Shared mailbox body'
+        ].join('\r\n')
+      });
+      assert.match(response.message, /Message queued/i);
+    }
+
+    assert.equal(relay.messages.length, 2);
+    assert.ok(relay.messages.every((message) => /^DKIM-Signature:/m.test(message)));
+    assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<member@shared-authmail.example>'));
+    assert.ok(relay.commands.some((command) => command === 'MAIL FROM:<member-alias@shared-authmail.example>'));
+
+    const auth = Buffer.from('\u0000member@shared-authmail.example\u0000member-mailbox-pass').toString('base64');
+    const otherMailboxSender = await smtpTranscript(server.address().port, [
+      'EHLO client.example.net',
+      `AUTH PLAIN ${auth}`,
+      'MAIL FROM:<owner@shared-authmail.example>'
+    ]);
+    assert.match(otherMailboxSender.at(-1), /^553 /);
+
+    const otherMailboxHeader = await smtpTranscript(server.address().port, [
+      'EHLO client.example.net',
+      `AUTH PLAIN ${auth}`,
+      'MAIL FROM:<member@shared-authmail.example>',
+      'RCPT TO:<recipient@example.net>',
+      'DATA',
+      [
+        'From: owner@shared-authmail.example',
+        'To: recipient@example.net',
+        'Subject: Block shared-domain impersonation',
+        '',
+        'This message must not be relayed.',
+        '.'
+      ].join('\r\n')
+    ]);
+    assert.match(otherMailboxHeader.at(-1), /^553 /);
+    assert.equal(relay.messages.length, 2);
+
+    for (const sender of ['member@shared-authmail.example', 'member-alias@shared-authmail.example']) {
+      const response = await sendViaSmtp({
+        host: '127.0.0.1',
+        port: server.address().port,
+        secure: false,
+        username: 'shared-domain-member-smtp',
+        password: 'member-smtp-pass',
+        helo: 'client.example.net',
+        mailFrom: sender,
+        recipients: ['recipient@example.net'],
+        rawMessage: [
+          `From: ${sender}`,
+          'To: recipient@example.net',
+          'Subject: Shared mailbox account credential submission',
+          '',
+          'Shared mailbox body'
+        ].join('\r\n')
+      });
+      assert.match(response.message, /Message queued/i);
+    }
+
+    const ownDomainResponse = await sendViaSmtp({
+      host: '127.0.0.1',
+      port: server.address().port,
+      secure: false,
+      username: 'shared-domain-member-smtp',
+      password: 'member-smtp-pass',
+      helo: 'client.example.net',
+      mailFrom: 'any-local-part@member-owned.example',
+      recipients: ['recipient@example.net'],
+      rawMessage: [
+        'From: any-local-part@member-owned.example',
+        'To: recipient@example.net',
+        'Subject: Owned domain account credential submission',
+        '',
+        'Owned domain body'
+      ].join('\r\n')
+    });
+    assert.match(ownDomainResponse.message, /Message queued/i);
+    assert.equal(relay.messages.length, 5);
+
+    const accountAuth = Buffer.from('\u0000shared-domain-member-smtp\u0000member-smtp-pass').toString('base64');
+    const accountOtherMailboxSender = await smtpTranscript(server.address().port, [
+      'EHLO client.example.net',
+      `AUTH PLAIN ${accountAuth}`,
+      'MAIL FROM:<owner@shared-authmail.example>'
+    ]);
+    assert.match(accountOtherMailboxSender.at(-1), /^553 /);
+
+    const accountOtherMailboxHeader = await smtpTranscript(server.address().port, [
+      'EHLO client.example.net',
+      `AUTH PLAIN ${accountAuth}`,
+      'MAIL FROM:<member@shared-authmail.example>',
+      'RCPT TO:<recipient@example.net>',
+      'DATA',
+      [
+        'From: owner@shared-authmail.example',
+        'To: recipient@example.net',
+        'Subject: Block account shared-domain impersonation',
+        '',
+        'This message must not be relayed.',
+        '.'
+      ].join('\r\n')
+    ]);
+    assert.match(accountOtherMailboxHeader.at(-1), /^553 /);
+    assert.equal(relay.messages.length, 5);
+  } finally {
+    await closeServer(server);
+    await relay.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' });

+ 33 - 1
test/ui/inbox-navigation.test.tsx

@@ -257,6 +257,24 @@ describe('Inbox request and mailbox behavior', () => {
       status: 'active'
     })));
   });
+
+  it('groups owned and shared domains when creating a mailbox', async () => {
+    const user = userEvent.setup();
+    const ownedDomain = domainFixture(1);
+    const sharedDomain = { ...domainFixture(2), userId: 2, mailboxSignupEnabled: true };
+    mockInboxApis([], [], [ownedDomain], [ownedDomain, sharedDomain]);
+    const router = createInboxRouter(['/inbox?workspace=routing'], 0);
+    renderRouter(router);
+
+    const createButtons = await screen.findAllByRole('button', { name: /新增收信邮箱/ });
+    await user.click(createButtons[0]);
+    await user.click(screen.getByRole('combobox', { name: '域名' }));
+
+    expect((await screen.findAllByText('我的域名')).length).toBeGreaterThan(0);
+    expect(screen.getAllByText('共享域名').length).toBeGreaterThan(0);
+    expect(screen.getAllByText(`@${ownedDomain.domain}`).length).toBeGreaterThan(0);
+    expect(screen.getAllByText(`@${sharedDomain.domain}`).length).toBeGreaterThan(0);
+  });
 });
 
 function mediaQueryList(matches: boolean, media: string): MediaQueryList {
@@ -294,8 +312,21 @@ function renderRouter(router: ReturnType<typeof createInboxRouter>) {
   );
 }
 
-function mockInboxApis(mailboxes: InboundMailbox[], messages: InboundMessage[], domains: Domain[] = []) {
+function mockInboxApis(
+  mailboxes: InboundMailbox[],
+  messages: InboundMessage[],
+  domains: Domain[] = [],
+  mailboxDomains: Domain[] = domains
+) {
   vi.spyOn(api, 'domains').mockResolvedValue({ domains });
+  vi.spyOn(api, 'inboundMailboxDomains').mockResolvedValue({
+    domains: mailboxDomains.map(({ id, userId, domain, mailboxSignupEnabled }) => ({
+      id,
+      userId,
+      domain,
+      mailboxSignupEnabled
+    }))
+  });
   vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes });
   vi.spyOn(api, 'inboundFolders').mockImplementation(async (mailboxId) => ({
     folders: [
@@ -323,6 +354,7 @@ function domainFixture(id: number): Domain {
     dmarcPolicy: 'none',
     dmarcRua: '',
     catchAllAddress: '',
+    mailboxSignupEnabled: false,
     status: {},
     createdAt: '2026-07-14T00:00:00.000Z',
     updatedAt: '2026-07-14T00:00:00.000Z'

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません