Pārlūkot izejas kodu

feat: add mailbox access and deletion workflows

AI-Co-Authored-By: Codex
chendeben 1 mēnesi atpakaļ
vecāks
revīzija
8e3dffe09f
64 mainītis faili ar 5736 papildinājumiem un 102 dzēšanām
  1. 0 0
      public/assets/Account-qElCm5AV.js
  2. 0 0
      public/assets/Admin-C0WP3L38.js
  3. 0 0
      public/assets/Admin-Dps5Iat-.js
  4. 0 0
      public/assets/ApiTokens-DWHLfndz.js
  5. 1 1
      public/assets/CodeBlock-ZqtC96TH.js
  6. 1 1
      public/assets/CopyOutlined-CqLRh6yQ.js
  7. 0 1
      public/assets/Dashboard-DoBahBs0.js
  8. 2 2
      public/assets/DashboardCharts-CkVgd978.js
  9. 0 0
      public/assets/DnsApi-bpv6jW2R.js
  10. 0 0
      public/assets/DomainDetail-B18RnkL2.js
  11. 0 0
      public/assets/Domains-wovk1poc.js
  12. 1 1
      public/assets/EditOutlined-BiwqHVQr.js
  13. 1 1
      public/assets/EllipsisOutlined-BPVXOKZP.js
  14. 1 1
      public/assets/ExportOutlined-659DgC0r.js
  15. 0 0
      public/assets/Inbox-DNarV4v3.js
  16. 0 0
      public/assets/Inbox-Dm_szY_4.js
  17. 1 1
      public/assets/MailboxPermissionTags-BAjhCwxd.js
  18. 1 1
      public/assets/PlusOutlined-YlpF_uyR.js
  19. 1 1
      public/assets/ReloadOutlined-B6H5UA27.js
  20. 1 1
      public/assets/SearchOutlined-DQht26Mm.js
  21. 0 0
      public/assets/SectionCard-BcCoMjfI.js
  22. 0 0
      public/assets/SendingLogs-PglUoX5O.js
  23. 0 0
      public/assets/Settings-C1h8YH0o.js
  24. 0 0
      public/assets/SmtpCredentials-D_iXqU85.js
  25. 1 1
      public/assets/StatusTag-BHQIjmW3.js
  26. 1 1
      public/assets/ThunderboltOutlined-CvkjA__o.js
  27. 0 0
      public/assets/Webhooks-CvbzsGE7.js
  28. 1 1
      public/assets/form-lkDIWt8f.js
  29. 1 1
      public/assets/grid-oQFtU3R0.js
  30. 0 1
      public/assets/index-BFO2uHVj.js
  31. 0 0
      public/assets/list-B8Rkonuq.js
  32. 0 0
      public/assets/login-BI7wFKuP.js
  33. 1 1
      public/assets/modal-DFePRHO7.js
  34. 0 0
      public/assets/popconfirm-BftuZgmt.js
  35. 1 0
      public/assets/row-BB_IAUfF.js
  36. 0 1
      public/assets/row-CUs1UAJQ.js
  37. 0 0
      public/assets/table-Elp-Gtxh.js
  38. 0 0
      public/assets/theme-Cp7BBVEE.css
  39. 0 0
      public/assets/theme-JEAJA3Ef.js
  40. 3 3
      public/index.html
  41. 5 5
      public/login.html
  42. 1021 13
      src/db.js
  43. 37 6
      src/dovecot-auth-server.js
  44. 96 0
      src/frontend/i18n/index.js
  45. 31 0
      src/frontend/services/api.ts
  46. 104 0
      src/frontend/styles.css
  47. 6 0
      src/frontend/types.ts
  48. 285 0
      src/inbound-mailbox-delete.js
  49. 45 3
      src/mail-access.js
  50. 444 17
      src/maildir-store.js
  51. 85 4
      src/maildir-sync.js
  52. 430 6
      src/pages/Admin/MailboxAccess.tsx
  53. 177 1
      src/pages/Inbox.tsx
  54. 506 17
      src/server.js
  55. 61 3
      src/submission.js
  56. 56 1
      test/dovecot-auth-server.test.js
  57. 1205 0
      test/inbound-mailbox-delete.test.js
  58. 58 1
      test/mail-access.test.js
  59. 80 0
      test/maildir-sync.test.js
  60. 143 0
      test/server-admin-api.test.js
  61. 292 0
      test/server-mailbox-access.test.js
  62. 134 1
      test/submission-inbound.test.js
  63. 163 0
      test/ui/inbox-navigation.test.tsx
  64. 252 2
      test/ui/mailbox-access.test.tsx

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/Account-qElCm5AV.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/Admin-C0WP3L38.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/Admin-Dps5Iat-.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/ApiTokens-DWHLfndz.js


+ 1 - 1
public/assets/CodeBlock-z4Jy8-Be.js → public/assets/CodeBlock-ZqtC96TH.js

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

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

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 1
public/assets/Dashboard-DoBahBs0.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 2 - 2
public/assets/DashboardCharts-CkVgd978.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/DnsApi-bpv6jW2R.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/DomainDetail-B18RnkL2.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/Domains-wovk1poc.js


+ 1 - 1
public/assets/EditOutlined-Xr2lIYb3.js → public/assets/EditOutlined-BiwqHVQr.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{d as r}from"./theme-Dpxy803y.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{d as r}from"./theme-JEAJA3Ef.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-Dtb0gNnp.js → public/assets/EllipsisOutlined-BPVXOKZP.js

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

+ 1 - 1
public/assets/ExportOutlined-CUq8majW.js → public/assets/ExportOutlined-659DgC0r.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{d as r}from"./theme-Dpxy803y.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{"fill-rule":`evenodd`,viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M880 912H144c-17.7 0-32-14.3-32-32V144c0-17.7 14.3-32 32-32h360c4.4 0 8 3.6 8 8v56c0 4.4-3.6 8-8 8H184v656h656V520c0-4.4 3.6-8 8-8h56c4.4 0 8 3.6 8 8v360c0 17.7-14.3 32-32 32zM770.87 199.13l-52.2-52.2a8.01 8.01 0 014.7-13.6l179.4-21c5.1-.6 9.5 3.7 8.9 8.9l-21 179.4c-.8 6.6-8.9 9.4-13.6 4.7l-52.4-52.4-256.2 256.2a8.03 8.03 0 01-11.3 0l-42.4-42.4a8.03 8.03 0 010-11.3l256.1-256.3z`}}]},name:`export`,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{d as r}from"./theme-JEAJA3Ef.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{"fill-rule":`evenodd`,viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M880 912H144c-17.7 0-32-14.3-32-32V144c0-17.7 14.3-32 32-32h360c4.4 0 8 3.6 8 8v56c0 4.4-3.6 8-8 8H184v656h656V520c0-4.4 3.6-8 8-8h56c4.4 0 8 3.6 8 8v360c0 17.7-14.3 32-32 32zM770.87 199.13l-52.2-52.2a8.01 8.01 0 014.7-13.6l179.4-21c5.1-.6 9.5 3.7 8.9 8.9l-21 179.4c-.8 6.6-8.9 9.4-13.6 4.7l-52.4-52.4-256.2 256.2a8.03 8.03 0 01-11.3 0l-42.4-42.4a8.03 8.03 0 010-11.3l256.1-256.3z`}}]},name:`export`,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};

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/Inbox-DNarV4v3.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/Inbox-Dm_szY_4.js


+ 1 - 1
public/assets/MailboxPermissionTags-W1srDjlA.js → public/assets/MailboxPermissionTags-BAjhCwxd.js

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

+ 1 - 1
public/assets/PlusOutlined-DoGMjNQS.js → public/assets/PlusOutlined-YlpF_uyR.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{d as r}from"./theme-Dpxy803y.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{d as r}from"./theme-JEAJA3Ef.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-9AZGcoed.js → public/assets/ReloadOutlined-B6H5UA27.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{d as r}from"./theme-Dpxy803y.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{d as r}from"./theme-JEAJA3Ef.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-CGjQXOn5.js → public/assets/SearchOutlined-DQht26Mm.js

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

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/SectionCard-BcCoMjfI.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/SendingLogs-PglUoX5O.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/Settings-C1h8YH0o.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/SmtpCredentials-D_iXqU85.js


+ 1 - 1
public/assets/StatusTag-BJWRAXJ8.js → public/assets/StatusTag-BHQIjmW3.js

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

+ 1 - 1
public/assets/ThunderboltOutlined-CLLfW6Eb.js → public/assets/ThunderboltOutlined-CvkjA__o.js

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

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/Webhooks-CvbzsGE7.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1 - 1
public/assets/form-lkDIWt8f.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1 - 1
public/assets/grid-oQFtU3R0.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 1
public/assets/index-BFO2uHVj.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/list-B8Rkonuq.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/login-BI7wFKuP.js


+ 1 - 1
public/assets/modal-BGjs3E6e.js → public/assets/modal-DFePRHO7.js

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

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/popconfirm-BftuZgmt.js


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

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

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

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

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/table-Elp-Gtxh.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/theme-Cp7BBVEE.css


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
public/assets/theme-JEAJA3Ef.js


+ 3 - 3
public/index.html

@@ -4,12 +4,12 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-DG1WRIOp.js"></script>
+    <script type="module" crossorigin src="/assets/index-BFO2uHVj.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-Dpxy803y.js">
-    <link rel="stylesheet" crossorigin href="/assets/theme-QBN1gbX1.css">
+    <link rel="modulepreload" crossorigin href="/assets/theme-JEAJA3Ef.js">
+    <link rel="stylesheet" crossorigin href="/assets/theme-Cp7BBVEE.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>
   <body>

+ 5 - 5
public/login.html

@@ -4,17 +4,17 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub Auth</title>
-    <script type="module" crossorigin src="/assets/login-CI8wBEa5.js"></script>
+    <script type="module" crossorigin src="/assets/login-BI7wFKuP.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-Dpxy803y.js">
-    <link rel="modulepreload" crossorigin href="/assets/grid-DzYWI3CD.js">
+    <link rel="modulepreload" crossorigin href="/assets/theme-JEAJA3Ef.js">
+    <link rel="modulepreload" crossorigin href="/assets/grid-oQFtU3R0.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-Dp5ppmPu.js">
+    <link rel="modulepreload" crossorigin href="/assets/form-lkDIWt8f.js">
     <link rel="modulepreload" crossorigin href="/assets/input-0_Xo22Fl.js">
-    <link rel="stylesheet" crossorigin href="/assets/theme-QBN1gbX1.css">
+    <link rel="stylesheet" crossorigin href="/assets/theme-Cp7BBVEE.css">
   </head>
   <body>
     <div id="auth-root"></div>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1021 - 13
src/db.js


+ 37 - 6
src/dovecot-auth-server.js

@@ -43,6 +43,7 @@ export function createDovecotAuthServer(options = {}) {
   server.requestTimeout = requestTimeoutMs;
   server.headersTimeout = requestTimeoutMs;
   server.keepAliveTimeout = 1_000;
+  server.invalidateAuthUser = (username) => Number(authCache?.invalidate?.(username) || 0);
   return server;
 }
 
@@ -119,18 +120,24 @@ async function verifyCachedCredential(request, context) {
 
   const cachedUser = context.authCache?.get(request.username, request.password);
   if (cachedUser) return { user: cachedUser };
-
-  return context.inFlightAuth.run(request.username, request.password, async () => {
+  const outcome = await context.inFlightAuth.run(request.username, request.password, async () => {
+    const cacheVersion = context.authCache?.version?.(request.username);
     const recheckedUser = context.authCache?.get(request.username, request.password);
-    if (recheckedUser) return { user: recheckedUser };
+    if (recheckedUser) return { identity: { user: recheckedUser }, cacheVersion };
 
     const authenticated = await context.verifyCredential(request.username, request.password);
     if (!authenticated) return null;
 
     const { user } = dovecotAuthIdentity(authenticated);
-    context.authCache?.set(request.username, request.password, user);
-    return { user };
+    context.authCache?.set(request.username, request.password, user, cacheVersion);
+    return { identity: { user }, cacheVersion };
   });
+  if (!outcome) return null;
+  if (
+    outcome.cacheVersion !== undefined
+    && outcome.cacheVersion !== context.authCache?.version?.(request.username)
+  ) return null;
+  return outcome.identity;
 }
 
 function dovecotAuthIdentity(authenticated, { webmailCredential = false } = {}) {
@@ -193,6 +200,7 @@ class SuccessfulAuthCache {
     this.secretDigest = Buffer.from(secretDigest);
     this.now = now;
     this.entries = new Map();
+    this.versions = new Map();
   }
 
   get(username, password) {
@@ -209,12 +217,14 @@ class SuccessfulAuthCache {
     return entry.user;
   }
 
-  set(username, password, user) {
+  set(username, password, user, expectedVersion = undefined) {
     if (!this.enabled()) return;
+    if (expectedVersion !== undefined && expectedVersion !== this.version(username)) return;
     const cleanUser = String(user || '').trim().toLowerCase();
     if (!cleanUser) return;
     const key = this.key(username, password);
     this.entries.set(key, {
+      username: normalizeAuthCacheUsername(username),
       user: cleanUser,
       expiresAt: this.now() + this.ttlMs
     });
@@ -229,6 +239,23 @@ class SuccessfulAuthCache {
     return this.ttlMs > 0 && this.maxEntries > 0;
   }
 
+  invalidate(username) {
+    const normalized = normalizeAuthCacheUsername(username);
+    if (!normalized) return 0;
+    this.versions.set(normalized, this.version(normalized) + 1);
+    let deleted = 0;
+    for (const [key, entry] of this.entries) {
+      if (entry.username !== normalized) continue;
+      this.entries.delete(key);
+      deleted += 1;
+    }
+    return deleted;
+  }
+
+  version(username) {
+    return this.versions.get(normalizeAuthCacheUsername(username)) || 0;
+  }
+
   key(username, password) {
     return crypto
       .createHmac('sha256', this.secretDigest)
@@ -239,6 +266,10 @@ class SuccessfulAuthCache {
   }
 }
 
+function normalizeAuthCacheUsername(value) {
+  return String(value || '').trim().toLowerCase();
+}
+
 class InFlightAuthChecks {
   constructor({
     maxEntries = defaultAuthCacheMaxEntries,

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

@@ -333,6 +333,21 @@ const messages = {
     'inbox.detailLoadFailed': '邮件详情加载失败。',
     'inbox.noReceivingAccess': '当前账号没有可收取邮件的邮箱权限。',
     'inbox.ownerManaged': '由邮箱所有者管理',
+    'inbox.deleteMailbox': '删除邮箱',
+    'inbox.deleteMailboxTitle': '迁移邮件并删除邮箱',
+    'inbox.deleteMailboxAction': '迁移邮件并删除',
+    'inbox.deleteMailboxWarning': '系统会先将源邮箱中的全部邮件迁移到目标邮箱,再停用并移除源邮箱账号。',
+    'inbox.deleteMailboxEmptyWarning': '此邮箱中没有邮件,可以直接停用并移除邮箱账号。',
+    'inbox.deleteMailboxEmptyAction': '删除邮箱',
+    'inbox.deleteMailboxSource': '待删除邮箱',
+    'inbox.deleteMailboxMessages': '待迁移邮件',
+    'inbox.deleteMailboxTarget': '目标邮箱',
+    'inbox.deleteMailboxTargetPlaceholder': '选择接收这些邮件的邮箱',
+    'inbox.deleteMailboxTargetRequired': '请选择目标邮箱',
+    'inbox.deleteMailboxNoTarget': '没有其他可管理的邮箱。请先创建一个目标邮箱,再删除当前邮箱。',
+    'inbox.deleteMailboxConfirmAddress': '输入完整邮箱地址确认',
+    'inbox.deleteMailboxConfirmAddressExtra': '为避免误删,请输入上方待删除邮箱的完整地址。',
+    'inbox.deleteMailboxConfirmAddressMismatch': '输入的邮箱地址与待删除邮箱不一致。',
     'account.title': '账号与邮箱权限',
     'account.subtitle': '查看账号信息,以及自己拥有或由管理员分配的邮箱权限。',
     'account.summary': '账号信息',
@@ -453,6 +468,39 @@ const messages = {
     'adminMailboxAccess.duplicateUser': '同一用户不能重复分配。',
     'adminMailboxAccess.disabledUser': '已禁用',
     'adminMailboxAccess.saved': '邮箱权限已保存',
+    'adminMailboxAccess.selectVisible': '选择全部筛选结果',
+    'adminMailboxAccess.selectMailbox': '选择邮箱',
+    'adminMailboxAccess.selectedVisible': '当前筛选中',
+    'adminMailboxAccess.clearSelection': '清空选择',
+    'adminMailboxAccess.bulkConfigure': '批量授权',
+    'adminMailboxAccess.bulkTitle': '批量配置邮箱权限',
+    'adminMailboxAccess.bulkHint': '仅处理所选邮箱与用户组合;其他用户已有授权不受影响,邮箱所有者组合会自动跳过。',
+    'adminMailboxAccess.bulkUpsertHint': '会将所选邮箱与用户组合的现有权限替换为下方权限;其他用户授权不受影响,邮箱所有者组合会自动跳过。',
+    'adminMailboxAccess.selectedMailboxes': '已选择邮箱',
+    'adminMailboxAccess.selectedMailboxPreview': '所选邮箱预览',
+    'adminMailboxAccess.bulkOperation': '批量操作',
+    'adminMailboxAccess.bulkUpsert': '设置或替换权限',
+    'adminMailboxAccess.bulkRemove': '移除授权',
+    'adminMailboxAccess.bulkUsers': '授权用户',
+    'adminMailboxAccess.bulkUsersPlaceholder': '选择一个或多个用户',
+    'adminMailboxAccess.bulkSaveAction': '应用权限',
+    'adminMailboxAccess.bulkRemoveAction': '移除授权',
+    'adminMailboxAccess.bulkRemoveWarning': '将从所有已选邮箱移除这些用户的授权,其他用户的权限不受影响。',
+    'adminMailboxAccess.bulkRemoveDisabledHint': '移除授权时可选择已禁用用户;已禁用用户不会出现在新增授权操作中。',
+    'adminMailboxAccess.bulkRemoveConfirmTitle': '确认批量移除授权',
+    'adminMailboxAccess.bulkRemoveConfirmWarning': '确认后将移除这些邮箱与用户组合中现有的授权。该操作不会删除邮箱或邮件。',
+    'adminMailboxAccess.bulkOwnerSkipPreview': '预计跳过邮箱所有者组合',
+    'adminMailboxAccess.bulkMailboxLimit': '单次最多选择邮箱数:',
+    'adminMailboxAccess.bulkUserLimit': '单次最多选择用户数:',
+    'adminMailboxAccess.bulkPairLimit': '单次最多处理邮箱与用户组合数:',
+    'adminMailboxAccess.bulkMailboxUnit': '个邮箱',
+    'adminMailboxAccess.bulkUserUnit': '个用户',
+    'adminMailboxAccess.bulkPairUnit': '个组合',
+    'adminMailboxAccess.bulkSaved': '批量邮箱权限已更新',
+    'adminMailboxAccess.bulkRemoved': '批量邮箱授权已移除',
+    'adminMailboxAccess.bulkChanged': '已更改',
+    'adminMailboxAccess.bulkUnchanged': '未变化',
+    'adminMailboxAccess.bulkSkippedOwner': '跳过所有者',
     'metrics.accepted': '已接收',
     'metrics.failed': '失败',
     'metrics.recipients': '收件人',
@@ -1018,6 +1066,21 @@ const messages = {
     'inbox.detailLoadFailed': 'Failed to load message detail.',
     'inbox.noReceivingAccess': 'This account does not have permission to receive mail for any mailbox.',
     'inbox.ownerManaged': 'Managed by the mailbox owner',
+    'inbox.deleteMailbox': 'Delete mailbox',
+    'inbox.deleteMailboxTitle': 'Migrate mail and delete mailbox',
+    'inbox.deleteMailboxAction': 'Migrate mail and delete',
+    'inbox.deleteMailboxWarning': 'MailHub first migrates every message in the source mailbox to the target mailbox, then disables and removes the source mailbox account.',
+    'inbox.deleteMailboxEmptyWarning': 'This mailbox has no messages, so its account can be disabled and removed directly.',
+    'inbox.deleteMailboxEmptyAction': 'Delete mailbox',
+    'inbox.deleteMailboxSource': 'Mailbox to delete',
+    'inbox.deleteMailboxMessages': 'Messages to migrate',
+    'inbox.deleteMailboxTarget': 'Target mailbox',
+    'inbox.deleteMailboxTargetPlaceholder': 'Select the mailbox that will receive these messages',
+    'inbox.deleteMailboxTargetRequired': 'Select a target mailbox',
+    'inbox.deleteMailboxNoTarget': 'There is no other mailbox you can manage. Create a target mailbox before deleting this one.',
+    'inbox.deleteMailboxConfirmAddress': 'Enter the full mailbox address to confirm',
+    'inbox.deleteMailboxConfirmAddressExtra': 'To prevent accidental deletion, enter the full source mailbox address shown above.',
+    'inbox.deleteMailboxConfirmAddressMismatch': 'The entered address does not match the mailbox being deleted.',
     'account.title': 'Account & mailbox access',
     'account.subtitle': 'Review your account and mailboxes you own or an administrator assigned.',
     'account.summary': 'Account information',
@@ -1124,6 +1187,39 @@ const messages = {
     'adminMailboxAccess.duplicateUser': 'A user cannot be assigned more than once.',
     'adminMailboxAccess.disabledUser': 'Disabled',
     'adminMailboxAccess.saved': 'Mailbox access saved',
+    'adminMailboxAccess.selectVisible': 'Select all filtered results',
+    'adminMailboxAccess.selectMailbox': 'Select mailbox',
+    'adminMailboxAccess.selectedVisible': 'In current results',
+    'adminMailboxAccess.clearSelection': 'Clear selection',
+    'adminMailboxAccess.bulkConfigure': 'Bulk access',
+    'adminMailboxAccess.bulkTitle': 'Configure mailbox access in bulk',
+    'adminMailboxAccess.bulkHint': 'Only the selected mailbox and user combinations are processed. Other users\' grants remain unchanged, and mailbox-owner combinations are skipped.',
+    'adminMailboxAccess.bulkUpsertHint': 'Existing access for the selected mailbox and user combinations will be replaced with the permissions below. Other users\' grants remain unchanged, and mailbox-owner combinations are skipped.',
+    'adminMailboxAccess.selectedMailboxes': 'Selected mailboxes',
+    'adminMailboxAccess.selectedMailboxPreview': 'Selected mailbox preview',
+    'adminMailboxAccess.bulkOperation': 'Bulk operation',
+    'adminMailboxAccess.bulkUpsert': 'Set or replace access',
+    'adminMailboxAccess.bulkRemove': 'Remove access',
+    'adminMailboxAccess.bulkUsers': 'Authorized users',
+    'adminMailboxAccess.bulkUsersPlaceholder': 'Select one or more users',
+    'adminMailboxAccess.bulkSaveAction': 'Apply access',
+    'adminMailboxAccess.bulkRemoveAction': 'Remove access',
+    'adminMailboxAccess.bulkRemoveWarning': 'These users will be removed from every selected mailbox. Other users are not affected.',
+    'adminMailboxAccess.bulkRemoveDisabledHint': 'Disabled users can be selected when removing access, but never when granting access.',
+    'adminMailboxAccess.bulkRemoveConfirmTitle': 'Confirm bulk access removal',
+    'adminMailboxAccess.bulkRemoveConfirmWarning': 'This removes existing grants from the selected mailbox and user combinations. It does not delete mailboxes or messages.',
+    'adminMailboxAccess.bulkOwnerSkipPreview': 'Expected mailbox-owner combinations skipped',
+    'adminMailboxAccess.bulkMailboxLimit': 'Maximum mailboxes per operation:',
+    'adminMailboxAccess.bulkUserLimit': 'Maximum users per operation:',
+    'adminMailboxAccess.bulkPairLimit': 'Maximum mailbox and user combinations per operation:',
+    'adminMailboxAccess.bulkMailboxUnit': 'mailboxes',
+    'adminMailboxAccess.bulkUserUnit': 'users',
+    'adminMailboxAccess.bulkPairUnit': 'combinations',
+    'adminMailboxAccess.bulkSaved': 'Bulk mailbox access updated',
+    'adminMailboxAccess.bulkRemoved': 'Bulk mailbox access removed',
+    'adminMailboxAccess.bulkChanged': 'Changed',
+    'adminMailboxAccess.bulkUnchanged': 'Unchanged',
+    'adminMailboxAccess.bulkSkippedOwner': 'Owners skipped',
     'metrics.accepted': 'Accepted',
     'metrics.failed': 'Failed',
     'metrics.recipients': 'Recipients',

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

@@ -12,6 +12,7 @@ import type {
   DomainPatchPayload,
   InboundMailboxDomain,
   InboundMailbox,
+  InboundMailboxDeleteResult,
   InboundFolder,
   InboundMessage,
   InboundMessageFilters,
@@ -140,6 +141,16 @@ export const api = {
     method: 'PATCH',
     data
   }),
+  deleteInboundMailbox: async (
+    id: number,
+    data: { targetMailboxId: number | null; confirmAddress: string }
+  ) => {
+    const response = await request<InboundMailboxDeleteResult | { result: InboundMailboxDeleteResult }>(
+      `/api/inbound-mailboxes/${id}`,
+      { method: 'DELETE', data }
+    );
+    return 'result' in response ? response.result : response;
+  },
   inboundMessages: (filters: InboundMessageFilters | number | null = {}) => {
     const normalized = typeof filters === 'number'
       ? { mailboxId: filters }
@@ -240,6 +251,26 @@ export const api = {
     method: 'PUT',
     data: { grants }
   }),
+  bulkUpdateAdminMailboxAccess: (data: {
+    mailboxIds: number[];
+    userIds: number[];
+    operation: 'upsert' | 'remove';
+    permissions?: MailboxPermissions;
+  }) => request<{
+    mailboxes: AdminMailboxAccessEntry[];
+    summary: {
+      mailboxCount: number;
+      userCount: number;
+      pairCount?: number;
+      changedGrantCount: number;
+      unchangedGrantCount?: number;
+      skippedOwnerCount: number;
+      revokedWebmailSessionCount?: number;
+    };
+  }>('/api/admin/inbound-mailboxes/access/bulk', {
+    method: 'POST',
+    data
+  }),
   updateAdminUser: (id: number, data: { role?: UserRole; status?: UserStatus; password?: string }) =>
     request<{ user: AdminUser }>(`/api/admin/users/${id}`, { method: 'PATCH', data }),
   approveAdminUser: (id: number) =>

+ 104 - 0
src/frontend/styles.css

@@ -932,6 +932,79 @@ code {
   max-width: 560px;
 }
 
+.admin-mailbox-access-select-visible {
+  align-items: center;
+  min-height: 44px;
+}
+
+.admin-mailbox-access-selection-bar {
+  background: var(--mh-primary-soft);
+  border: 1px solid rgba(79, 70, 229, 0.18);
+  border-radius: var(--mh-radius-control);
+  display: grid;
+  gap: 4px;
+  padding: 8px 12px;
+}
+
+.admin-mailbox-access-selection-bar .ant-btn-link {
+  padding-inline: 4px;
+}
+
+.admin-mailbox-access-table .ant-table-selection-column .ant-checkbox-wrapper,
+.admin-mailbox-access-card .ant-card-head-title > .ant-checkbox-wrapper {
+  align-items: center;
+  display: inline-flex;
+  min-height: 44px;
+  min-width: 44px;
+}
+
+.admin-mailbox-access-table .ant-table-selection-column .ant-checkbox-wrapper {
+  justify-content: center;
+}
+
+.admin-mailbox-access-card .ant-card-head-title {
+  min-width: 0;
+}
+
+.admin-mailbox-access-card .ant-card-head-title > .ant-checkbox-wrapper {
+  max-width: 100%;
+}
+
+.admin-mailbox-access-card .ant-card-head-title .ant-checkbox + span {
+  overflow-wrap: anywhere;
+}
+
+.admin-mailbox-access-preview {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  max-width: 100%;
+}
+
+.admin-mailbox-access-preview .ant-tag {
+  margin-inline-end: 0;
+  max-width: 100%;
+  overflow-wrap: anywhere;
+  white-space: normal;
+}
+
+.admin-mailbox-access-operation {
+  display: inline-flex;
+  flex-wrap: wrap;
+}
+
+.admin-mailbox-access-operation .ant-radio-button-wrapper {
+  align-items: center;
+  display: inline-flex;
+  min-height: 44px;
+  white-space: normal;
+}
+
+.admin-mailbox-access-bulk-modal .ant-select-selector {
+  align-items: center;
+  min-height: 44px !important;
+}
+
 .admin-mailbox-access-card,
 .account-mailbox-card {
   min-width: 0;
@@ -1456,6 +1529,37 @@ code {
     width: 100%;
   }
 
+  .admin-mailbox-access-select-visible {
+    width: 100%;
+  }
+
+  .admin-mailbox-access-selection-bar .ant-space,
+  .admin-mailbox-access-selection-bar .ant-space-item {
+    width: 100%;
+  }
+
+  .admin-mailbox-access-selection-bar .ant-btn-link {
+    justify-content: flex-start;
+    padding-inline: 0;
+  }
+
+  .admin-mailbox-access-operation {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr);
+    width: 100%;
+  }
+
+  .admin-mailbox-access-operation .ant-radio-button-wrapper {
+    border-inline-start-width: 1px;
+    justify-content: center;
+    width: 100%;
+  }
+
+  .admin-mailbox-access-operation .ant-radio-button-wrapper + .ant-radio-button-wrapper {
+    border-radius: var(--mh-radius-control);
+    margin-top: 8px;
+  }
+
   .admin-mailbox-permissions {
     grid-template-columns: minmax(0, 1fr);
   }

+ 6 - 0
src/frontend/types.ts

@@ -361,6 +361,12 @@ export interface InboundMailbox {
   updatedAt: string;
 }
 
+export interface InboundMailboxDeleteResult {
+  deletedMailbox: Pick<InboundMailbox, 'id' | 'address'>;
+  targetMailbox: InboundMailbox | null;
+  migratedMessageCount: number;
+}
+
 export interface WebmailLogin {
   action: string;
   ticket: string;

+ 285 - 0
src/inbound-mailbox-delete.js

@@ -0,0 +1,285 @@
+import {
+  cleanupStagedMaildirMessages,
+  newMaildirStorageKey,
+  publishStagedMaildirMessages,
+  readMaildirMessage,
+  removeMaildirMessageFilesByStorageKeyPrefix,
+  restoreQuarantinedMaildirMailbox,
+  scanMaildirMailbox,
+  stageMaildirMessage
+} from './maildir-store.js';
+import {
+  completeInboundMailboxDeletionJob,
+  listPendingInboundMailboxDeletionJobs,
+  restorePreparedInboundMailboxDeletion,
+  updateInboundMailboxDeletionJob
+} from './db.js';
+import { acquireMaildirReconciliationLocks } from './maildir-sync.js';
+
+const storageKeyPattern = /^[a-z0-9][a-z0-9._-]{0,191}$/;
+
+export async function recoverPendingInboundMailboxDeletions({
+  root,
+  maildirEnabled: _maildirEnabled = true,
+  logger = console
+} = {}) {
+  const jobs = listPendingInboundMailboxDeletionJobs();
+  for (const job of jobs) {
+    const releaseLocks = await acquireMaildirReconciliationLocks([
+      job.sourceMailboxId,
+      job.targetMailboxId
+    ]);
+    let sourceRestored = job.phase === 'source_restored';
+    try {
+      const mayHaveRestoredSource = [
+        'restoring_source',
+        'source_restored'
+      ].includes(job.phase);
+      if (job.sourceDeletedAt || job.phase === 'db_committed') {
+        completeInboundMailboxDeletionJob(job.jobId, 'completed');
+        continue;
+      }
+      updateInboundMailboxDeletionJob(job.jobId, { phase: 'recovering', errorMessage: '' });
+      const hasRecordedMaildirWork = Boolean(job.quarantineRelpath)
+        || job.quarantineMoved !== null
+        || (Array.isArray(job.targetArtifacts) && job.targetArtifacts.length > 0);
+      if (hasRecordedMaildirWork) {
+        if (job.targetAddress) {
+          await removeMaildirMessageFilesByStorageKeyPrefix({
+            root,
+            address: job.targetAddress,
+            storageKeyPrefix: mailboxDeletionStorageKeyPrefix(job.jobId)
+          });
+        }
+        updateInboundMailboxDeletionJob(job.jobId, { phase: 'restoring_source' });
+        const recordedQuarantineMoved = job.phase === 'quarantine_planned'
+          && job.quarantineMoved === null
+          ? false
+          : job.quarantineMoved;
+        const restoredQuarantine = await restoreQuarantinedMaildirMailbox({
+          root,
+          quarantine: {
+            moved: recordedQuarantineMoved,
+            address: job.sourceAddress,
+            quarantineRelpath: job.quarantineRelpath || `.mailhub-quarantine/delete-${job.jobId}`,
+            allowAlreadyRestored: mayHaveRestoredSource,
+            allowNotYetQuarantined: job.phase === 'quarantining_source'
+          }
+        });
+        if (
+          job.quarantineMoved === true
+          && !restoredQuarantine
+          && job.phase !== 'quarantining_source'
+        ) {
+          throw new Error('Maildir 隔离目录未恢复,无法重新启用源邮箱。');
+        }
+        sourceRestored = true;
+        updateInboundMailboxDeletionJob(job.jobId, { phase: 'source_restored' });
+      }
+      const restored = restorePreparedInboundMailboxDeletion(
+        job.actorUserId,
+        job.sourceMailboxId,
+        job.originalStatus
+      );
+      if (!restored) throw new Error('邮箱删除任务无法恢复源邮箱状态。');
+      completeInboundMailboxDeletionJob(job.jobId, 'rolled_back');
+      logger.warn?.(`Recovered interrupted mailbox deletion job ${job.jobId}.`);
+    } catch (error) {
+      if (error?.maildirSourceRenamed === true) sourceRestored = true;
+      updateInboundMailboxDeletionJob(job.jobId, {
+        phase: sourceRestored
+          ? 'source_restored'
+          : (job.phase === 'restoring_source' ? 'restoring_source' : 'recovery_failed'),
+        errorMessage: error.message || 'recovery failed'
+      });
+      throw error;
+    } finally {
+      releaseLocks();
+    }
+  }
+  return jobs.length;
+}
+
+export async function stageInboundMailboxMaildirTransfer({
+  root,
+  sourceMailbox,
+  targetMailbox,
+  messages,
+  reservedStorageKeys = [],
+  sourceEntries = null,
+  jobId = ''
+}) {
+  if (!sourceMailbox?.address || !targetMailbox?.address) throw new Error('邮箱迁移信息不完整。');
+  if (sourceMailbox.address === targetMailbox.address) throw new Error('待删除邮箱和目标邮箱不能相同。');
+  if (!Array.isArray(messages)) throw new Error('邮件迁移数据不完整。');
+
+  const cleanJobId = String(jobId || '').trim().toLowerCase();
+  if (cleanJobId && !/^[a-f0-9]{32}$/.test(cleanJobId)) throw new Error('邮箱迁移任务标识不正确。');
+  const [scannedSourceEntries, targetEntries] = await Promise.all([
+    Array.isArray(sourceEntries)
+      ? Promise.resolve(sourceEntries)
+      : scanMaildirMailbox({ root, address: sourceMailbox.address }),
+    scanMaildirMailbox({ root, address: targetMailbox.address })
+  ]);
+  const sourceByRelpath = new Map(scannedSourceEntries.map((entry) => [entry.relpath, entry]));
+  const sourceByStorageKey = new Map(scannedSourceEntries.map((entry) => [entry.storageKey, entry]));
+  const reserved = new Set([
+    ...reservedStorageKeys,
+    ...targetEntries.map((entry) => entry.storageKey)
+  ].map((key) => String(key || '').trim().toLowerCase()).filter(Boolean));
+  const stagedMessages = [];
+  const storageUpdates = [];
+
+  try {
+    for (const message of messages) {
+      const sourceEntry = message.storage?.backend === 'maildir'
+        ? (
+            sourceByRelpath.get(String(message.storage.relpath || ''))
+            || sourceByStorageKey.get(String(message.storage.key || ''))
+          )
+        : null;
+      const rawMessageBytes = sourceEntry
+        ? (await readMaildirMessage(sourceEntry)).bytes
+        : mailboxTransferRawBytes(message);
+      const storageKey = cleanJobId
+        ? mailboxDeletionStorageKey(cleanJobId, message.id, reserved)
+        : normalizedAvailableStorageKey(message.storage?.key, reserved);
+      const staged = await stageMaildirMessage({
+        root,
+        address: targetMailbox.address,
+        rawMessageBytes,
+        folder: sourceEntry?.folder || message.folder,
+        flags: sourceEntry?.flags || message.flags,
+        keywords: sourceEntry?.keywords || message.keywords,
+        read: sourceEntry?.read ?? message.read,
+        receivedAt: sourceEntry?.receivedAt || message.receivedAt,
+        storageKey
+      });
+      reserved.add(staged.storage.key);
+      stagedMessages.push(staged);
+      storageUpdates.push({ messageId: Number(message.id), storage: staged.storage });
+    }
+  } catch (error) {
+    try {
+      await cleanupStagedMaildirMessages({
+        root,
+        address: targetMailbox.address,
+        stagedMessages
+      });
+    } catch (cleanupError) {
+      error.cleanupError = cleanupError;
+    }
+    throw error;
+  }
+
+  return { storageUpdates, stagedMessages };
+}
+
+export async function publishInboundMailboxMaildirTransfer({ root, targetMailbox, stagedMessages }) {
+  if (!targetMailbox?.address) return [];
+  return publishStagedMaildirMessages({
+    root,
+    address: targetMailbox.address,
+    stagedMessages
+  });
+}
+
+export async function rollbackInboundMailboxMaildirTransfer({
+  root,
+  targetMailbox,
+  stagedMessages,
+  published = false
+}) {
+  if (!targetMailbox?.address) return 0;
+  return cleanupStagedMaildirMessages({
+    root,
+    address: targetMailbox.address,
+    stagedMessages,
+    publishedMessages: published ? stagedMessages : []
+  });
+}
+
+export async function verifyPublishedInboundMailboxMaildirTransfer({
+  root,
+  targetMailbox,
+  storageUpdates,
+  jobId = ''
+}) {
+  const updates = Array.isArray(storageUpdates) ? storageUpdates : [];
+  if (!targetMailbox?.address || !updates.length) return updates;
+  const prefix = mailboxDeletionStorageKeyPrefix(jobId);
+  const entries = await scanMaildirMailbox({ root, address: targetMailbox.address });
+  const entriesByStorageKey = new Map();
+  for (const entry of entries) {
+    if (!entry.storageKey.startsWith(prefix)) continue;
+    const matches = entriesByStorageKey.get(entry.storageKey) || [];
+    matches.push(entry);
+    entriesByStorageKey.set(entry.storageKey, matches);
+  }
+  if (entriesByStorageKey.size !== updates.length) throw mailboxDeletionMessageConflict();
+
+  const verified = [];
+  for (const update of updates) {
+    const storageKey = String(update?.storage?.key || '');
+    const matches = entriesByStorageKey.get(storageKey) || [];
+    if (matches.length !== 1) throw mailboxDeletionMessageConflict();
+    const entry = matches[0];
+    const { bytes, sha256 } = await readMaildirMessage(entry);
+    if (update.storage.sha256 && update.storage.sha256 !== sha256) {
+      throw mailboxDeletionMessageConflict();
+    }
+    verified.push({
+      ...update,
+      storage: {
+        ...update.storage,
+        backend: 'maildir',
+        key: storageKey,
+        relpath: entry.relpath,
+        sha256,
+        size: bytes.length,
+        mtimeMs: entry.mtimeMs,
+        indexedAt: new Date().toISOString()
+      }
+    });
+  }
+  return verified;
+}
+
+function normalizedAvailableStorageKey(value, reserved) {
+  const key = String(value || '').trim().toLowerCase();
+  if (storageKeyPattern.test(key) && !reserved.has(key)) return key;
+  return nextStorageKey(reserved);
+}
+
+function nextStorageKey(reserved) {
+  let key = newMaildirStorageKey('append');
+  while (reserved.has(key)) key = newMaildirStorageKey('append');
+  return key;
+}
+
+function mailboxDeletionStorageKey(jobId, messageId, reserved) {
+  const id = Number(messageId);
+  if (!Number.isSafeInteger(id) || id <= 0) throw new Error('邮件迁移数据不完整。');
+  const key = `${mailboxDeletionStorageKeyPrefix(jobId)}${id}`;
+  if (reserved.has(key)) throw new Error(`Maildir 存储标识冲突:${key}`);
+  return key;
+}
+
+export function mailboxDeletionStorageKeyPrefix(jobId) {
+  const cleanJobId = String(jobId || '').trim().toLowerCase();
+  if (!/^[a-f0-9]{32}$/.test(cleanJobId)) throw new Error('邮箱迁移任务标识不正确。');
+  return `mhappend-${cleanJobId}-`;
+}
+
+function mailboxTransferRawBytes(message) {
+  if (message.rawMessageBytes === null || message.rawMessageBytes === undefined) {
+    throw new Error(`源 Maildir 邮件文件不存在:${Number(message.id) || 0}`);
+  }
+  return Buffer.from(message.rawMessageBytes);
+}
+
+function mailboxDeletionMessageConflict() {
+  const error = new Error('邮件迁移文件状态已发生变化,请刷新后重试。');
+  error.code = 'INBOUND_MAILBOX_DELETE_MESSAGE_CONFLICT';
+  return error;
+}

+ 45 - 3
src/mail-access.js

@@ -5,6 +5,7 @@ import {
   STANDARD_INBOUND_FOLDERS,
   createInboundFolder,
   createInboundMessage,
+  getInboundMailboxByAddress,
   getInboundMailboxProtocolMessage,
   inboundFolderExists,
   listInboundFolders,
@@ -277,6 +278,7 @@ class ImapSession {
   }
 
   createFolder(tag, rest) {
+    if (!this.mailboxIsCurrent()) return this.write(`${tag} NO Mailbox is no longer available`);
     const [mailboxName] = tokenizeImap(rest);
     const folder = normalizeImapFolder(mailboxName);
     if (!folder) return this.write(`${tag} BAD CREATE expects a mailbox name`);
@@ -285,6 +287,7 @@ class ImapSession {
   }
 
   append(tag, rest) {
+    if (!this.mailboxIsCurrent()) return this.write(`${tag} NO Mailbox is no longer available`);
     const literalMatch = String(rest || '').match(/\{(\d+)\+?\}\s*$/);
     if (!literalMatch) return this.write(`${tag} BAD APPEND expects a literal message`);
     const bytes = Number(literalMatch[1]);
@@ -304,6 +307,9 @@ class ImapSession {
 
   async finishAppend(pending, rawMessage) {
     const parsedMessage = await parseInboundMessage(rawMessage);
+    if (!this.mailboxIsCurrent()) {
+      return this.write(`${pending.tag} NO Mailbox is no longer available`);
+    }
     const normalizedRaw = normalizeRawMessage(parsedMessage);
     const message = createInboundMessage(this.mailbox, {
       ...parsedMessage,
@@ -311,7 +317,15 @@ class ImapSession {
       rawMessage: normalizedRaw,
       rawMessageBytes: rawMessage
     });
-    if (pending.flags.has('\\SEEN')) markInboundMessageRead(this.mailbox.userId, message.id, true);
+    if (pending.flags.has('\\SEEN')) {
+      markInboundMessageRead(
+        this.mailbox.userId,
+        message.id,
+        true,
+        {},
+        { mailboxId: this.mailbox.id }
+      );
+    }
     this.write(`${pending.tag} OK APPEND completed`);
   }
 
@@ -380,6 +394,7 @@ class ImapSession {
   }
 
   store(tag, rest, byUid) {
+    if (!this.mailboxIsCurrent()) return this.write(`${tag} NO Mailbox is no longer available`);
     const parsed = rest.match(/^(\S+)\s+(\S+)\s+(.+)$/);
     if (!parsed) return this.write(`${tag} BAD STORE expects sequence, item, and flags`);
     const [, set, itemRaw, flagsRaw] = parsed;
@@ -390,7 +405,13 @@ class ImapSession {
     for (const entry of entries) {
       if (flags.has('\\SEEN')) {
         const read = !item.startsWith('-FLAGS');
-        markInboundMessageRead(this.mailbox.userId, entry.message.id, read);
+        markInboundMessageRead(
+          this.mailbox.userId,
+          entry.message.id,
+          read,
+          {},
+          { mailboxId: this.mailbox.id }
+        );
         entry.message.read = read;
       }
       if (flags.has('\\DELETED')) {
@@ -403,6 +424,7 @@ class ImapSession {
   }
 
   expunge(tag) {
+    if (!this.mailboxIsCurrent()) return this.write(`${tag} NO Mailbox is no longer available`);
     const entries = this.messages
       .map((message, index) => ({ message, seq: index + 1 }))
       .filter((entry) => this.deletedUids.has(entry.message.id));
@@ -414,6 +436,12 @@ class ImapSession {
   }
 
   closeMailbox(tag) {
+    if (!this.mailboxIsCurrent()) {
+      this.deletedUids.clear();
+      this.selected = false;
+      this.messages = [];
+      return this.write(`${tag} NO Mailbox is no longer available`);
+    }
     const ids = [...this.deletedUids];
     if (ids.length) softDeleteInboundMessages(this.mailbox.userId, this.mailbox.id, ids, { folder: this.selectedFolder });
     this.deletedUids.clear();
@@ -431,6 +459,10 @@ class ImapSession {
     this.messages = mailboxProtocolMessages(this.mailbox, this.selectedFolder);
   }
 
+  mailboxIsCurrent() {
+    return isCurrentInboundMailbox(this.mailbox);
+  }
+
   upgradeToTls() {
     this.socket.removeListener('data', this.onDataBound);
     const secureSocket = new tls.TLSSocket(this.socket, {
@@ -627,7 +659,7 @@ class Pop3Session {
   }
 
   quit() {
-    if (this.authenticated && this.deletedIndexes.size) {
+    if (this.authenticated && this.deletedIndexes.size && isCurrentInboundMailbox(this.mailbox)) {
       const ids = [...this.deletedIndexes]
         .map((index) => this.messages[index - 1]?.id)
         .filter(Boolean);
@@ -672,6 +704,16 @@ class Pop3Session {
   }
 }
 
+function isCurrentInboundMailbox(mailbox) {
+  const current = getInboundMailboxByAddress(mailbox?.address);
+  return Boolean(
+    current
+    && current.id === Number(mailbox?.id)
+    && current.userId === Number(mailbox?.userId)
+    && current.domainId === Number(mailbox?.domainId)
+  );
+}
+
 function loadTlsMaterial(config) {
   if (!config.tlsKeyPath || !config.tlsCertPath) return null;
   try {

+ 444 - 17
src/maildir-store.js

@@ -1,6 +1,8 @@
 import crypto from 'node:crypto';
 import path from 'node:path';
 import {
+  chmod,
+  lstat,
   mkdir,
   link,
   open,
@@ -81,6 +83,34 @@ export async function writeMaildirMessage({
   receivedAt = new Date(),
   storageKey = '',
   durable = true
+}) {
+  const staged = await stageMaildirMessage({
+    root,
+    address,
+    rawMessageBytes,
+    folder,
+    flags,
+    keywords,
+    read,
+    receivedAt,
+    storageKey,
+    durable
+  });
+  await publishStagedMaildirMessages({ root, address, stagedMessages: [staged] });
+  return staged.storage;
+}
+
+export async function stageMaildirMessage({
+  root,
+  address,
+  rawMessageBytes,
+  folder = 'INBOX',
+  flags = [],
+  keywords = [],
+  read = undefined,
+  receivedAt = new Date(),
+  storageKey = '',
+  durable = true
 }) {
   const bytes = Buffer.from(rawMessageBytes || Buffer.alloc(0));
   const cleanFolder = normalizeFolder(folder);
@@ -101,35 +131,112 @@ export async function writeMaildirMessage({
   const destinationName = maildirFlags ? `${baseName}:2,${maildirFlags}` : baseName;
   const tmpPath = path.join(folderPath, 'tmp', `${baseName}.${crypto.randomBytes(6).toString('hex')}`);
   const destinationPath = path.join(folderPath, destinationBucket, destinationName);
-  const handle = await open(tmpPath, 'wx', 0o600);
+  let handle;
   try {
+    handle = await open(tmpPath, 'wx', 0o600);
     await handle.writeFile(bytes);
     if (durable) await handle.sync();
-  } finally {
     await handle.close();
+    handle = null;
+    await utimes(tmpPath, timestamp, timestamp);
+    if (durable) await syncDirectoryChain(path.dirname(tmpPath), path.resolve(root));
+  } catch (error) {
+    await handle?.close().catch(() => null);
+    await unlink(tmpPath).catch(() => null);
+    throw error;
   }
-  await utimes(tmpPath, timestamp, timestamp);
+  return {
+    storage: storageMetadata({
+      root,
+      address,
+      filePath: destinationPath,
+      storageKey: cleanStorageKey,
+      bytes,
+      timestamp
+    }),
+    stagingRelpath: relativeMaildirPath(root, address, tmpPath),
+    durable: Boolean(durable)
+  };
+}
+
+export async function publishStagedMaildirMessages({ root, address, stagedMessages = [] }) {
+  const staged = Array.isArray(stagedMessages) ? stagedMessages : [];
+  const published = [];
   try {
-    await link(tmpPath, destinationPath);
+    for (const message of staged) {
+      const temporaryPath = maildirPathFromRelpath(root, address, message.stagingRelpath);
+      const destinationPath = maildirPathFromRelpath(root, address, message.storage?.relpath);
+      try {
+        await link(temporaryPath, destinationPath);
+      } catch (error) {
+        if (error?.code === 'EEXIST') {
+          throw new Error(`Maildir 存储标识冲突:${message.storage?.key || ''}`);
+        }
+        throw error;
+      }
+      published.push(message);
+      await unlink(temporaryPath);
+    }
+    if (staged.some((message) => message.durable)) {
+      await flushMaildirFiles([], {
+        root,
+        directories: staged.flatMap((message) => [
+          path.dirname(maildirPathFromRelpath(root, address, message.stagingRelpath)),
+          path.dirname(maildirPathFromRelpath(root, address, message.storage?.relpath))
+        ])
+      });
+    }
+    return staged.map((message) => message.storage);
   } catch (error) {
-    await unlink(tmpPath).catch(() => null);
-    if (error?.code === 'EEXIST') throw new Error(`Maildir 存储标识冲突:${cleanStorageKey}`);
+    await cleanupStagedMaildirMessages({
+      root,
+      address,
+      stagedMessages: staged,
+      publishedMessages: published
+    }).catch((cleanupError) => {
+      error.cleanupError = cleanupError;
+    });
     throw error;
   }
-  await unlink(tmpPath);
-  if (durable) await syncDirectoryChain(path.dirname(destinationPath), path.resolve(root));
-  return storageMetadata({
+}
+
+export async function cleanupStagedMaildirMessages({
+  root,
+  address,
+  stagedMessages = [],
+  publishedMessages = []
+}) {
+  const temporary = (Array.isArray(stagedMessages) ? stagedMessages : [])
+    .map((message) => ({ relpath: message.stagingRelpath }));
+  const published = (Array.isArray(publishedMessages) ? publishedMessages : [])
+    .map((message) => message.storage);
+  return removeMaildirMessageFiles({
     root,
     address,
-    filePath: destinationPath,
-    storageKey: cleanStorageKey,
-    bytes,
-    timestamp
+    storages: [...temporary, ...published]
   });
 }
 
 export async function scanMaildirMailbox({ root, address }) {
-  const folderDirectories = await listMaildirFolderDirectories(root, address);
+  return scanMaildirHome(maildirHomePath(root, address));
+}
+
+export async function snapshotQuarantinedMaildirMailbox({ root, quarantine }) {
+  if (!quarantine?.moved) return { entries: [], folders: [] };
+  const home = quarantinedMaildirHomePath(root, quarantine);
+  const folderDirectories = await listMaildirFolderDirectoriesAtHome(home);
+  const entries = await scanMaildirHome(home, folderDirectories);
+  await mapWithConcurrency(entries, 16, async (entry) => {
+    entry.sha256 = (await readMaildirMessage(entry)).sha256;
+  });
+  return {
+    entries,
+    folders: folderDirectories.map((folder) => folder.folder)
+  };
+}
+
+async function scanMaildirHome(home, knownFolderDirectories = null) {
+  const folderDirectories = knownFolderDirectories || await listMaildirFolderDirectoriesAtHome(home);
   if (!folderDirectories.length) return [];
 
   const messages = [];
@@ -153,7 +260,7 @@ export async function scanMaildirMailbox({ root, address }) {
         return {
           storageKey: storageKeyFromFilename(baseName),
           filePath,
-          relpath: relativeMaildirPath(root, address, filePath),
+          relpath: relativePathFromMaildirHome(home, filePath),
           folder: folder.folder,
           flags,
           keywords,
@@ -249,6 +356,213 @@ export async function setMaildirMessageSeen({ root, address, storageKey, relpath
   });
 }
 
+export async function removeMaildirMessageFiles({ root, address, storages = [] }) {
+  const cleanRoot = path.resolve(String(root || ''));
+  const home = maildirHomePath(cleanRoot, address);
+  const files = [...new Set((Array.isArray(storages) ? storages : [])
+    .map((storage) => String(storage?.relpath || '').trim())
+    .filter(Boolean)
+    .map((relpath) => {
+      const filePath = path.resolve(home, ...relpath.split('/'));
+      if (!isPathInside(home, filePath)) throw new Error('Maildir 存储路径不正确。');
+      return filePath;
+    }))];
+  const directories = new Set();
+  for (const filePath of files) {
+    try {
+      await unlink(filePath);
+      directories.add(path.dirname(filePath));
+    } catch (error) {
+      if (error?.code !== 'ENOENT') throw error;
+    }
+  }
+  if (directories.size) {
+    await flushMaildirFiles([], { root: cleanRoot, directories: [...directories] });
+  }
+  return files.length;
+}
+
+export async function quarantineMaildirMailbox({
+  root,
+  address,
+  mailboxId,
+  jobId = '',
+  beforeQuarantine = null
+}) {
+  const cleanRoot = path.resolve(String(root || ''));
+  const sourcePath = maildirHomePath(cleanRoot, address);
+  const quarantineRoot = path.join(cleanRoot, '.mailhub-quarantine');
+  await mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
+  const id = Number(mailboxId);
+  const prefix = Number.isSafeInteger(id) && id > 0 ? `${id}-` : '';
+  const cleanJobId = String(jobId || '').trim().toLowerCase();
+  if (cleanJobId && !/^[a-f0-9]{32}$/.test(cleanJobId)) throw new Error('Maildir 隔离任务标识不正确。');
+  const quarantineName = cleanJobId
+    ? `delete-${cleanJobId}`
+    : `${prefix}${Date.now()}-${crypto.randomBytes(12).toString('hex')}`;
+  const quarantinePath = path.join(quarantineRoot, quarantineName);
+  const quarantineRelpath = path.relative(cleanRoot, quarantinePath).split(path.sep).join('/');
+  const source = await maildirPathKind(sourcePath);
+  if (source !== 'directory' && source !== 'missing') {
+    throw new Error('源 Maildir 路径不是可迁移的邮箱目录。');
+  }
+  const existingQuarantine = await maildirPathKind(quarantinePath);
+  if (existingQuarantine !== 'missing' && (existingQuarantine !== 'directory' || source !== 'missing')) {
+    throw new Error('Maildir 隔离路径状态冲突。');
+  }
+  const moved = existingQuarantine === 'directory' || source === 'directory';
+  if (beforeQuarantine !== null && typeof beforeQuarantine !== 'function') {
+    throw new Error('Maildir 隔离准备回调不正确。');
+  }
+  await beforeQuarantine?.({ moved, address, quarantineRelpath });
+  if (existingQuarantine === 'missing' && source === 'directory') {
+    try {
+      await rename(sourcePath, quarantinePath);
+    } catch (error) {
+      if (error?.code === 'ENOENT') {
+        throw new Error('源 Maildir 在隔离前已发生变化。', { cause: error });
+      }
+      throw error;
+    }
+  }
+  await createMailboxDeletionTombstone(sourcePath, quarantineRelpath);
+  if (moved) await setMaildirTreeWritable(quarantinePath, false);
+  await flushMaildirFiles([], {
+    root: cleanRoot,
+    directories: [cleanRoot, quarantineRoot, ...(moved ? [quarantinePath] : [])]
+  });
+  return {
+    moved,
+    address,
+    quarantineRelpath
+  };
+}
+
+export async function restoreQuarantinedMaildirMailbox({
+  root,
+  quarantine,
+  flush = flushMaildirFiles
+}) {
+  if (typeof flush !== 'function') throw new Error('Maildir 恢复刷盘函数不正确。');
+  const cleanRoot = path.resolve(String(root || ''));
+  const quarantineRoot = path.join(cleanRoot, '.mailhub-quarantine');
+  const quarantinePath = quarantinedMaildirHomePath(cleanRoot, quarantine);
+  const sourcePath = maildirHomePath(cleanRoot, quarantine?.address);
+  const state = await inspectMailboxDeletionPaths({
+    sourcePath,
+    quarantinePath,
+    quarantineRelpath: quarantine?.quarantineRelpath
+  });
+  let moved = quarantine?.moved;
+  if (moved !== true && moved !== false && moved !== null && moved !== undefined) {
+    throw new Error('Maildir 隔离状态不正确。');
+  }
+
+  if (moved === true) {
+    if (state.quarantine === 'missing' && state.source === 'directory') {
+      if (quarantine?.allowAlreadyRestored === true) return true;
+      if (quarantine?.allowNotYetQuarantined === true) return false;
+    }
+    if (state.quarantine !== 'directory') {
+      throw new Error('Maildir 隔离目录缺失,无法安全恢复邮箱。');
+    }
+    if (!['missing', 'tombstone'].includes(state.source)) {
+      throw new Error('源 Maildir 路径已被重新创建,无法自动回滚。');
+    }
+  } else if (moved === false) {
+    if (state.quarantine !== 'missing') {
+      throw new Error('Maildir 隔离状态与文件系统不一致。');
+    }
+    if (!['missing', 'directory', 'tombstone'].includes(state.source)) {
+      throw new Error('源 Maildir 路径状态不正确。');
+    }
+  } else if (state.quarantine === 'directory' && ['missing', 'tombstone'].includes(state.source)) {
+    moved = true;
+  } else if (state.quarantine === 'missing' && state.source === 'directory') {
+    moved = false;
+  } else {
+    throw new Error('Maildir 隔离状态无法安全判定。');
+  }
+
+  if (!moved) {
+    if (state.source === 'tombstone') await unlink(sourcePath);
+    if (state.source === 'tombstone') {
+      await flush([], { root: cleanRoot, directories: [cleanRoot] });
+    }
+    return false;
+  }
+  await setMaildirTreeWritable(quarantinePath, true);
+  if (state.source === 'tombstone') await unlink(sourcePath);
+  await rename(quarantinePath, sourcePath);
+  try {
+    await flush([], {
+      root: cleanRoot,
+      directories: [cleanRoot, quarantineRoot]
+    });
+  } catch (error) {
+    const restoreError = error instanceof Error ? error : new Error(String(error || 'Maildir 恢复刷盘失败。'));
+    restoreError.maildirSourceRenamed = true;
+    throw restoreError;
+  }
+  return true;
+}
+
+export async function assertQuarantinedMaildirMailboxUnchanged({ root, quarantine, snapshot }) {
+  const cleanRoot = path.resolve(String(root || ''));
+  const sourcePath = maildirHomePath(cleanRoot, quarantine?.address);
+  const quarantinePath = quarantinedMaildirHomePath(cleanRoot, quarantine);
+  const state = await inspectMailboxDeletionPaths({
+    sourcePath,
+    quarantinePath,
+    quarantineRelpath: quarantine?.quarantineRelpath
+  });
+  if (state.source !== 'tombstone') {
+    throw new Error('源 Maildir 删除保护已失效,无法安全提交邮箱删除。');
+  }
+  if (quarantine?.moved) {
+    if (state.quarantine !== 'directory') {
+      throw new Error('Maildir 隔离目录缺失,无法安全提交邮箱删除。');
+    }
+    const current = await snapshotQuarantinedMaildirMailbox({ root: cleanRoot, quarantine });
+    if (!sameMaildirSnapshot(snapshot, current)) {
+      throw new Error('邮箱删除期间源 Maildir 已发生变化,请重试。');
+    }
+  } else if (state.quarantine !== 'missing') {
+    throw new Error('Maildir 隔离状态与文件系统不一致。');
+  }
+  return true;
+}
+
+export async function removeMaildirMessageFilesByStorageKeyPrefix({ root, address, storageKeyPrefix }) {
+  const prefix = String(storageKeyPrefix || '').trim().toLowerCase();
+  if (!/^[a-z0-9][a-z0-9-]{7,191}$/.test(prefix)) throw new Error('Maildir 存储前缀不正确。');
+  const folderDirectories = await listMaildirFolderDirectories(root, address);
+  const removedDirectories = new Set();
+  let removed = 0;
+  for (const folder of folderDirectories) {
+    for (const bucket of ['tmp', 'new', 'cur']) {
+      const directory = path.join(folder.path, bucket);
+      let entries = [];
+      try {
+        entries = await readdir(directory, { withFileTypes: true });
+      } catch (error) {
+        if (error?.code === 'ENOENT') continue;
+        throw error;
+      }
+      for (const entry of entries) {
+        if (!entry.isFile() || !entry.name.toLowerCase().startsWith(prefix)) continue;
+        await unlink(path.join(directory, entry.name));
+        removed += 1;
+        removedDirectories.add(directory);
+      }
+    }
+  }
+  if (removedDirectories.size) {
+    await flushMaildirFiles([], { root, directories: [...removedDirectories] });
+  }
+  return removed;
+}
+
 export function newMaildirStorageKey(origin = 'smtp') {
   const cleanOrigin = String(origin || 'smtp').toLowerCase().replace(/[^a-z0-9]/g, '') || 'smtp';
   return `mh${cleanOrigin}-${crypto.randomBytes(16).toString('hex')}`;
@@ -322,12 +636,16 @@ function decodeMaildirStorageEscapes(value) {
 }
 
 async function listMaildirFolderDirectories(root, address) {
-  const mailRoot = path.join(maildirHomePath(root, address), 'mail');
+  return listMaildirFolderDirectoriesAtHome(maildirHomePath(root, address));
+}
+
+async function listMaildirFolderDirectoriesAtHome(home) {
+  const mailRoot = path.join(home, 'mail');
   let rootEntries = [];
   try {
     rootEntries = await readdir(mailRoot, { withFileTypes: true });
   } catch (error) {
-    if (error?.code === 'ENOENT') return [];
+    if (['ENOENT', 'ENOTDIR'].includes(error?.code)) return [];
     throw error;
   }
   const folders = [];
@@ -467,6 +785,10 @@ function normalizeDate(value) {
 
 function relativeMaildirPath(root, address, filePath) {
   const home = maildirHomePath(root, address);
+  return relativePathFromMaildirHome(home, filePath);
+}
+
+function relativePathFromMaildirHome(home, filePath) {
   const relative = path.relative(home, filePath).split(path.sep).join('/');
   if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) {
     throw new Error('Maildir 存储路径不正确。');
@@ -474,6 +796,111 @@ function relativeMaildirPath(root, address, filePath) {
   return relative;
 }
 
+function quarantinedMaildirHomePath(root, quarantine) {
+  const cleanRoot = path.resolve(String(root || ''));
+  const quarantineRoot = path.join(cleanRoot, '.mailhub-quarantine');
+  const quarantinePath = path.resolve(cleanRoot, ...String(quarantine?.quarantineRelpath || '').split('/'));
+  if (path.dirname(quarantinePath) !== quarantineRoot) throw new Error('Maildir 隔离路径不正确。');
+  return quarantinePath;
+}
+
+async function createMailboxDeletionTombstone(sourcePath, quarantineRelpath) {
+  const marker = mailboxDeletionTombstoneMarker(quarantineRelpath);
+  let handle;
+  try {
+    handle = await open(sourcePath, 'wx', 0o400);
+    await handle.writeFile(marker);
+    await handle.sync();
+  } catch (error) {
+    if (error?.code !== 'EEXIST') throw error;
+    const state = await mailboxDeletionSourcePathKind(sourcePath, quarantineRelpath);
+    if (state !== 'tombstone') throw new Error('源 Maildir 删除保护路径冲突。');
+  } finally {
+    await handle?.close();
+  }
+}
+
+async function inspectMailboxDeletionPaths({ sourcePath, quarantinePath, quarantineRelpath }) {
+  const [source, quarantine] = await Promise.all([
+    mailboxDeletionSourcePathKind(sourcePath, quarantineRelpath),
+    maildirPathKind(quarantinePath)
+  ]);
+  if (!['missing', 'directory'].includes(quarantine)) {
+    throw new Error('Maildir 隔离路径不是邮箱目录。');
+  }
+  return { source, quarantine };
+}
+
+async function mailboxDeletionSourcePathKind(sourcePath, quarantineRelpath) {
+  const kind = await maildirPathKind(sourcePath);
+  if (kind !== 'file') return kind;
+  try {
+    const marker = await readFile(sourcePath, 'utf8');
+    return marker === mailboxDeletionTombstoneMarker(quarantineRelpath) ? 'tombstone' : 'file';
+  } catch (error) {
+    if (error?.code === 'ENOENT') return 'missing';
+    throw error;
+  }
+}
+
+async function maildirPathKind(filePath) {
+  try {
+    const entry = await lstat(filePath);
+    if (entry.isDirectory()) return 'directory';
+    if (entry.isFile()) return 'file';
+    return 'other';
+  } catch (error) {
+    if (error?.code === 'ENOENT') return 'missing';
+    throw error;
+  }
+}
+
+async function setMaildirTreeWritable(home, writable) {
+  const entry = await lstat(home);
+  if (!entry.isDirectory()) throw new Error('Maildir 隔离路径不是邮箱目录。');
+  const children = await readdir(home, { withFileTypes: true });
+  for (const child of children) {
+    const childPath = path.join(home, child.name);
+    if (child.isDirectory()) {
+      await setMaildirTreeWritable(childPath, writable);
+    } else if (child.isFile()) {
+      await chmod(childPath, writable ? 0o600 : 0o400);
+    } else {
+      throw new Error('Maildir 隔离目录包含不支持的文件类型。');
+    }
+  }
+  await chmod(home, writable ? 0o700 : 0o500);
+}
+
+function mailboxDeletionTombstoneMarker(quarantineRelpath) {
+  return `mailhub-mailbox-deletion:${String(quarantineRelpath || '')}\n`;
+}
+
+function sameMaildirSnapshot(left, right) {
+  const normalize = (snapshot) => (Array.isArray(snapshot?.entries) ? snapshot.entries : [])
+    .map((entry) => [
+      String(entry.relpath || ''),
+      Number(entry.size || 0),
+      Number(entry.mtimeMs || 0),
+      String(entry.sha256 || '')
+    ])
+    .sort((a, b) => a[0].localeCompare(b[0]));
+  const leftEntries = normalize(left);
+  const rightEntries = normalize(right);
+  if (leftEntries.length !== rightEntries.length) return false;
+  return leftEntries.every((entry, index) => (
+    entry.length === rightEntries[index].length
+    && entry.every((value, valueIndex) => value === rightEntries[index][valueIndex])
+  ));
+}
+
+function maildirPathFromRelpath(root, address, relpath) {
+  const home = maildirHomePath(root, address);
+  const filePath = path.resolve(home, ...String(relpath || '').split('/'));
+  if (!isPathInside(home, filePath)) throw new Error('Maildir 存储路径不正确。');
+  return filePath;
+}
+
 function storageMetadata({ root, address, filePath, storageKey, bytes, timestamp }) {
   return {
     backend: 'maildir',

+ 85 - 4
src/maildir-sync.js

@@ -29,6 +29,8 @@ import {
   writeMaildirMessage
 } from './maildir-store.js';
 
+const reconciliationLocks = new Map();
+
 export async function migrateInboundMessagesToMaildir({
   root,
   batchSize = 250,
@@ -127,15 +129,51 @@ export async function migrateInboundMessagesToMaildir({
   return { processed, written, reused, lastId };
 }
 
-export async function reconcileMaildirMailbox({ root, mailbox, missingCounts = new Map() }) {
-  await ensureMaildirMailbox(root, mailbox.address);
+export async function acquireMaildirReconciliationLocks(ids) {
+  const mailboxIds = normalizeReconciliationLockIds(ids);
+  const releases = [];
+  try {
+    for (const mailboxId of mailboxIds) {
+      releases.push(await acquireReconciliationLock(mailboxId));
+    }
+  } catch (error) {
+    for (const release of releases.reverse()) release();
+    throw error;
+  }
+
+  let released = false;
+  return () => {
+    if (released) return;
+    released = true;
+    for (const release of releases.reverse()) release();
+  };
+}
+
+export async function reconcileMaildirMailbox({
+  root,
+  mailbox,
+  missingCounts = new Map(),
+  skipLock = false,
+  snapshot = null
+}) {
+  if (skipLock) return reconcileMaildirMailboxLocked({ root, mailbox, missingCounts, snapshot });
+  const release = await acquireMaildirReconciliationLocks([mailbox?.id]);
+  try {
+    return await reconcileMaildirMailboxLocked({ root, mailbox, missingCounts, snapshot });
+  } finally {
+    release();
+  }
+}
+
+async function reconcileMaildirMailboxLocked({ root, mailbox, missingCounts, snapshot = null }) {
+  if (!snapshot) await ensureMaildirMailbox(root, mailbox.address);
   const folderReport = syncInboundMaildirFolders(
     mailbox,
-    await listMaildirFolders({ root, address: mailbox.address })
+    snapshot ? snapshot.folders : await listMaildirFolders({ root, address: mailbox.address })
   );
   const index = listInboundMaildirIndex(mailbox.id);
   const entries = assignStorageKeys(
-    await scanMaildirMailbox({ root, address: mailbox.address }),
+    snapshot ? snapshot.entries : await scanMaildirMailbox({ root, address: mailbox.address }),
     index
   );
   const indexByKey = new Map(index.map((message) => [message.storageKey, message]));
@@ -318,6 +356,49 @@ function missingCounterKey(mailboxId, storageKey) {
   return `${mailboxId}:${storageKey}`;
 }
 
+function normalizeReconciliationLockIds(ids) {
+  const values = Array.isArray(ids) ? ids : [ids];
+  const normalized = values
+    .filter((value) => value !== null && value !== undefined && value !== '')
+    .map((value) => Number(value));
+  if (normalized.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
+    throw new Error('Maildir 邮箱锁 ID 不正确。');
+  }
+  return [...new Set(normalized)].sort((left, right) => left - right);
+}
+
+function acquireReconciliationLock(mailboxId) {
+  let state = reconciliationLocks.get(mailboxId);
+  if (!state) {
+    state = { locked: false, waiters: [] };
+    reconciliationLocks.set(mailboxId, state);
+  }
+  if (!state.locked) {
+    state.locked = true;
+    return Promise.resolve(releaseReconciliationLock(mailboxId, state));
+  }
+  return new Promise((resolve) => {
+    state.waiters.push(() => resolve(releaseReconciliationLock(mailboxId, state)));
+  });
+}
+
+function releaseReconciliationLock(mailboxId, state) {
+  let released = false;
+  return () => {
+    if (released) return;
+    released = true;
+    const next = state.waiters.shift();
+    if (next) {
+      next();
+      return;
+    }
+    state.locked = false;
+    if (reconciliationLocks.get(mailboxId) === state) {
+      reconciliationLocks.delete(mailboxId);
+    }
+  };
+}
+
 async function cleanupMaildirMigrationStaging({ root, batchSize }) {
   let afterMessageId = 0;
   while (true) {

+ 430 - 6
src/pages/Admin/MailboxAccess.tsx

@@ -2,7 +2,8 @@ import {
   DeleteOutlined,
   EditOutlined,
   PlusOutlined,
-  ReloadOutlined
+  ReloadOutlined,
+  TeamOutlined
 } from '@ant-design/icons';
 import {
   Alert,
@@ -12,8 +13,11 @@ import {
   Checkbox,
   Descriptions,
   Drawer,
+  Form,
   Input,
   List,
+  Modal,
+  Radio,
   Select,
   Skeleton,
   Space,
@@ -22,6 +26,7 @@ import {
   Typography
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
+import type { InputRef } from 'antd';
 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useSearchParams } from 'react-router-dom';
 
@@ -38,9 +43,16 @@ import type {
 import { useMediaQuery } from '../../frontend/use-media-query';
 
 type DraftGrant = { userId: number } & MailboxPermissions;
+type BulkOperation = 'upsert' | 'remove';
+type BulkAccessRequest = Parameters<typeof api.bulkUpdateAdminMailboxAccess>[0];
+
+const bulkActionKey = 'mailbox-access:bulk';
+const maxBulkMailboxes = 500;
+const maxBulkUsers = 500;
+const maxBulkPairs = 5000;
 
 export function AdminMailboxAccess({ users }: { users: AdminUser[] }) {
-  const { message } = AntApp.useApp();
+  const { message, modal } = AntApp.useApp();
   const { t } = useI18n();
   const isTableView = useMediaQuery('(min-width: 768px)');
   const [searchParams, setSearchParams] = useSearchParams();
@@ -50,7 +62,20 @@ export function AdminMailboxAccess({ users }: { users: AdminUser[] }) {
   const [query, setQuery] = useState('');
   const [draftGrants, setDraftGrants] = useState<DraftGrant[]>([]);
   const [actionKey, setActionKey] = useState('');
+  const [selectedMailboxIds, setSelectedMailboxIds] = useState<number[]>([]);
+  const [bulkOpen, setBulkOpen] = useState(false);
+  const [bulkOperation, setBulkOperation] = useState<BulkOperation>('upsert');
+  const [bulkUserIds, setBulkUserIds] = useState<number[]>([]);
+  const [bulkPermissions, setBulkPermissions] = useState<MailboxPermissions>({
+    view: true,
+    receive: false,
+    send: false
+  });
+  const [bulkError, setBulkError] = useState('');
   const drawerTitleRef = useRef<HTMLSpanElement>(null);
+  const searchInputRef = useRef<InputRef>(null);
+  const bulkSubmittingRef = useRef(false);
+  const focusSearchAfterBulkCloseRef = useRef(false);
   const mailboxId = positiveInteger(searchParams.get('mailboxId'));
   const selected = mailboxId ? entries.find((entry) => entry.mailbox.id === mailboxId) || null : null;
 
@@ -59,7 +84,10 @@ export function AdminMailboxAccess({ users }: { users: AdminUser[] }) {
     setLoadError('');
     try {
       const result = await api.adminMailboxAccess();
-      setEntries(result.mailboxes || []);
+      const nextEntries = result.mailboxes || [];
+      const validMailboxIds = new Set(nextEntries.map((entry) => entry.mailbox.id));
+      setEntries(nextEntries);
+      setSelectedMailboxIds((current) => current.filter((id) => validMailboxIds.has(id)));
     } catch (error) {
       setLoadError(error instanceof Error ? error.message : t('common.error'));
     } finally {
@@ -92,6 +120,33 @@ export function AdminMailboxAccess({ users }: { users: AdminUser[] }) {
       entry.owner.email
     ].some((value) => String(value || '').toLowerCase().includes(needle)));
   }, [entries, query]);
+  const selectedMailboxIdSet = useMemo(() => new Set(selectedMailboxIds), [selectedMailboxIds]);
+  const entriesByMailboxId = useMemo(
+    () => new Map(entries.map((entry) => [entry.mailbox.id, entry])),
+    [entries]
+  );
+  const selectedMailboxEntries = useMemo(() => selectedMailboxIds
+    .map((id) => entriesByMailboxId.get(id))
+    .filter((entry): entry is AdminMailboxAccessEntry => Boolean(entry)), [entriesByMailboxId, selectedMailboxIds]);
+  const visibleMailboxIds = useMemo(() => visibleEntries.map((entry) => entry.mailbox.id), [visibleEntries]);
+  const visibleSelectedCount = useMemo(
+    () => visibleMailboxIds.filter((id) => selectedMailboxIdSet.has(id)).length,
+    [selectedMailboxIdSet, visibleMailboxIds]
+  );
+  const bulkUserOptions = useMemo(() => users
+    .filter((user) => bulkOperation === 'remove' || user.status !== 'disabled')
+    .map((user) => ({
+      value: user.id,
+      label: `${user.username} · ${user.email}${user.status === 'disabled' ? ` · ${t('adminMailboxAccess.disabledUser')}` : ''}`
+    })), [bulkOperation, t, users]);
+  const bulkOwnerSkipCount = useMemo(() => {
+    const selectedUserIds = new Set(bulkUserIds);
+    return selectedMailboxEntries.reduce((count, entry) => (
+      count + (selectedUserIds.has(ownerIdForEntry(entry)) ? 1 : 0)
+    ), 0);
+  }, [bulkUserIds, selectedMailboxEntries]);
+  const bulkLimitError = bulkLimitErrorMessage(t, selectedMailboxIds.length, bulkUserIds.length);
+  const bulkSubmitting = actionKey === bulkActionKey;
 
   function openDrawer(entry: AdminMailboxAccessEntry) {
     const next = new URLSearchParams(searchParams);
@@ -119,6 +174,130 @@ export function AdminMailboxAccess({ users }: { users: AdminUser[] }) {
     setDraftGrants((current) => [...current, { userId: candidate.id, view: true, receive: false, send: false }]);
   }
 
+  function toggleMailboxSelection(mailboxId: number, checked: boolean) {
+    setSelectedMailboxIds((current) => {
+      const next = new Set(current);
+      if (checked) next.add(mailboxId);
+      else next.delete(mailboxId);
+      return [...next];
+    });
+  }
+
+  function toggleVisibleSelection(checked: boolean) {
+    setSelectedMailboxIds((current) => {
+      const next = new Set(current);
+      for (const id of visibleMailboxIds) {
+        if (checked) next.add(id);
+        else next.delete(id);
+      }
+      return [...next];
+    });
+  }
+
+  function openBulkAccess() {
+    if (!selectedMailboxIds.length) return;
+    const mailboxLimitError = bulkLimitErrorMessage(t, selectedMailboxIds.length, 0);
+    if (mailboxLimitError) {
+      message.error(mailboxLimitError);
+      return;
+    }
+    setBulkOperation('upsert');
+    setBulkUserIds([]);
+    setBulkPermissions({ view: true, receive: false, send: false });
+    setBulkError('');
+    focusSearchAfterBulkCloseRef.current = false;
+    setBulkOpen(true);
+  }
+
+  function changeBulkOperation(operation: BulkOperation) {
+    setBulkOperation(operation);
+    setBulkError('');
+    if (operation !== 'upsert') return;
+    const eligibleUserIds = new Set(users
+      .filter((user) => user.status !== 'disabled')
+      .map((user) => user.id));
+    setBulkUserIds((current) => current.filter((userId) => eligibleUserIds.has(userId)));
+  }
+
+  function updateBulkPermission(patch: Partial<MailboxPermissions>) {
+    setBulkPermissions((current) => normalizePermissions({ ...current, ...patch }));
+  }
+
+  function requestBulkAccess() {
+    if (bulkSubmittingRef.current || !selectedMailboxIds.length || !bulkUserIds.length) return;
+    const permissions = normalizePermissions(bulkPermissions);
+    if (bulkOperation === 'upsert' && !permissions.view && !permissions.receive && !permissions.send) {
+      message.error(t('adminMailboxAccess.permissionRequired'));
+      return;
+    }
+    if (bulkLimitError) {
+      message.error(bulkLimitError);
+      return;
+    }
+    const request: BulkAccessRequest = {
+      mailboxIds: [...selectedMailboxIds],
+      userIds: [...bulkUserIds],
+      operation: bulkOperation,
+      ...(bulkOperation === 'upsert' ? { permissions } : {})
+    };
+    if (bulkOperation !== 'remove') {
+      void saveBulkAccess(request);
+      return;
+    }
+    const confirmation = modal.confirm({
+      title: t('adminMailboxAccess.bulkRemoveConfirmTitle'),
+      content: (
+        <Space direction="vertical" size={12} className="full-width">
+          <Typography.Text>
+            {bulkScopeText(t, request.mailboxIds.length, request.userIds.length)}
+          </Typography.Text>
+          {bulkOwnerSkipCount ? (
+            <Typography.Text type="secondary">
+              {t('adminMailboxAccess.bulkOwnerSkipPreview')} {bulkOwnerSkipCount}
+            </Typography.Text>
+          ) : null}
+          <Alert type="warning" showIcon message={t('adminMailboxAccess.bulkRemoveConfirmWarning')} />
+        </Space>
+      ),
+      okText: t('adminMailboxAccess.bulkRemoveAction'),
+      cancelText: t('common.cancel'),
+      okButtonProps: { danger: true, style: { minHeight: 44 } },
+      cancelButtonProps: { 'aria-label': t('common.cancel'), style: { minHeight: 44 } },
+      width: 520,
+      onOk: async () => {
+        confirmation.update({
+          keyboard: false,
+          maskClosable: false,
+          cancelButtonProps: { 'aria-label': t('common.cancel'), disabled: true, style: { minHeight: 44 } }
+        });
+        await saveBulkAccess(request);
+      }
+    });
+  }
+
+  async function saveBulkAccess(request: BulkAccessRequest) {
+    if (bulkSubmittingRef.current) return;
+    bulkSubmittingRef.current = true;
+    setBulkError('');
+    setActionKey(bulkActionKey);
+    try {
+      const result = await api.bulkUpdateAdminMailboxAccess(request);
+      const updatedById = new Map(result.mailboxes.map((entry) => [entry.mailbox.id, entry]));
+      setEntries((current) => current.map((entry) => updatedById.get(entry.mailbox.id) || entry));
+      message.success(bulkResultMessage(t, request.operation, result.summary));
+      focusSearchAfterBulkCloseRef.current = true;
+      setSelectedMailboxIds([]);
+      setBulkOpen(false);
+    } catch (error) {
+      const errorMessage = error instanceof Error ? error.message : t('common.error');
+      setBulkError(errorMessage);
+      message.error(errorMessage);
+    } finally {
+      bulkSubmittingRef.current = false;
+      setActionKey('');
+    }
+  }
+
   function updateGrant(index: number, patch: Partial<DraftGrant>) {
     setDraftGrants((current) => current.map((grant, currentIndex) => {
       if (currentIndex !== index) return grant;
@@ -220,27 +399,101 @@ export function AdminMailboxAccess({ users }: { users: AdminUser[] }) {
     <Space direction="vertical" size={16} className="full-width">
       <Space wrap className="admin-mailbox-access-toolbar">
         <Input.Search
+          ref={searchInputRef}
           allowClear
           aria-label={t('adminMailboxAccess.search')}
           placeholder={t('adminMailboxAccess.search')}
           value={query}
+          disabled={bulkSubmitting}
           onChange={(event) => setQuery(event.target.value)}
         />
-        <Button icon={<ReloadOutlined />} loading={loading} style={{ minHeight: 44 }} onClick={() => void loadEntries()}>
+        <Button icon={<ReloadOutlined />} loading={loading} disabled={bulkSubmitting} style={{ minHeight: 44 }} onClick={() => void loadEntries()}>
           {t('common.refresh')}
         </Button>
+        <Checkbox
+          className="admin-mailbox-access-select-visible"
+          checked={Boolean(visibleMailboxIds.length) && visibleSelectedCount === visibleMailboxIds.length}
+          indeterminate={visibleSelectedCount > 0 && visibleSelectedCount < visibleMailboxIds.length}
+          disabled={loading || bulkSubmitting || !visibleMailboxIds.length}
+          onChange={(event) => toggleVisibleSelection(event.target.checked)}
+        >
+          {t('adminMailboxAccess.selectVisible')} ({visibleMailboxIds.length})
+        </Checkbox>
+        <Button
+          type="primary"
+          icon={<TeamOutlined />}
+          aria-label={`${t('adminMailboxAccess.bulkConfigure')}${selectedMailboxIds.length ? ` (${selectedMailboxIds.length})` : ''}`}
+          disabled={loading || bulkSubmitting || !selectedMailboxIds.length || Boolean(bulkLimitErrorMessage(t, selectedMailboxIds.length, 0))}
+          style={{ minHeight: 44 }}
+          onClick={openBulkAccess}
+        >
+          {t('adminMailboxAccess.bulkConfigure')}{selectedMailboxIds.length ? ` (${selectedMailboxIds.length})` : ''}
+        </Button>
       </Space>
+      {selectedMailboxIds.length ? (
+        <div className="admin-mailbox-access-selection-bar" aria-live="polite">
+          <Space wrap size={12}>
+            <Typography.Text strong>
+              {t('adminMailboxAccess.selectedMailboxes')} {selectedMailboxIds.length}
+            </Typography.Text>
+            <Typography.Text type="secondary">
+              {t('adminMailboxAccess.selectedVisible')} {visibleSelectedCount}
+            </Typography.Text>
+            <Button
+              type="link"
+              disabled={bulkSubmitting}
+              style={{ minHeight: 44 }}
+              onClick={() => setSelectedMailboxIds([])}
+            >
+              {t('adminMailboxAccess.clearSelection')}
+            </Button>
+          </Space>
+          {bulkLimitErrorMessage(t, selectedMailboxIds.length, 0) ? (
+            <Typography.Text type="danger" role="alert">
+              {bulkLimitErrorMessage(t, selectedMailboxIds.length, 0)}
+            </Typography.Text>
+          ) : null}
+        </div>
+      ) : null}
       {loadError ? <Alert type="error" showIcon message={loadError} action={<Button onClick={() => void loadEntries()}>{t('common.refresh')}</Button>} /> : null}
       <SectionCard title={t('adminMailboxAccess.title')} extra={<Tag>{entries.length}</Tag>}>
         {loading && !entries.length ? <Skeleton active paragraph={{ rows: 8 }} /> : visibleEntries.length ? (
           isTableView ? (
-            <Table rowKey={(entry) => entry.mailbox.id} columns={columns} dataSource={visibleEntries} scroll={{ x: 760 }} />
+            <Table
+              className="admin-mailbox-access-table"
+              rowKey={(entry) => entry.mailbox.id}
+              rowSelection={{
+                selectedRowKeys: selectedMailboxIds,
+                preserveSelectedRowKeys: true,
+                getCheckboxProps: (entry) => ({
+                  disabled: loading || bulkSubmitting,
+                  'aria-label': `${t('adminMailboxAccess.selectMailbox')} · ${entry.mailbox.address}`
+                }),
+                onChange: (keys) => setSelectedMailboxIds(keys.map(Number))
+              }}
+              columns={columns}
+              dataSource={visibleEntries}
+              scroll={{ x: 760 }}
+            />
           ) : (
             <List
               dataSource={visibleEntries}
               renderItem={(entry) => (
                 <List.Item>
-                  <Card size="small" className="full-width admin-mailbox-access-card" title={entry.mailbox.address}>
+                  <Card
+                    size="small"
+                    className="full-width admin-mailbox-access-card"
+                    title={(
+                      <Checkbox
+                        checked={selectedMailboxIdSet.has(entry.mailbox.id)}
+                        disabled={loading || bulkSubmitting}
+                        aria-label={`${t('adminMailboxAccess.selectMailbox')} · ${entry.mailbox.address}`}
+                        onChange={(event) => toggleMailboxSelection(entry.mailbox.id, event.target.checked)}
+                      >
+                        {entry.mailbox.address}
+                      </Checkbox>
+                    )}
+                  >
                     <Space direction="vertical" size={12} className="full-width">
                       <Typography.Text type="secondary">{t('adminMailboxAccess.owner')} · {entry.owner.username}</Typography.Text>
                       <Tag>{t('adminMailboxAccess.assignedUsers')} {entry.grants.length}</Tag>
@@ -263,6 +516,135 @@ export function AdminMailboxAccess({ users }: { users: AdminUser[] }) {
         ) : <EmptyState description={query ? t('adminMailboxAccess.noSearchResult') : t('adminMailboxAccess.empty')} />}
       </SectionCard>
 
+      <Modal
+        className="admin-mailbox-access-bulk-modal"
+        title={t('adminMailboxAccess.bulkTitle')}
+        open={bulkOpen}
+        width={600}
+        okText={bulkOperation === 'remove'
+          ? t('adminMailboxAccess.bulkRemoveAction')
+          : t('adminMailboxAccess.bulkSaveAction')}
+        cancelText={t('common.cancel')}
+        okButtonProps={{
+          danger: bulkOperation === 'remove',
+          disabled: bulkSubmitting || !bulkUserIds.length || Boolean(bulkLimitError),
+          loading: bulkSubmitting,
+          style: { minHeight: 44 }
+        }}
+        cancelButtonProps={{ 'aria-label': t('common.cancel'), disabled: bulkSubmitting, style: { minHeight: 44 } }}
+        closable={!bulkSubmitting}
+        keyboard={!bulkSubmitting}
+        maskClosable={!bulkSubmitting}
+        destroyOnHidden
+        afterClose={() => {
+          if (!focusSearchAfterBulkCloseRef.current) return;
+          focusSearchAfterBulkCloseRef.current = false;
+          window.requestAnimationFrame(() => searchInputRef.current?.focus({ preventScroll: true }));
+        }}
+        onCancel={() => {
+          if (!bulkSubmittingRef.current) setBulkOpen(false);
+        }}
+        onOk={requestBulkAccess}
+      >
+        <Space direction="vertical" size={16} className="full-width">
+          <Alert
+            type="info"
+            showIcon
+            message={bulkOperation === 'upsert'
+              ? t('adminMailboxAccess.bulkUpsertHint')
+              : t('adminMailboxAccess.bulkHint')}
+            description={(
+              <Space direction="vertical" size={8} className="full-width">
+                <Typography.Text>
+                  {t('adminMailboxAccess.selectedMailboxes')} {selectedMailboxIds.length}
+                </Typography.Text>
+                <div className="admin-mailbox-access-preview" aria-label={t('adminMailboxAccess.selectedMailboxPreview')}>
+                  {selectedMailboxEntries.slice(0, 5).map((entry) => (
+                    <Tag key={entry.mailbox.id} title={entry.mailbox.address}>{entry.mailbox.address}</Tag>
+                  ))}
+                  {selectedMailboxEntries.length > 5 ? <Tag>+{selectedMailboxEntries.length - 5}</Tag> : null}
+                </div>
+                {bulkUserIds.length ? (
+                  <Typography.Text type="secondary">
+                    {bulkScopeText(t, selectedMailboxIds.length, bulkUserIds.length)}
+                  </Typography.Text>
+                ) : null}
+                {bulkOwnerSkipCount ? (
+                  <Typography.Text type="secondary">
+                    {t('adminMailboxAccess.bulkOwnerSkipPreview')} {bulkOwnerSkipCount}
+                  </Typography.Text>
+                ) : null}
+              </Space>
+            )}
+          />
+          {bulkLimitError ? <Alert type="error" showIcon message={bulkLimitError} /> : null}
+          {bulkError ? <Alert type="error" showIcon message={bulkError} /> : null}
+          <Form layout="vertical" className="full-width" disabled={bulkSubmitting}>
+            <Form.Item label={t('adminMailboxAccess.bulkOperation')} required>
+              <div
+                role="radiogroup"
+                aria-label={t('adminMailboxAccess.bulkOperation')}
+                aria-required="true"
+              >
+                <Radio.Group
+                  className="admin-mailbox-access-operation"
+                  value={bulkOperation}
+                  onChange={(event) => changeBulkOperation(event.target.value as BulkOperation)}
+                >
+                  <Radio.Button value="upsert">{t('adminMailboxAccess.bulkUpsert')}</Radio.Button>
+                  <Radio.Button value="remove">{t('adminMailboxAccess.bulkRemove')}</Radio.Button>
+                </Radio.Group>
+              </div>
+            </Form.Item>
+            <Form.Item
+              label={t('adminMailboxAccess.bulkUsers')}
+              required
+              extra={bulkOperation === 'remove' ? t('adminMailboxAccess.bulkRemoveDisabledHint') : undefined}
+            >
+              <Select
+                mode="multiple"
+                showSearch
+                optionFilterProp="label"
+                aria-label={t('adminMailboxAccess.bulkUsers')}
+                aria-required="true"
+                value={bulkUserIds}
+                options={bulkUserOptions}
+                maxTagCount="responsive"
+                maxTagTextLength={32}
+                placeholder={t('adminMailboxAccess.bulkUsersPlaceholder')}
+                onChange={setBulkUserIds}
+                className="full-width"
+              />
+            </Form.Item>
+            {bulkOperation === 'upsert' ? (
+              <Form.Item
+                label={t('adminMailboxAccess.permissions')}
+                required
+                extra={t('adminMailboxAccess.permissionHint')}
+              >
+                <div role="group" aria-label={t('adminMailboxAccess.permissions')} className="admin-mailbox-permissions">
+                  <Checkbox
+                    checked={bulkPermissions.view}
+                    disabled={bulkSubmitting || bulkPermissions.receive || bulkPermissions.send}
+                    onChange={(event) => updateBulkPermission({ view: event.target.checked })}
+                  >
+                    {t('mailboxPermission.view')}
+                  </Checkbox>
+                  <Checkbox disabled={bulkSubmitting} checked={bulkPermissions.receive} onChange={(event) => updateBulkPermission({ receive: event.target.checked })}>
+                    {t('mailboxPermission.receive')}
+                  </Checkbox>
+                  <Checkbox disabled={bulkSubmitting} checked={bulkPermissions.send} onChange={(event) => updateBulkPermission({ send: event.target.checked })}>
+                    {t('mailboxPermission.send')}
+                  </Checkbox>
+                </div>
+              </Form.Item>
+            ) : (
+              <Alert type="warning" showIcon message={t('adminMailboxAccess.bulkRemoveWarning')} />
+            )}
+          </Form>
+        </Space>
+      </Modal>
+
       <Drawer
         title={<span ref={drawerTitleRef} tabIndex={-1}>{selected?.mailbox.address || t('adminMailboxAccess.drawerTitle')}</span>}
         width={isTableView ? 720 : '100%'}
@@ -385,3 +767,45 @@ function positiveInteger(value: string | null) {
   const number = Number(value);
   return Number.isInteger(number) && number > 0 ? number : null;
 }
+
+function bulkLimitErrorMessage(
+  t: (key: string) => string,
+  mailboxCount: number,
+  userCount: number
+) {
+  if (mailboxCount > maxBulkMailboxes) {
+    return `${t('adminMailboxAccess.bulkMailboxLimit')} ${maxBulkMailboxes}`;
+  }
+  if (userCount > maxBulkUsers) {
+    return `${t('adminMailboxAccess.bulkUserLimit')} ${maxBulkUsers}`;
+  }
+  if (mailboxCount * userCount > maxBulkPairs) {
+    return `${t('adminMailboxAccess.bulkPairLimit')} ${maxBulkPairs}`;
+  }
+  return '';
+}
+
+function bulkScopeText(t: (key: string) => string, mailboxCount: number, userCount: number) {
+  return `${mailboxCount} ${t('adminMailboxAccess.bulkMailboxUnit')} × ${userCount} ${t('adminMailboxAccess.bulkUserUnit')} = ${mailboxCount * userCount} ${t('adminMailboxAccess.bulkPairUnit')}`;
+}
+
+function bulkResultMessage(
+  t: (key: string) => string,
+  operation: BulkOperation,
+  summary: {
+    mailboxCount: number;
+    userCount: number;
+    pairCount?: number;
+    changedGrantCount: number;
+    unchangedGrantCount?: number;
+    skippedOwnerCount: number;
+  }
+) {
+  const pairCount = summary.pairCount ?? summary.mailboxCount * summary.userCount;
+  const unchangedGrantCount = summary.unchangedGrantCount
+    ?? Math.max(0, pairCount - summary.changedGrantCount - summary.skippedOwnerCount);
+  const prefix = t(operation === 'remove'
+    ? 'adminMailboxAccess.bulkRemoved'
+    : 'adminMailboxAccess.bulkSaved');
+  return `${prefix}: ${t('adminMailboxAccess.bulkChanged')} ${summary.changedGrantCount} · ${t('adminMailboxAccess.bulkUnchanged')} ${unchangedGrantCount} · ${t('adminMailboxAccess.bulkSkippedOwner')} ${summary.skippedOwnerCount}`;
+}

+ 177 - 1
src/pages/Inbox.tsx

@@ -111,6 +111,9 @@ export default function Inbox() {
   const [searchDraft, setSearchDraft] = useState(searchParams.get('q') || '');
   const [mailboxOpen, setMailboxOpen] = useState(false);
   const [editingMailbox, setEditingMailbox] = useState<InboundMailbox | null>(null);
+  const [deleteMailboxSource, setDeleteMailboxSource] = useState<InboundMailbox | null>(null);
+  const [deleteTargetMailboxId, setDeleteTargetMailboxId] = useState<number | null>(null);
+  const [deleteConfirmAddress, setDeleteConfirmAddress] = useState('');
   const [mailboxListQuery, setMailboxListQuery] = useState('');
   const [mailboxSort, setMailboxSort] = useState<MailboxSort>('activity');
   const [routeListQuery, setRouteListQuery] = useState('');
@@ -164,6 +167,21 @@ export default function Inbox() {
     () => sortMailboxes(filterMailboxes(mailboxes, mailboxListQuery), mailboxSort),
     [mailboxListQuery, mailboxSort, mailboxes]
   );
+  const deleteTargetMailboxes = useMemo(
+    () => mailboxes.filter((item) => item.id !== deleteMailboxSource?.id && isMailboxMigrationTarget(item, user?.id)),
+    [deleteMailboxSource?.id, mailboxes, user?.id]
+  );
+  const deleteTargetMailbox = useMemo(
+    () => deleteTargetMailboxes.find((item) => item.id === deleteTargetMailboxId) || null,
+    [deleteTargetMailboxId, deleteTargetMailboxes]
+  );
+  const deleteTargetOptions = useMemo(() => deleteTargetMailboxes.map((item) => ({
+    value: item.id,
+    label: locale.startsWith('en')
+      ? `${item.address} · ${countLabel(item.messageCount)} messages`
+      : `${item.address} · ${countLabel(item.messageCount)} 封邮件`
+  })), [deleteTargetMailboxes, locale]);
+  const deleteMailboxIsEmpty = deleteMailboxSource?.messageCount === 0;
   const visibleRoutes = useMemo(
     () => sortRoutes(filterRoutes(domains, routeListQuery), routeSort),
     [domains, routeListQuery, routeSort]
@@ -192,6 +210,13 @@ export default function Inbox() {
     return () => { baseRequestId.current += 1; };
   }, [loadBase]);
 
+  useEffect(() => {
+    setDeleteMailboxSource((current) => {
+      if (!current) return current;
+      return mailboxes.find((mailbox) => mailbox.id === current.id) || current;
+    });
+  }, [mailboxes]);
+
   useEffect(() => {
     if (!receivingMailboxes.length) return;
     if (routeMessageId && (!searchParams.has('mailboxId') || !searchParams.has('folder'))) return;
@@ -523,6 +548,62 @@ export default function Inbox() {
     }
   }
 
+  function openDeleteMailbox(mailbox: InboundMailbox) {
+    if (!isMailboxOwner(mailbox, user?.id)) return;
+    setDeleteMailboxSource(mailbox);
+    setDeleteTargetMailboxId(null);
+    setDeleteConfirmAddress('');
+  }
+
+  function closeDeleteMailbox() {
+    if (deleteMailboxSource && actionKey === `mailbox:delete:${deleteMailboxSource.id}`) return;
+    setDeleteMailboxSource(null);
+    setDeleteTargetMailboxId(null);
+    setDeleteConfirmAddress('');
+  }
+
+  async function deleteMailbox() {
+    const source = deleteMailboxSource;
+    const target = deleteTargetMailbox;
+    if (!source || deleteConfirmAddress !== source.address || (!deleteMailboxIsEmpty && !target)) return;
+    const key = `mailbox:delete:${source.id}`;
+    if (actionKey === key) return;
+    setActionKey(key);
+    try {
+      const result = await api.deleteInboundMailbox(source.id, {
+        targetMailboxId: target?.id ?? null,
+        confirmAddress: deleteConfirmAddress
+      });
+      if (selectedMailboxId === source.id) {
+        const next = new URLSearchParams(searchParams);
+        if (target) {
+          next.set('mailboxId', String(target.id));
+          next.set('folder', 'INBOX');
+        } else {
+          next.delete('mailboxId');
+          next.delete('folder');
+        }
+        next.delete('page');
+        setSearchParams(next, { replace: true });
+      }
+      setDeleteMailboxSource(null);
+      setDeleteTargetMailboxId(null);
+      setDeleteConfirmAddress('');
+      await loadBase();
+      const migratedMessageCount = Number(result.migratedMessageCount) || 0;
+      message.success(result.targetMailbox
+        ? (locale.startsWith('en')
+          ? `Mailbox deleted. ${migratedMessageCount} messages migrated to ${result.targetMailbox.address}.`
+          : `邮箱已删除,已将 ${migratedMessageCount} 封邮件迁移到 ${result.targetMailbox.address}。`)
+        : (locale.startsWith('en') ? 'Mailbox deleted.' : '邮箱已删除'));
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+      await loadBase();
+    } finally {
+      setActionKey('');
+    }
+  }
+
   function openCatchAll(domain: Domain) {
     setCatchAllDomain(domain);
     catchAllForm.setFieldsValue({ catchAllAddress: domain.catchAllAddress || '' });
@@ -562,6 +643,15 @@ export default function Inbox() {
             <Button icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button>
             <Button icon={<SettingOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button>
             <Button icon={<MailOutlined />} onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button>
+            <Button
+              danger
+              aria-label={`${t('inbox.deleteMailbox')} ${item.address}`}
+              icon={<DeleteOutlined />}
+              onClick={() => openDeleteMailbox(item)}
+              style={{ minHeight: 44 }}
+            >
+              {t('inbox.deleteMailbox')}
+            </Button>
           </Space>
         ) : <Typography.Text type="secondary">{t('inbox.ownerManaged')}</Typography.Text>}
       </Space>
@@ -768,6 +858,84 @@ export default function Inbox() {
         )}
       </Drawer>
 
+      <Modal
+        title={deleteMailboxIsEmpty ? t('inbox.deleteMailbox') : t('inbox.deleteMailboxTitle')}
+        open={Boolean(deleteMailboxSource)}
+        width={560}
+        okText={deleteMailboxIsEmpty ? t('inbox.deleteMailboxEmptyAction') : t('inbox.deleteMailboxAction')}
+        cancelText={t('common.cancel')}
+        okButtonProps={{
+          danger: true,
+          disabled: !deleteMailboxSource
+            || deleteConfirmAddress !== deleteMailboxSource.address
+            || (!deleteMailboxIsEmpty && !deleteTargetMailbox),
+          loading: Boolean(deleteMailboxSource && actionKey === `mailbox:delete:${deleteMailboxSource.id}`),
+          style: { minHeight: 44 }
+        }}
+        cancelButtonProps={{ style: { minHeight: 44 } }}
+        closable={!(deleteMailboxSource && actionKey === `mailbox:delete:${deleteMailboxSource.id}`)}
+        maskClosable={!(deleteMailboxSource && actionKey === `mailbox:delete:${deleteMailboxSource.id}`)}
+        onCancel={closeDeleteMailbox}
+        onOk={() => void deleteMailbox()}
+        destroyOnHidden
+      >
+        <Space direction="vertical" size={16} className="full-width">
+          <Alert type="warning" showIcon message={t(deleteMailboxIsEmpty ? 'inbox.deleteMailboxEmptyWarning' : 'inbox.deleteMailboxWarning')} />
+          <Descriptions bordered column={1} size="small">
+            <Descriptions.Item label={t('inbox.deleteMailboxSource')}>
+              <Typography.Text code>{deleteMailboxSource?.address}</Typography.Text>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('inbox.deleteMailboxMessages')}>
+              <Typography.Text strong>{countLabel(deleteMailboxSource?.messageCount)}</Typography.Text>
+            </Descriptions.Item>
+          </Descriptions>
+          <Form layout="vertical" className="full-width">
+            <Form.Item
+              label={t('inbox.deleteMailboxConfirmAddress')}
+              required
+              extra={t('inbox.deleteMailboxConfirmAddressExtra')}
+              validateStatus={deleteConfirmAddress && deleteConfirmAddress !== deleteMailboxSource?.address ? 'error' : undefined}
+              help={deleteConfirmAddress && deleteConfirmAddress !== deleteMailboxSource?.address
+                ? t('inbox.deleteMailboxConfirmAddressMismatch')
+                : undefined}
+            >
+              <Input
+                aria-label={t('inbox.deleteMailboxConfirmAddress')}
+                autoComplete="off"
+                value={deleteConfirmAddress}
+                placeholder={deleteMailboxSource?.address}
+                onChange={(event) => setDeleteConfirmAddress(event.target.value)}
+              />
+            </Form.Item>
+          </Form>
+          {!deleteMailboxIsEmpty && deleteTargetOptions.length ? (
+            <Form layout="vertical" className="full-width">
+              <Form.Item label={t('inbox.deleteMailboxTarget')} required extra={t('inbox.deleteMailboxTargetRequired')}>
+                <Select
+                  aria-label={t('inbox.deleteMailboxTarget')}
+                  value={deleteTargetMailboxId ?? undefined}
+                  options={deleteTargetOptions}
+                  placeholder={t('inbox.deleteMailboxTargetPlaceholder')}
+                  showSearch
+                  optionFilterProp="label"
+                  onChange={setDeleteTargetMailboxId}
+                  style={{ minHeight: 44 }}
+                />
+              </Form.Item>
+            </Form>
+          ) : !deleteMailboxIsEmpty ? <Alert type="error" showIcon message={t('inbox.deleteMailboxNoTarget')} /> : null}
+          {!deleteMailboxIsEmpty && deleteMailboxSource && deleteTargetMailbox ? (
+            <Alert
+              type="info"
+              showIcon
+              message={locale.startsWith('en')
+                ? `${countLabel(deleteMailboxSource.messageCount)} messages will be moved to ${deleteTargetMailbox.address} before deletion.`
+                : `删除前会先将 ${countLabel(deleteMailboxSource.messageCount)} 封邮件移入 ${deleteTargetMailbox.address}。`}
+            />
+          ) : null}
+        </Space>
+      </Modal>
+
       <Modal title={t('inbox.catchAllTitle')} open={Boolean(catchAllDomain)} confirmLoading={actionKey.startsWith('catch-all:')} onCancel={() => setCatchAllDomain(null)} onOk={() => void saveCatchAll()}>
         <Form form={catchAllForm} layout="vertical"><Form.Item name="catchAllAddress" label={t('inbox.catchAllAddress')} extra={t('inbox.catchAllExtra')}><Input placeholder="catchall@example.com / /dev/null" /></Form.Item></Form>
       </Modal>
@@ -1095,7 +1263,15 @@ function WebmailButton({ block = false, mailbox, locale, loading, onClick }: {
 
 function isMailboxOwner(mailbox: InboundMailbox, userId?: number) {
   if (!userId) return false;
-  return mailbox.access?.type === 'owner' || (mailbox.ownerUserId || mailbox.userId) === userId;
+  if (mailbox.access) return mailbox.access.type === 'owner';
+  return (mailbox.ownerUserId || mailbox.userId) === userId;
+}
+
+function isMailboxMigrationTarget(mailbox: InboundMailbox, userId?: number) {
+  if (!isMailboxOwner(mailbox, userId) || mailbox.status !== 'active') return false;
+  if (!mailbox.temporary || !mailbox.expiresAt) return true;
+  const expiresAt = Date.parse(mailbox.expiresAt);
+  return !Number.isFinite(expiresAt) || expiresAt > Date.now();
 }
 
 function MailboxAccessSource({ mailbox, t }: { mailbox: InboundMailbox; t: (key: string) => string }) {

+ 506 - 17
src/server.js

@@ -5,10 +5,13 @@ import http from 'node:http';
 import path from 'node:path';
 import { fileURLToPath, domainToASCII } from 'node:url';
 import {
+  assertInboundMailboxDeletionNotInProgress,
   authenticateUser,
   authenticateApiToken,
   approveUser,
+  bulkUpdateInboundMailboxGrants,
   claimLegacyData,
+  completeInboundMailboxDeletionJob,
   createApiToken,
   createAccountToken,
   createDomain,
@@ -21,6 +24,7 @@ import {
   consumeAccountToken,
   deleteDnsCredential,
   deleteDomain,
+  deleteInboundMailboxWithMessageTransfer,
   deleteSmtpCredential,
   deleteWebhook,
   enqueueWebhookTestDelivery,
@@ -34,6 +38,7 @@ import {
   getInboundMessageMaildirStorage,
   getInboundMailbox,
   getInboundMailboxForSender,
+  getWebhook,
   getSendEvent,
   getSendAnalytics,
   getSettings,
@@ -51,8 +56,10 @@ import {
   listDnsCredentials,
   listDomains,
   listInboundMailboxDomains,
+  listInboundMailboxTransferMessages,
   listInboundMailboxes,
   listInboundMailboxFolders,
+  listInboundMaildirIndex,
   searchInboundMessages,
   searchSendEvents,
   listSmtpCredentials,
@@ -64,10 +71,12 @@ import {
   logSendEvent,
   markInboundMessageRead,
   recordInboundMessageMaildirStorage,
+  prepareInboundMailboxDeletion,
   markUserEmailVerified,
   previewUserMerge,
   replayWebhookDelivery,
   replaceInboundMailboxGrants,
+  restorePreparedInboundMailboxDeletion,
   rotateApiToken,
   rotateWebhookSecret,
   saveDnsCredential,
@@ -83,6 +92,7 @@ import {
   transferDnsCredential,
   transferDomain,
   updateInboundMailbox,
+  updateInboundMailboxDeletionJob,
   updateApiToken,
   updateDkim,
   updateDomain,
@@ -96,6 +106,13 @@ import {
   revokeApiToken,
   verifyUserCredentials
 } from './db.js';
+import {
+  publishInboundMailboxMaildirTransfer,
+  recoverPendingInboundMailboxDeletions,
+  rollbackInboundMailboxMaildirTransfer,
+  stageInboundMailboxMaildirTransfer,
+  verifyPublishedInboundMailboxMaildirTransfer
+} from './inbound-mailbox-delete.js';
 import { applyDnsSetup, testDnsCredential } from './dns-providers.js';
 import { startDnsAutoChecker } from './dns-auto-checker.js';
 import { startPostfixDeliveryTracker } from './delivery-tracker.js';
@@ -120,8 +137,20 @@ import {
 } from './mail-access.js';
 import { startDovecotAuthServer } from './dovecot-auth-server.js';
 import { repairStoredInboundMime } from './inbound-mime-repair.js';
-import { maildirRootFromEnvironment, setMaildirMessageSeen } from './maildir-store.js';
-import { startMaildirReconciler } from './maildir-sync.js';
+import {
+  assertQuarantinedMaildirMailboxUnchanged,
+  maildirRootFromEnvironment,
+  quarantineMaildirMailbox,
+  removeMaildirMessageFilesByStorageKeyPrefix,
+  restoreQuarantinedMaildirMailbox,
+  snapshotQuarantinedMaildirMailbox,
+  setMaildirMessageSeen
+} from './maildir-store.js';
+import {
+  acquireMaildirReconciliationLocks,
+  reconcileMaildirMailbox,
+  startMaildirReconciler
+} from './maildir-sync.js';
 import {
   parseSubmissionListeners,
   publicSubmissionListeners,
@@ -244,8 +273,30 @@ const defaultSettings = {
 const emailVerificationPurpose = 'email_verification';
 const passwordResetPurpose = 'password_reset';
 const webmailSsoConfig = loadWebmailSsoConfig(process.env);
+const pendingInboundMailboxDeletions = new Set();
+let dovecotAuthServer = null;
+
+function hasPendingInboundMailboxDeletion(mailboxIds) {
+  return mailboxIds.some((value) => {
+    const mailboxId = Number(value);
+    return Number.isSafeInteger(mailboxId)
+      && mailboxId > 0
+      && pendingInboundMailboxDeletions.has(mailboxId);
+  });
+}
+
+function sendInboundMailboxDeletionInProgress(res) {
+  return sendJson(res, 409, {
+    error: '该邮箱正在参与删除或邮件迁移,请稍后刷新。',
+    code: 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  });
+}
 
 initDatabase(envConfig.dataDir, envConfig.sessionSecret);
+await recoverPendingInboundMailboxDeletions({
+  root: envConfig.maildirRoot,
+  maildirEnabled: envConfig.mailAccessBackend === 'dovecot'
+});
 try {
   const inboundMimeRepair = await repairStoredInboundMime();
   if (inboundMimeRepair.repaired) {
@@ -349,7 +400,7 @@ server.listen(envConfig.port, '0.0.0.0', () => {
 });
 
 if (envConfig.dovecotAuthEnabled) {
-  startDovecotAuthServer({
+  dovecotAuthServer = startDovecotAuthServer({
     host: envConfig.dovecotAuthHost,
     port: envConfig.dovecotAuthPort,
     secretFile: envConfig.dovecotAuthSecretFile,
@@ -545,9 +596,16 @@ async function handleApi(req, res, url, user) {
   }
   const inboundMailboxMatch = pathname.match(/^\/api\/inbound-mailboxes\/(\d+)$/);
   if (inboundMailboxMatch && (method === 'PATCH' || method === 'PUT')) {
+    const mailboxId = Number(inboundMailboxMatch[1]);
+    if (hasPendingInboundMailboxDeletion([mailboxId])) {
+      return sendInboundMailboxDeletionInProgress(res);
+    }
     const body = await readJson(req);
+    if (hasPendingInboundMailboxDeletion([mailboxId])) {
+      return sendInboundMailboxDeletionInProgress(res);
+    }
     try {
-      const mailbox = updateInboundMailbox(user.id, Number(inboundMailboxMatch[1]), inboundMailboxPatch(body));
+      const mailbox = updateInboundMailbox(user.id, mailboxId, inboundMailboxPatch(body));
       if (!mailbox) return sendJson(res, 404, { error: '收信邮箱不存在。' });
       return sendJson(res, 200, {
         mailbox,
@@ -557,9 +615,293 @@ async function handleApi(req, res, url, user) {
       if (error?.code === 'INBOUND_MAILBOX_NAMESPACE_CONFLICT') {
         return sendJson(res, 409, { error: error.message });
       }
+      if (error?.code === 'INBOUND_MAILBOX_DELETE_IN_PROGRESS') {
+        return sendJson(res, 409, { error: error.message, code: error.code });
+      }
       return sendJson(res, 400, { error: error.message || '收信邮箱更新失败。' });
     }
   }
+  if (inboundMailboxMatch && method === 'DELETE') {
+    const sourceMailboxId = Number(inboundMailboxMatch[1]);
+    if (!Number.isSafeInteger(sourceMailboxId) || sourceMailboxId <= 0) {
+      return sendJson(res, 400, {
+        error: '待删除邮箱不存在或当前账号无权删除。',
+        code: 'INBOUND_MAILBOX_DELETE_INVALID_ID'
+      });
+    }
+    if (pendingInboundMailboxDeletions.has(sourceMailboxId)) {
+      return sendJson(res, 409, {
+        error: '该邮箱正在删除,请稍后刷新。',
+        code: 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+      });
+    }
+    let body;
+    try {
+      body = await readJson(req);
+    } catch {
+      return sendJson(res, 400, {
+        error: '请求内容不是有效的 JSON。',
+        code: 'INBOUND_MAILBOX_DELETE_INVALID_BODY'
+      });
+    }
+    const hasTargetMailbox = body.targetMailboxId !== null
+      && body.targetMailboxId !== undefined
+      && body.targetMailboxId !== '';
+    const targetMailboxId = !hasTargetMailbox
+      ? null
+      : Number(body.targetMailboxId);
+    if (hasTargetMailbox && (!Number.isSafeInteger(targetMailboxId) || targetMailboxId <= 0)) {
+      return sendJson(res, 400, {
+        error: '目标邮箱不存在或当前账号无权管理。',
+        code: 'INBOUND_MAILBOX_DELETE_INVALID_ID'
+      });
+    }
+    const reservedMailboxIds = [...new Set([sourceMailboxId, targetMailboxId].filter((id) => id !== null))];
+    if (reservedMailboxIds.some((id) => pendingInboundMailboxDeletions.has(id))) {
+      return sendJson(res, 409, {
+        error: '该邮箱正在参与删除或邮件迁移,请稍后刷新。',
+        code: 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+      });
+    }
+    const deletionJobId = crypto.randomBytes(16).toString('hex');
+    for (const mailboxId of reservedMailboxIds) pendingInboundMailboxDeletions.add(mailboxId);
+    let prepared = null;
+    let stagedMaildir = null;
+    let maildirPublished = false;
+    let maildirQuarantine = null;
+    let frozenSnapshot = null;
+    let releaseReconciliationLocks = null;
+    let completed = false;
+    let sourceMaildirRestored = false;
+    try {
+      releaseReconciliationLocks = await acquireMaildirReconciliationLocks([
+        sourceMailboxId,
+        Number.isSafeInteger(targetMailboxId) && targetMailboxId > 0 ? targetMailboxId : null
+      ]);
+      prepared = prepareInboundMailboxDeletion(user.id, sourceMailboxId, targetMailboxId, {
+        confirmAddress: body.confirmAddress,
+        jobId: deletionJobId
+      });
+      dovecotAuthServer?.invalidateAuthUser?.(prepared.sourceMailbox.address);
+      if (envConfig.mailAccessBackend === 'dovecot') {
+        const quarantineRelpath = `.mailhub-quarantine/delete-${deletionJobId}`;
+        updateInboundMailboxDeletionJob(deletionJobId, {
+          phase: 'quarantine_planned',
+          quarantineRelpath
+        });
+        maildirQuarantine = await quarantineMaildirMailbox({
+          root: envConfig.maildirRoot,
+          address: prepared.sourceMailbox.address,
+          mailboxId: prepared.sourceMailbox.id,
+          jobId: deletionJobId,
+          beforeQuarantine: ({ moved, quarantineRelpath: plannedRelpath }) => {
+            maildirQuarantine = {
+              moved,
+              address: prepared.sourceMailbox.address,
+              quarantineRelpath: plannedRelpath,
+              allowNotYetQuarantined: true
+            };
+            updateInboundMailboxDeletionJob(deletionJobId, {
+              phase: 'quarantining_source',
+              quarantineRelpath: plannedRelpath,
+              quarantineMoved: moved
+            });
+          }
+        });
+        updateInboundMailboxDeletionJob(deletionJobId, {
+          phase: 'source_quarantined',
+          quarantineRelpath: maildirQuarantine.quarantineRelpath || quarantineRelpath,
+          quarantineMoved: maildirQuarantine.moved
+        });
+        frozenSnapshot = await snapshotQuarantinedMaildirMailbox({
+          root: envConfig.maildirRoot,
+          quarantine: maildirQuarantine
+        });
+        await reconcileMaildirMailbox({
+          root: envConfig.maildirRoot,
+          mailbox: prepared.sourceMailbox,
+          snapshot: frozenSnapshot,
+          skipLock: true
+        });
+        const messages = listInboundMailboxTransferMessages(user.id, sourceMailboxId);
+        if (!messages) {
+          const error = new Error('待删除邮箱不存在或当前账号无权删除。');
+          error.code = 'INBOUND_MAILBOX_DELETE_SOURCE_NOT_FOUND';
+          throw error;
+        }
+        if (messages.length > 0 && !prepared.targetMailbox) {
+          const error = new Error('该邮箱仍有邮件,请选择接收邮件的目标邮箱。');
+          error.code = 'INBOUND_MAILBOX_DELETE_TARGET_REQUIRED';
+          throw error;
+        }
+        if (prepared.targetMailbox) {
+          updateInboundMailboxDeletionJob(deletionJobId, { phase: 'staging_target' });
+          stagedMaildir = await stageInboundMailboxMaildirTransfer({
+            root: envConfig.maildirRoot,
+            sourceMailbox: prepared.sourceMailbox,
+            targetMailbox: prepared.targetMailbox,
+            messages,
+            reservedStorageKeys: listInboundMaildirIndex(targetMailboxId)
+              .map((message) => message.storageKey),
+            sourceEntries: frozenSnapshot.entries,
+            jobId: deletionJobId
+          });
+          updateInboundMailboxDeletionJob(deletionJobId, {
+            phase: 'target_staged',
+            targetArtifacts: stagedMaildir.stagedMessages.map((message, index) => ({
+              messageId: stagedMaildir.storageUpdates[index]?.messageId,
+              storageKey: message.storage?.key,
+              storageRelpath: message.storage?.relpath,
+              stagingRelpath: message.stagingRelpath
+            }))
+          });
+        }
+      }
+      if (envConfig.mailAccessBackend === 'dovecot' && maildirQuarantine) {
+        await assertQuarantinedMaildirMailboxUnchanged({
+          root: envConfig.maildirRoot,
+          quarantine: maildirQuarantine,
+          snapshot: frozenSnapshot
+        });
+      }
+      if (stagedMaildir) {
+        updateInboundMailboxDeletionJob(deletionJobId, { phase: 'publishing_target' });
+        await publishInboundMailboxMaildirTransfer({
+          root: envConfig.maildirRoot,
+          targetMailbox: prepared.targetMailbox,
+          stagedMessages: stagedMaildir.stagedMessages
+        });
+        maildirPublished = true;
+        updateInboundMailboxDeletionJob(deletionJobId, { phase: 'target_published' });
+        stagedMaildir.storageUpdates = await verifyPublishedInboundMailboxMaildirTransfer({
+          root: envConfig.maildirRoot,
+          targetMailbox: prepared.targetMailbox,
+          storageUpdates: stagedMaildir.storageUpdates,
+          jobId: deletionJobId
+        });
+      }
+      updateInboundMailboxDeletionJob(deletionJobId, { phase: 'committing_database' });
+      const result = deleteInboundMailboxWithMessageTransfer(
+        user.id,
+        sourceMailboxId,
+        targetMailboxId,
+        {
+          storageUpdates: stagedMaildir?.storageUpdates ?? null,
+          deletionJobId
+        }
+      );
+      completed = true;
+      completeInboundMailboxDeletionJob(deletionJobId, 'completed');
+      dovecotAuthServer?.invalidateAuthUser?.(prepared.sourceMailbox.address);
+      try {
+        logAudit({
+          actorUserId: user.id,
+          action: 'mailbox.delete_with_message_transfer',
+          targetType: 'inbound_mailbox',
+          targetId: sourceMailboxId,
+          targetUserId: user.id,
+          summary: {
+            sourceAddress: result.deletedMailbox.address,
+            targetMailboxId: result.targetMailbox?.id || null,
+            targetAddress: result.targetMailbox?.address || '',
+            migratedMessageCount: result.migratedMessageCount,
+            maildirRecoveryRetained: Boolean(maildirQuarantine?.moved)
+          }
+        });
+      } catch (auditError) {
+        console.warn(`Unable to record mailbox deletion audit log: ${auditError.message || auditError}`);
+      }
+      // The source Maildir is retained under the non-scanned quarantine root.
+      // This preserves files appended by an existing IMAP session without
+      // keeping the deleted address active or exposing the recovery path.
+      return sendJson(res, 200, result);
+    } catch (error) {
+      let rollbackSucceeded = true;
+      let stagedCleanupError = null;
+      if (!completed && stagedMaildir) {
+        try {
+          await rollbackInboundMailboxMaildirTransfer({
+            root: envConfig.maildirRoot,
+            targetMailbox: prepared.targetMailbox,
+            stagedMessages: stagedMaildir.stagedMessages,
+            published: maildirPublished
+          });
+        } catch (cleanupError) {
+          stagedCleanupError = cleanupError;
+          console.error('Unable to roll back staged mailbox Maildir files.', cleanupError);
+        }
+      }
+      if (
+        !completed
+        && prepared?.targetMailbox
+        && envConfig.mailAccessBackend === 'dovecot'
+      ) {
+        try {
+          await removeMaildirMessageFilesByStorageKeyPrefix({
+            root: envConfig.maildirRoot,
+            address: prepared.targetMailbox.address,
+            storageKeyPrefix: `mhappend-${deletionJobId}-`
+          });
+          stagedCleanupError = null;
+        } catch (cleanupError) {
+          rollbackSucceeded = false;
+          console.error('Unable to sweep mailbox deletion target Maildir files.', cleanupError);
+        }
+      } else if (stagedCleanupError) {
+        rollbackSucceeded = false;
+      }
+      if (!completed && prepared && envConfig.mailAccessBackend === 'dovecot') {
+        try {
+          updateInboundMailboxDeletionJob(deletionJobId, { phase: 'restoring_source' });
+          const restoredQuarantine = await restoreQuarantinedMaildirMailbox({
+            root: envConfig.maildirRoot,
+            quarantine: maildirQuarantine || {
+              moved: false,
+              address: prepared.sourceMailbox.address,
+              quarantineRelpath: `.mailhub-quarantine/delete-${deletionJobId}`,
+              allowAlreadyRestored: false
+            }
+          });
+          if (
+            maildirQuarantine?.moved === true
+            && !restoredQuarantine
+            && maildirQuarantine.allowNotYetQuarantined !== true
+          ) {
+            throw new Error('Maildir 隔离目录未恢复,无法重新启用源邮箱。');
+          }
+          sourceMaildirRestored = true;
+          updateInboundMailboxDeletionJob(deletionJobId, { phase: 'source_restored' });
+        } catch (restoreError) {
+          if (restoreError?.maildirSourceRenamed === true) sourceMaildirRestored = true;
+          rollbackSucceeded = false;
+          console.error('Unable to restore quarantined source Maildir.', restoreError);
+        }
+      }
+      if (!completed && prepared && rollbackSucceeded) {
+        rollbackSucceeded = restorePreparedInboundMailboxDeletion(
+          user.id,
+          sourceMailboxId,
+          prepared.originalSourceStatus
+        );
+      }
+      if (!completed && prepared) {
+        if (rollbackSucceeded) {
+          completeInboundMailboxDeletionJob(deletionJobId, 'rolled_back');
+        } else {
+          updateInboundMailboxDeletionJob(deletionJobId, {
+            phase: sourceMaildirRestored
+              ? 'source_restored'
+              : 'rollback_failed',
+            errorMessage: error.message || 'mailbox deletion rollback failed'
+          });
+        }
+      }
+      return sendInboundMailboxDeletionError(res, error);
+    } finally {
+      releaseReconciliationLocks?.();
+      for (const mailboxId of reservedMailboxIds) pendingInboundMailboxDeletions.delete(mailboxId);
+    }
+  }
   if (method === 'GET' && pathname === '/api/inbound-messages') {
     if (!requireApiTokenScope(req, res, 'messages:read')) return;
     try {
@@ -593,20 +935,66 @@ async function handleApi(req, res, url, user) {
     if (method === 'PATCH') {
       const body = await readJson(req);
       const access = apiTokenMailboxAccess(req, user, 'receive');
-      const storage = getInboundMessageMaildirStorage(user.id, id, access);
-      if (storage?.backend === 'maildir') {
-        const updatedStorage = await setMaildirMessageSeen({
-          root: envConfig.maildirRoot,
-          address: storage.mailboxAddress,
-          storageKey: storage.key,
-          relpath: storage.relpath,
-          seen: body.read !== false
-        });
-        if (!updatedStorage) return sendJson(res, 409, { error: '邮件存储已发生变化,请刷新后重试。' });
-        recordInboundMessageMaildirStorage(id, updatedStorage);
+      let storage = getInboundMessageMaildirStorage(user.id, id, access);
+      let releaseMessageLock = null;
+      try {
+        if (!storage) return sendJson(res, 404, { message: null });
+        if (hasPendingInboundMailboxDeletion([storage.mailboxId])) {
+          return sendInboundMailboxDeletionInProgress(res);
+        }
+        assertInboundMailboxDeletionNotInProgress(storage.mailboxId);
+        if (storage?.backend === 'maildir') {
+          const lockedMailboxId = storage.mailboxId;
+          releaseMessageLock = await acquireMaildirReconciliationLocks([lockedMailboxId]);
+          storage = getInboundMessageMaildirStorage(user.id, id, access);
+          if (!storage || storage.mailboxId !== lockedMailboxId || storage.backend !== 'maildir') {
+            return sendJson(res, 409, { error: '邮件存储已发生变化,请刷新后重试。' });
+          }
+          if (pendingInboundMailboxDeletions.has(storage.mailboxId)) {
+            return sendJson(res, 409, {
+              error: '该邮箱正在参与删除或邮件迁移,请稍后刷新。',
+              code: 'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+            });
+          }
+          assertInboundMailboxDeletionNotInProgress(storage.mailboxId);
+          const updatedStorage = await setMaildirMessageSeen({
+            root: envConfig.maildirRoot,
+            address: storage.mailboxAddress,
+            storageKey: storage.key,
+            relpath: storage.relpath,
+            seen: body.read !== false
+          });
+          if (!updatedStorage) return sendJson(res, 409, { error: '邮件存储已发生变化,请刷新后重试。' });
+          if (!recordInboundMessageMaildirStorage(id, updatedStorage, { mailboxId: storage.mailboxId })) {
+            return sendJson(res, 409, { error: '邮件存储已发生变化,请刷新后重试。' });
+          }
+          const message = markInboundMessageRead(
+            user.id,
+            id,
+            body.read !== false,
+            access,
+            { mailboxId: storage.mailboxId }
+          );
+          return sendJson(res, message ? 200 : 409, {
+            ...(message ? { message } : { error: '邮件存储已发生变化,请刷新后重试。' })
+          });
+        }
+        const message = markInboundMessageRead(
+          user.id,
+          id,
+          body.read !== false,
+          access,
+          { mailboxId: storage.mailboxId }
+        );
+        return sendJson(res, message ? 200 : 404, { message });
+      } catch (error) {
+        if (error?.code === 'INBOUND_MAILBOX_DELETE_IN_PROGRESS') {
+          return sendJson(res, 409, { error: error.message, code: error.code });
+        }
+        throw error;
+      } finally {
+        releaseMessageLock?.();
       }
-      const message = markInboundMessageRead(user.id, id, body.read !== false, access);
-      return sendJson(res, message ? 200 : 404, { message });
     }
   }
   const sendEventMatch = pathname.match(/^\/api\/events\/(\d+)$/);
@@ -875,6 +1263,12 @@ async function handleApi(req, res, url, user) {
     const body = await readJson(req);
     try {
       await assertSafeWebhookUrl(String(body.url || '').trim());
+      const requestedMailbox = body.mailboxId === undefined || body.mailboxId === null || body.mailboxId === ''
+        ? null
+        : getInboundMailbox(Number(body.mailboxId), user.id);
+      if (requestedMailbox && hasPendingInboundMailboxDeletion([requestedMailbox.id])) {
+        return sendInboundMailboxDeletionInProgress(res);
+      }
       const webhook = createWebhook(user.id, {
         name: body.name,
         url: body.url,
@@ -885,6 +1279,9 @@ async function handleApi(req, res, url, user) {
       });
       return sendJson(res, 201, { webhook });
     } catch (error) {
+      if (error?.code === 'INBOUND_MAILBOX_DELETE_IN_PROGRESS') {
+        return sendJson(res, 409, { error: error.message, code: error.code });
+      }
       return sendJson(res, 400, { error: error.message || 'Webhook 创建失败。' });
     }
   }
@@ -898,6 +1295,17 @@ async function handleApi(req, res, url, user) {
         if (body.url !== undefined) {
           await assertSafeWebhookUrl(String(body.url || '').trim());
         }
+        const currentWebhook = getWebhook(id, user.id);
+        if (!currentWebhook) return sendJson(res, 404, { error: 'Webhook 不存在。' });
+        const requestedMailbox = body.mailboxId === undefined || body.mailboxId === null || body.mailboxId === ''
+          ? null
+          : getInboundMailbox(Number(body.mailboxId), user.id);
+        if (hasPendingInboundMailboxDeletion([
+          currentWebhook.mailboxId,
+          requestedMailbox?.id
+        ])) {
+          return sendInboundMailboxDeletionInProgress(res);
+        }
         const patch = {};
         if (body.name !== undefined) patch.name = body.name;
         if (body.url !== undefined) patch.url = body.url;
@@ -909,6 +1317,9 @@ async function handleApi(req, res, url, user) {
         if (!webhook) return sendJson(res, 404, { error: 'Webhook 不存在。' });
         return sendJson(res, 200, { webhook });
       } catch (error) {
+        if (error?.code === 'INBOUND_MAILBOX_DELETE_IN_PROGRESS') {
+          return sendJson(res, 409, { error: error.message, code: error.code });
+        }
         return sendJson(res, 400, { error: error.message || 'Webhook 更新失败。' });
       }
     }
@@ -1083,11 +1494,47 @@ async function handleAdminApi(req, res, url, user) {
   if (method === 'GET' && pathname === '/api/admin/mailbox-access') {
     return sendJson(res, 200, { mailboxes: listAdminInboundMailboxAccess() });
   }
+  if (method === 'POST' && pathname === '/api/admin/inbound-mailboxes/access/bulk') {
+    try {
+      const body = await readJson(req);
+      if (hasPendingInboundMailboxDeletion(Array.isArray(body.mailboxIds) ? body.mailboxIds : [])) {
+        return sendInboundMailboxDeletionInProgress(res);
+      }
+      const result = bulkUpdateInboundMailboxGrants({
+        actorUserId: user.id,
+        mailboxIds: body.mailboxIds,
+        userIds: body.userIds,
+        operation: body.operation,
+        permissions: body.permissions
+      });
+      return sendJson(res, 200, result);
+    } catch (error) {
+      if (error?.code === 'INBOUND_MAILBOX_DELETE_IN_PROGRESS') {
+        return sendJson(res, 409, { error: error.message, code: error.code });
+      }
+      if ([
+        'BULK_MAILBOX_ACCESS_MAILBOX_NOT_FOUND',
+        'BULK_MAILBOX_ACCESS_USER_NOT_FOUND'
+      ].includes(error?.code)) {
+        return sendJson(res, 404, { error: error.message, code: error.code });
+      }
+      if (error?.code === 'BULK_MAILBOX_ACCESS_USER_DISABLED') {
+        return sendJson(res, 409, { error: error.message, code: error.code });
+      }
+      return sendJson(res, 400, {
+        error: error?.message || '批量邮箱权限保存失败。',
+        ...(error?.code ? { code: error.code } : {})
+      });
+    }
+  }
   const mailboxAccessMatch = pathname.match(/^\/api\/admin\/inbound-mailboxes\/(\d+)\/access$/);
   if (mailboxAccessMatch && (method === 'PUT' || method === 'PATCH')) {
     const mailboxId = Number(mailboxAccessMatch[1]);
     try {
       const body = await readJson(req);
+      if (hasPendingInboundMailboxDeletion([mailboxId])) {
+        return sendInboundMailboxDeletionInProgress(res);
+      }
       const mailbox = replaceInboundMailboxGrants(mailboxId, body.grants);
       logAudit({
         actorUserId: user.id,
@@ -1105,6 +1552,9 @@ async function handleAdminApi(req, res, url, user) {
       });
       return sendJson(res, 200, { mailbox });
     } catch (error) {
+      if (error?.code === 'INBOUND_MAILBOX_DELETE_IN_PROGRESS') {
+        return sendJson(res, 409, { error: error.message, code: error.code });
+      }
       if (error?.message === '收信邮箱不存在。') {
         return sendJson(res, 404, { error: error.message });
       }
@@ -1456,6 +1906,9 @@ function adminAuditFilters(searchParams) {
 }
 
 function sendAdminTransferError(res, error) {
+  if (error?.code === 'INBOUND_MAILBOX_DELETE_IN_PROGRESS') {
+    return sendJson(res, 409, { error: error.message, code: error.code });
+  }
   if (['域名不存在。', 'DNS 凭据不存在。', 'API Token 不存在。'].includes(error?.message)) {
     return sendJson(res, 404, { error: error.message });
   }
@@ -1466,6 +1919,9 @@ function sendAdminTransferError(res, error) {
 }
 
 function sendAdminMigrationError(res, error) {
+  if (error?.code === 'INBOUND_MAILBOX_DELETE_IN_PROGRESS') {
+    return sendJson(res, 409, { error: error.message, code: error.code });
+  }
   if (['源用户不存在。'].includes(error?.message)) return sendJson(res, 404, { error: error.message });
   if ([
     '目标用户不可用。',
@@ -1477,6 +1933,39 @@ function sendAdminMigrationError(res, error) {
   throw error;
 }
 
+function sendInboundMailboxDeletionError(res, error) {
+  const code = String(error?.code || '');
+  if ([
+    'INBOUND_MAILBOX_DELETE_INVALID_ID',
+    'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH',
+    'INBOUND_MAILBOX_DELETE_SAME_TARGET',
+    'INBOUND_MAILBOX_DELETE_TARGET_REQUIRED'
+  ].includes(code)) {
+    return sendJson(res, 400, { error: error.message, code });
+  }
+  if ([
+    'INBOUND_MAILBOX_DELETE_SOURCE_NOT_FOUND',
+    'INBOUND_MAILBOX_DELETE_TARGET_NOT_FOUND'
+  ].includes(code)) {
+    return sendJson(res, 404, { error: error.message, code });
+  }
+  if ([
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS',
+    'INBOUND_MAILBOX_DELETE_REFERENCES_BLOCKED',
+    'INBOUND_MAILBOX_DELETE_TARGET_INACTIVE',
+    'INBOUND_MAILBOX_DELETE_NOT_PREPARED',
+    'INBOUND_MAILBOX_DELETE_MESSAGE_CONFLICT'
+  ].includes(code)) {
+    return sendJson(res, 409, {
+      error: error.message,
+      code,
+      ...(Array.isArray(error.blockers) ? { blockers: error.blockers } : {})
+    });
+  }
+  console.error('Inbound mailbox deletion failed.', error);
+  return sendJson(res, 500, { error: '邮箱删除失败,请稍后重试。' });
+}
+
 function auditUserIdParam(value, { allowSystem = false } = {}) {
   if (value === null) return undefined;
   const text = String(value).trim();

+ 61 - 3
src/submission.js

@@ -17,7 +17,11 @@ import {
   verifySmtpCredential
 } from './db.js';
 import { parseInboundMessage } from './inbound-mail.js';
-import { newMaildirStorageKey, writeMaildirMessage } from './maildir-store.js';
+import {
+  newMaildirStorageKey,
+  removeMaildirMessageFiles,
+  writeMaildirMessage
+} from './maildir-store.js';
 import {
   addHeadersToRawMessage,
   buildDeliverabilityHeaders,
@@ -406,7 +410,8 @@ class SubmissionSession {
 
   mail(argument) {
     if (!this.authenticated && !this.config.inboundEnabled) return this.write(530, 'Authentication required');
-    if (!this.webmailAuthorizationValid()) {
+    if (!this.submissionAuthorizationValid()) {
+      this.resetEnvelope(false);
       this.clearAuthentication();
       return this.write(530, 'Authentication no longer valid');
     }
@@ -449,6 +454,11 @@ class SubmissionSession {
 
   data() {
     if (!this.authenticated && !this.config.inboundEnabled) return this.write(530, 'Authentication required');
+    if (!this.submissionAuthorizationValid()) {
+      this.resetEnvelope(false);
+      this.clearAuthentication();
+      return this.write(530, 'Authentication no longer valid');
+    }
     if (!this.mailFromAccepted || !this.recipients.length) return this.write(503, 'Need MAIL FROM and RCPT TO first');
     this.dataMode = true;
     this.dataLines = [];
@@ -465,7 +475,7 @@ class SubmissionSession {
     }
     const rawMessageBytes = Buffer.from(`${this.dataLines.join('\r\n')}\r\n`, 'latin1');
     if (!this.authenticated) return await this.finishInboundData(rawMessageBytes);
-    if (!this.webmailAuthorizationValid()) {
+    if (!this.submissionAuthorizationValid()) {
       this.resetEnvelope(false);
       this.clearAuthentication();
       return this.write(530, 'Authentication no longer valid');
@@ -608,6 +618,14 @@ class SubmissionSession {
   async finishInboundData(rawMessageBytes) {
     if (!this.config.inboundEnabled) return this.write(530, 'Authentication required');
     if (!this.inboundRoutes.length) return this.write(550, 'Recipient is not a local MailHub mailbox');
+    const currentRoutes = this.inboundRoutes.map((cachedRoute) => (
+      resolveInboundRecipient(cachedRoute.recipient)
+    ));
+    if (currentRoutes.some((route, index) => !sameInboundRouteDestination(route, this.inboundRoutes[index]))) {
+      this.resetEnvelope(false);
+      return this.write(451, 'Recipient routing changed; please retry');
+    }
+    this.inboundRoutes = currentRoutes;
     try {
       const parsedMessage = await parseInboundMessage(rawMessageBytes, this.recipients);
       const rawMessage = rawMessageBytes.toString('utf8');
@@ -645,6 +663,18 @@ class SubmissionSession {
               ...(storage ? { storage } : {})
             });
           } catch (error) {
+            if (storage && error?.code === 'INBOUND_MAILBOX_DELIVERY_UNAVAILABLE') {
+              try {
+                await removeMaildirMessageFiles({
+                  root: this.config.maildirRoot,
+                  address: route.mailbox.address,
+                  storages: [storage]
+                });
+              } catch (cleanupError) {
+                console.error(`Inbound Maildir cleanup failed for ${route.recipient}: ${cleanupError.message}`);
+              }
+              throw error;
+            }
             if (!storage) throw error;
             // Maildir is the durable source. The reconciler will rebuild a
             // transiently failed SQLite index and webhook outbox atomically
@@ -717,6 +747,27 @@ class SubmissionSession {
     );
   }
 
+  submissionAuthorizationValid() {
+    if (!this.webmailAuthorizationValid()) return false;
+    if (!this.authMailbox) return true;
+    const currentMailbox = getInboundMailboxForSender(
+      this.authMailbox.userId,
+      this.authMailbox.address,
+      { ownerOnly: true }
+    );
+    if (
+      !currentMailbox
+      || currentMailbox.id !== Number(this.authMailbox.id)
+      || currentMailbox.address !== this.authMailbox.address
+      || currentMailbox.userId !== Number(this.authMailbox.userId)
+      || currentMailbox.domainId !== Number(this.authMailbox.domainId)
+    ) {
+      return false;
+    }
+    this.authMailbox = currentMailbox;
+    return true;
+  }
+
   clearAuthentication() {
     this.authenticated = false;
     this.user = null;
@@ -738,6 +789,13 @@ class SubmissionSession {
   }
 }
 
+function sameInboundRouteDestination(current, cached) {
+  if (!current || !cached) return false;
+  return Number(current.mailbox?.id || 0) === Number(cached.mailbox?.id || 0)
+    && Boolean(current.drop) === Boolean(cached.drop)
+    && Number(current.domainId || 0) === Number(cached.domainId || 0);
+}
+
 function extractPathAddress(argument, { allowEmpty = false } = {}) {
   const match = String(argument || '').match(/FROM:\s*<([^>]*)>|TO:\s*<([^>]*)>/i);
   const raw = match ? (match[1] ?? match[2]) : argument;

+ 56 - 1
test/dovecot-auth-server.test.js

@@ -138,11 +138,16 @@ test('Dovecot authentication bridge caches only successful credential checks', a
     assert.deepEqual(cached.json, { authenticated: true, user: 'alice@example.com' });
     assert.equal(verifierCalls, 1);
 
+    assert.equal(server.invalidateAuthUser('ALICE@example.com'), 1);
+    const afterInvalidation = await request(server, { body: authBody({ password: 'correct-password' }) });
+    assert.deepEqual(afterInvalidation.json, { authenticated: true, user: 'alice@example.com' });
+    assert.equal(verifierCalls, 2);
+
     const failed = await request(server, { body: authBody({ password: 'wrong-password' }) });
     assert.deepEqual(failed.json, { authenticated: false });
     const failedAgain = await request(server, { body: authBody({ password: 'wrong-password' }) });
     assert.deepEqual(failedAgain.json, { authenticated: false });
-    assert.equal(verifierCalls, 3);
+    assert.equal(verifierCalls, 4);
   } finally {
     await close(server);
   }
@@ -310,6 +315,56 @@ test('Dovecot authentication bridge coalesces concurrent credential checks', asy
   }
 });
 
+test('Dovecot authentication bridge rejects an in-flight success invalidated during mailbox deletion', async () => {
+  let verifierCalls = 0;
+  let mailboxActive = true;
+  let releaseVerifier;
+  const verifierGate = new Promise((resolve) => {
+    releaseVerifier = resolve;
+  });
+  const server = createDovecotAuthServer({
+    secretFile: writeSecret(sharedSecret),
+    authCacheTtlMs: 60_000,
+    verifyCredential: async () => {
+      verifierCalls += 1;
+      const acceptedBeforeInvalidation = mailboxActive;
+      if (verifierCalls === 1) await verifierGate;
+      return acceptedBeforeInvalidation
+        ? { mailbox: { address: 'Alice@Example.com' } }
+        : null;
+    }
+  });
+  await listen(server);
+
+  try {
+    const beforeDeletion = request(server, {
+      body: authBody({ password: 'correct-password' })
+    });
+    await waitFor(() => verifierCalls === 1);
+
+    mailboxActive = false;
+    assert.equal(server.invalidateAuthUser('alice@example.com'), 0);
+    const afterDeletion = request(server, {
+      body: authBody({ password: 'correct-password' })
+    });
+    await new Promise((resolve) => setTimeout(resolve, 20));
+    assert.equal(verifierCalls, 1);
+    releaseVerifier();
+
+    assert.deepEqual((await beforeDeletion).json, { authenticated: false });
+    assert.deepEqual((await afterDeletion).json, { authenticated: false });
+    assert.equal(verifierCalls, 1);
+
+    const freshAttempt = await request(server, {
+      body: authBody({ password: 'correct-password' })
+    });
+    assert.deepEqual(freshAttempt.json, { authenticated: false });
+    assert.equal(verifierCalls, 2);
+  } finally {
+    await close(server);
+  }
+});
+
 test('Dovecot authentication bridge applies the shared limiter to the supplied remote IP', async () => {
   let verifierCalls = 0;
   const limiter = new AuthenticationRateLimiter({

+ 1205 - 0
test/inbound-mailbox-delete.test.js

@@ -0,0 +1,1205 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync, readFileSync } from 'node:fs';
+import { chmod, rename as renameFile, stat as statFile, unlink, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  bulkUpdateInboundMailboxGrants,
+  completeInboundMailboxDeletionJob,
+  createApiToken,
+  createDomain,
+  createInboundFolder,
+  createInboundMailbox,
+  createInboundMessage,
+  createInboundMessageWithWebhook,
+  createImportedInboundMessage,
+  createUser,
+  createWebhook,
+  createWebmailLoginTicket,
+  deleteDomain,
+  deleteInboundMailboxWithMessageTransfer,
+  executeUserMerge,
+  getInboundMailbox,
+  getInboundMailboxByAddress,
+  getInboundMessage,
+  initDatabase,
+  listPendingInboundMailboxDeletionJobs,
+  listInboundMailboxFolders,
+  listInboundMailboxTransferMessages,
+  markInboundMessageRead,
+  prepareInboundMailboxDeletion,
+  recordInboundMessageMaildirStorage,
+  replaceInboundMailboxGrants,
+  restorePreparedInboundMailboxDeletion,
+  transferDomain,
+  updateInboundMailboxDeletionJob,
+  updateDomain,
+  updateInboundMailbox,
+  updateWebhook
+} from '../src/db.js';
+import {
+  publishInboundMailboxMaildirTransfer,
+  recoverPendingInboundMailboxDeletions,
+  rollbackInboundMailboxMaildirTransfer,
+  stageInboundMailboxMaildirTransfer,
+  verifyPublishedInboundMailboxMaildirTransfer
+} from '../src/inbound-mailbox-delete.js';
+import {
+  assertQuarantinedMaildirMailboxUnchanged,
+  quarantineMaildirMailbox,
+  readMaildirMessage,
+  restoreQuarantinedMaildirMailbox,
+  scanMaildirMailbox,
+  snapshotQuarantinedMaildirMailbox,
+  writeMaildirMessage
+} from '../src/maildir-store.js';
+
+test('owned mailbox deletion moves visible messages and retires mailbox access state atomically', () => {
+  const database = initDatabase(
+    mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-db-')),
+    'mailbox-delete-secret'
+  );
+  const owner = createUser({ username: 'delete-owner', email: 'delete-owner@example.com', password: 'password123' });
+  const assignee = createUser({ username: 'delete-assignee', email: 'delete-assignee@example.com', password: 'password123' });
+  const sourceDomain = createTestDomain(owner.id, 'delete-source.example');
+  const targetDomain = createTestDomain(owner.id, 'delete-target.example');
+  const source = createInboundMailbox(owner.id, { address: 'source@delete-source.example' });
+  const target = createInboundMailbox(owner.id, { address: 'target@delete-target.example' });
+  createInboundFolder(source, 'Projects/2026');
+  replaceInboundMailboxGrants(source.id, [{ userId: assignee.id, receive: true }]);
+  const webmail = createWebmailLoginTicket(owner.id, source.id, { audience: 'https://roundcube.test' });
+  const webhook = createWebhook(owner.id, {
+    name: 'source received',
+    url: 'https://hooks.example.com/received',
+    events: ['received'],
+    mailboxId: source.id
+  });
+
+  const visibleMessage = createInboundMessage(source, {
+    folder: 'Projects/2026',
+    sender: 'sender@example.net',
+    recipients: [source.address],
+    subject: 'Move me',
+    rawMessage: 'Subject: Move me\r\n\r\nOriginal raw MIME',
+    textBody: 'Original raw MIME'
+  });
+  database.prepare(`
+    UPDATE inbound_messages
+    SET read_state = 'true', flags_json = '["\\\\Seen","\\\\Flagged"]', keywords_json = '["important"]'
+    WHERE id = ?
+  `).run(visibleMessage.id);
+  const removedMessage = createInboundMessage(source, {
+    sender: 'removed@example.net',
+    recipients: [source.address],
+    subject: 'Already removed',
+    rawMessage: 'Subject: Already removed\r\n\r\nRemoved body',
+    textBody: 'Removed body'
+  });
+  database.prepare('UPDATE inbound_messages SET deleted_at = ? WHERE id = ?')
+    .run(new Date().toISOString(), removedMessage.id);
+
+  const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
+    confirmAddress: source.address
+  });
+  assert.equal(prepared.sourceMailbox.id, source.id);
+  assert.equal(prepared.targetMailbox.id, target.id);
+  assert.equal(prepared.originalSourceStatus, 'active');
+  assert.equal(getInboundMailboxByAddress(source.address), null);
+
+  const result = deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id);
+  assert.deepEqual(result.deletedMailbox, { id: source.id, address: source.address });
+  assert.equal(result.deleted, true);
+  assert.equal(result.targetMailbox.id, target.id);
+  assert.equal(result.targetMailbox.messageCount, 1);
+  assert.equal(result.migratedMessageCount, 1);
+  assert.equal(getInboundMailbox(source.id, owner.id), null);
+
+  const moved = getInboundMessage(owner.id, visibleMessage.id);
+  assert.equal(moved.mailboxId, target.id);
+  assert.equal(moved.userId, owner.id);
+  assert.equal(moved.domainId, targetDomain.id);
+  assert.equal(moved.folder, 'Projects/2026');
+  assert.equal(moved.rawMessage, 'Subject: Move me\r\n\r\nOriginal raw MIME');
+  assert.deepEqual(moved.flags, ['\\Seen', '\\Flagged']);
+  assert.deepEqual(moved.keywords, ['important']);
+  assert.equal(moved.read, true);
+  assert.ok(listInboundMailboxFolders(owner.id, target.id).some((folder) => folder.name === 'Projects/2026'));
+
+  const removedRow = database.prepare('SELECT mailbox_id, domain_id, deleted_at FROM inbound_messages WHERE id = ?')
+    .get(removedMessage.id);
+  assert.equal(removedRow.mailbox_id, source.id);
+  assert.equal(removedRow.domain_id, sourceDomain.id);
+  assert.ok(removedRow.deleted_at);
+  assert.equal(database.prepare('SELECT COUNT(*) AS count FROM inbound_mailbox_grants WHERE mailbox_id = ?').get(source.id).count, 0);
+  assert.ok(database.prepare('SELECT revoked_at FROM webmail_sessions WHERE id = ?').get(webmail.id).revoked_at);
+  assert.equal(database.prepare('SELECT enabled FROM webhooks WHERE id = ?').get(webhook.id).enabled, 'false');
+  assert.ok(database.prepare('SELECT deleted_at FROM inbound_folders WHERE mailbox_id = ? AND name = ?')
+    .get(source.id, 'Projects/2026').deleted_at);
+  assert.equal(deleteDomain(sourceDomain.id, owner.id), true);
+});
+
+test('mailbox deletion validates ownership, target state, and permits an empty mailbox without a target', () => {
+  const database = initDatabase(
+    mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-validation-')),
+    'mailbox-delete-validation-secret'
+  );
+  const owner = createUser({ username: 'validation-owner', email: 'validation-owner@example.com', password: 'password123' });
+  const other = createUser({ username: 'validation-other', email: 'validation-other@example.com', password: 'password123' });
+  createTestDomain(owner.id, 'validation-owner.example');
+  createTestDomain(other.id, 'validation-other.example');
+  const source = createInboundMailbox(owner.id, { address: 'source@validation-owner.example' });
+  const target = createInboundMailbox(owner.id, { address: 'target@validation-owner.example' });
+  const empty = createInboundMailbox(owner.id, { address: 'empty@validation-owner.example' });
+  const otherTarget = createInboundMailbox(other.id, { address: 'target@validation-other.example' });
+  createInboundMessage(source, {
+    sender: 'sender@example.net',
+    recipients: [source.address],
+    subject: 'Needs a target',
+    rawMessage: 'Subject: Needs a target\r\n\r\nBody'
+  });
+
+  assertDeletionCode(
+    () => prepareInboundMailboxDeletion(owner.id, source.id, target.id),
+    'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH'
+  );
+  assertDeletionCode(
+    () => prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: ` ${source.address}` }),
+    'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH'
+  );
+  assertDeletionCode(
+    () => prepareInboundMailboxDeletion(owner.id, source.id, source.id, { confirmAddress: source.address }),
+    'INBOUND_MAILBOX_DELETE_SAME_TARGET'
+  );
+  assertDeletionCode(
+    () => prepareInboundMailboxDeletion(owner.id, source.id, otherTarget.id, { confirmAddress: source.address }),
+    'INBOUND_MAILBOX_DELETE_TARGET_NOT_FOUND'
+  );
+  assertDeletionCode(
+    () => prepareInboundMailboxDeletion(owner.id, source.id, null, { confirmAddress: source.address }),
+    'INBOUND_MAILBOX_DELETE_TARGET_REQUIRED'
+  );
+  updateInboundMailbox(owner.id, target.id, { status: 'disabled' });
+  assertDeletionCode(
+    () => prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address }),
+    'INBOUND_MAILBOX_DELETE_TARGET_INACTIVE'
+  );
+
+  const preparedEmpty = prepareInboundMailboxDeletion(owner.id, empty.id, null, {
+    confirmAddress: empty.address
+  });
+  assert.equal(preparedEmpty.targetMailbox, null);
+  const deletedEmpty = deleteInboundMailboxWithMessageTransfer(owner.id, empty.id, null);
+  assert.equal(deletedEmpty.targetMailbox, null);
+  assert.equal(deletedEmpty.migratedMessageCount, 0);
+
+  updateInboundMailbox(owner.id, target.id, { status: 'active' });
+  const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
+    confirmAddress: source.address
+  });
+  assertDeletionCode(
+    () => deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, { storageUpdates: [] }),
+    'INBOUND_MAILBOX_DELETE_MESSAGE_CONFLICT'
+  );
+  assert.equal(database.prepare('SELECT mailbox_id FROM inbound_messages WHERE mailbox_id = ?').get(source.id).mailbox_id, source.id);
+  assert.equal(restorePreparedInboundMailboxDeletion(owner.id, source.id, prepared.originalSourceStatus), true);
+  assert.equal(getInboundMailboxByAddress(source.address).id, source.id);
+  assert.equal(listInboundMailboxTransferMessages(other.id, source.id), null);
+
+  const staleRoute = getInboundMailboxByAddress(source.address);
+  updateInboundMailbox(owner.id, source.id, { status: 'disabled' });
+  assertDeletionCode(
+    () => createInboundMessageWithWebhook(staleRoute, {
+      sender: 'late@example.net',
+      recipients: [source.address],
+      rawMessage: 'Subject: Too late\r\n\r\nBody'
+    }),
+    'INBOUND_MAILBOX_DELIVERY_UNAVAILABLE'
+  );
+  assert.equal(database.prepare(`
+    SELECT COUNT(*) AS count
+    FROM inbound_messages
+    WHERE mailbox_id = ? AND deleted_at IS NULL
+  `).get(source.id).count, 1);
+});
+
+test('mailbox deletion reports catch-all, forwarding, and selected-token blockers without rebinding them', () => {
+  initDatabase(
+    mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-blockers-')),
+    'mailbox-delete-blockers-secret'
+  );
+  const owner = createUser({ username: 'blocker-owner', email: 'blocker-owner@example.com', password: 'password123' });
+  const domain = createTestDomain(owner.id, 'delete-blockers.example');
+  const source = createInboundMailbox(owner.id, { address: 'source@delete-blockers.example' });
+  const target = createInboundMailbox(owner.id, { address: 'target@delete-blockers.example' });
+  const forwarding = createInboundMailbox(owner.id, { address: 'forwarding@delete-blockers.example' });
+  updateDomain(domain.id, owner.id, { catchAllAddress: source.address });
+  updateInboundMailbox(owner.id, forwarding.id, { forwardTo: [source.address] });
+  createApiToken(owner.id, 'selected source', {
+    scopes: ['mailboxes:read'],
+    mailboxAccess: 'selected',
+    mailboxIds: [source.id]
+  });
+
+  assert.throws(
+    () => prepareInboundMailboxDeletion(owner.id, source.id, target.id, { confirmAddress: source.address }),
+    (error) => {
+      assert.equal(error.code, 'INBOUND_MAILBOX_DELETE_REFERENCES_BLOCKED');
+      assert.deepEqual(error.blockers, [
+        { type: 'domainCatchAll', count: 1 },
+        { type: 'mailboxForwarding', count: 1 },
+        { type: 'apiTokenSelection', count: 1 }
+      ]);
+      return true;
+    }
+  );
+  assert.equal(getInboundMailboxByAddress(source.address).status, 'active');
+  assert.equal(getInboundMailboxByAddress(forwarding.address).forwardTo[0], source.address);
+
+  const lateSource = createInboundMailbox(owner.id, { address: 'late-source@delete-blockers.example' });
+  const prepared = prepareInboundMailboxDeletion(owner.id, lateSource.id, target.id, {
+    confirmAddress: lateSource.address
+  });
+  updateInboundMailbox(owner.id, forwarding.id, { forwardTo: [lateSource.address] });
+  assert.throws(
+    () => deleteInboundMailboxWithMessageTransfer(owner.id, lateSource.id, target.id),
+    (error) => {
+      assert.equal(error.code, 'INBOUND_MAILBOX_DELETE_REFERENCES_BLOCKED');
+      assert.deepEqual(error.blockers, [{ type: 'mailboxForwarding', count: 1 }]);
+      return true;
+    }
+  );
+  assert.equal(restorePreparedInboundMailboxDeletion(
+    owner.id,
+    lateSource.id,
+    prepared.originalSourceStatus
+  ), true);
+  assert.equal(getInboundMailboxByAddress(lateSource.address).status, 'active');
+});
+
+test('mailbox deletion job rejects a skipped filesystem phase and mailbox address drift', () => {
+  const database = initDatabase(
+    mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-job-guard-')),
+    'mailbox-delete-job-guard-secret'
+  );
+  const owner = createUser({ username: 'job-guard-owner', email: 'job-guard-owner@example.com', password: 'password123' });
+  const mergeTarget = createUser({ username: 'job-guard-target', email: 'job-guard-target@example.com', password: 'password123' });
+  const domain = createTestDomain(owner.id, 'job-guard.example');
+  const target = createInboundMailbox(owner.id, { address: 'target@job-guard.example' });
+
+  const phaseSource = createInboundMailbox(owner.id, { address: 'phase-source@job-guard.example' });
+  const phaseJobId = '10000000000000000000000000000001';
+  const phasePrepared = prepareInboundMailboxDeletion(owner.id, phaseSource.id, target.id, {
+    confirmAddress: phaseSource.address,
+    jobId: phaseJobId
+  });
+  assertDeletionCode(
+    () => deleteInboundMailboxWithMessageTransfer(owner.id, phaseSource.id, target.id, {
+      deletionJobId: phaseJobId
+    }),
+    'INBOUND_MAILBOX_DELETE_NOT_PREPARED'
+  );
+  assertDeletionCode(
+    () => updateInboundMailbox(owner.id, phaseSource.id, { status: 'active' }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+  assertDeletionCode(
+    () => updateInboundMailbox(owner.id, target.id, { displayName: 'Must stay stable' }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+  assertDeletionCode(
+    () => transferDomain({
+      actorUserId: owner.id,
+      domainId: domain.id,
+      targetUserId: mergeTarget.id
+    }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+  assertDeletionCode(
+    () => executeUserMerge({
+      actorUserId: owner.id,
+      sourceUserId: owner.id,
+      targetUserId: mergeTarget.id,
+      confirmation: `MERGE ${owner.username} INTO ${mergeTarget.username}`
+    }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+  assert.equal(database.prepare('SELECT user_id FROM domains WHERE id = ?').get(domain.id).user_id, owner.id);
+  assert.equal(restorePreparedInboundMailboxDeletion(
+    owner.id,
+    phaseSource.id,
+    phasePrepared.originalSourceStatus
+  ), true);
+  assert.equal(completeInboundMailboxDeletionJob(phaseJobId, 'rolled_back'), true);
+  assert.equal(updateInboundMailbox(owner.id, target.id, { displayName: 'Stable again' }).displayName, 'Stable again');
+
+  const source = createInboundMailbox(owner.id, { address: 'source@job-guard.example' });
+  const sourceJobId = '10000000000000000000000000000002';
+  const sourcePrepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
+    confirmAddress: source.address,
+    jobId: sourceJobId
+  });
+  updateInboundMailboxDeletionJob(sourceJobId, { phase: 'committing_database' });
+  database.prepare('UPDATE inbound_mailboxes SET address = ? WHERE id = ?')
+    .run('renamed-source@job-guard.example', source.id);
+  assertDeletionCode(
+    () => deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, {
+      deletionJobId: sourceJobId
+    }),
+    'INBOUND_MAILBOX_DELETE_NOT_PREPARED'
+  );
+  assert.equal(restorePreparedInboundMailboxDeletion(
+    owner.id,
+    source.id,
+    sourcePrepared.originalSourceStatus
+  ), true);
+  assert.equal(completeInboundMailboxDeletionJob(sourceJobId, 'rolled_back'), true);
+
+  const targetDriftSource = createInboundMailbox(owner.id, { address: 'target-drift-source@job-guard.example' });
+  const targetJobId = '10000000000000000000000000000003';
+  const targetPrepared = prepareInboundMailboxDeletion(owner.id, targetDriftSource.id, target.id, {
+    confirmAddress: targetDriftSource.address,
+    jobId: targetJobId
+  });
+  updateInboundMailboxDeletionJob(targetJobId, { phase: 'committing_database' });
+  database.prepare('UPDATE inbound_mailboxes SET address = ? WHERE id = ?')
+    .run('renamed-target@job-guard.example', target.id);
+  assertDeletionCode(
+    () => deleteInboundMailboxWithMessageTransfer(owner.id, targetDriftSource.id, target.id, {
+      deletionJobId: targetJobId
+    }),
+    'INBOUND_MAILBOX_DELETE_NOT_PREPARED'
+  );
+  assert.equal(restorePreparedInboundMailboxDeletion(
+    owner.id,
+    targetDriftSource.id,
+    targetPrepared.originalSourceStatus
+  ), true);
+  assert.equal(completeInboundMailboxDeletionJob(targetJobId, 'rolled_back'), true);
+});
+
+test('pending mailbox deletion blocks grants, mailbox webhooks, and message mutations', () => {
+  const database = initDatabase(
+    mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-mutation-guard-')),
+    'mailbox-delete-mutation-guard-secret'
+  );
+  const owner = createUser({
+    username: 'mutation-guard-owner',
+    email: 'mutation-guard-owner@example.com',
+    password: 'password123'
+  });
+  const assignee = createUser({
+    username: 'mutation-guard-assignee',
+    email: 'mutation-guard-assignee@example.com',
+    password: 'password123'
+  });
+  createTestDomain(owner.id, 'mutation-guard.example');
+  const source = createInboundMailbox(owner.id, { address: 'source@mutation-guard.example' });
+  const target = createInboundMailbox(owner.id, { address: 'target@mutation-guard.example' });
+  const inboundMessage = createInboundMessage(source, {
+    sender: 'sender@example.net',
+    recipients: [source.address],
+    subject: 'Mutation guard',
+    rawMessage: 'Subject: Mutation guard\r\n\r\nBody'
+  });
+  const existingWebhook = createWebhook(owner.id, {
+    name: 'existing mailbox webhook',
+    url: 'https://hooks.example.com/existing',
+    events: ['received'],
+    mailboxId: source.id
+  });
+  const jobId = '10000000000000000000000000000004';
+  const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
+    confirmAddress: source.address,
+    jobId
+  });
+
+  assertDeletionCode(
+    () => replaceInboundMailboxGrants(source.id, [{ userId: assignee.id, receive: true }]),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+  assertDeletionCode(
+    () => bulkUpdateInboundMailboxGrants({
+      actorUserId: owner.id,
+      mailboxIds: [target.id],
+      userIds: [assignee.id],
+      operation: 'upsert',
+      permissions: { receive: true }
+    }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+  assertDeletionCode(
+    () => createWebhook(owner.id, {
+      name: 'late mailbox webhook',
+      url: 'https://hooks.example.com/late',
+      events: ['received'],
+      mailboxId: source.id
+    }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+  assertDeletionCode(
+    () => updateWebhook(owner.id, existingWebhook.id, { name: 'must not change' }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+  assertDeletionCode(
+    () => recordInboundMessageMaildirStorage(inboundMessage.id, {
+      key: 'mhsmtp-mutation-guard',
+      relpath: 'mail/cur/mhsmtp-mutation-guard:2,S',
+      size: 1,
+      mtimeMs: 1,
+      indexedAt: '2026-01-02T03:04:05.000Z'
+    }, { mailboxId: source.id }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+  assertDeletionCode(
+    () => markInboundMessageRead(owner.id, inboundMessage.id, true, {}, { mailboxId: source.id }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+
+  assert.equal(database.prepare('SELECT COUNT(*) AS count FROM inbound_mailbox_grants').get().count, 0);
+  assert.equal(database.prepare('SELECT name FROM webhooks WHERE id = ?').get(existingWebhook.id).name, 'existing mailbox webhook');
+  const unchangedMessage = database.prepare(`
+    SELECT read_state, storage_backend
+    FROM inbound_messages
+    WHERE id = ?
+  `).get(inboundMessage.id);
+  assert.equal(unchangedMessage.read_state, 'false');
+  assert.equal(unchangedMessage.storage_backend, 'sqlite');
+
+  assert.equal(restorePreparedInboundMailboxDeletion(
+    owner.id,
+    source.id,
+    prepared.originalSourceStatus
+  ), true);
+  assert.equal(completeInboundMailboxDeletionJob(jobId, 'rolled_back'), true);
+
+  const driftSource = createInboundMailbox(owner.id, { address: 'drift-source@mutation-guard.example' });
+  const driftMessage = createInboundMessage(driftSource, {
+    sender: 'sender@example.net',
+    recipients: [driftSource.address],
+    subject: 'Mailbox drift guard',
+    rawMessage: 'Subject: Mailbox drift guard\r\n\r\nBody'
+  });
+  prepareInboundMailboxDeletion(owner.id, driftSource.id, target.id, {
+    confirmAddress: driftSource.address
+  });
+  deleteInboundMailboxWithMessageTransfer(owner.id, driftSource.id, target.id);
+  assert.equal(recordInboundMessageMaildirStorage(driftMessage.id, {
+    key: 'mhsmtp-stale-source-write',
+    relpath: 'mail/cur/mhsmtp-stale-source-write:2,S',
+    size: 1,
+    mtimeMs: 1,
+    indexedAt: '2026-01-02T03:04:05.000Z'
+  }, { mailboxId: driftSource.id }), false);
+  assert.equal(
+    markInboundMessageRead(owner.id, driftMessage.id, true, {}, { mailboxId: driftSource.id }),
+    null
+  );
+  const movedMessage = getInboundMessage(owner.id, driftMessage.id);
+  assert.equal(movedMessage.mailboxId, target.id);
+  assert.equal(movedMessage.read, false);
+  assert.equal(
+    database.prepare('SELECT storage_backend FROM inbound_messages WHERE id = ?').get(driftMessage.id).storage_backend,
+    'sqlite'
+  );
+});
+
+test('mailbox deletion preparation rejects overlapping source and target participants', () => {
+  const database = initDatabase(
+    mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-overlap-guard-')),
+    'mailbox-delete-overlap-guard-secret'
+  );
+  const owner = createUser({
+    username: 'overlap-guard-owner',
+    email: 'overlap-guard-owner@example.com',
+    password: 'password123'
+  });
+  createTestDomain(owner.id, 'overlap-guard.example');
+  const source = createInboundMailbox(owner.id, { address: 'source@overlap-guard.example' });
+  const target = createInboundMailbox(owner.id, { address: 'target@overlap-guard.example' });
+  const third = createInboundMailbox(owner.id, { address: 'third@overlap-guard.example' });
+  const firstJobId = '10000000000000000000000000000005';
+  const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
+    confirmAddress: source.address,
+    jobId: firstJobId
+  });
+
+  assertDeletionCode(
+    () => prepareInboundMailboxDeletion(owner.id, target.id, third.id, {
+      confirmAddress: target.address,
+      jobId: '10000000000000000000000000000006'
+    }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+  assertDeletionCode(
+    () => prepareInboundMailboxDeletion(owner.id, third.id, target.id, {
+      confirmAddress: third.address,
+      jobId: '10000000000000000000000000000007'
+    }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+  assertDeletionCode(
+    () => prepareInboundMailboxDeletion(owner.id, third.id, source.id, {
+      confirmAddress: third.address,
+      jobId: '10000000000000000000000000000008'
+    }),
+    'INBOUND_MAILBOX_DELETE_IN_PROGRESS'
+  );
+
+  assert.equal(database.prepare(`
+    SELECT COUNT(*) AS count
+    FROM inbound_mailbox_deletion_jobs
+    WHERE completed_at IS NULL
+  `).get().count, 1);
+  assert.equal(database.prepare('SELECT status FROM inbound_mailboxes WHERE id = ?').get(source.id).status, 'disabled');
+  assert.equal(database.prepare('SELECT status FROM inbound_mailboxes WHERE id = ?').get(target.id).status, 'active');
+  assert.equal(database.prepare('SELECT status FROM inbound_mailboxes WHERE id = ?').get(third.id).status, 'active');
+
+  assert.equal(restorePreparedInboundMailboxDeletion(
+    owner.id,
+    source.id,
+    prepared.originalSourceStatus
+  ), true);
+  assert.equal(completeInboundMailboxDeletionJob(firstJobId, 'rolled_back'), true);
+});
+
+test('mailbox deletion preparation serializes participant checks before state changes', () => {
+  const dbSource = readFileSync(new URL('../src/db.js', import.meta.url), 'utf8');
+  const prepareStart = dbSource.indexOf('export function prepareInboundMailboxDeletion');
+  const prepareEnd = dbSource.indexOf('export function updateInboundMailboxDeletionJob', prepareStart);
+  const prepareSource = dbSource.slice(prepareStart, prepareEnd);
+  const participantGuard = prepareSource.indexOf('assertNoPendingInboundMailboxDeletion({');
+  const disableSource = prepareSource.indexOf("SET status = 'disabled'");
+  assert.ok(prepareStart >= 0 && prepareEnd > prepareStart);
+  assert.ok(participantGuard >= 0 && participantGuard < disableSource);
+  assert.match(prepareSource, /\}, \{ immediate: true \}\);/);
+
+  const serverSource = readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
+  const mapperStart = serverSource.indexOf('function sendInboundMailboxDeletionError');
+  const mapperEnd = serverSource.indexOf('function auditUserIdParam', mapperStart);
+  assert.match(
+    serverSource.slice(mapperStart, mapperEnd),
+    /'INBOUND_MAILBOX_DELETE_IN_PROGRESS'/
+  );
+});
+
+test('Dovecot mailbox transfer stages target files, keeps source recovery files, and supports rollback', async () => {
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-maildir-'));
+  const root = path.join(dataDir, 'maildir');
+  initDatabase(dataDir, 'mailbox-delete-maildir-secret');
+  const owner = createUser({ username: 'maildir-owner', email: 'maildir-owner@example.com', password: 'password123' });
+  createTestDomain(owner.id, 'maildir-delete.example');
+  const source = createInboundMailbox(owner.id, { address: 'source@maildir-delete.example' });
+  const target = createInboundMailbox(owner.id, { address: 'target@maildir-delete.example' });
+  const sourceRaw = Buffer.from('Subject: Physical\r\n\r\nPhysical body');
+  const sourceStorage = await writeMaildirMessage({
+    root,
+    address: source.address,
+    rawMessageBytes: sourceRaw,
+    folder: 'Archive',
+    flags: ['\\Seen', '\\Flagged'],
+    keywords: ['keep-me'],
+    read: true,
+    storageKey: 'mhsmtp-shared-key',
+    receivedAt: '2026-01-02T03:04:05.000Z'
+  });
+  createImportedInboundMessage(source, {
+    importSource: 'mailbox-delete-test',
+    sourceKey: 'physical-message',
+    folder: 'Archive',
+    flags: ['\\Seen', '\\Flagged'],
+    keywords: ['keep-me'],
+    read: true,
+    receivedAt: '2026-01-02T03:04:05.000Z',
+    rawMessageBytes: sourceRaw,
+    storage: sourceStorage
+  });
+  createInboundMessage(source, {
+    sender: 'sqlite@example.net',
+    recipients: [source.address],
+    subject: 'SQLite message',
+    rawMessage: 'Subject: SQLite message\r\n\r\nSQLite body',
+    textBody: 'SQLite body'
+  });
+
+  const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
+    confirmAddress: source.address
+  });
+  const messages = listInboundMailboxTransferMessages(owner.id, source.id);
+  const staged = await stageInboundMailboxMaildirTransfer({
+    root,
+    sourceMailbox: prepared.sourceMailbox,
+    targetMailbox: prepared.targetMailbox,
+    messages,
+    reservedStorageKeys: ['mhsmtp-shared-key']
+  });
+  assert.equal(staged.storageUpdates.length, 2);
+  assert.equal(staged.storageUpdates.some((update) => update.storage.key === 'mhsmtp-shared-key'), false);
+  assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1);
+  assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 0);
+  const rollbackQuarantine = await quarantineMaildirMailbox({
+    root,
+    address: source.address,
+    mailboxId: source.id
+  });
+  assert.equal(rollbackQuarantine.moved, true);
+  assert.match(rollbackQuarantine.quarantineRelpath, /^\.mailhub-quarantine\//);
+  assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 0);
+  assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine: rollbackQuarantine }), true);
+  assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1);
+  await publishInboundMailboxMaildirTransfer({
+    root,
+    targetMailbox: prepared.targetMailbox,
+    stagedMessages: staged.stagedMessages
+  });
+  assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 2);
+
+  const retainedQuarantine = await quarantineMaildirMailbox({
+    root,
+    address: source.address,
+    mailboxId: source.id
+  });
+  assert.equal(retainedQuarantine.moved, true);
+
+  const result = deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, {
+    storageUpdates: staged.storageUpdates
+  });
+  assert.equal(result.migratedMessageCount, 2);
+  const targetEntries = await scanMaildirMailbox({ root, address: target.address });
+  assert.equal(targetEntries.length, 2);
+  assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 0);
+  const archive = targetEntries.find((entry) => entry.folder === 'Archive');
+  assert.ok(archive);
+  assert.deepEqual((await readMaildirMessage(archive)).bytes, sourceRaw);
+  assert.deepEqual(archive.flags.sort(), ['\\Flagged', '\\Seen']);
+  assert.deepEqual(archive.keywords, ['keep-me']);
+
+  const rollbackSource = createInboundMailbox(owner.id, { address: 'rollback@maildir-delete.example' });
+  const rollbackRaw = Buffer.from('Subject: Rollback\r\n\r\nRollback body');
+  const rollbackStorage = await writeMaildirMessage({
+    root,
+    address: rollbackSource.address,
+    rawMessageBytes: rollbackRaw,
+    storageKey: 'mhsmtp-rollback-source'
+  });
+  createImportedInboundMessage(rollbackSource, {
+    importSource: 'mailbox-delete-test',
+    sourceKey: 'rollback-message',
+    receivedAt: '2026-01-02T03:04:05.000Z',
+    rawMessageBytes: rollbackRaw,
+    storage: rollbackStorage
+  });
+  const rollbackPrepared = prepareInboundMailboxDeletion(owner.id, rollbackSource.id, target.id, {
+    confirmAddress: rollbackSource.address
+  });
+  const rollbackStage = await stageInboundMailboxMaildirTransfer({
+    root,
+    sourceMailbox: rollbackPrepared.sourceMailbox,
+    targetMailbox: rollbackPrepared.targetMailbox,
+    messages: listInboundMailboxTransferMessages(owner.id, rollbackSource.id),
+    reservedStorageKeys: targetEntries.map((entry) => entry.storageKey)
+  });
+  assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 2);
+  await publishInboundMailboxMaildirTransfer({
+    root,
+    targetMailbox: rollbackPrepared.targetMailbox,
+    stagedMessages: rollbackStage.stagedMessages
+  });
+  assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 3);
+  const failedQuarantine = await quarantineMaildirMailbox({
+    root,
+    address: rollbackSource.address,
+    mailboxId: rollbackSource.id
+  });
+  assertDeletionCode(
+    () => deleteInboundMailboxWithMessageTransfer(owner.id, rollbackSource.id, target.id, { storageUpdates: [] }),
+    'INBOUND_MAILBOX_DELETE_MESSAGE_CONFLICT'
+  );
+  assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine: failedQuarantine }), true);
+  await rollbackInboundMailboxMaildirTransfer({
+    root,
+    targetMailbox: rollbackPrepared.targetMailbox,
+    stagedMessages: rollbackStage.stagedMessages,
+    published: true
+  });
+  assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 2);
+  restorePreparedInboundMailboxDeletion(owner.id, rollbackSource.id, rollbackPrepared.originalSourceStatus);
+  assert.equal((await scanMaildirMailbox({ root, address: rollbackSource.address })).length, 1);
+});
+
+test('quarantined Maildir is read-only until rollback restores mailbox permissions', async () => {
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-freeze-'));
+  const root = path.join(dataDir, 'maildir');
+  initDatabase(dataDir, 'mailbox-delete-freeze-secret');
+  const owner = createUser({ username: 'freeze-owner', email: 'freeze-owner@example.com', password: 'password123' });
+  createTestDomain(owner.id, 'freeze.example');
+  const source = createInboundMailbox(owner.id, { address: 'source@freeze.example' });
+  await writeMaildirMessage({
+    root,
+    address: source.address,
+    rawMessageBytes: Buffer.from('Subject: Freeze\r\n\r\nBody'),
+    storageKey: 'mhsmtp-freeze'
+  });
+
+  const quarantine = await quarantineMaildirMailbox({
+    root,
+    address: source.address,
+    mailboxId: source.id,
+    jobId: '30000000000000000000000000000001'
+  });
+  const snapshot = await snapshotQuarantinedMaildirMailbox({ root, quarantine });
+  const entry = snapshot.entries[0];
+  assert.ok(entry);
+  assert.equal(await assertQuarantinedMaildirMailboxUnchanged({ root, quarantine, snapshot }), true);
+  assert.equal((await statFile(entry.filePath)).mode & 0o777, 0o400);
+  assert.equal((await statFile(path.dirname(entry.filePath))).mode & 0o777, 0o500);
+  if (typeof process.getuid !== 'function' || process.getuid() !== 0) {
+    await assert.rejects(writeFile(entry.filePath, Buffer.from('changed')), (error) => (
+      ['EACCES', 'EPERM'].includes(error?.code)
+    ));
+    await assert.rejects(renameFile(entry.filePath, `${entry.filePath}.renamed`), (error) => (
+      ['EACCES', 'EPERM'].includes(error?.code)
+    ));
+  }
+  await chmod(entry.filePath, 0o600);
+  await writeFile(entry.filePath, Buffer.from('Subject: Freeze changed\r\n\r\nBody'));
+  await chmod(entry.filePath, 0o400);
+  await assert.rejects(
+    assertQuarantinedMaildirMailboxUnchanged({ root, quarantine, snapshot }),
+    /已发生变化/
+  );
+
+  assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine }), true);
+  const restored = (await scanMaildirMailbox({ root, address: source.address }))[0];
+  assert.equal((await statFile(restored.filePath)).mode & 0o777, 0o600);
+  assert.equal((await statFile(path.dirname(restored.filePath))).mode & 0o777, 0o700);
+  await writeMaildirMessage({
+    root,
+    address: source.address,
+    rawMessageBytes: Buffer.from('Subject: Writable again\r\n\r\nBody'),
+    storageKey: 'mhsmtp-writable-again'
+  });
+  assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 2);
+});
+
+test('published target transfer follows IMAP relpaths and rejects missing files before database commit', async () => {
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-target-verify-'));
+  const root = path.join(dataDir, 'maildir');
+  initDatabase(dataDir, 'mailbox-delete-target-verify-secret');
+  const owner = createUser({ username: 'target-verify-owner', email: 'target-verify-owner@example.com', password: 'password123' });
+  createTestDomain(owner.id, 'target-verify.example');
+  const source = createInboundMailbox(owner.id, { address: 'source@target-verify.example' });
+  const target = createInboundMailbox(owner.id, { address: 'target@target-verify.example' });
+  const raw = Buffer.from('Subject: Target verify\r\n\r\nBody');
+  const storage = await writeMaildirMessage({
+    root,
+    address: source.address,
+    rawMessageBytes: raw,
+    storageKey: 'mhsmtp-target-verify'
+  });
+  createImportedInboundMessage(source, {
+    importSource: 'mailbox-delete-target-verify',
+    sourceKey: 'message',
+    rawMessageBytes: raw,
+    receivedAt: '2026-01-02T03:04:05.000Z',
+    storage
+  });
+  const jobId = '30000000000000000000000000000002';
+  const staged = await stageInboundMailboxMaildirTransfer({
+    root,
+    sourceMailbox: source,
+    targetMailbox: target,
+    messages: listInboundMailboxTransferMessages(owner.id, source.id),
+    jobId
+  });
+  await publishInboundMailboxMaildirTransfer({
+    root,
+    targetMailbox: target,
+    stagedMessages: staged.stagedMessages
+  });
+  const published = (await scanMaildirMailbox({ root, address: target.address }))[0];
+  const renamedPath = path.join(path.dirname(path.dirname(published.filePath)), 'cur', `${path.basename(published.filePath)}:2,S`);
+  await renameFile(published.filePath, renamedPath);
+
+  const verified = await verifyPublishedInboundMailboxMaildirTransfer({
+    root,
+    targetMailbox: target,
+    storageUpdates: staged.storageUpdates,
+    jobId
+  });
+  assert.equal(verified.length, 1);
+  assert.match(verified[0].storage.relpath, /\/cur\//);
+  await unlink(renamedPath);
+  await assert.rejects(
+    verifyPublishedInboundMailboxMaildirTransfer({
+      root,
+      targetMailbox: target,
+      storageUpdates: staged.storageUpdates,
+      jobId
+    }),
+    (error) => error?.code === 'INBOUND_MAILBOX_DELETE_MESSAGE_CONFLICT'
+  );
+});
+
+test('HTTP mailbox deletion freezes source before publish and verifies target immediately before commit', () => {
+  const serverSource = readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
+  const routeStart = serverSource.indexOf("if (inboundMailboxMatch && method === 'DELETE')");
+  const routeEnd = serverSource.indexOf("if (method === 'GET' && pathname === '/api/inbound-messages')", routeStart);
+  const route = serverSource.slice(routeStart, routeEnd);
+  const sourceVerification = route.indexOf('await assertQuarantinedMaildirMailboxUnchanged');
+  const targetPublish = route.indexOf('await publishInboundMailboxMaildirTransfer');
+  const targetVerification = route.indexOf('await verifyPublishedInboundMailboxMaildirTransfer');
+  const committingPhase = route.indexOf("phase: 'committing_database'");
+  const databaseCommit = route.indexOf('deleteInboundMailboxWithMessageTransfer');
+  assert.ok(routeStart >= 0 && routeEnd > routeStart);
+  assert.ok(sourceVerification >= 0 && sourceVerification < targetPublish);
+  assert.ok(targetPublish < targetVerification);
+  assert.ok(targetVerification < committingPhase);
+  assert.ok(committingPhase < databaseCommit);
+  assert.match(
+    route,
+    /restoreError\?\.maildirSourceRenamed === true/
+  );
+});
+
+test('HTTP mutation routes reserve deletion participants before mailbox-scoped writes', () => {
+  const serverSource = readFileSync(new URL('../src/server.js', import.meta.url), 'utf8');
+
+  const messageRouteStart = serverSource.indexOf("const inboundMessageMatch = pathname.match");
+  const messageRouteEnd = serverSource.indexOf("const sendEventMatch = pathname.match", messageRouteStart);
+  const messageRoute = serverSource.slice(messageRouteStart, messageRouteEnd);
+  const lockedMailbox = messageRoute.indexOf('const lockedMailboxId = storage.mailboxId');
+  const acquireMessageLock = messageRoute.indexOf('await acquireMaildirReconciliationLocks([lockedMailboxId])');
+  const reloadMessageStorage = messageRoute.indexOf('storage = getInboundMessageMaildirStorage', acquireMessageLock);
+  const verifyLockedMailbox = messageRoute.indexOf('storage.mailboxId !== lockedMailboxId', reloadMessageStorage);
+  const pendingMessageGuard = messageRoute.indexOf('pendingInboundMailboxDeletions.has(storage.mailboxId)', verifyLockedMailbox);
+  const persistedMessageGuard = messageRoute.indexOf('assertInboundMailboxDeletionNotInProgress(storage.mailboxId)', pendingMessageGuard);
+  const mutateMessageFile = messageRoute.indexOf('await setMaildirMessageSeen', persistedMessageGuard);
+  const recordMessageStorage = messageRoute.indexOf('recordInboundMessageMaildirStorage', mutateMessageFile);
+  assert.ok(messageRouteStart >= 0 && messageRouteEnd > messageRouteStart);
+  assert.ok(lockedMailbox >= 0 && lockedMailbox < acquireMessageLock);
+  assert.ok(acquireMessageLock < reloadMessageStorage);
+  assert.ok(reloadMessageStorage < verifyLockedMailbox);
+  assert.ok(verifyLockedMailbox < pendingMessageGuard);
+  assert.ok(pendingMessageGuard < persistedMessageGuard);
+  assert.ok(persistedMessageGuard < mutateMessageFile);
+  assert.ok(mutateMessageFile < recordMessageStorage);
+  assert.equal((messageRoute.match(/pendingInboundMailboxDeletions\.has\(storage\.mailboxId\)/g) || []).length, 1);
+
+  const webhookCreateStart = serverSource.indexOf("if (method === 'POST' && pathname === '/api/webhooks')");
+  const webhookCreateEnd = serverSource.indexOf('const webhookMatch = pathname.match', webhookCreateStart);
+  const webhookCreateRoute = serverSource.slice(webhookCreateStart, webhookCreateEnd);
+  assert.ok(webhookCreateStart >= 0 && webhookCreateEnd > webhookCreateStart);
+  assert.ok(webhookCreateRoute.indexOf('await assertSafeWebhookUrl') < webhookCreateRoute.indexOf('const requestedMailbox ='));
+  assert.ok(webhookCreateRoute.indexOf('const requestedMailbox =') < webhookCreateRoute.indexOf('hasPendingInboundMailboxDeletion([requestedMailbox.id])'));
+  assert.ok(webhookCreateRoute.indexOf('hasPendingInboundMailboxDeletion([requestedMailbox.id])') < webhookCreateRoute.indexOf('createWebhook(user.id'));
+
+  const webhookUpdateStart = serverSource.indexOf("if (method === 'PATCH' && !action)", webhookCreateEnd);
+  const webhookUpdateEnd = serverSource.indexOf("if (method === 'DELETE' && !action)", webhookUpdateStart);
+  const webhookUpdateRoute = serverSource.slice(webhookUpdateStart, webhookUpdateEnd);
+  assert.ok(webhookUpdateStart >= 0 && webhookUpdateEnd > webhookUpdateStart);
+  assert.ok(webhookUpdateRoute.indexOf('const currentWebhook = getWebhook') < webhookUpdateRoute.indexOf('hasPendingInboundMailboxDeletion(['));
+  assert.ok(webhookUpdateRoute.indexOf('hasPendingInboundMailboxDeletion([') < webhookUpdateRoute.indexOf('updateWebhook(user.id'));
+
+  const bulkGrantStart = serverSource.indexOf("if (method === 'POST' && pathname === '/api/admin/inbound-mailboxes/access/bulk')");
+  const bulkGrantEnd = serverSource.indexOf('const mailboxAccessMatch = pathname.match', bulkGrantStart);
+  const bulkGrantRoute = serverSource.slice(bulkGrantStart, bulkGrantEnd);
+  assert.ok(bulkGrantStart >= 0 && bulkGrantEnd > bulkGrantStart);
+  assert.ok(bulkGrantRoute.indexOf('const body = await readJson(req)') < bulkGrantRoute.indexOf('hasPendingInboundMailboxDeletion('));
+  assert.ok(bulkGrantRoute.indexOf('hasPendingInboundMailboxDeletion(') < bulkGrantRoute.indexOf('bulkUpdateInboundMailboxGrants'));
+
+  const singleGrantStart = serverSource.indexOf("if (mailboxAccessMatch && (method === 'PUT' || method === 'PATCH'))");
+  const singleGrantEnd = serverSource.indexOf('const transferDomainMatch = pathname.match', singleGrantStart);
+  const singleGrantRoute = serverSource.slice(singleGrantStart, singleGrantEnd);
+  assert.ok(singleGrantStart >= 0 && singleGrantEnd > singleGrantStart);
+  assert.ok(singleGrantRoute.indexOf('const body = await readJson(req)') < singleGrantRoute.indexOf('hasPendingInboundMailboxDeletion([mailboxId])'));
+  assert.ok(singleGrantRoute.indexOf('hasPendingInboundMailboxDeletion([mailboxId])') < singleGrantRoute.indexOf('replaceInboundMailboxGrants'));
+});
+
+test('interrupted mailbox deletion jobs recover every filesystem phase idempotently', async (t) => {
+  const phases = ['prepared', 'source_quarantined', 'target_staged', 'target_published', 'db_committed'];
+  for (const [phaseIndex, phase] of phases.entries()) {
+    await t.test(phase, async () => {
+      const dataDir = mkdtempSync(path.join(tmpdir(), `mailhub-mailbox-delete-recovery-${phase}-`));
+      const root = path.join(dataDir, 'maildir');
+      initDatabase(dataDir, `mailbox-delete-recovery-${phase}-secret`);
+      const owner = createUser({
+        username: `recovery-owner-${phaseIndex}`,
+        email: `recovery-owner-${phaseIndex}@example.com`,
+        password: 'password123'
+      });
+      createTestDomain(owner.id, `recovery-${phaseIndex}.example`);
+      const source = createInboundMailbox(owner.id, { address: `source@recovery-${phaseIndex}.example` });
+      const target = createInboundMailbox(owner.id, { address: `target@recovery-${phaseIndex}.example` });
+      const raw = Buffer.from(`Subject: Recovery ${phase}\r\n\r\nBody`);
+      const sourceStorage = await writeMaildirMessage({
+        root,
+        address: source.address,
+        rawMessageBytes: raw,
+        storageKey: `mhsmtp-recovery-${phaseIndex}`
+      });
+      createImportedInboundMessage(source, {
+        importSource: 'mailbox-delete-recovery-test',
+        sourceKey: phase,
+        rawMessageBytes: raw,
+        receivedAt: '2026-01-02T03:04:05.000Z',
+        storage: sourceStorage
+      });
+      const jobId = (phaseIndex + 1).toString(16).padStart(32, '0');
+      const prepared = prepareInboundMailboxDeletion(owner.id, source.id, target.id, {
+        confirmAddress: source.address,
+        jobId
+      });
+      let quarantine = null;
+      let staged = null;
+      if (phase !== 'prepared') {
+        const quarantineRelpath = `.mailhub-quarantine/delete-${jobId}`;
+        updateInboundMailboxDeletionJob(jobId, {
+          phase: 'quarantining_source',
+          quarantineRelpath
+        });
+        quarantine = await quarantineMaildirMailbox({
+          root,
+          address: source.address,
+          mailboxId: source.id,
+          jobId
+        });
+        updateInboundMailboxDeletionJob(jobId, {
+          phase: 'source_quarantined',
+          quarantineRelpath: quarantine.quarantineRelpath,
+          quarantineMoved: quarantine.moved
+        });
+      }
+      if (['target_staged', 'target_published', 'db_committed'].includes(phase)) {
+        const snapshot = await snapshotQuarantinedMaildirMailbox({ root, quarantine });
+        staged = await stageInboundMailboxMaildirTransfer({
+          root,
+          sourceMailbox: prepared.sourceMailbox,
+          targetMailbox: prepared.targetMailbox,
+          messages: listInboundMailboxTransferMessages(owner.id, source.id),
+          sourceEntries: snapshot.entries,
+          jobId
+        });
+        updateInboundMailboxDeletionJob(jobId, {
+          phase: 'target_staged',
+          targetArtifacts: staged.stagedMessages.map((message, index) => ({
+            messageId: staged.storageUpdates[index].messageId,
+            storageKey: message.storage.key,
+            storageRelpath: message.storage.relpath,
+            stagingRelpath: message.stagingRelpath
+          }))
+        });
+      }
+      if (['target_published', 'db_committed'].includes(phase)) {
+        await publishInboundMailboxMaildirTransfer({
+          root,
+          targetMailbox: prepared.targetMailbox,
+          stagedMessages: staged.stagedMessages
+        });
+        updateInboundMailboxDeletionJob(jobId, { phase: 'target_published' });
+      }
+      if (phase === 'db_committed') {
+        updateInboundMailboxDeletionJob(jobId, { phase: 'committing_database' });
+        deleteInboundMailboxWithMessageTransfer(owner.id, source.id, target.id, {
+          storageUpdates: staged.storageUpdates,
+          deletionJobId: jobId
+        });
+      }
+
+      assert.equal(listPendingInboundMailboxDeletionJobs().length, 1);
+      assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: true, logger: {} }), 1);
+      assert.equal(listPendingInboundMailboxDeletionJobs().length, 0);
+      assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: true, logger: {} }), 0);
+
+      if (phase === 'db_committed') {
+        assert.equal(getInboundMailbox(source.id, owner.id), null);
+        assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 1);
+      } else {
+        assert.equal(getInboundMailboxByAddress(source.address).status, 'active');
+        assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1);
+        assert.equal((await scanMaildirMailbox({ root, address: target.address })).length, 0);
+      }
+    });
+  }
+});
+
+test('mailbox deletion recovery distinguishes planned, absent, restored, and lost quarantine states', async (t) => {
+  await t.test('planned quarantine with no source Maildir restores database state', async () => {
+    const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-planned-'));
+    const root = path.join(dataDir, 'maildir');
+    initDatabase(dataDir, 'mailbox-delete-planned-secret');
+    const owner = createUser({ username: 'planned-owner', email: 'planned-owner@example.com', password: 'password123' });
+    createTestDomain(owner.id, 'planned.example');
+    const source = createInboundMailbox(owner.id, { address: 'source@planned.example' });
+    const jobId = '20000000000000000000000000000001';
+    prepareInboundMailboxDeletion(owner.id, source.id, null, {
+      confirmAddress: source.address,
+      jobId
+    });
+    updateInboundMailboxDeletionJob(jobId, {
+      phase: 'quarantine_planned',
+      quarantineRelpath: `.mailhub-quarantine/delete-${jobId}`
+    });
+
+    assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }), 1);
+    assert.equal(getInboundMailboxByAddress(source.address).status, 'active');
+  });
+
+  await t.test('known absent source Maildir removes tombstone and restores database state', async () => {
+    const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-absent-'));
+    const root = path.join(dataDir, 'maildir');
+    initDatabase(dataDir, 'mailbox-delete-absent-secret');
+    const owner = createUser({ username: 'absent-owner', email: 'absent-owner@example.com', password: 'password123' });
+    createTestDomain(owner.id, 'absent.example');
+    const source = createInboundMailbox(owner.id, { address: 'source@absent.example' });
+    const jobId = '20000000000000000000000000000002';
+    prepareInboundMailboxDeletion(owner.id, source.id, null, {
+      confirmAddress: source.address,
+      jobId
+    });
+    const quarantineRelpath = `.mailhub-quarantine/delete-${jobId}`;
+    updateInboundMailboxDeletionJob(jobId, { phase: 'quarantine_planned', quarantineRelpath });
+    const quarantine = await quarantineMaildirMailbox({
+      root,
+      address: source.address,
+      mailboxId: source.id,
+      jobId,
+      beforeQuarantine: ({ moved }) => updateInboundMailboxDeletionJob(jobId, {
+        phase: 'quarantining_source',
+        quarantineMoved: moved
+      })
+    });
+    assert.equal(quarantine.moved, false);
+    updateInboundMailboxDeletionJob(jobId, {
+      phase: 'source_quarantined',
+      quarantineRelpath: quarantine.quarantineRelpath,
+      quarantineMoved: false
+    });
+
+    assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }), 1);
+    assert.equal(getInboundMailboxByAddress(source.address).status, 'active');
+  });
+
+  await t.test('recovery is idempotent when rename succeeds before restore completion', async () => {
+    const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-restored-'));
+    const root = path.join(dataDir, 'maildir');
+    initDatabase(dataDir, 'mailbox-delete-restored-secret');
+    const owner = createUser({ username: 'restored-owner', email: 'restored-owner@example.com', password: 'password123' });
+    createTestDomain(owner.id, 'restored.example');
+    const source = createInboundMailbox(owner.id, { address: 'source@restored.example' });
+    await writeMaildirMessage({
+      root,
+      address: source.address,
+      rawMessageBytes: Buffer.from('Subject: Restored\r\n\r\nBody'),
+      storageKey: 'mhsmtp-restored'
+    });
+    const jobId = '20000000000000000000000000000003';
+    prepareInboundMailboxDeletion(owner.id, source.id, null, {
+      confirmAddress: source.address,
+      jobId
+    });
+    const quarantine = await quarantineMaildirMailbox({
+      root,
+      address: source.address,
+      mailboxId: source.id,
+      jobId,
+      beforeQuarantine: ({ moved, quarantineRelpath }) => updateInboundMailboxDeletionJob(jobId, {
+        phase: 'quarantining_source',
+        quarantineRelpath,
+        quarantineMoved: moved
+      })
+    });
+    updateInboundMailboxDeletionJob(jobId, {
+      phase: 'source_quarantined',
+      quarantineRelpath: quarantine.quarantineRelpath,
+      quarantineMoved: true
+    });
+    updateInboundMailboxDeletionJob(jobId, { phase: 'restoring_source' });
+    await assert.rejects(
+      restoreQuarantinedMaildirMailbox({
+        root,
+        quarantine,
+        flush: async () => {
+          throw new Error('simulated restore flush failure');
+        }
+      }),
+      (error) => error?.maildirSourceRenamed === true
+    );
+
+    assert.equal(await recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }), 1);
+    assert.equal(getInboundMailboxByAddress(source.address).status, 'active');
+    assert.equal((await scanMaildirMailbox({ root, address: source.address })).length, 1);
+  });
+
+  await t.test('recorded moved quarantine missing outside restore phase fails closed', async () => {
+    const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-mailbox-delete-lost-'));
+    const root = path.join(dataDir, 'maildir');
+    initDatabase(dataDir, 'mailbox-delete-lost-secret');
+    const owner = createUser({ username: 'lost-owner', email: 'lost-owner@example.com', password: 'password123' });
+    createTestDomain(owner.id, 'lost.example');
+    const source = createInboundMailbox(owner.id, { address: 'source@lost.example' });
+    await writeMaildirMessage({
+      root,
+      address: source.address,
+      rawMessageBytes: Buffer.from('Subject: Lost\r\n\r\nBody'),
+      storageKey: 'mhsmtp-lost'
+    });
+    const jobId = '20000000000000000000000000000004';
+    prepareInboundMailboxDeletion(owner.id, source.id, null, {
+      confirmAddress: source.address,
+      jobId
+    });
+    const quarantine = await quarantineMaildirMailbox({
+      root,
+      address: source.address,
+      mailboxId: source.id,
+      jobId,
+      beforeQuarantine: ({ moved, quarantineRelpath }) => updateInboundMailboxDeletionJob(jobId, {
+        phase: 'quarantining_source',
+        quarantineRelpath,
+        quarantineMoved: moved
+      })
+    });
+    updateInboundMailboxDeletionJob(jobId, {
+      phase: 'source_quarantined',
+      quarantineRelpath: quarantine.quarantineRelpath,
+      quarantineMoved: true
+    });
+    assert.equal(await restoreQuarantinedMaildirMailbox({ root, quarantine }), true);
+    updateInboundMailboxDeletionJob(jobId, { phase: 'rollback_failed' });
+
+    await assert.rejects(
+      recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }),
+      /隔离目录缺失/
+    );
+    assert.equal(getInboundMailboxByAddress(source.address), null);
+    assert.equal(listPendingInboundMailboxDeletionJobs()[0].phase, 'recovery_failed');
+    await assert.rejects(
+      recoverPendingInboundMailboxDeletions({ root, maildirEnabled: false, logger: {} }),
+      /隔离目录缺失/
+    );
+  });
+});
+
+function createTestDomain(userId, domain) {
+  return createDomain(userId, {
+    domain,
+    selector: 'mh',
+    verificationToken: `verify-${domain}`,
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: `mail.${domain}`,
+    sendingIp: '192.0.2.50',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+}
+
+function assertDeletionCode(callback, code) {
+  assert.throws(callback, (error) => {
+    assert.equal(error.code, code);
+    return true;
+  });
+}

+ 58 - 1
test/mail-access.test.js

@@ -12,10 +12,12 @@ import {
   createInboundMessage,
   createImportedInboundMessage,
   createUser,
+  deleteInboundMailboxWithMessageTransfer,
   getInboundMessage,
   inboundFolderExists,
   initDatabase,
-  listInboundMessages
+  listInboundMessages,
+  prepareInboundMailboxDeletion
 } from '../src/db.js';
 import { startMailboxAccessServers } from '../src/mail-access.js';
 
@@ -72,6 +74,61 @@ test('IMAP SELECT keeps message bodies lazy and FETCH hydrates one message', asy
   }
 });
 
+test('stale legacy IMAP sessions cannot mutate messages after mailbox deletion', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-deleted-mailbox-test-')), 'mail-access-secret');
+  const { user, mailbox } = createMailboxFixture('deleted-session.example', 'deleted-session-user');
+  const target = createInboundMailbox(user.id, {
+    address: 'archive@deleted-session.example',
+    password: 'mailbox-pass-123'
+  });
+  const message = createInboundMessage(mailbox, {
+    sender: 'sender@example.net',
+    recipients: [mailbox.address],
+    subject: 'Delete session race',
+    rawMessage: 'Subject: Delete session race\r\n\r\nBody',
+    textBody: 'Body'
+  });
+
+  const [server] = startMailboxAccessServers({
+    hostname: 'mail.deleted-session.example',
+    imapEnabled: true,
+    imapListeners: [{ port: 0, protocol: 'imap' }],
+    pop3Enabled: false,
+    pop3Listeners: [],
+    allowInsecureAuth: true
+  });
+  await waitForListening(server);
+
+  try {
+    const client = await connectClient(server.address().port);
+    await client.readUntil(/\* OK .* IMAP ready\r\n/);
+    await client.command('A1 LOGIN "admin@deleted-session.example" "mailbox-pass-123"', /A1 OK/);
+    await client.command('A2 SELECT INBOX', /A2 OK/);
+    await client.command(`A3 UID STORE ${message.id} +FLAGS.SILENT (\\Deleted)`, /A3 OK/);
+
+    prepareInboundMailboxDeletion(user.id, mailbox.id, target.id, {
+      confirmAddress: mailbox.address
+    });
+    deleteInboundMailboxWithMessageTransfer(user.id, mailbox.id, target.id);
+
+    assert.match(
+      await client.command(`A4 UID STORE ${message.id} +FLAGS.SILENT (\\Seen)`, /A4 NO/),
+      /Mailbox is no longer available/
+    );
+    assert.match(await client.command('A5 EXPUNGE', /A5 NO/), /Mailbox is no longer available/);
+    assert.match(await client.command('A6 CREATE Projects', /A6 NO/), /Mailbox is no longer available/);
+    assert.match(await client.command('A7 APPEND INBOX {0}', /A7 NO/), /Mailbox is no longer available/);
+    await client.command('A8 LOGOUT', /A8 OK/);
+    client.close();
+
+    const moved = getInboundMessage(user.id, message.id);
+    assert.equal(moved.mailboxId, target.id);
+    assert.equal(moved.read, false);
+  } finally {
+    await closeServer(server);
+  }
+});
+
 test('IMAP exposes imported Maildir flags and Dovecot keywords', async () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-imap-flags-test-')), 'mail-access-secret');
   const { mailbox } = createMailboxFixture('flags.example', 'flags-user');

+ 80 - 0
test/maildir-sync.test.js

@@ -28,6 +28,7 @@ import {
   writeMaildirMessage
 } from '../src/maildir-store.js';
 import {
+  acquireMaildirReconciliationLocks,
   migrateInboundMessagesToMaildir,
   reconcileMaildirMailbox
 } from '../src/maildir-sync.js';
@@ -138,6 +139,85 @@ test('SQLite migration is resumable and Maildir changes reconcile into the manag
   assert.equal(getInboundMessage(user.id, original.id), null);
 });
 
+test('Maildir reconciliation waits for sorted mailbox locks and releases them idempotently', async () => {
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-lock-db-'));
+  const maildirRoot = path.join(dataDir, 'maildir');
+  initDatabase(dataDir, 'maildir-lock-secret');
+  const user = createUser({
+    username: 'maildir-lock-user',
+    email: 'maildir-lock-user@example.com',
+    password: 'password123'
+  });
+  createDomain(user.id, {
+    domain: 'maildir-lock.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.maildir-lock.example',
+    sendingIp: '192.0.2.74',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const source = createInboundMailbox(user.id, {
+    address: 'source@maildir-lock.example',
+    password: 'mailbox-pass-123'
+  });
+  const target = createInboundMailbox(user.id, {
+    address: 'target@maildir-lock.example',
+    password: 'mailbox-pass-456'
+  });
+
+  const releaseTransfer = await acquireMaildirReconciliationLocks([
+    target.id,
+    source.id,
+    target.id
+  ]);
+  let sourceSettled = false;
+  let targetSettled = false;
+  const sourceReconcile = reconcileMaildirMailbox({ root: maildirRoot, mailbox: source })
+    .finally(() => { sourceSettled = true; });
+  const targetReconcile = reconcileMaildirMailbox({ root: maildirRoot, mailbox: target })
+    .finally(() => { targetSettled = true; });
+  await new Promise((resolve) => setImmediate(resolve));
+  assert.equal(sourceSettled, false);
+  assert.equal(targetSettled, false);
+  const lockedReport = await reconcileMaildirMailbox({
+    root: maildirRoot,
+    mailbox: source,
+    skipLock: true
+  });
+  assert.equal(lockedReport.mailboxId, source.id);
+  assert.equal(sourceSettled, false);
+
+  releaseTransfer();
+  releaseTransfer();
+  const reports = await Promise.all([sourceReconcile, targetReconcile]);
+  assert.deepEqual(reports.map((report) => report.mailboxId).sort((left, right) => left - right), [
+    source.id,
+    target.id
+  ]);
+
+  const releaseAgain = await acquireMaildirReconciliationLocks([source.id, target.id]);
+  let reversedOrderAcquired = false;
+  const reversedOrder = acquireMaildirReconciliationLocks([target.id, source.id])
+    .then((release) => {
+      reversedOrderAcquired = true;
+      return release;
+    });
+  await new Promise((resolve) => setImmediate(resolve));
+  assert.equal(reversedOrderAcquired, false);
+  releaseAgain();
+  const releaseReversedOrder = await reversedOrder;
+  assert.equal(reversedOrderAcquired, true);
+  releaseReversedOrder();
+  await assert.rejects(
+    acquireMaildirReconciliationLocks([source.id, 0]),
+    /邮箱锁 ID 不正确/
+  );
+});
+
 test('SQLite migration stops when a resumable Maildir key contains different bytes', async () => {
   const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-maildir-conflict-db-'));
   const maildirRoot = path.join(dataDir, 'maildir');

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

@@ -219,6 +219,149 @@ test('users can manage inbound mailboxes and read inbound messages', async () =>
     });
     assert.equal(markRead.status, 200);
     assert.equal((await markRead.json()).message.read, true);
+
+    const createTarget = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        address: 'archive@inbound-api.example',
+        displayName: 'Archive',
+        password: 'archive-pass-123'
+      })
+    });
+    assert.equal(createTarget.status, 201);
+    const targetMailbox = (await createTarget.json()).mailbox;
+
+    const invalidSourceId = await fetch(`${baseUrl}/api/inbound-mailboxes/0`, {
+      method: 'DELETE',
+      headers: { Cookie: cookie }
+    });
+    assert.equal(invalidSourceId.status, 400);
+    assert.equal((await invalidSourceId.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_ID');
+
+    const unsafeSourceId = await fetch(`${baseUrl}/api/inbound-mailboxes/9007199254740993`, {
+      method: 'DELETE',
+      headers: { Cookie: cookie }
+    });
+    assert.equal(unsafeSourceId.status, 400);
+    assert.equal((await unsafeSourceId.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_ID');
+
+    const unsafeTargetId = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
+      method: 'DELETE',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        targetMailboxId: '9007199254740993',
+        confirmAddress: mailbox.address
+      })
+    });
+    assert.equal(unsafeTargetId.status, 400);
+    assert.equal((await unsafeTargetId.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_ID');
+
+    const invalidDeleteBody = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
+      method: 'DELETE',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: '{"targetMailboxId":'
+    });
+    assert.equal(invalidDeleteBody.status, 400);
+    assert.equal((await invalidDeleteBody.json()).code, 'INBOUND_MAILBOX_DELETE_INVALID_BODY');
+
+    const emptyDeleteBody = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
+      method: 'DELETE',
+      headers: { Cookie: cookie }
+    });
+    assert.equal(emptyDeleteBody.status, 400);
+    assert.equal((await emptyDeleteBody.json()).code, 'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH');
+
+    const missingConfirmation = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
+      method: 'DELETE',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({ targetMailboxId: targetMailbox.id })
+    });
+    assert.equal(missingConfirmation.status, 400);
+    assert.equal((await missingConfirmation.json()).code, 'INBOUND_MAILBOX_DELETE_CONFIRMATION_MISMATCH');
+
+    const sameTarget = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
+      method: 'DELETE',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        targetMailboxId: mailbox.id,
+        confirmAddress: mailbox.address
+      })
+    });
+    assert.equal(sameTarget.status, 400);
+    assert.equal((await sameTarget.json()).code, 'INBOUND_MAILBOX_DELETE_SAME_TARGET');
+
+    const deleteMailbox = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
+      method: 'DELETE',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        targetMailboxId: targetMailbox.id,
+        confirmAddress: mailbox.address
+      })
+    });
+    assert.equal(deleteMailbox.status, 200);
+    const deleted = await deleteMailbox.json();
+    assert.equal(deleted.deleted, true);
+    assert.deepEqual(deleted.deletedMailbox, { id: mailbox.id, address: mailbox.address });
+    assert.equal(deleted.targetMailbox.id, targetMailbox.id);
+    assert.equal(deleted.migratedMessageCount, 1);
+
+    const movedDetail = await fetch(`${baseUrl}/api/inbound-messages/${messageId}`, { headers: { Cookie: cookie } });
+    assert.equal(movedDetail.status, 200);
+    assert.equal((await movedDetail.json()).message.mailboxId, targetMailbox.id);
+    const deletedSource = await fetch(`${baseUrl}/api/inbound-mailboxes/${mailbox.id}`, {
+      method: 'PATCH',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({ displayName: 'Should not update' })
+    });
+    assert.equal(deletedSource.status, 404);
+
+    const createEmpty = await fetch(`${baseUrl}/api/inbound-mailboxes`, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({
+        address: 'empty@inbound-api.example',
+        password: 'empty-mailbox-pass-123'
+      })
+    });
+    assert.equal(createEmpty.status, 201);
+    const emptyMailbox = (await createEmpty.json()).mailbox;
+    const deleteEmpty = await fetch(`${baseUrl}/api/inbound-mailboxes/${emptyMailbox.id}`, {
+      method: 'DELETE',
+      headers: {
+        'Content-Type': 'application/json',
+        Cookie: cookie
+      },
+      body: JSON.stringify({ confirmAddress: emptyMailbox.address })
+    });
+    assert.equal(deleteEmpty.status, 200);
+    const emptyResult = await deleteEmpty.json();
+    assert.equal(emptyResult.targetMailbox, null);
+    assert.equal(emptyResult.migratedMessageCount, 0);
   } finally {
     child.kill('SIGTERM');
     await waitForExit(child, 1000);

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

@@ -5,6 +5,7 @@ import net from 'node:net';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
 import process from 'node:process';
+import { DatabaseSync } from 'node:sqlite';
 import { test } from 'node:test';
 
 test('mailbox grants constrain admin, session, token, and send API access', async (t) => {
@@ -91,6 +92,219 @@ test('mailbox grants constrain admin, session, token, and send API access', asyn
       });
     });
 
+    await t.test('administrators can update selected mailbox grants in bulk without replacing unrelated access', async () => {
+      const ordinaryBulk = await bulkUpdateGrants(fixture.baseUrl, readerCookie, {
+        mailboxIds: [seeded.sharedMailboxId],
+        userIds: [seeded.viewerUserId],
+        operation: 'upsert',
+        permissions: { view: true, receive: false, send: false }
+      });
+      assert.equal(ordinaryBulk.status, 403);
+
+      await replaceGrants(
+        fixture.baseUrl,
+        adminCookie,
+        seeded.readerMailboxId,
+        [{ userId: seeded.ownerUserId, permissions: ['view'] }]
+      );
+      const updated = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
+        mailboxIds: [seeded.sharedMailboxId, seeded.otherMailboxId, seeded.readerMailboxId],
+        userIds: [seeded.readerUserId, seeded.viewerUserId],
+        operation: 'upsert',
+        permissions: { view: false, receive: true, send: false }
+      });
+      assert.equal(updated.status, 200, JSON.stringify(updated.body));
+      assert.deepEqual(updated.body.summary, {
+        mailboxCount: 3,
+        userCount: 2,
+        pairCount: 6,
+        changedGrantCount: 5,
+        unchangedGrantCount: 0,
+        skippedOwnerCount: 1,
+        revokedWebmailSessionCount: 0
+      });
+      assert.deepEqual(
+        updated.body.mailboxes.map((entry) => entry.mailbox.id),
+        [seeded.sharedMailboxId, seeded.otherMailboxId, seeded.readerMailboxId]
+      );
+      for (const mailboxId of [seeded.sharedMailboxId, seeded.otherMailboxId]) {
+        const entry = findAdminMailbox(updated.body.mailboxes, mailboxId);
+        for (const userId of [seeded.readerUserId, seeded.viewerUserId]) {
+          assert.deepEqual(grantPermissions(entry, userId), {
+            view: true,
+            receive: true,
+            send: false
+          });
+        }
+      }
+      const readerMailbox = findAdminMailbox(updated.body.mailboxes, seeded.readerMailboxId);
+      assert.deepEqual(grantPermissions(readerMailbox, seeded.ownerUserId), {
+        view: true,
+        receive: false,
+        send: false
+      });
+      assert.equal(
+        readerMailbox.grants.some((grant) => grant.user.id === seeded.readerUserId),
+        false
+      );
+      assert.deepEqual(grantPermissions(readerMailbox, seeded.viewerUserId), {
+        view: true,
+        receive: true,
+        send: false
+      });
+
+      const audit = await requestJson(
+        fixture.baseUrl,
+        '/api/admin/audit-logs?action=admin.bulk_update_mailbox_access',
+        { cookie: adminCookie }
+      );
+      assert.equal(audit.status, 200);
+      assert.equal(audit.body.logs.length, 1);
+      assert.equal(audit.body.logs[0].summary.operation, 'upsert');
+      assert.deepEqual(audit.body.logs[0].summary.mailboxIds, [
+        seeded.sharedMailboxId,
+        seeded.otherMailboxId,
+        seeded.readerMailboxId
+      ]);
+
+      const unchanged = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
+        mailboxIds: [seeded.otherMailboxId],
+        userIds: [seeded.viewerUserId],
+        operation: 'upsert',
+        permissions: { view: false, receive: true, send: false }
+      });
+      assert.equal(unchanged.status, 200, JSON.stringify(unchanged.body));
+      assert.equal(unchanged.body.summary.changedGrantCount, 0);
+      assert.equal(unchanged.body.summary.unchangedGrantCount, 1);
+
+      const loweredSessionId = insertPendingWebmailSession(
+        fixture.dataDir,
+        seeded.readerUserId,
+        seeded.sharedMailboxId
+      );
+      const lowered = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
+        mailboxIds: [seeded.sharedMailboxId],
+        userIds: [seeded.readerUserId],
+        operation: 'upsert',
+        permissions: { view: false, receive: false, send: true }
+      });
+      assert.equal(lowered.status, 200, JSON.stringify(lowered.body));
+      assert.equal(lowered.body.summary.changedGrantCount, 1);
+      assert.equal(lowered.body.summary.revokedWebmailSessionCount, 1);
+      assert.ok(webmailSessionRevokedAt(fixture.dataDir, loweredSessionId));
+
+      const restoredReceive = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
+        mailboxIds: [seeded.sharedMailboxId],
+        userIds: [seeded.readerUserId],
+        operation: 'upsert',
+        permissions: { view: false, receive: true, send: false }
+      });
+      assert.equal(restoredReceive.status, 200, JSON.stringify(restoredReceive.body));
+      const removedSessionId = insertPendingWebmailSession(
+        fixture.dataDir,
+        seeded.readerUserId,
+        seeded.sharedMailboxId
+      );
+      const removed = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
+        mailboxIds: [seeded.sharedMailboxId],
+        userIds: [seeded.readerUserId],
+        operation: 'remove'
+      });
+      assert.equal(removed.status, 200, JSON.stringify(removed.body));
+      assert.equal(removed.body.summary.changedGrantCount, 1);
+      assert.equal(removed.body.summary.revokedWebmailSessionCount, 1);
+      assert.ok(webmailSessionRevokedAt(fixture.dataDir, removedSessionId));
+      assert.deepEqual(
+        grantPermissions(findAdminMailbox(removed.body.mailboxes, seeded.sharedMailboxId), seeded.viewerUserId),
+        { view: true, receive: true, send: false }
+      );
+
+      const beforeRejectedBatch = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
+        cookie: adminCookie
+      });
+      const beforeViewerPermissions = grantPermissions(
+        findAdminMailbox(beforeRejectedBatch.body.mailboxes, seeded.sharedMailboxId),
+        seeded.viewerUserId
+      );
+      const deletedMailboxBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
+        mailboxIds: [seeded.sharedMailboxId, seeded.deletedMailboxId],
+        userIds: [seeded.viewerUserId],
+        operation: 'upsert',
+        permissions: { view: true, receive: false, send: true }
+      });
+      assert.equal(deletedMailboxBatch.status, 404);
+      assert.equal(deletedMailboxBatch.body.code, 'BULK_MAILBOX_ACCESS_MAILBOX_NOT_FOUND');
+
+      const disabledUserUpsert = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
+        mailboxIds: [seeded.sharedMailboxId],
+        userIds: [seeded.disabledUserId],
+        operation: 'upsert',
+        permissions: { view: true, receive: false, send: false }
+      });
+      assert.equal(disabledUserUpsert.status, 409);
+      assert.equal(disabledUserUpsert.body.code, 'BULK_MAILBOX_ACCESS_USER_DISABLED');
+
+      const disabledUserRemoval = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
+        mailboxIds: [seeded.otherMailboxId],
+        userIds: [seeded.disabledUserId],
+        operation: 'remove'
+      });
+      assert.equal(disabledUserRemoval.status, 200, JSON.stringify(disabledUserRemoval.body));
+      assert.equal(disabledUserRemoval.body.summary.changedGrantCount, 1);
+      assert.equal(
+        findAdminMailbox(disabledUserRemoval.body.mailboxes, seeded.otherMailboxId)
+          .grants.some((grant) => grant.user.id === seeded.disabledUserId),
+        false
+      );
+
+      const missingUserBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
+        mailboxIds: [seeded.sharedMailboxId],
+        userIds: [seeded.viewerUserId, 999_999],
+        operation: 'remove'
+      });
+      assert.equal(missingUserBatch.status, 404);
+      assert.equal(missingUserBatch.body.code, 'BULK_MAILBOX_ACCESS_USER_NOT_FOUND');
+
+      const oversizedBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
+        mailboxIds: Array.from({ length: 501 }, (_value, index) => index + 1),
+        userIds: [seeded.viewerUserId],
+        operation: 'remove'
+      });
+      assert.equal(oversizedBatch.status, 400);
+      assert.equal(oversizedBatch.body.code, 'BULK_MAILBOX_ACCESS_LIMIT_EXCEEDED');
+
+      const excessiveCombinationBatch = await bulkUpdateGrants(fixture.baseUrl, adminCookie, {
+        mailboxIds: Array.from({ length: 101 }, (_value, index) => index + 1),
+        userIds: Array.from({ length: 50 }, (_value, index) => index + 1_000),
+        operation: 'remove'
+      });
+      assert.equal(excessiveCombinationBatch.status, 400);
+      assert.equal(excessiveCombinationBatch.body.code, 'BULK_MAILBOX_ACCESS_LIMIT_EXCEEDED');
+
+      const afterRejectedBatch = await requestJson(fixture.baseUrl, '/api/admin/mailbox-access', {
+        cookie: adminCookie
+      });
+      assert.deepEqual(
+        grantPermissions(
+          findAdminMailbox(afterRejectedBatch.body.mailboxes, seeded.sharedMailboxId),
+          seeded.viewerUserId
+        ),
+        beforeViewerPermissions
+      );
+
+      await replaceGrants(
+        fixture.baseUrl,
+        adminCookie,
+        seeded.sharedMailboxId,
+        [
+          { userId: seeded.readerUserId, permissions: ['receive', 'send'] },
+          { userId: seeded.viewerUserId, permissions: ['view'] }
+        ]
+      );
+      await replaceGrants(fixture.baseUrl, adminCookie, seeded.otherMailboxId, []);
+      await replaceGrants(fixture.baseUrl, adminCookie, seeded.readerMailboxId, []);
+    });
+
     await t.test('owned, effective, and all mailbox scopes stay distinct', async () => {
       assert.deepEqual(
         await mailboxAddresses(fixture.baseUrl, '/api/inbound-mailboxes?scope=owned', ownerCookie),
@@ -360,6 +574,7 @@ test('mailbox grants constrain admin, session, token, and send API access', asyn
 
 function seedMailboxAccessFixtures(dataDir, sessionSecret, readerRelayPort) {
   const script = `
+    import { DatabaseSync } from 'node:sqlite';
     import {
       createDomain,
       createInboundMailbox,
@@ -389,6 +604,12 @@ function seedMailboxAccessFixtures(dataDir, sessionSecret, readerRelayPort) {
       password: 'password123',
       status: 'active'
     });
+    const disabled = createUser({
+      username: 'access-disabled',
+      email: 'access-disabled@example.test',
+      password: 'password123',
+      status: 'disabled'
+    });
     const readerRelay = saveSmtpRelay(reader.id, {
       name: 'Reader private relay',
       host: '127.0.0.1',
@@ -427,6 +648,27 @@ function seedMailboxAccessFixtures(dataDir, sessionSecret, readerRelayPort) {
       address: 'own@access-reader.example',
       password: 'mailbox-password'
     });
+    const deletedMailbox = createInboundMailbox(owner.id, {
+      address: 'deleted@access-owner.example',
+      password: 'mailbox-password'
+    });
+    const fixtureDatabase = new DatabaseSync(process.env.DATA_DIR + '/mailhub.sqlite');
+    fixtureDatabase
+      .prepare("UPDATE inbound_mailboxes SET status = 'disabled', deleted_at = ?, updated_at = ? WHERE id = ?")
+      .run('2026-07-18T00:00:00.000Z', '2026-07-18T00:00:00.000Z', deletedMailbox.id);
+    fixtureDatabase
+      .prepare(\`
+        INSERT INTO inbound_mailbox_grants (
+          mailbox_id, user_id, can_view, can_receive, can_send, created_at, updated_at
+        ) VALUES (?, ?, 'true', 'false', 'false', ?, ?)
+      \`)
+      .run(
+        otherMailbox.id,
+        disabled.id,
+        '2026-07-18T00:00:00.000Z',
+        '2026-07-18T00:00:00.000Z'
+      );
+    fixtureDatabase.close();
     const createMessage = (mailbox, subject, sequence) => createInboundMessage(mailbox, {
       sender: 'sender@example.net',
       recipients: [mailbox.address],
@@ -444,9 +686,11 @@ function seedMailboxAccessFixtures(dataDir, sessionSecret, readerRelayPort) {
       ownerUserId: owner.id,
       readerUserId: reader.id,
       viewerUserId: viewer.id,
+      disabledUserId: disabled.id,
       sharedMailboxId: sharedMailbox.id,
       otherMailboxId: otherMailbox.id,
       readerMailboxId: readerMailbox.id,
+      deletedMailboxId: deletedMailbox.id,
       readerRelayId: readerRelay.id,
       sharedMessageId: sharedMessage.id,
       readerMessageId: readerMessage.id
@@ -480,6 +724,54 @@ async function replaceGrants(baseUrl, adminCookie, mailboxId, grants) {
   return response.body.mailbox;
 }
 
+function bulkUpdateGrants(baseUrl, cookie, body) {
+  return requestJson(baseUrl, '/api/admin/inbound-mailboxes/access/bulk', {
+    method: 'POST',
+    cookie,
+    body
+  });
+}
+
+let webmailSessionSequence = 0;
+
+function insertPendingWebmailSession(dataDir, actorUserId, mailboxId) {
+  webmailSessionSequence += 1;
+  const database = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'));
+  try {
+    const createdAt = new Date().toISOString();
+    const result = database
+      .prepare(`
+        INSERT INTO webmail_sessions (
+          actor_user_id, mailbox_id, audience, ticket_hash, ticket_expires_at,
+          credential_expires_at, created_at
+        ) VALUES (?, ?, ?, ?, ?, ?, ?)
+      `)
+      .run(
+        actorUserId,
+        mailboxId,
+        'https://webmail.example.test',
+        `bulk-access-test-ticket-${process.pid}-${webmailSessionSequence}`,
+        '2099-01-01T00:00:00.000Z',
+        '2099-01-01T00:00:00.000Z',
+        createdAt
+      );
+    return Number(result.lastInsertRowid);
+  } finally {
+    database.close();
+  }
+}
+
+function webmailSessionRevokedAt(dataDir, sessionId) {
+  const database = new DatabaseSync(path.join(dataDir, 'mailhub.sqlite'), { readOnly: true });
+  try {
+    return database
+      .prepare('SELECT revoked_at FROM webmail_sessions WHERE id = ?')
+      .get(sessionId)?.revoked_at || '';
+  } finally {
+    database.close();
+  }
+}
+
 function findAdminMailbox(mailboxes, mailboxId) {
   const mailbox = mailboxes.find((entry) => entry.mailbox.id === mailboxId);
   assert.ok(mailbox, `expected admin mailbox ${mailboxId}`);

+ 134 - 1
test/submission-inbound.test.js

@@ -10,9 +10,11 @@ import {
   createInboundMailbox,
   createUser,
   createWebhook,
+  deleteInboundMailboxWithMessageTransfer,
   initDatabase,
   listInboundMessages,
   listWebhookDeliveries,
+  prepareInboundMailboxDeletion,
   replaceInboundMailboxGrants,
   saveSmtpRelay,
   saveSmtpCredential,
@@ -190,6 +192,59 @@ test('SMTP rejects unauthenticated inbound mail for unknown recipients', async (
   }
 });
 
+test('SMTP rechecks a cached RCPT route and rejects DATA after the mailbox is deleted', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-inbound-delete-race-')), 'inbound-secret');
+  const user = createUser({ username: 'delete-race', email: 'delete-race@example.com', password: 'password123' });
+  createDomain(user.id, {
+    domain: 'delete-race.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.delete-race.example',
+    sendingIp: '192.0.2.18',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(user.id, { address: 'support@delete-race.example' });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.delete-race.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true
+  });
+  await waitForListening(server);
+
+  try {
+    const transcript = await smtpTranscript(server.address().port, [
+      'EHLO sender.example.net',
+      'MAIL FROM:<alice@example.net>',
+      'RCPT TO:<support@delete-race.example>',
+      () => {
+        prepareInboundMailboxDeletion(user.id, mailbox.id, null, {
+          confirmAddress: mailbox.address
+        });
+        deleteInboundMailboxWithMessageTransfer(user.id, mailbox.id, null);
+        return 'DATA';
+      },
+      [
+        'From: alice@example.net',
+        'To: support@delete-race.example',
+        'Subject: Must retry',
+        '',
+        'This message must not recreate the deleted mailbox Maildir.',
+        '.'
+      ].join('\r\n')
+    ]);
+    assert.match(transcript.at(-1), /^451 /);
+    assert.equal(listInboundMessages(user.id).length, 0);
+  } finally {
+    await closeServer(server);
+  }
+});
+
 test('SMTP authenticates with a mailbox account address and password', async () => {
   initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-mailbox-auth-')), 'inbound-secret');
   const user = createUser({ username: 'mailbox-auth', email: 'mailbox-auth@example.com', password: 'password123' });
@@ -275,6 +330,82 @@ test('SMTP authenticates with a mailbox account address and password', async ()
   }
 });
 
+test('authenticated mailbox SMTP sessions are revoked before MAIL and DATA after mailbox deletion', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-mailbox-delete-session-')), 'inbound-secret');
+  const user = createUser({
+    username: 'deleted-session-smtp',
+    email: 'deleted-session-smtp@example.com',
+    password: 'password123'
+  });
+  createDomain(user.id, {
+    domain: 'deleted-session-smtp.example',
+    selector: 'mh',
+    verificationToken: 'verify',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'mail.deleted-session-smtp.example',
+    sendingIp: '192.0.2.18',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailBoundaryMailbox = createInboundMailbox(user.id, {
+    address: 'mail-boundary@deleted-session-smtp.example',
+    password: 'mailbox-pass-123'
+  });
+  const dataBoundaryMailbox = createInboundMailbox(user.id, {
+    address: 'data-boundary@deleted-session-smtp.example',
+    password: 'mailbox-pass-123'
+  });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'mx.deleted-session-smtp.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true
+  });
+  await waitForListening(server);
+
+  const deleteMailbox = (mailbox) => {
+    prepareInboundMailboxDeletion(user.id, mailbox.id, null, {
+      confirmAddress: mailbox.address
+    });
+    deleteInboundMailboxWithMessageTransfer(user.id, mailbox.id, null);
+  };
+
+  try {
+    const mailAuth = Buffer.from(
+      `\u0000${mailBoundaryMailbox.address}\u0000mailbox-pass-123`
+    ).toString('base64');
+    const mailTranscript = await smtpTranscript(server.address().port, [
+      'EHLO sender.example.net',
+      `AUTH PLAIN ${mailAuth}`,
+      () => {
+        deleteMailbox(mailBoundaryMailbox);
+        return `MAIL FROM:<${mailBoundaryMailbox.address}>`;
+      }
+    ]);
+    assert.match(mailTranscript.at(-1), /^530 Authentication no longer valid$/);
+
+    const dataAuth = Buffer.from(
+      `\u0000${dataBoundaryMailbox.address}\u0000mailbox-pass-123`
+    ).toString('base64');
+    const dataTranscript = await smtpTranscript(server.address().port, [
+      'EHLO sender.example.net',
+      `AUTH PLAIN ${dataAuth}`,
+      `MAIL FROM:<${dataBoundaryMailbox.address}>`,
+      'RCPT TO:<recipient@example.net>',
+      () => {
+        deleteMailbox(dataBoundaryMailbox);
+        return 'DATA';
+      }
+    ]);
+    assert.match(dataTranscript.at(-1), /^530 Authentication no longer valid$/);
+  } finally {
+    await closeServer(server);
+  }
+});
+
 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' });
@@ -870,7 +1001,9 @@ async function smtpTranscript(port, commands) {
             resolve(responses);
             return;
           }
-          socket.write(`${commands[index]}\r\n`);
+          Promise.resolve(typeof commands[index] === 'function' ? commands[index]() : commands[index])
+            .then((command) => socket.write(`${command}\r\n`))
+            .catch(reject);
         }
       }
     });

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

@@ -321,6 +321,169 @@ describe('Inbox request and mailbox behavior', () => {
     })));
   });
 
+  it('migrates mail before deleting an owned mailbox and excludes shared mailboxes as targets', async () => {
+    const user = userEvent.setup();
+    const source = mailboxFixture(1, { messageCount: 5 });
+    const target = mailboxFixture(2, { messageCount: 1 });
+    const assigned = mailboxFixture(3, {
+      ownerUserId: 1,
+      access: { type: 'assigned', permissions: { view: true, receive: true, send: true } }
+    });
+    const disabled = mailboxFixture(4, { status: 'disabled' });
+    const expired = mailboxFixture(5, {
+      temporary: true,
+      expiresAt: '2000-01-01T00:00:00.000Z'
+    });
+    mockInboxApis([source, target, assigned, disabled, expired], []);
+    const loadMailboxes = vi.mocked(api.inboundMailboxes);
+    let resolveDelete!: (value: {
+      deletedMailbox: InboundMailbox;
+      targetMailbox: InboundMailbox;
+      migratedMessageCount: number;
+    }) => void;
+    const remove = vi.spyOn(api, 'deleteInboundMailbox').mockReturnValue(new Promise((resolve) => {
+      resolveDelete = resolve;
+    }));
+    const router = createInboxRouter(['/inbox?workspace=routing'], 0);
+    renderRouter(router);
+
+    const sourceCard = (await screen.findByText(source.address)).closest('.ant-card') as HTMLElement;
+    const assignedCard = screen.getByText(assigned.address).closest('.ant-card') as HTMLElement;
+    expect(within(assignedCard).queryByRole('button', { name: `删除邮箱 ${assigned.address}` })).toBeNull();
+
+    await user.click(within(sourceCard).getByRole('button', { name: `删除邮箱 ${source.address}` }));
+    const dialog = await screen.findByRole('dialog', { name: '迁移邮件并删除邮箱' });
+    expect(within(dialog).getByText(source.address)).toBeTruthy();
+    expect(within(dialog).getByText('5')).toBeTruthy();
+    const destructiveButton = within(dialog).getByRole('button', { name: '迁移邮件并删除' }) as HTMLButtonElement;
+    expect(destructiveButton.disabled).toBe(true);
+
+    await user.click(within(dialog).getByRole('combobox', { name: '目标邮箱' }));
+    const listbox = await screen.findByRole('listbox');
+    const targetLabel = `${target.address} · 1 封邮件`;
+    expect(within(listbox).getByRole('option', { name: targetLabel })).toBeTruthy();
+    expect(within(listbox).queryByRole('option', { name: new RegExp(source.address) })).toBeNull();
+    expect(within(listbox).queryByRole('option', { name: new RegExp(assigned.address) })).toBeNull();
+    expect(within(listbox).queryByRole('option', { name: new RegExp(disabled.address) })).toBeNull();
+    expect(within(listbox).queryByRole('option', { name: new RegExp(expired.address) })).toBeNull();
+    await user.click(screen.getByText(targetLabel));
+    expect(destructiveButton.disabled).toBe(true);
+    const confirmation = within(dialog).getByRole('textbox', { name: '输入完整邮箱地址确认' });
+    await user.type(confirmation, 'wrong@example.test');
+    expect(within(dialog).getByText('输入的邮箱地址与待删除邮箱不一致。')).toBeTruthy();
+    expect(destructiveButton.disabled).toBe(true);
+    await user.clear(confirmation);
+    await user.type(confirmation, source.address);
+    expect(destructiveButton.disabled).toBe(false);
+
+    await user.click(destructiveButton);
+    await waitFor(() => expect(remove).toHaveBeenCalledWith(source.id, {
+      targetMailboxId: target.id,
+      confirmAddress: source.address
+    }));
+    await waitFor(() => expect(destructiveButton.classList.contains('ant-btn-loading')).toBe(true));
+    fireEvent.click(destructiveButton);
+    expect(remove).toHaveBeenCalledTimes(1);
+
+    await act(async () => {
+      resolveDelete({ deletedMailbox: source, targetMailbox: target, migratedMessageCount: 5 });
+      await Promise.resolve();
+    });
+    expect(await screen.findByText(`邮箱已删除,已将 5 封邮件迁移到 ${target.address}。`)).toBeTruthy();
+    await waitFor(() => expect(loadMailboxes).toHaveBeenCalledTimes(2));
+    expect(screen.queryByRole('dialog', { name: '迁移邮件并删除邮箱' })).toBeNull();
+  });
+
+  it('blocks deletion when no other owned mailbox can receive the messages', async () => {
+    const user = userEvent.setup();
+    const source = mailboxFixture(1);
+    const assigned = mailboxFixture(2, {
+      access: { type: 'assigned', permissions: { view: true, receive: true, send: true } }
+    });
+    mockInboxApis([source, assigned], []);
+    const remove = vi.spyOn(api, 'deleteInboundMailbox');
+    const router = createInboxRouter(['/inbox?workspace=routing'], 0);
+    renderRouter(router);
+
+    const sourceCard = (await screen.findByText(source.address)).closest('.ant-card') as HTMLElement;
+    await user.click(within(sourceCard).getByRole('button', { name: `删除邮箱 ${source.address}` }));
+    const dialog = await screen.findByRole('dialog', { name: '迁移邮件并删除邮箱' });
+    expect(within(dialog).getByText('没有其他可管理的邮箱。请先创建一个目标邮箱,再删除当前邮箱。')).toBeTruthy();
+    expect((within(dialog).getByRole('button', { name: '迁移邮件并删除' }) as HTMLButtonElement).disabled).toBe(true);
+    expect(remove).not.toHaveBeenCalled();
+  });
+
+  it('deletes an empty owned mailbox without requiring a migration target', async () => {
+    const user = userEvent.setup();
+    const source = mailboxFixture(1, { messageCount: 0 });
+    mockInboxApis([source], []);
+    const remove = vi.spyOn(api, 'deleteInboundMailbox').mockResolvedValue({
+      deletedMailbox: source,
+      targetMailbox: null,
+      migratedMessageCount: 0
+    });
+    const router = createInboxRouter(['/inbox?workspace=routing'], 0);
+    renderRouter(router);
+
+    const sourceCard = (await screen.findByText(source.address)).closest('.ant-card') as HTMLElement;
+    await user.click(within(sourceCard).getByRole('button', { name: `删除邮箱 ${source.address}` }));
+    const dialog = await screen.findByRole('dialog', { name: '删除邮箱' });
+    expect(within(dialog).getByText('此邮箱中没有邮件,可以直接停用并移除邮箱账号。')).toBeTruthy();
+    expect(within(dialog).queryByRole('combobox', { name: '目标邮箱' })).toBeNull();
+    const destructiveButton = within(dialog).getByRole('button', { name: '删除邮箱' }) as HTMLButtonElement;
+    expect(destructiveButton.disabled).toBe(true);
+    await user.type(
+      within(dialog).getByRole('textbox', { name: '输入完整邮箱地址确认' }),
+      source.address
+    );
+    expect(destructiveButton.disabled).toBe(false);
+
+    await user.click(destructiveButton);
+    await waitFor(() => expect(remove).toHaveBeenCalledWith(source.id, {
+      targetMailboxId: null,
+      confirmAddress: source.address
+    }));
+    expect(await screen.findByText('邮箱已删除')).toBeTruthy();
+  });
+
+  it('refreshes an empty mailbox deletion dialog when mail arrives before deletion', async () => {
+    const user = userEvent.setup();
+    const source = mailboxFixture(1, { messageCount: 0 });
+    const refreshedSource = mailboxFixture(1, { messageCount: 1 });
+    const target = mailboxFixture(2, { messageCount: 0 });
+    mockInboxApis([source, target], []);
+    const loadMailboxes = vi.mocked(api.inboundMailboxes)
+      .mockResolvedValueOnce({ mailboxes: [source, target] })
+      .mockResolvedValue({ mailboxes: [refreshedSource, target] });
+    const remove = vi.spyOn(api, 'deleteInboundMailbox').mockRejectedValue(
+      new Error('Mailbox now contains messages and requires a migration target.')
+    );
+    const router = createInboxRouter(['/inbox?workspace=routing'], 0);
+    renderRouter(router);
+
+    const sourceCard = (await screen.findByText(source.address)).closest('.ant-card') as HTMLElement;
+    await user.click(within(sourceCard).getByRole('button', { name: `删除邮箱 ${source.address}` }));
+    const emptyDialog = await screen.findByRole('dialog', { name: '删除邮箱' });
+    await user.type(
+      within(emptyDialog).getByRole('textbox', { name: '输入完整邮箱地址确认' }),
+      source.address
+    );
+    await user.click(within(emptyDialog).getByRole('button', { name: '删除邮箱' }));
+
+    await waitFor(() => expect(remove).toHaveBeenCalledWith(source.id, {
+      targetMailboxId: null,
+      confirmAddress: source.address
+    }));
+    await waitFor(() => expect(loadMailboxes).toHaveBeenCalledTimes(2));
+
+    const refreshedDialog = await screen.findByRole('dialog', { name: '迁移邮件并删除邮箱' });
+    expect(within(refreshedDialog).getByRole('combobox', { name: '目标邮箱' })).toBeTruthy();
+    expect((within(refreshedDialog).getByRole('textbox', { name: '输入完整邮箱地址确认' }) as HTMLInputElement).value)
+      .toBe(source.address);
+    expect((within(refreshedDialog).getByRole('button', { name: '迁移邮件并删除' }) as HTMLButtonElement).disabled)
+      .toBe(true);
+  });
+
   it('groups owned and shared domains when creating a mailbox', async () => {
     const user = userEvent.setup();
     const ownedDomain = domainFixture(1);

+ 252 - 2
test/ui/mailbox-access.test.tsx

@@ -1,5 +1,5 @@
 import { App as AntApp, ConfigProvider } from 'antd';
-import { act, render, screen, waitFor, within } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom';
 import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -75,6 +75,212 @@ describe('Mailbox access UI', () => {
     await waitFor(() => expect(document.activeElement).toBe(configure));
   });
 
+  it('replaces only the selected mailbox-user grants in bulk and reports detailed results', async () => {
+    const browser = userEvent.setup();
+    const entries = [
+      accessEntryFixture(11, 'support-1@example.test'),
+      accessEntryFixture(12, 'support-2@example.test')
+    ];
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
+    vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: entries });
+    const bulkUpdate = vi.spyOn(api, 'bulkUpdateAdminMailboxAccess').mockResolvedValue({
+      mailboxes: entries,
+      summary: {
+        mailboxCount: 2,
+        userCount: 2,
+        changedGrantCount: 2,
+        skippedOwnerCount: 2
+      }
+    });
+    renderMailboxAccessRoute();
+
+    const search = await screen.findByRole('searchbox', { name: '搜索邮箱、所有者或邮箱地址' });
+    await browser.click(await screen.findByRole('checkbox', { name: `选择邮箱 · ${entries[0].mailbox.address}` }));
+    await browser.click(screen.getByRole('checkbox', { name: `选择邮箱 · ${entries[1].mailbox.address}` }));
+    expect(screen.getByText('已选择邮箱 2')).toBeTruthy();
+    await browser.click(screen.getByRole('button', { name: '批量授权 (2)' }));
+
+    const dialog = await screen.findByRole('dialog', { name: '批量配置邮箱权限' });
+    expect(within(dialog).getByText(entries[0].mailbox.address)).toBeTruthy();
+    expect(within(dialog).getByText(entries[1].mailbox.address)).toBeTruthy();
+    expect(within(dialog).getByRole('radiogroup', { name: '批量操作' }).getAttribute('aria-required')).toBe('true');
+    expect(within(dialog).getByText(/现有权限替换为下方权限/)).toBeTruthy();
+
+    await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
+    await browser.click(await screen.findByText('owner · owner@example.test', { selector: '.ant-select-item-option-content' }));
+    await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
+    await browser.click(await screen.findByText('reader · reader@example.test', { selector: '.ant-select-item-option-content' }));
+    expect(within(dialog).getByText('2 个邮箱 × 2 个用户 = 4 个组合')).toBeTruthy();
+    expect(within(dialog).getByText('预计跳过邮箱所有者组合 2')).toBeTruthy();
+
+    await browser.click(within(dialog).getByRole('checkbox', { name: '收取邮件' }));
+    const viewPermission = within(dialog).getByRole('checkbox', { name: '查看配置' }) as HTMLInputElement;
+    expect(viewPermission.checked).toBe(true);
+    expect(viewPermission.disabled).toBe(true);
+    expect(within(dialog).getByText(/收取邮件或发送邮件会自动包含查看配置权限/)).toBeTruthy();
+    await browser.click(within(dialog).getByRole('button', { name: '应用权限' }));
+
+    await waitFor(() => expect(bulkUpdate).toHaveBeenCalledWith({
+      mailboxIds: [11, 12],
+      userIds: [1, 2],
+      operation: 'upsert',
+      permissions: { view: true, receive: true, send: false }
+    }));
+    expect(await screen.findByText('批量邮箱权限已更新: 已更改 2 · 未变化 0 · 跳过所有者 2')).toBeTruthy();
+    expect(screen.queryByRole('dialog', { name: '批量配置邮箱权限' })).toBeNull();
+    expect((screen.getByRole('button', { name: '批量授权' }) as HTMLButtonElement).disabled).toBe(true);
+    await waitFor(() => expect(document.activeElement).toBe(search));
+  });
+
+  it('offers disabled users only for removal and drops them before switching back to grant mode', async () => {
+    const browser = userEvent.setup();
+    const entry = accessEntryFixture(13, 'disabled-removal@example.test');
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
+    vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: [entry] });
+    const bulkUpdate = vi.spyOn(api, 'bulkUpdateAdminMailboxAccess');
+    renderMailboxAccessRoute();
+
+    await browser.click(await screen.findByRole('checkbox', { name: `选择邮箱 · ${entry.mailbox.address}` }));
+    await browser.click(screen.getByRole('button', { name: '批量授权 (1)' }));
+    const dialog = await screen.findByRole('dialog', { name: '批量配置邮箱权限' });
+    const disabledUserLabel = 'disabled · disabled@example.test · 已禁用';
+
+    await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
+    expect(screen.getByText('pending · pending@example.test', { selector: '.ant-select-item-option-content' })).toBeTruthy();
+    expect(screen.queryByText(disabledUserLabel, { selector: '.ant-select-item-option-content' })).toBeNull();
+    await browser.keyboard('{Escape}');
+
+    await browser.click(within(dialog).getByText('移除授权'));
+    expect(within(dialog).getByText(/移除授权时可选择已禁用用户/)).toBeTruthy();
+    await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
+    await browser.click(await screen.findByText(disabledUserLabel, { selector: '.ant-select-item-option-content' }));
+    await browser.keyboard('{Escape}');
+    expect((within(dialog).getByRole('button', { name: '移除授权' }) as HTMLButtonElement).disabled).toBe(false);
+
+    await browser.click(within(dialog).getByText('设置或替换权限'));
+    expect((within(dialog).getByRole('button', { name: '应用权限' }) as HTMLButtonElement).disabled).toBe(true);
+    await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
+    expect(screen.queryByText(disabledUserLabel, { selector: '.ant-select-item-option-content' })).toBeNull();
+    expect(bulkUpdate).not.toHaveBeenCalled();
+  });
+
+  it('previews only five selected mailboxes, clears hidden selections, and prunes removed IDs after reload', async () => {
+    const browser = userEvent.setup();
+    const entries = Array.from({ length: 6 }, (_item, index) => (
+      accessEntryFixture(20 + index, `mailbox-${index + 1}@example.test`)
+    ));
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
+    const list = vi.spyOn(api, 'adminMailboxAccess')
+      .mockResolvedValueOnce({ mailboxes: entries })
+      .mockResolvedValueOnce({ mailboxes: entries.slice(0, 5) });
+    renderMailboxAccessRoute();
+
+    await browser.click(await screen.findByRole('checkbox', { name: '选择全部筛选结果 (6)' }));
+    expect(screen.getByText('已选择邮箱 6')).toBeTruthy();
+    await browser.click(screen.getByRole('button', { name: '批量授权 (6)' }));
+    const dialog = await screen.findByRole('dialog', { name: '批量配置邮箱权限' });
+    for (const entry of entries.slice(0, 5)) {
+      expect(within(dialog).getByText(entry.mailbox.address)).toBeTruthy();
+    }
+    expect(within(dialog).queryByText(entries[5].mailbox.address)).toBeNull();
+    expect(within(dialog).getByText('+1')).toBeTruthy();
+    await browser.click(within(dialog).getByRole('button', { name: '取消' }));
+    await waitFor(() => expect(screen.queryByRole('dialog', { name: '批量配置邮箱权限' })).toBeNull());
+
+    const search = screen.getByRole('searchbox', { name: '搜索邮箱、所有者或邮箱地址' });
+    await browser.type(search, 'mailbox-6');
+    expect(await screen.findByText('当前筛选中 1')).toBeTruthy();
+    await browser.click(screen.getByRole('button', { name: '清空选择' }));
+    expect(screen.queryByText('已选择邮箱 6')).toBeNull();
+
+    await browser.click(screen.getByRole('checkbox', { name: `选择邮箱 · ${entries[5].mailbox.address}` }));
+    expect(screen.getByText('已选择邮箱 1')).toBeTruthy();
+    await browser.click(screen.getByRole('button', { name: /刷新/ }));
+    await waitFor(() => expect(list).toHaveBeenCalledTimes(2));
+    await waitFor(() => expect(screen.queryByText('已选择邮箱 1')).toBeNull());
+    expect((screen.getByRole('button', { name: '批量授权' }) as HTMLButtonElement).disabled).toBe(true);
+  });
+
+  it('requires a second confirmation for bulk removal and preserves the form after failure', async () => {
+    const browser = userEvent.setup();
+    const entry = accessEntryFixture(31, 'remove@example.test');
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
+    vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: [entry] });
+    let rejectBulk!: (reason?: unknown) => void;
+    const bulkUpdate = vi.spyOn(api, 'bulkUpdateAdminMailboxAccess').mockReturnValue(new Promise((_resolve, reject) => {
+      rejectBulk = reject;
+    }));
+    renderMailboxAccessRoute();
+
+    await browser.click(await screen.findByRole('checkbox', { name: `选择邮箱 · ${entry.mailbox.address}` }));
+    await browser.click(screen.getByRole('button', { name: '批量授权 (1)' }));
+    const dialog = await screen.findByRole('dialog', { name: '批量配置邮箱权限' });
+    await browser.click(within(dialog).getByText('移除授权'));
+    await browser.click(within(dialog).getByRole('combobox', { name: '授权用户' }));
+    await browser.click(await screen.findByText('reader · reader@example.test', { selector: '.ant-select-item-option-content' }));
+    await waitFor(() => expect(within(dialog).getByText('1 个邮箱 × 1 个用户 = 1 个组合')).toBeTruthy());
+    await browser.click(within(dialog).getByRole('button', { name: '移除授权' }));
+
+    expect(bulkUpdate).not.toHaveBeenCalled();
+    const confirmationTitle = await screen.findByText(
+      '确认批量移除授权',
+      { selector: '.ant-modal-confirm-title' }
+    );
+    const confirmation = confirmationTitle.closest('.ant-modal') as HTMLElement;
+    expect(confirmation).toBeTruthy();
+    expect(within(confirmation).getByText('1 个邮箱 × 1 个用户 = 1 个组合')).toBeTruthy();
+    await browser.click(within(confirmation).getByRole('button', { name: '移除授权' }));
+    await waitFor(() => expect(bulkUpdate).toHaveBeenCalledWith({
+      mailboxIds: [31],
+      userIds: [2],
+      operation: 'remove'
+    }));
+
+    expect((within(dialog).getByRole('combobox', { name: '授权用户' }) as HTMLInputElement).disabled).toBe(true);
+    expect((within(dialog).getByRole('button', { name: '取消' }) as HTMLButtonElement).disabled).toBe(true);
+    expect((within(confirmation).getByRole('button', { name: '取消' }) as HTMLButtonElement).disabled).toBe(true);
+    fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
+    expect(document.body.contains(dialog)).toBe(true);
+
+    await act(async () => {
+      rejectBulk(new Error('批量授权暂时不可用'));
+      await Promise.resolve();
+    });
+    await waitFor(() => expect(screen.queryByText(
+      '确认批量移除授权',
+      { selector: '.ant-modal-confirm-title' }
+    )).toBeNull());
+    const retainedDialog = screen.getByRole('dialog', { name: '批量配置邮箱权限' });
+    expect(within(retainedDialog).getByText('批量授权暂时不可用')).toBeTruthy();
+    expect((within(retainedDialog).getByRole('radio', { name: '移除授权' }) as HTMLInputElement).checked).toBe(true);
+    await browser.click(within(retainedDialog).getByRole('combobox', { name: '授权用户' }));
+    const retainedUserOption = await screen.findByText(
+      'reader · reader@example.test',
+      { selector: '.ant-select-item-option-content' }
+    );
+    expect(retainedUserOption.closest('.ant-select-item-option')?.classList.contains(
+      'ant-select-item-option-selected'
+    )).toBe(true);
+    expect(within(retainedDialog).getByText('已选择邮箱 1')).toBeTruthy();
+  });
+
+  it('blocks a selection larger than the mailbox batch limit', async () => {
+    const browser = userEvent.setup();
+    vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQueryList(query === '(min-width: 768px)', query));
+    const entries = Array.from({ length: 501 }, (_item, index) => (
+      accessEntryFixture(1000 + index, `limit-${index + 1}@example.test`)
+    ));
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
+    vi.spyOn(api, 'adminMailboxAccess').mockResolvedValue({ mailboxes: entries });
+    const bulkUpdate = vi.spyOn(api, 'bulkUpdateAdminMailboxAccess');
+    renderMailboxAccessRoute();
+
+    await browser.click(await screen.findByRole('checkbox', { name: '选择全部筛选结果 (501)' }));
+    expect(await screen.findByText('单次最多选择邮箱数: 500')).toBeTruthy();
+    expect((screen.getByRole('button', { name: '批量授权 (501)' }) as HTMLButtonElement).disabled).toBe(true);
+    expect(bulkUpdate).not.toHaveBeenCalled();
+  });
+
   it('shows owned and assigned mailboxes in account center with permission-based actions', async () => {
     const browser = userEvent.setup();
     const owned = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
@@ -221,6 +427,47 @@ function accessEntry(): AdminMailboxAccessEntry {
   };
 }
 
+function accessEntryFixture(id: number, address: string): AdminMailboxAccessEntry {
+  const mailbox = mailboxFixture(
+    id,
+    address,
+    'owner',
+    { view: true, receive: true, send: true },
+    adminUsers[0].id
+  );
+  return {
+    mailbox,
+    owner: adminUsers[0],
+    grants: [{
+      user: adminUsers[2],
+      permissions: { view: true, receive: false, send: true },
+      createdAt: '2026-07-18T00:00:00.000Z',
+      updatedAt: '2026-07-18T00:00:00.000Z'
+    }]
+  };
+}
+
+function renderMailboxAccessRoute() {
+  const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
+    initialEntries: ['/admin/mailbox-access']
+  });
+  renderWithRouter(router, adminContext);
+  return router;
+}
+
+function mediaQueryList(matches: boolean, media: string): MediaQueryList {
+  return {
+    matches,
+    media,
+    onchange: null,
+    addListener: () => undefined,
+    removeListener: () => undefined,
+    addEventListener: () => undefined,
+    removeEventListener: () => undefined,
+    dispatchEvent: () => false
+  };
+}
+
 function mailboxFixture(
   id: number,
   address: string,
@@ -256,7 +503,10 @@ function mailboxFixture(
 
 const adminUsers: AdminUser[] = [
   { id: 1, username: 'owner', email: 'owner@example.test', role: 'admin', status: 'active', resourceCounts: resourceCounts() },
-  { id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active', resourceCounts: resourceCounts() }
+  { id: 2, username: 'reader', email: 'reader@example.test', role: 'user', status: 'active', resourceCounts: resourceCounts() },
+  { id: 3, username: 'sender', email: 'sender@example.test', role: 'user', status: 'active', resourceCounts: resourceCounts() },
+  { id: 4, username: 'disabled', email: 'disabled@example.test', role: 'user', status: 'disabled', resourceCounts: resourceCounts() },
+  { id: 5, username: 'pending', email: 'pending@example.test', role: 'user', status: 'pending_email', resourceCounts: resourceCounts() }
 ];
 
 function contextFor(user: User): AppContextValue {

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels