Bladeren bron

feat: redesign delivery operations console

AI-Co-Authored-By: Codex
chendeben 1 maand geleden
bovenliggende
commit
b229042484
100 gewijzigde bestanden met toevoegingen van 8321 en 4048 verwijderingen
  1. 1 0
      .gitattributes
  2. 793 17
      package-lock.json
  3. 10 4
      package.json
  4. 0 0
      public/assets/Admin-BUDyRMGR.js
  5. 0 0
      public/assets/ApiTokens-BCcpUvp-.js
  6. 1 0
      public/assets/CodeBlock-Cehl-9Ci.js
  7. 1 0
      public/assets/CopyOutlined-BbN7wZaI.js
  8. 1 0
      public/assets/Dashboard-DykW5jzJ.js
  9. 2 0
      public/assets/DashboardCharts-D-0vx0AG.js
  10. 1 0
      public/assets/DeleteOutlined-J0odQPKG.js
  11. 0 0
      public/assets/DnsApi-C5ryZxid.js
  12. 0 0
      public/assets/DomainDetail-HdQk4-E6.js
  13. 0 0
      public/assets/Domains-4xZfGUFp.js
  14. 1 0
      public/assets/EditOutlined-CO1ElKoL.js
  15. 1 0
      public/assets/EllipsisOutlined-BF-4cC5U.js
  16. 1 0
      public/assets/EmptyState-CeUjt5Wb.js
  17. 0 0
      public/assets/Inbox-DqlZOpVQ.js
  18. 1 0
      public/assets/PlusOutlined-B79Dovl9.js
  19. 1 0
      public/assets/ReloadOutlined-By3LF9WE.js
  20. 1 0
      public/assets/SearchOutlined-8ZUFC3UK.js
  21. 0 0
      public/assets/SendingLogs-CcPGMgQp.js
  22. 0 0
      public/assets/Settings-ymCnqq58.js
  23. 0 0
      public/assets/SmtpCredentials-Irlwliwa.js
  24. 0 0
      public/assets/StatusPill-BYj3zI1_.js
  25. 1 0
      public/assets/StatusTag-BfusN1FN.js
  26. 1 0
      public/assets/ThunderboltOutlined-C6K0mmNg.js
  27. 0 0
      public/assets/Webhooks-DA-y9GWA.js
  28. 0 0
      public/assets/analytics-model-BpU8MjWz.js
  29. 0 0
      public/assets/badge-CFbskMgu.js
  30. 2 0
      public/assets/card-DMbCO2Fu.js
  31. 0 0
      public/assets/descriptions-gniVz27W.js
  32. 1 0
      public/assets/domain-model-BLOrIAIx.js
  33. 3 0
      public/assets/form-y5mvm6Rb.js
  34. 0 1
      public/assets/index-BJZnYPOR.js
  35. 1 0
      public/assets/index-Dy4xV4t8.js
  36. 0 0
      public/assets/input-7g-iBRng.js
  37. 0 0
      public/assets/input-number-Cb6-nOBv.js
  38. 0 0
      public/assets/jsx-runtime-C5RSiriy.js
  39. 0 0
      public/assets/list-6VGU-0Wr.js
  40. 0 0
      public/assets/login-B2OuF-HT.js
  41. 0 0
      public/assets/login-qcNsJrb8.js
  42. 1 0
      public/assets/modal-C7xDa6TW.js
  43. 1 0
      public/assets/navigation-state-ixI_efPF.js
  44. 0 0
      public/assets/popconfirm-D_VEsRZH.js
  45. 0 0
      public/assets/react-dom-stZKkA6x.js
  46. 1 0
      public/assets/row-Zz2thRPV.js
  47. 0 0
      public/assets/segmented-C01cwBXf.js
  48. 0 0
      public/assets/styles-Dz22uXjV.js
  49. 0 0
      public/assets/styles-xyyjYU3P.css
  50. 0 0
      public/assets/switch-CKxlbFCo.js
  51. 0 0
      public/assets/table-DDUhevyo.js
  52. 0 0
      public/assets/theme-CT_1QDVH.css
  53. 0 0
      public/assets/theme-CVfSdO4m.js
  54. 0 0
      public/assets/useForm-C8Hf5VoE.js
  55. 5 3
      public/index.html
  56. 10 3
      public/login.html
  57. 1 1
      src/components/common/PageHeader.tsx
  58. 172 109
      src/components/domain/AddDomainDrawer.tsx
  59. 196 11
      src/db.js
  60. 184 655
      src/frontend/App.tsx
  61. 18 0
      src/frontend/app-context.tsx
  62. 11 5
      src/frontend/auth/AuthApp.tsx
  63. 22 2
      src/frontend/auth/auth-model.js
  64. 54 4
      src/frontend/i18n/index.js
  65. 19 0
      src/frontend/navigation-state.ts
  66. 34 9
      src/frontend/services/api.ts
  67. 266 2
      src/frontend/styles.css
  68. 4 1
      src/frontend/theme.ts
  69. 53 6
      src/frontend/types.ts
  70. 223 102
      src/layouts/AdminLayout.tsx
  71. 334 192
      src/pages/Admin/index.tsx
  72. 242 206
      src/pages/ApiTokens.tsx
  73. 258 404
      src/pages/Dashboard.tsx
  74. 121 0
      src/pages/DashboardCharts.tsx
  75. 277 99
      src/pages/DnsApi.tsx
  76. 429 383
      src/pages/Domains/DomainDetail.tsx
  77. 501 135
      src/pages/Domains/index.tsx
  78. 550 628
      src/pages/Inbox.tsx
  79. 482 493
      src/pages/SendingLogs.tsx
  80. 184 142
      src/pages/Settings.tsx
  81. 275 273
      src/pages/SmtpCredentials.tsx
  82. 234 129
      src/pages/Webhooks.tsx
  83. 149 21
      src/server.js
  84. 3 0
      test/db.test.js
  85. 25 2
      test/frontend-auth-model.test.js
  86. 4 0
      test/frontend-i18n.test.js
  87. 1 0
      test/frontend-theme.test.js
  88. 18 6
      test/server-admin-api.test.js
  89. 564 0
      test/server-listing-api.test.js
  90. 66 0
      test/ui/add-domain-drawer.test.tsx
  91. 102 0
      test/ui/admin-layout.test.tsx
  92. 94 0
      test/ui/api-token-secret.test.tsx
  93. 21 0
      test/ui/auth-next.test.ts
  94. 125 0
      test/ui/direct-detail-navigation.test.tsx
  95. 112 0
      test/ui/domains-delete.test.tsx
  96. 326 0
      test/ui/domains-workflow.test.tsx
  97. 204 0
      test/ui/inbox-navigation.test.tsx
  98. 317 0
      test/ui/operations-navigation.test.tsx
  99. 153 0
      test/ui/settings-navigation-guard.test.tsx
  100. 50 0
      test/ui/setup.ts

+ 1 - 0
.gitattributes

@@ -0,0 +1 @@
+public/assets/*.js -whitespace

File diff suppressed because it is too large
+ 793 - 17
package-lock.json


+ 10 - 4
package.json

@@ -28,8 +28,9 @@
     "dev:ui": "vite --host 0.0.0.0",
     "build": "tsc --noEmit && vite build",
     "preview:ui": "vite preview --host 0.0.0.0",
-    "test": "node --test",
-    "release:check": "npm test && npm run build",
+    "test": "node --test test/*.test.js",
+    "test:ui": "vitest run",
+    "release:check": "npm test && npm run test:ui && npm run build",
     "deploy:remote": "bash scripts/deploy-remote.sh"
   },
   "engines": {
@@ -42,13 +43,18 @@
     "antd": "^5.29.3",
     "parse5": "^8.0.1",
     "react": "^19.2.7",
-    "react-dom": "^19.2.7"
+    "react-dom": "^19.2.7",
+    "react-router-dom": "^7.18.1"
   },
   "devDependencies": {
+    "@testing-library/react": "^16.3.2",
+    "@testing-library/user-event": "^14.6.1",
     "@types/react": "^19.2.17",
     "@types/react-dom": "^19.2.3",
     "@vitejs/plugin-react": "^6.0.3",
+    "jsdom": "^29.1.1",
     "typescript": "^5.9.3",
-    "vite": "^8.1.3"
+    "vite": "^8.1.3",
+    "vitest": "^4.1.10"
   }
 }

File diff suppressed because it is too large
+ 0 - 0
public/assets/Admin-BUDyRMGR.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/ApiTokens-BCcpUvp-.js


+ 1 - 0
public/assets/CodeBlock-Cehl-9Ci.js

@@ -0,0 +1 @@
+import{At as e,n as t,t as n}from"./jsx-runtime-C5RSiriy.js";import{t as r}from"./CopyOutlined-BbN7wZaI.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 - 0
public/assets/CopyOutlined-BbN7wZaI.js

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

File diff suppressed because it is too large
+ 1 - 0
public/assets/Dashboard-DykW5jzJ.js


File diff suppressed because it is too large
+ 2 - 0
public/assets/DashboardCharts-D-0vx0AG.js


+ 1 - 0
public/assets/DeleteOutlined-J0odQPKG.js

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

File diff suppressed because it is too large
+ 0 - 0
public/assets/DnsApi-C5ryZxid.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/DomainDetail-HdQk4-E6.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/Domains-4xZfGUFp.js


+ 1 - 0
public/assets/EditOutlined-CO1ElKoL.js

@@ -0,0 +1 @@
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-CVfSdO4m.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:`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`}})),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 - 0
public/assets/EllipsisOutlined-BF-4cC5U.js

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

@@ -0,0 +1 @@
+import{et as e,t}from"./jsx-runtime-C5RSiriy.js";var n=t();function r({description:t,action:r,icon:i}){return(0,n.jsx)(`div`,{className:`empty-state`,children:(0,n.jsx)(e,{image:i||e.PRESENTED_IMAGE_SIMPLE,description:t,children:r})})}export{r as t};

File diff suppressed because it is too large
+ 0 - 0
public/assets/Inbox-DqlZOpVQ.js


+ 1 - 0
public/assets/PlusOutlined-B79Dovl9.js

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

@@ -0,0 +1 @@
+import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-CVfSdO4m.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 - 0
public/assets/SearchOutlined-8ZUFC3UK.js

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

File diff suppressed because it is too large
+ 0 - 0
public/assets/SendingLogs-CcPGMgQp.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/Settings-ymCnqq58.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/SmtpCredentials-Irlwliwa.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/StatusPill-BYj3zI1_.js


+ 1 - 0
public/assets/StatusTag-BfusN1FN.js

@@ -0,0 +1 @@
+import{a as e}from"./theme-CVfSdO4m.js";import{t}from"./jsx-runtime-C5RSiriy.js";import{t as n}from"./badge-CFbskMgu.js";import{t as r}from"./StatusPill-BYj3zI1_.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 - 0
public/assets/ThunderboltOutlined-C6K0mmNg.js

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

File diff suppressed because it is too large
+ 0 - 0
public/assets/Webhooks-DA-y9GWA.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/analytics-model-BpU8MjWz.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/badge-CFbskMgu.js


File diff suppressed because it is too large
+ 2 - 0
public/assets/card-DMbCO2Fu.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/descriptions-gniVz27W.js


+ 1 - 0
public/assets/domain-model-BLOrIAIx.js

@@ -0,0 +1 @@
+var e=[`verification`,`dkim`,`spf`,`dmarc`,`sender-a`],t={ok:{key:`success`,label:`已通过`,color:`success`},verified:{key:`success`,label:`已通过`,color:`success`},pending:{key:`pending`,label:`等待生效`,color:`warning`},warn:{key:`error`,label:`配置错误`,color:`error`},failed:{key:`error`,label:`配置错误`,color:`error`},error:{key:`error`,label:`配置错误`,color:`error`},missing:{key:`idle`,label:`未配置`,color:`default`},idle:{key:`idle`,label:`未配置`,color:`default`}};function n(e={}){return t[String(e.status||``).toLowerCase()]||t.missing}function r(e={}){return Array.isArray(e.current)?e.current.filter(e=>e):e.current?[e.current]:n(e).key===`success`&&e.value?[e.value]:[]}function i(t={}){let n=Array.isArray(t.status?.records)?t.status.records:[],r=new Map(n.map(e=>[e.key,e]));return e.map(e=>r.get(e)).filter(Boolean)}function a(t=[]){let n=new Map(e.map((e,t)=>[e,t]));return[...t].filter(e=>n.has(e.key)).sort((e,t)=>n.get(e.key)-n.get(t.key))}function o(t={}){let r=i(t),a=e.length,o=r.filter(e=>n(e).key===`success`).length,s=r.filter(e=>{let t=n(e).key;return t===`error`||t===`idle`}).length+Math.max(0,a-r.length),c=a?Math.round(o/a*100):0,l=s>0?`error`:o===a?`success`:`warning`;return{status:l,label:l===`success`?`健康`:l===`warning`?`等待 DNS 生效`:`需要处理`,passed:o,total:a,percent:c,dnsIssues:s,checkedAt:t.status?.checkedAt||``}}export{a as i,r as n,n as r,o as t};

File diff suppressed because it is too large
+ 3 - 0
public/assets/form-y5mvm6Rb.js


File diff suppressed because it is too large
+ 0 - 1
public/assets/index-BJZnYPOR.js


File diff suppressed because it is too large
+ 1 - 0
public/assets/index-Dy4xV4t8.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/input-7g-iBRng.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/input-number-Cb6-nOBv.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/jsx-runtime-C5RSiriy.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/list-6VGU-0Wr.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/login-B2OuF-HT.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/login-qcNsJrb8.js


+ 1 - 0
public/assets/modal-C7xDa6TW.js

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

+ 1 - 0
public/assets/navigation-state-ixI_efPF.js

@@ -0,0 +1 @@
+function e(e){if(!e||typeof e!=`object`)return null;let t=e.mailhubDetail;if(!t||typeof t!=`object`)return null;let{listPath:n,depth:r,origin:i}=t;return typeof n!=`string`||!n.startsWith(`/`)||!Number.isInteger(r)||Number(r)<1||i!==void 0&&i!==`list`&&i!==`direct`?null:{listPath:n,depth:Number(r),origin:i||`list`}}function t(e,t,n=`list`){return{mailhubDetail:{listPath:e,depth:t,origin:n}}}export{e as n,t};

File diff suppressed because it is too large
+ 0 - 0
public/assets/popconfirm-D_VEsRZH.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/react-dom-stZKkA6x.js


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

@@ -0,0 +1 @@
+import{d as e,u as t}from"./jsx-runtime-C5RSiriy.js";var n=e,r=t;export{n,r as t};

File diff suppressed because it is too large
+ 0 - 0
public/assets/segmented-C01cwBXf.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/styles-Dz22uXjV.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/styles-xyyjYU3P.css


File diff suppressed because it is too large
+ 0 - 0
public/assets/switch-CKxlbFCo.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/table-DDUhevyo.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/theme-CT_1QDVH.css


File diff suppressed because it is too large
+ 0 - 0
public/assets/theme-CVfSdO4m.js


File diff suppressed because it is too large
+ 0 - 0
public/assets/useForm-C8Hf5VoE.js


+ 5 - 3
public/index.html

@@ -4,10 +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-BJZnYPOR.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-Dz22uXjV.js">
+    <script type="module" crossorigin src="/assets/index-Dy4xV4t8.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="stylesheet" crossorigin href="/assets/styles-xyyjYU3P.css">
+    <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-C5RSiriy.js">
+    <link rel="modulepreload" crossorigin href="/assets/theme-CVfSdO4m.js">
+    <link rel="stylesheet" crossorigin href="/assets/theme-CT_1QDVH.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>
   <body>

+ 10 - 3
public/login.html

@@ -4,10 +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-qcNsJrb8.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-Dz22uXjV.js">
+    <script type="module" crossorigin src="/assets/login-B2OuF-HT.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="stylesheet" crossorigin href="/assets/styles-xyyjYU3P.css">
+    <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-C5RSiriy.js">
+    <link rel="modulepreload" crossorigin href="/assets/theme-CVfSdO4m.js">
+    <link rel="modulepreload" crossorigin href="/assets/card-DMbCO2Fu.js">
+    <link rel="modulepreload" crossorigin href="/assets/segmented-C01cwBXf.js">
+    <link rel="modulepreload" crossorigin href="/assets/useForm-C8Hf5VoE.js">
+    <link rel="modulepreload" crossorigin href="/assets/form-y5mvm6Rb.js">
+    <link rel="modulepreload" crossorigin href="/assets/input-7g-iBRng.js">
+    <link rel="stylesheet" crossorigin href="/assets/theme-CT_1QDVH.css">
   </head>
   <body>
     <div id="auth-root"></div>

+ 1 - 1
src/components/common/PageHeader.tsx

@@ -13,7 +13,7 @@ export function PageHeader({
   return (
     <div className="page-header">
       <div className="page-header__text">
-        <Typography.Title level={3} className="page-header__title">
+        <Typography.Title level={1} className="page-header__title">
           {title}
         </Typography.Title>
         {subtitle ? (

+ 172 - 109
src/components/domain/AddDomainDrawer.tsx

@@ -1,7 +1,22 @@
-import { CheckOutlined } from '@ant-design/icons';
-import { Button, Checkbox, Drawer, Form, Input, Select, Space, Steps, Typography } from 'antd';
-import { useEffect, useState } from 'react';
+import { CheckOutlined, CloudOutlined, CodeOutlined, MailOutlined, SendOutlined } from '@ant-design/icons';
+import {
+  Alert,
+  Button,
+  Checkbox,
+  Collapse,
+  Descriptions,
+  Drawer,
+  Form,
+  Input,
+  Radio,
+  Select,
+  Space,
+  Steps,
+  Typography
+} from 'antd';
+import { useEffect, useMemo, useState } from 'react';
 
+import { StatusPill } from '../common/StatusPill';
 import { useI18n } from '../../frontend/i18n/react';
 import type { AddDomainPayload, DnsCredential, RuntimeConfig, SmtpRelay } from '../../frontend/types';
 
@@ -15,6 +30,11 @@ interface AddDomainDrawerProps {
   onSubmit: (values: AddDomainPayload) => Promise<void>;
 }
 
+interface DomainWizardValues extends AddDomainPayload {
+  purpose: 'sending' | 'sending-receiving';
+  dnsMode: 'automatic' | 'manual';
+}
+
 export function AddDomainDrawer({
   open,
   loading,
@@ -24,14 +44,22 @@ export function AddDomainDrawer({
   onClose,
   onSubmit
 }: AddDomainDrawerProps) {
-  const { t } = useI18n();
-  const [form] = Form.useForm<AddDomainPayload>();
+  const { locale } = useI18n();
+  const copy = locale.startsWith('en') ? enCopy : zhCopy;
+  const [form] = Form.useForm<DomainWizardValues>();
   const [current, setCurrent] = useState(0);
-  const steps = [t('addDomain.stepDomain'), t('addDomain.stepDns'), t('addDomain.stepPolicy'), t('addDomain.stepConfirm')];
+  const values = Form.useWatch([], form);
+  const dnsMode = Form.useWatch('dnsMode', form);
+  const steps = useMemo(() => [copy.domainAndPurpose, copy.dnsAndDelivery, copy.reviewAndCreate], [copy]);
 
   useEffect(() => {
     if (!open) return;
+    form.resetFields();
     form.setFieldsValue({
+      purpose: 'sending',
+      dnsMode: dnsCredentials.length ? 'automatic' : 'manual',
+      dnsCredentialId: dnsCredentials[0]?.id,
+      smtpRelayId: undefined,
       senderHost: config?.mailHostname || '',
       sendingIp: config?.sendingIp || '',
       selector: defaultSelector(),
@@ -40,146 +68,171 @@ export function AddDomainDrawer({
       immediateCheck: true
     });
     setCurrent(0);
-  }, [config, form, open]);
+  }, [config, dnsCredentials, form, open]);
 
   async function next() {
-    await form.validateFields(stepFields(current));
+    try {
+      await form.validateFields(stepFields(current));
+    } catch {
+      return;
+    }
+    if (current === 1 && dnsMode === 'automatic' && !form.getFieldValue('dnsCredentialId')) {
+      form.setFields([{ name: 'dnsCredentialId', errors: [copy.selectDnsCredential] }]);
+      return;
+    }
     setCurrent((value) => Math.min(value + 1, steps.length - 1));
   }
 
   async function submit() {
-    const values = await form.validateFields();
-    await onSubmit(values);
+    let result: DomainWizardValues;
+    try {
+      result = await form.validateFields();
+    } catch {
+      return;
+    }
+    const payload: AddDomainPayload = {
+      domain: result.domain.trim().toLowerCase(),
+      senderHost: result.senderHost?.trim(),
+      sendingIp: result.sendingIp?.trim(),
+      dnsCredentialId: result.dnsMode === 'automatic' ? result.dnsCredentialId : undefined,
+      smtpRelayId: result.smtpRelayId || null,
+      selector: result.selector?.trim(),
+      dmarcPolicy: result.dmarcPolicy,
+      spfExtra: result.spfExtra?.trim(),
+      immediateCheck: result.immediateCheck
+    };
+    try {
+      await onSubmit(payload);
+    } catch {
+      return;
+    }
     form.resetFields();
     setCurrent(0);
   }
 
   return (
     <Drawer
-      title={t('addDomain.title')}
-      width={560}
+      title={copy.title}
+      width="min(640px, 100vw)"
       open={open}
       onClose={onClose}
       destroyOnHidden
+      maskClosable={!loading}
       className="add-domain-drawer"
-      styles={{
-        header: { padding: '16px 24px' },
-        body: { padding: '20px 24px 28px' },
-        footer: { padding: '12px 24px' }
-      }}
+      styles={{ header: { padding: '16px 24px' }, body: { padding: '20px 24px 28px' }, footer: { padding: '12px 24px' } }}
       footer={
         <div className="drawer-footer">
-          <Button onClick={onClose}>{t('common.cancel')}</Button>
+          <Button style={{ minHeight: 44 }} disabled={loading} onClick={onClose}>{copy.cancel}</Button>
           <Space size={8}>
-            <Button disabled={current === 0} onClick={() => setCurrent((value) => value - 1)}>
-              {t('common.previous')}
-            </Button>
+            <Button style={{ minHeight: 44 }} disabled={current === 0 || loading} onClick={() => setCurrent((value) => value - 1)}>{copy.previous}</Button>
             {current < steps.length - 1 ? (
-              <Button type="primary" onClick={next}>
-                {t('common.next')}
-              </Button>
+              <Button type="primary" style={{ minHeight: 44 }} onClick={() => void next()}>{copy.next}</Button>
             ) : (
-              <Button type="primary" icon={<CheckOutlined />} loading={loading} onClick={submit}>
-                {t('addDomain.create')}
-              </Button>
+              <Button type="primary" style={{ minHeight: 44 }} icon={<CheckOutlined />} loading={loading} onClick={() => void submit()}>{copy.createAndVerify}</Button>
             )}
           </Space>
         </div>
       }
     >
       <Space direction="vertical" size={24} className="full-width">
-        <Steps current={current} items={steps.map((title) => ({ title }))} direction="vertical" responsive className="add-domain-steps" />
-        <Form form={form} layout="vertical" requiredMark="optional">
-          <div hidden={current !== 0}>
+        <Steps current={current} items={steps.map((title) => ({ title }))} responsive aria-label={copy.progress} />
+        <Form form={form} layout="vertical" requiredMark="optional" preserve>
+          <section hidden={current !== 0} aria-label={copy.domainAndPurpose}>
             <Form.Item
               name="domain"
-              label={t('domains.domain')}
-              rules={[{ required: true, message: t('addDomain.domainRequired') }]}
-              extra={t('addDomain.domainExtra')}
+              label={copy.domain}
+              rules={[
+                { required: true, message: copy.domainRequired },
+                { pattern: /^(?!-)(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/, message: copy.domainInvalid }
+              ]}
+              extra={copy.domainHint}
             >
-              <Input placeholder="example.com" autoComplete="off" />
+              <Input placeholder="example.com" autoComplete="off" style={{ minHeight: 44 }} />
             </Form.Item>
-            <Form.Item name="senderHost" label={t('domains.senderHost')} rules={[{ required: true, message: t('addDomain.senderHostRequired') }]}>
-              <Input placeholder="mail.example.com" autoComplete="off" />
+            <Form.Item name="purpose" label={copy.purpose} rules={[{ required: true }]}>
+              <Radio.Group style={{ width: '100%' }}>
+                <Space direction="vertical" size={12} className="full-width">
+                  <Radio value="sending"><Space><SendOutlined /><span><Typography.Text strong>{copy.sendingOnly}</Typography.Text><br /><Typography.Text type="secondary">{copy.sendingOnlyHint}</Typography.Text></span></Space></Radio>
+                  <Radio value="sending-receiving"><Space><MailOutlined /><span><Typography.Text strong>{copy.sendAndReceive}</Typography.Text><br /><Typography.Text type="secondary">{copy.sendAndReceiveHint}</Typography.Text></span></Space></Radio>
+                </Space>
+              </Radio.Group>
             </Form.Item>
-            <Form.Item name="sendingIp" label={t('domains.sendingIp')} rules={[{ required: true, message: t('addDomain.sendingIpRequired') }]}>
-              <Input placeholder="203.0.113.10" autoComplete="off" />
-            </Form.Item>
-          </div>
-          <div hidden={current !== 1}>
-            <Form.Item
-              name="dnsCredentialId"
-              label={t('domains.dnsApi')}
-              extra={t('addDomain.dnsExtra')}
-            >
-              <Select
-                allowClear
-                placeholder={t('addDomain.manualDns')}
-                options={dnsCredentials.map((credential) => ({
-                  value: credential.id,
-                  label: `${credential.name} · ${providerLabel(credential.provider)}`
-                }))}
-              />
+            {values?.purpose === 'sending-receiving' ? <Alert type="info" showIcon message={copy.receiveFollowUp} /> : null}
+          </section>
+
+          <section hidden={current !== 1} aria-label={copy.dnsAndDelivery}>
+            <Form.Item name="dnsMode" label={copy.dnsConfiguration} rules={[{ required: true }]}>
+              <Radio.Group optionType="button" buttonStyle="solid" style={{ minHeight: 44 }}>
+                <Radio.Button value="automatic" disabled={!dnsCredentials.length}><CloudOutlined /> {copy.automatic}</Radio.Button>
+                <Radio.Button value="manual"><CodeOutlined /> {copy.manual}</Radio.Button>
+              </Radio.Group>
             </Form.Item>
-            <Typography.Paragraph type="secondary">
-              {t('addDomain.dnsHint')}
-            </Typography.Paragraph>
-            <Form.Item
-              name="smtpRelayId"
-              label={t('smtpRelay.domainDefault')}
-              extra={t('smtpRelay.domainDefaultExtra')}
-            >
+            {dnsMode === 'automatic' ? (
+              <Form.Item name="dnsCredentialId" label={copy.dnsCredential} rules={[{ required: true, message: copy.selectDnsCredential }]} extra={copy.autoDnsHint}>
+                <Select
+                  placeholder={copy.selectDnsCredential}
+                  style={{ minHeight: 44 }}
+                  options={dnsCredentials.map((credential) => ({ value: credential.id, label: `${credential.name} · ${providerLabel(credential.provider)}` }))}
+                />
+              </Form.Item>
+            ) : (
+              <Alert type="info" showIcon message={copy.manualDnsTitle} description={copy.manualDnsHint} />
+            )}
+
+            <Form.Item name="smtpRelayId" label={copy.relay} extra={copy.relayHint} style={{ marginTop: 20 }}>
               <Select
                 allowClear
-                placeholder={t('smtpRelay.useResolutionOrder')}
-                options={smtpRelays.map((relay) => ({
-                  value: relay.id,
-                  label: relayLabel(relay, t)
-                }))}
-              />
-            </Form.Item>
-          </div>
-          <div hidden={current !== 2}>
-            <Form.Item name="selector" label="DKIM selector" rules={[{ required: true, message: t('addDomain.selectorRequired') }]}>
-              <Input placeholder="mh202607" autoComplete="off" />
-            </Form.Item>
-            <Form.Item name="dmarcPolicy" label="DMARC">
-              <Select
-                options={[
-                  { value: 'none', label: 'none' },
-                  { value: 'quarantine', label: 'quarantine' },
-                  { value: 'reject', label: 'reject' }
-                ]}
+                placeholder={copy.defaultDelivery}
+                style={{ minHeight: 44 }}
+                options={smtpRelays.map((relay) => ({ value: relay.id, label: relayLabel(relay, copy.defaultLabel) }))}
               />
             </Form.Item>
-            <Form.Item name="spfExtra" label={t('addDomain.spfExtra')}>
-              <Input.TextArea rows={3} placeholder="include:spf.mailjet.com include:amazonses.com" />
-            </Form.Item>
-          </div>
-          <div hidden={current !== 3}>
-            <Typography.Title level={5}>{t('addDomain.generatedTitle')}</Typography.Title>
-            <ul className="confirm-list">
-              <li>{t('addDomain.recordVerification')}</li>
-              <li>{t('addDomain.recordDkim')}</li>
-              <li>{t('addDomain.recordSpf')}</li>
-              <li>{t('addDomain.recordDmarc')}</li>
-              <li>{t('addDomain.recordSenderA')}</li>
-              <li>{t('addDomain.recordPtr')}</li>
-            </ul>
-            <Form.Item name="immediateCheck" valuePropName="checked">
-              <Checkbox>{t('addDomain.immediateCheck')}</Checkbox>
+
+            <Collapse
+              ghost
+              items={[{
+                key: 'advanced',
+                label: copy.advanced,
+                forceRender: true,
+                children: (
+                  <>
+                    <Form.Item name="senderHost" label={copy.senderHost} rules={[{ required: true, message: copy.senderHostRequired }]}><Input placeholder="mail.example.com" autoComplete="off" style={{ minHeight: 44 }} /></Form.Item>
+                    <Form.Item name="sendingIp" label={copy.sendingIp} rules={[{ required: true, message: copy.sendingIpRequired }]}><Input placeholder="203.0.113.10" autoComplete="off" style={{ minHeight: 44 }} /></Form.Item>
+                    <Form.Item name="selector" label="DKIM selector" rules={[{ required: true, message: copy.selectorRequired }]}><Input placeholder="mh202607" autoComplete="off" style={{ minHeight: 44 }} /></Form.Item>
+                    <Form.Item name="dmarcPolicy" label="DMARC"><Select style={{ minHeight: 44 }} options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} /></Form.Item>
+                    <Form.Item name="spfExtra" label={copy.spfExtra}><Input.TextArea rows={3} placeholder="include:spf.example.com" /></Form.Item>
+                  </>
+                )
+              }]}
+            />
+          </section>
+
+          <section hidden={current !== 2} aria-label={copy.reviewAndCreate}>
+            <Alert type="info" showIcon message={copy.reviewHint} style={{ marginBottom: 20 }} />
+            <Descriptions bordered size="small" column={1}>
+              <Descriptions.Item label={copy.domain}>{values?.domain || '-'}</Descriptions.Item>
+              <Descriptions.Item label={copy.purpose}>{values?.purpose === 'sending-receiving' ? copy.sendAndReceive : copy.sendingOnly}</Descriptions.Item>
+              <Descriptions.Item label={copy.dnsConfiguration}>
+                <StatusPill tone={values?.dnsMode === 'automatic' ? 'info' : 'neutral'}>{values?.dnsMode === 'automatic' ? copy.automatic : copy.manual}</StatusPill>
+              </Descriptions.Item>
+              <Descriptions.Item label={copy.dnsCredential}>{values?.dnsMode === 'automatic' ? dnsCredentials.find((item) => item.id === values?.dnsCredentialId)?.name || '-' : copy.notApplicable}</Descriptions.Item>
+              <Descriptions.Item label={copy.relay}>{smtpRelays.find((item) => item.id === values?.smtpRelayId)?.name || copy.defaultDelivery}</Descriptions.Item>
+              <Descriptions.Item label="DKIM selector">{values?.selector || '-'}</Descriptions.Item>
+              <Descriptions.Item label="DMARC">{values?.dmarcPolicy || 'none'}</Descriptions.Item>
+            </Descriptions>
+            <Form.Item name="immediateCheck" valuePropName="checked" style={{ marginTop: 20 }}>
+              <Checkbox>{copy.verifyImmediately}</Checkbox>
             </Form.Item>
-          </div>
+          </section>
         </Form>
       </Space>
     </Drawer>
   );
 }
 
-function stepFields(step: number): Array<keyof AddDomainPayload> {
-  if (step === 0) return ['domain', 'senderHost', 'sendingIp'];
-  if (step === 1) return ['dnsCredentialId', 'smtpRelayId'];
-  if (step === 2) return ['selector', 'dmarcPolicy', 'spfExtra'];
+function stepFields(step: number): Array<keyof DomainWizardValues> {
+  if (step === 0) return ['domain', 'purpose'];
+  if (step === 1) return ['dnsMode', 'dnsCredentialId', 'smtpRelayId', 'senderHost', 'sendingIp', 'selector', 'dmarcPolicy', 'spfExtra'];
   return [];
 }
 
@@ -189,13 +242,23 @@ function defaultSelector() {
 }
 
 function providerLabel(provider: string) {
-  return {
-    cloudflare: 'Cloudflare',
-    aliyun: 'Aliyun DNS',
-    dnspod: 'Tencent DNSPod'
-  }[provider] || provider;
+  return ({ cloudflare: 'Cloudflare', aliyun: 'Aliyun DNS', dnspod: 'Tencent DNSPod' } as Record<string, string>)[provider] || provider;
 }
 
-function relayLabel(relay: SmtpRelay, t: (key: string) => string) {
-  return `${relay.name}${relay.isDefault ? ` · ${t('smtpRelay.default')}` : ''} · ${relay.host}:${relay.port}`;
+function relayLabel(relay: SmtpRelay, defaultLabel: string) {
+  return `${relay.name}${relay.isDefault ? ` · ${defaultLabel}` : ''} · ${relay.host}:${relay.port}`;
 }
+
+const zhCopy = {
+  title: '添加发信域名', progress: '添加域名进度', domainAndPurpose: '域名与用途', dnsAndDelivery: 'DNS 与投递', reviewAndCreate: '检查并创建', cancel: '取消', previous: '上一步', next: '下一步', createAndVerify: '创建并验证',
+  domain: '域名', domainRequired: '请输入域名', domainInvalid: '请输入有效的根域名', domainHint: '请输入用于发件地址的根域名,例如 example.com。', purpose: '用途', sendingOnly: '仅发送邮件', sendingOnlyHint: '配置 SPF、DKIM、DMARC 和发信主机。', sendAndReceive: '发送并接收邮件', sendAndReceiveHint: '创建后继续配置邮箱与收信路由。', receiveFollowUp: '域名创建后,可在“收信配置”页签中创建邮箱和路由。',
+  dnsConfiguration: 'DNS 配置方式', automatic: '自动配置', manual: '手动配置', dnsCredential: 'DNS 凭据', selectDnsCredential: '请选择 DNS 凭据', autoDnsHint: '创建后将使用该凭据写入所需记录;你仍可先检查摘要。', manualDnsTitle: '手动配置 DNS', manualDnsHint: '创建后会展示每条记录的主机名、类型和值,便于复制到当前 DNS 服务商。', relay: '发送中继', relayHint: '留空时按系统默认解析顺序投递。', defaultDelivery: '使用系统默认投递', defaultLabel: '默认', advanced: '高级选项', senderHost: '发信主机', senderHostRequired: '请输入发信主机', sendingIp: '发信 IP', sendingIpRequired: '请输入发信 IP', selectorRequired: '请输入 DKIM selector', spfExtra: 'SPF 扩展机制',
+  reviewHint: '创建操作不会隐藏任何现有能力;DNS 自动写入和验证结果可在域名详情中继续处理。', notApplicable: '不适用', verifyImmediately: '创建后立即检查 DNS 生效状态'
+};
+
+const enCopy: typeof zhCopy = {
+  title: 'Add sending domain', progress: 'Add domain progress', domainAndPurpose: 'Domain and purpose', dnsAndDelivery: 'DNS and delivery', reviewAndCreate: 'Review and create', cancel: 'Cancel', previous: 'Previous', next: 'Next', createAndVerify: 'Create and verify',
+  domain: 'Domain', domainRequired: 'Enter a domain', domainInvalid: 'Enter a valid root domain', domainHint: 'Use the root domain for your From addresses, for example example.com.', purpose: 'Purpose', sendingOnly: 'Send email', sendingOnlyHint: 'Configure SPF, DKIM, DMARC, and the sending host.', sendAndReceive: 'Send and receive email', sendAndReceiveHint: 'Continue with mailbox and inbound routing after creation.', receiveFollowUp: 'After creation, configure mailboxes and routes from the Receiving tab.',
+  dnsConfiguration: 'DNS configuration', automatic: 'Automatic', manual: 'Manual', dnsCredential: 'DNS credential', selectDnsCredential: 'Select a DNS credential', autoDnsHint: 'The credential can apply required records after creation; review them first if needed.', manualDnsTitle: 'Manual DNS configuration', manualDnsHint: 'The domain details will show each host, type, and value to copy into your DNS provider.', relay: 'Sending relay', relayHint: 'Leave empty to use the system delivery resolution order.', defaultDelivery: 'Use system delivery', defaultLabel: 'Default', advanced: 'Advanced options', senderHost: 'Sending host', senderHostRequired: 'Enter a sending host', sendingIp: 'Sending IP', sendingIpRequired: 'Enter a sending IP', selectorRequired: 'Enter a DKIM selector', spfExtra: 'Additional SPF mechanisms',
+  reviewHint: 'Creation keeps all existing capabilities available. Continue DNS application and verification from domain details.', notApplicable: 'Not applicable', verifyImmediately: 'Check DNS status immediately after creation'
+};

+ 196 - 11
src/db.js

@@ -1176,6 +1176,60 @@ export function listInboundMessages(userId, { mailboxId = null, folder = 'INBOX'
     .map((row) => publicInboundMessage(row));
 }
 
+export function searchInboundMessages(userId, filters = {}) {
+  const page = normalizePageNumber(filters.page);
+  const pageSize = normalizePageSize(filters.pageSize);
+  const where = ['msg.user_id = ?', 'msg.deleted_at IS NULL'];
+  const params = [userId];
+  if (filters.mailboxId) {
+    where.push('msg.mailbox_id = ?');
+    params.push(Number(filters.mailboxId));
+  }
+  if (filters.folder !== null && filters.folder !== undefined) {
+    where.push('msg.folder = ?');
+    params.push(normalizeInboundFolder(filters.folder) || 'INBOX');
+  }
+  if (typeof filters.read === 'boolean') {
+    where.push('msg.read_state = ?');
+    params.push(boolString(filters.read));
+  }
+  const query = String(filters.q || '').trim();
+  if (query) {
+    const pattern = `%${escapeLikePattern(query)}%`;
+    where.push(`(
+      msg.sender LIKE ? ESCAPE '\\'
+      OR msg.recipients_json LIKE ? ESCAPE '\\'
+      OR msg.subject LIKE ? ESCAPE '\\'
+      OR msg.message_id LIKE ? ESCAPE '\\'
+      OR msg.preview LIKE ? ESCAPE '\\'
+      OR m.address LIKE ? ESCAPE '\\'
+    )`);
+    params.push(pattern, pattern, pattern, pattern, pattern, pattern);
+  }
+  const whereSql = where.join(' AND ');
+  const total = Number(requireDb()
+    .prepare(`
+      SELECT COUNT(*) AS total
+      FROM inbound_messages msg
+      JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
+      WHERE ${whereSql}
+    `)
+    .get(...params)?.total || 0);
+  const rows = requireDb()
+    .prepare(`
+      SELECT msg.*, m.address AS mailbox_address, d.domain
+      FROM inbound_messages msg
+      JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
+      JOIN domains d ON d.id = msg.domain_id
+      WHERE ${whereSql}
+      ORDER BY msg.received_at DESC, msg.id DESC
+      LIMIT ? OFFSET ?
+    `)
+    .all(...params, pageSize, (page - 1) * pageSize)
+    .map((row) => publicInboundMessage(row));
+  return { messages: rows, total, page, pageSize };
+}
+
 export function getInboundMessage(userId, id) {
   const row = requireDb()
     .prepare(`
@@ -1204,6 +1258,36 @@ export function listInboundFolders(mailbox) {
   return [...STANDARD_INBOUND_FOLDERS, ...custom];
 }
 
+export function listInboundMailboxFolders(userId, mailboxId) {
+  const mailbox = getInboundMailbox(mailboxId, userId);
+  if (!mailbox) return null;
+  const counts = requireDb()
+    .prepare(`
+      SELECT
+        folder,
+        COUNT(*) AS message_count,
+        COALESCE(SUM(CASE WHEN read_state = 'false' THEN 1 ELSE 0 END), 0) AS unread_count
+      FROM inbound_messages
+      WHERE mailbox_id = ? AND user_id = ? AND deleted_at IS NULL
+      GROUP BY folder
+    `)
+    .all(Number(mailboxId), userId);
+  const countByFolder = new Map(counts.map((row) => [normalizeInboundFolder(row.folder) || 'INBOX', row]));
+  const names = listInboundFolders(mailbox);
+  for (const name of countByFolder.keys()) {
+    if (!names.includes(name)) names.push(name);
+  }
+  return names.map((name) => {
+    const count = countByFolder.get(name);
+    return {
+      name,
+      specialUse: inboundFolderSpecialUse(name),
+      messageCount: Number(count?.message_count || 0),
+      unreadCount: Number(count?.unread_count || 0)
+    };
+  });
+}
+
 export function createInboundFolder(mailbox, folder) {
   if (!mailbox?.id || !mailbox?.userId) throw new Error('收信邮箱不存在。');
   const name = normalizeInboundFolder(folder);
@@ -1545,17 +1629,87 @@ export function listSendEvents(userId, limit = 30) {
       LIMIT ?
     `)
     .all(userId, limit);
+  return hydrateSendEventRows(rows);
+}
+
+export function searchSendEvents(userId, filters = {}) {
+  const page = normalizePageNumber(filters.page);
+  const pageSize = normalizePageSize(filters.pageSize);
+  const where = ['e.user_id = ?'];
+  const params = [userId];
+  if (filters.status) {
+    where.push('e.status = ?');
+    params.push(String(filters.status));
+  }
+  if (filters.domainId) {
+    where.push('e.domain_id = ?');
+    params.push(Number(filters.domainId));
+  }
+  if (filters.from) {
+    where.push('e.created_at >= ?');
+    params.push(String(filters.from));
+  }
+  if (filters.to) {
+    where.push('e.created_at <= ?');
+    params.push(String(filters.to));
+  }
+  const recipient = String(filters.recipient || '').trim();
+  if (recipient) {
+    where.push("e.recipients LIKE ? ESCAPE '\\'");
+    params.push(`%${escapeLikePattern(recipient)}%`);
+  }
+  const query = String(filters.q || '').trim();
+  if (query) {
+    const pattern = `%${escapeLikePattern(query)}%`;
+    const identifierId = sendEventIdentifierId(query);
+    const clauses = [
+      "e.sender LIKE ? ESCAPE '\\'",
+      "e.recipients LIKE ? ESCAPE '\\'",
+      "e.subject LIKE ? ESCAPE '\\'",
+      "e.detail LIKE ? ESCAPE '\\'",
+      "e.queue_id LIKE ? ESCAPE '\\'",
+      "e.delivery_log_json LIKE ? ESCAPE '\\'",
+      "e.delivery_attempts_json LIKE ? ESCAPE '\\'"
+    ];
+    if (identifierId !== null) clauses.unshift('e.id = ?');
+    where.push(`(${clauses.join(' OR ')})`);
+    if (identifierId !== null) params.push(identifierId);
+    params.push(pattern, pattern, pattern, pattern, pattern, pattern, pattern);
+  }
+  const whereSql = where.join(' AND ');
+  const total = Number(requireDb()
+    .prepare(`SELECT COUNT(*) AS total FROM send_events e WHERE ${whereSql}`)
+    .get(...params)?.total || 0);
+  const rows = requireDb()
+    .prepare(`
+      SELECT e.*, d.domain
+      FROM send_events e
+      LEFT JOIN domains d ON d.id = e.domain_id
+      WHERE ${whereSql}
+      ORDER BY e.created_at DESC, e.id DESC
+      LIMIT ? OFFSET ?
+    `)
+    .all(...params, pageSize, (page - 1) * pageSize);
+  return {
+    events: hydrateSendEventRows(rows),
+    total,
+    page,
+    pageSize
+  };
+}
+
+function hydrateSendEventRows(rows) {
   const trackingAggregates = listTrackingAggregates(rows.map((row) => row.id));
   return rows.map((row) => {
-      const event = publicSendEvent(row);
-      return {
-        ...event,
-        tracking: {
-          ...event.tracking,
-          summary: trackingAggregates.get(event.id)?.summary || emptyTrackingSummary()
-        }
-      };
-    });
+    const event = publicSendEvent(row);
+    return {
+      ...event,
+      tracking: {
+        ...event.tracking,
+        summary: trackingAggregates.get(event.id)?.summary || emptyTrackingSummary()
+      }
+    };
+  });
 }
 
 export function getSendEvent(userId, eventId, { trackingSecret = '' } = {}) {
@@ -3623,13 +3777,11 @@ function publicInboundMessage(row, { includeBody = false } = {}) {
 function publicSmtpCredential(row, { includeHash = false, includePassword = false, includeSecret = false } = {}) {
   if (!row) return null;
   const password = includePassword ? decryptSecret(row.password_secret) : '';
-  const passwordRecoverable = Boolean(row.password_secret && (password || decryptSecret(row.password_secret)));
   return {
     id: row.id,
     userId: row.user_id,
     username: row.username,
     passwordSet: Boolean(row.password_hash),
-    passwordRecoverable,
     ...(includePassword ? { password } : {}),
     ...(includeHash ? { passwordHash: row.password_hash } : {}),
     ...(includeSecret ? { passwordSecret: row.password_secret } : {}),
@@ -3721,6 +3873,7 @@ function publicSendEvent(row) {
     status: row.status,
     detail: row.detail,
     queueId: row.queue_id,
+    messageId: `mh-${row.id}`,
     deliveryLog: safeJson(row.delivery_log_json, []),
     deliveryAttempts: safeJson(row.delivery_attempts_json, []),
     tracking: {
@@ -4071,6 +4224,38 @@ function normalizeInboundFolder(value, fallback = '') {
     .join('/');
 }
 
+function inboundFolderSpecialUse(folder) {
+  return {
+    Sent: '\\Sent',
+    Drafts: '\\Drafts',
+    Trash: '\\Trash',
+    Junk: '\\Junk',
+    Archive: '\\Archive'
+  }[folder] || null;
+}
+
+function normalizePageNumber(value) {
+  const page = Number(value || 1);
+  return Number.isSafeInteger(page) && page > 0 ? page : 1;
+}
+
+function normalizePageSize(value) {
+  const pageSize = Number(value || 30);
+  if (!Number.isSafeInteger(pageSize) || pageSize < 1) return 30;
+  return Math.min(pageSize, 100);
+}
+
+function escapeLikePattern(value) {
+  return String(value || '').replace(/[\\%_]/g, '\\$&');
+}
+
+function sendEventIdentifierId(value) {
+  const match = /^(?:mh-)?(\d+)$/i.exec(String(value || '').trim());
+  if (!match) return null;
+  const id = Number(match[1]);
+  return Number.isSafeInteger(id) && id > 0 ? id : null;
+}
+
 function isStandardInboundFolder(value) {
   const clean = normalizeInboundFolder(value);
   return STANDARD_INBOUND_FOLDERS.some((folder) => folder === clean);

+ 184 - 655
src/frontend/App.tsx

@@ -1,702 +1,231 @@
-import { App as AntApp, ConfigProvider, Form, Input, Modal, Select } from 'antd';
-import { useEffect, useMemo, useState } from 'react';
+import { App as AntApp, Button, ConfigProvider, Result, Skeleton } from 'antd';
+import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
+import {
+  createBrowserRouter,
+  createRoutesFromElements,
+  Navigate,
+  Outlet,
+  Route,
+  RouterProvider,
+  useLocation,
+  useNavigationType
+} from 'react-router-dom';
 
-import { AddDomainDrawer } from '../components/domain/AddDomainDrawer';
 import { AdminLayout } from '../layouts/AdminLayout';
-import AdminPage from '../pages/Admin';
-import ApiTokens from '../pages/ApiTokens';
-import Dashboard from '../pages/Dashboard';
-import DnsApi from '../pages/DnsApi';
-import DomainDetail from '../pages/Domains/DomainDetail';
-import DomainsPage from '../pages/Domains';
-import Inbox from '../pages/Inbox';
-import PlaceholderPage from '../pages/PlaceholderPage';
-import SendingLogs from '../pages/SendingLogs';
-import Settings from '../pages/Settings';
-import SmtpCredentials from '../pages/SmtpCredentials';
-import Webhooks from '../pages/Webhooks';
+import { AppContext, useAppContext } from './app-context';
 import { I18nProvider, useI18n } from './i18n/react';
-import { buildDnsApplyFeedback } from './domain-model.js';
 import { api } from './services/api';
 import './styles.css';
 import { mailhubTheme } from './theme';
-import type {
-  AddDomainPayload,
-  ApiToken,
-  AppData,
-  DnsCredential,
-  Domain,
-  DomainMode,
-  DomainPatchPayload,
-  InboundMailbox,
-  InboundMessage,
-  MailboxClientConfig,
-  RuntimeConfig,
-  SmtpCredential,
-  SmtpRelay,
-  SmtpRelayPayload,
-  User,
-  ViewKey
-} from './types';
-
-const emptyData: AppData = {
-  me: null,
-  config: null,
-  domains: [],
-  events: [],
-  analytics: null,
-  smtpCredential: null,
-  smtpCredentials: [],
-  smtpRelays: [],
-  inboundMailboxes: [],
-  inboundMessages: [],
-  dnsCredentials: [],
-  apiTokens: [],
-  settings: null,
-  users: []
-};
-
-const viewTitleKeys: Record<ViewKey, string> = {
-  dashboard: 'nav.dashboard',
-  domains: 'nav.domains',
-  'dns-api': 'nav.dnsApi',
-  smtp: 'nav.smtp',
-  inbox: 'nav.inbox',
-  tokens: 'nav.tokens',
-  logs: 'nav.logs',
-  webhooks: 'nav.webhooks',
-  admin: 'nav.admin',
-  settings: 'nav.settings'
-};
+import type { RuntimeConfig, User } from './types';
+
+const Dashboard = lazy(() => import('../pages/Dashboard'));
+const Domains = lazy(() => import('../pages/Domains'));
+const DomainDetail = lazy(() => import('../pages/Domains/DomainDetail'));
+const SendingLogs = lazy(() => import('../pages/SendingLogs'));
+const Inbox = lazy(() => import('../pages/Inbox'));
+const SmtpCredentials = lazy(() => import('../pages/SmtpCredentials'));
+const ApiTokens = lazy(() => import('../pages/ApiTokens'));
+const Webhooks = lazy(() => import('../pages/Webhooks'));
+const DnsApi = lazy(() => import('../pages/DnsApi'));
+const Admin = lazy(() => import('../pages/Admin'));
+const Settings = lazy(() => import('../pages/Settings'));
+
+const router = createBrowserRouter(createRoutesFromElements(
+  <Route element={<ConsoleBootstrap />}>
+    <Route element={<AdminLayout />}>
+      <Route index element={<Navigate to="/overview" replace />} />
+      <Route path="overview" element={<RoutePage><Dashboard /></RoutePage>} />
+      <Route path="activity" element={<RoutePage><SendingLogs /></RoutePage>} />
+      <Route path="activity/:eventId" element={<RoutePage><SendingLogs /></RoutePage>} />
+      <Route path="domains" element={<RoutePage><Domains /></RoutePage>} />
+      <Route path="domains/:id/:section" element={<RoutePage><DomainDetail /></RoutePage>} />
+      <Route path="inbox" element={<RoutePage><Inbox /></RoutePage>} />
+      <Route path="inbox/messages/:messageId" element={<RoutePage><Inbox /></RoutePage>} />
+      <Route path="integrations/smtp" element={<RoutePage><SmtpCredentials /></RoutePage>} />
+      <Route path="integrations/api-keys" element={<RoutePage><ApiTokens /></RoutePage>} />
+      <Route path="integrations/webhooks" element={<RoutePage><Webhooks /></RoutePage>} />
+      <Route path="integrations/dns" element={<RoutePage><DnsApi /></RoutePage>} />
+      <Route element={<AdminOnly />}>
+        <Route path="admin" element={<Navigate to="/admin/users" replace />} />
+        <Route path="admin/:section" element={<RoutePage><Admin /></RoutePage>} />
+        <Route path="settings" element={<RoutePage><Settings /></RoutePage>} />
+      </Route>
+      <Route path="*" element={<NotFound />} />
+    </Route>
+  </Route>
+));
 
 export default function App() {
   return (
     <ConfigProvider theme={mailhubTheme}>
       <AntApp>
         <I18nProvider>
-          <MailHubConsole />
+          <RouterProvider router={router} />
         </I18nProvider>
       </AntApp>
     </ConfigProvider>
   );
 }
 
-function MailHubConsole() {
-  const { message } = AntApp.useApp();
+function ConsoleBootstrap() {
   const { t } = useI18n();
-  const [data, setData] = useState<AppData>(emptyData);
-  const [activeView, setActiveView] = useState<ViewKey>('dashboard');
-  const [domainMode, setDomainMode] = useState<DomainMode>('list');
-  const [selectedDomainId, setSelectedDomainId] = useState<number | null>(null);
-  const [initialDomainTab, setInitialDomainTab] = useState('overview');
-  const [addOpen, setAddOpen] = useState(false);
+  const [user, setUser] = useState<User | null>(null);
+  const [config, setConfig] = useState<RuntimeConfig | null>(null);
   const [loading, setLoading] = useState(true);
-  const [actionLoading, setActionLoading] = useState(false);
-  const [testDomain, setTestDomain] = useState<Domain | null>(null);
-  const [testForm] = Form.useForm();
+  const [error, setError] = useState('');
 
-  const selectedDomain = data.domains.find((domain) => domain.id === selectedDomainId) || data.domains[0] || null;
-
-  useEffect(() => {
-    void loadAll();
-  }, []);
-
-  async function loadAll() {
+  const loadBootstrap = useCallback(async () => {
     setLoading(true);
+    setError('');
     try {
-      const me = await api.me();
-      const [
-        config,
-        domains,
-        events,
-        inboundMailboxes,
-        inboundMessages,
-        analytics,
-        smtpCredential,
-        smtpCredentials,
-        smtpRelays,
-        dnsCredentials,
-        apiTokens
-      ] = await Promise.all([
-        api.config(),
-        api.domains(),
-        api.events(),
-        api.inboundMailboxes(),
-        api.inboundMessages(),
-        api.analytics(7),
-        api.smtpCredential(),
-        api.smtpCredentials(),
-        api.smtpRelays(),
-        api.dnsCredentials(),
-        api.apiTokens()
-      ]);
-      let settings: RuntimeConfig | null = null;
-      let users: User[] = [];
-      if (me.user.role === 'admin') {
-        const [settingsResult, usersResult] = await Promise.all([api.adminSettings(), api.adminUsers()]);
-        settings = settingsResult.settings;
-        users = usersResult.users;
+      const [meResult, configResult] = await Promise.all([api.me(), api.config()]);
+      setUser(meResult.user);
+      setConfig(configResult);
+    } catch (reason) {
+      const nextError = reason instanceof Error ? reason.message : '操作失败';
+      if (/Authentication required/i.test(nextError)) {
+        window.location.assign(loginPathForCurrentLocation());
+        return;
       }
-      setData({
-        me: me.user,
-        config,
-        domains: domains.domains || [],
-        events: events.events || [],
-        inboundMailboxes: inboundMailboxes.mailboxes || [],
-        inboundMessages: inboundMessages.messages || [],
-        analytics: analytics.analytics || null,
-        smtpCredential: smtpCredential.credential || null,
-        smtpCredentials: smtpCredentials.credentials || [],
-        smtpRelays: smtpRelays.relays || [],
-        dnsCredentials: dnsCredentials.credentials || [],
-        apiTokens: apiTokens.tokens || [],
-        settings,
-        users
-      });
-      setSelectedDomainId((current) => {
-        if (current && domains.domains.some((domain) => domain.id === current)) return current;
-        return domains.domains[0]?.id || null;
-      });
-    } catch (error) {
-      const text = error instanceof Error ? error.message : t('common.error');
-      message.error(text);
-      if (/Authentication required/i.test(text)) window.location.href = '/login';
+      setError(nextError);
     } finally {
       setLoading(false);
     }
-  }
-
-  function replaceDomain(domain: Domain) {
-    setData((current) => ({
-      ...current,
-      domains: current.domains.map((item) => item.id === domain.id ? domain : item)
-    }));
-  }
-
-  async function runAction<T>(fn: () => Promise<T>, success?: string) {
-    setActionLoading(true);
-    try {
-      const result = await fn();
-      if (success) message.success(success);
-      return result;
-    } catch (error) {
-      message.error(error instanceof Error ? error.message : t('common.error'));
-      return null;
-    } finally {
-      setActionLoading(false);
-    }
-  }
-
-  async function createDomain(values: AddDomainPayload) {
-    const immediateCheck = Boolean(values.immediateCheck);
-    const result = await runAction(async () => api.createDomain(values), t('actions.domainCreated'));
-    if (!result?.domain) return;
-    let nextDomain = result.domain;
-    setData((current) => ({ ...current, domains: [nextDomain, ...current.domains] }));
-    setSelectedDomainId(nextDomain.id);
-    setActiveView('domains');
-    setDomainMode('detail');
-    setInitialDomainTab('dns');
-    setAddOpen(false);
-    if (immediateCheck) {
-      const checked = await runAction(async () => api.checkDomain(nextDomain.id), t('actions.dnsCheckCompleted'));
-      if (checked?.domain) {
-        nextDomain = checked.domain;
-        replaceDomain(nextDomain);
-      }
-    }
-  }
-
-  function viewDetail(domain: Domain, tab = 'overview') {
-    setSelectedDomainId(domain.id);
-    setInitialDomainTab(tab);
-    setDomainMode('detail');
-    setActiveView('domains');
-  }
-
-  async function checkDomain(domain: Domain) {
-    const result = await runAction(async () => api.checkDomain(domain.id), t('actions.dnsCheckRefreshed'));
-    if (result?.domain) replaceDomain(result.domain);
-  }
-
-  async function applyDns(domain: Domain) {
-    const result = await runAction(async () => api.applyDns(domain.id));
-    if (result) {
-      const feedback = buildDnsApplyFeedback(result.apply, {
-        completed: t('actions.dnsApplyCompleted'),
-        partial: t('actions.dnsApplyPartial')
-      });
-      if (feedback.type === 'warning') {
-        message.warning(feedback.message);
-      } else {
-        message.success(feedback.message);
-      }
-    }
-    if (result?.domain) {
-      replaceDomain(result.domain);
-      setInitialDomainTab('dns');
-      viewDetail(result.domain, 'dns');
-    }
-  }
-
-  async function patchDomain(domain: Domain, values: DomainPatchPayload) {
-    const result = await runAction(async () => api.patchDomain(domain.id, values), t('actions.domainSaved'));
-    if (result?.domain) replaceDomain(result.domain);
-  }
-
-  async function deleteDomain(domain: Domain) {
-    const result = await runAction(async () => api.deleteDomain(domain.id), t('actions.domainDeleted'));
-    if (!result?.deleted) return;
-    setData((current) => ({ ...current, domains: current.domains.filter((item) => item.id !== domain.id) }));
-    if (selectedDomainId === domain.id) {
-      setSelectedDomainId(null);
-      setDomainMode('list');
-    }
-  }
-
-  function openTestModal(domain: Domain) {
-    setTestDomain(domain);
-    testForm.setFieldsValue({
-      from: `noreply@${domain.domain}`,
-      subject: `MailHub test for ${domain.domain}`,
-      text: `This is a MailHub test message from ${domain.domain}.`,
-      html: `<p>This is a MailHub test message from ${domain.domain}.</p><p><a href="${window.location.origin}/login">Open MailHub</a></p>`,
-      smtpRelayId: domain.smtpRelayId || undefined
-    });
-  }
-
-  async function submitTestMail() {
-    if (!testDomain) return;
-    const values = await testForm.validateFields();
-    await runAction(async () => api.sendTest(testDomain.id, values), t('actions.testMailQueued'));
-    setTestDomain(null);
-    const [events, analytics] = await Promise.all([api.events(), api.analytics(7)]);
-    setData((current) => ({ ...current, events: events.events || [], analytics: analytics.analytics || current.analytics }));
-  }
-
-  async function loadSendEvent(id: number) {
-    const result = await api.event(id);
-    return result.event;
-  }
-
-  async function copy(value: string) {
-    if (!value || value === '-') return;
-    await navigator.clipboard.writeText(value);
-    message.success(t('common.copied'));
-  }
-
-  async function saveDnsCredential(values: Record<string, unknown>, id?: number) {
-    const result = await runAction(async () => api.saveDnsCredential(values, id), id ? t('actions.dnsApiUpdated') : t('actions.dnsApiCreated'));
-    if (!result?.credential) return;
-    setData((current) => ({
-      ...current,
-      dnsCredentials: id
-        ? current.dnsCredentials.map((item) => item.id === id ? result.credential : item)
-        : [result.credential, ...current.dnsCredentials]
-    }));
-  }
-
-  async function testDnsCredential(credential: DnsCredential) {
-    await runAction(async () => api.testDnsCredential(credential.id), `${credential.name} ${t('actions.dnsApiTestCompleted')}`);
-  }
+  }, []);
 
-  async function deleteDnsCredential(credential: DnsCredential) {
-    const result = await runAction(async () => api.deleteDnsCredential(credential.id), t('actions.dnsApiDeleted'));
-    if (!result?.deleted) return;
-    setData((current) => ({
-      ...current,
-      dnsCredentials: current.dnsCredentials.filter((item) => item.id !== credential.id),
-      domains: current.domains.map((domain) => domain.dnsCredentialId === credential.id ? { ...domain, dnsCredentialId: null } : domain)
-    }));
-  }
+  useEffect(() => {
+    void loadBootstrap();
+  }, [loadBootstrap]);
 
-  async function loadSmtpLoginCredential(id: number) {
-    const result = await runAction(async () => api.smtpCredentialDetail(id));
-    return result?.credential || null;
-  }
+  const logout = useCallback(async () => {
+    await api.logout().catch(() => null);
+    window.location.assign('/login');
+  }, []);
 
-  async function saveSmtpLoginCredential(values: { username: string; password?: string }, id?: number) {
-    const result = await runAction(
-      async () => api.saveSmtpLoginCredential(values, id),
-      id ? t('actions.smtpUpdated') : t('actions.smtpCreated')
+  const context = useMemo(() => ({
+    user,
+    config,
+    refreshBootstrap: loadBootstrap,
+    logout
+  }), [config, loadBootstrap, logout, user]);
+
+  if (loading) return <BootstrapSkeleton />;
+
+  if (error || !user || !config) {
+    return (
+      <Result
+        status="error"
+        title={t('shell.loadingFailed')}
+        subTitle={error || t('common.error')}
+        extra={[
+          <Button type="primary" key="retry" onClick={() => void loadBootstrap()}>
+            {t('shell.retry')}
+          </Button>,
+          <Button key="login" href={loginPathForCurrentLocation()}>
+            {t('shell.backToLogin')}
+          </Button>
+        ]}
+      />
     );
-    if (!result?.credential) return null;
-    setData((current) => {
-      const credentials = id
-        ? current.smtpCredentials.map((item) => item.id === id ? result.credential : item)
-        : [result.credential, ...current.smtpCredentials];
-      return {
-        ...current,
-        smtpCredential: credentials[0] || null,
-        smtpCredentials: credentials,
-        config: current.config?.submission
-          ? {
-              ...current.config,
-              submission: {
-                ...current.config.submission,
-                username: credentials[0]?.username || '',
-                passwordSet: Boolean(credentials[0]?.passwordSet)
-              }
-            }
-          : current.config
-      };
-    });
-    return result.credential;
-  }
-
-  async function deleteSmtpLoginCredential(credential: SmtpCredential) {
-    const credentialId = credential.id;
-    if (!credentialId) return;
-    const result = await runAction(async () => api.deleteSmtpCredential(credentialId), t('actions.smtpDeleted'));
-    if (!result?.deleted) return;
-    setData((current) => {
-      const credentials = current.smtpCredentials.filter((item) => item.id !== credentialId);
-      return {
-        ...current,
-        smtpCredential: credentials[0] || null,
-        smtpCredentials: credentials,
-        config: current.config?.submission
-          ? {
-              ...current.config,
-              submission: {
-                ...current.config.submission,
-                username: credentials[0]?.username || '',
-                passwordSet: Boolean(credentials[0]?.passwordSet)
-              }
-            }
-          : current.config
-      };
-    });
   }
 
-  async function loadSmtpRelay(id: number) {
-    const result = await runAction(async () => api.smtpRelay(id));
-    return result?.relay || null;
-  }
+  return (
+    <AppContext.Provider value={context}>
+      <ScrollRestoration />
+      <Outlet />
+    </AppContext.Provider>
+  );
+}
 
-  async function saveSmtpRelay(values: SmtpRelayPayload, id?: number) {
-    const result = await runAction(
-      async () => api.saveSmtpRelay(values, id),
-      id ? t('actions.smtpRelayUpdated') : t('actions.smtpRelayCreated')
+function AdminOnly() {
+  const { user } = useAppContext();
+  const { t } = useI18n();
+  if (user?.role !== 'admin') {
+    return (
+      <Result
+        status="403"
+        title={t('shell.forbiddenTitle')}
+        subTitle={t('shell.forbiddenDescription')}
+        extra={<Button type="primary" href="/overview">{t('shell.backOverview')}</Button>}
+      />
     );
-    if (!result?.relay) return null;
-    setData((current) => ({
-      ...current,
-      smtpRelays: id
-        ? current.smtpRelays.map((item) => item.id === id ? result.relay : item)
-        : [result.relay, ...current.smtpRelays]
-    }));
-    return result.relay;
-  }
-
-  async function deleteSmtpRelay(relay: SmtpRelay) {
-    const result = await runAction(async () => api.deleteSmtpRelay(relay.id), t('actions.smtpRelayDeleted'));
-    if (!result?.deleted) return;
-    setData((current) => ({
-      ...current,
-      smtpRelays: current.smtpRelays.filter((item) => item.id !== relay.id),
-      domains: current.domains.map((domain) => domain.smtpRelayId === relay.id ? { ...domain, smtpRelayId: null } : domain)
-    }));
-  }
-
-  async function createInboundMailbox(values: {
-    address: string;
-    displayName?: string;
-    password: string;
-    aliases?: string;
-    forwardTo?: string;
-    keepForwarded?: boolean;
-    quotaMb?: number | string | null;
-  }): Promise<{ mailbox: InboundMailbox; clientConfig?: MailboxClientConfig } | null> {
-    const result = await runAction(async () => api.createInboundMailbox(values), t('actions.inboundMailboxCreated'));
-    if (!result?.mailbox) return null;
-    setData((current) => ({
-      ...current,
-      inboundMailboxes: [result.mailbox, ...current.inboundMailboxes]
-    }));
-    return result;
-  }
-
-  async function loadInboundMessages(mailboxId?: number | null) {
-    const result = await runAction(async () => api.inboundMessages(mailboxId));
-    if (!result?.messages) return [];
-    setData((current) => ({ ...current, inboundMessages: result.messages }));
-    return result.messages;
-  }
-
-  async function loadInboundMessage(id: number): Promise<InboundMessage | null> {
-    const result = await api.inboundMessage(id);
-    let inboundMessage = result.message;
-    if (inboundMessage && !inboundMessage.read) {
-      const readResult = await api.markInboundMessageRead(id, true);
-      inboundMessage = readResult.message || { ...inboundMessage, read: true };
-    }
-    if (inboundMessage) {
-      setData((current) => {
-        const previous = current.inboundMessages.find((item) => item.id === inboundMessage.id);
-        const shouldDecrementUnread = Boolean(previous && !previous.read && inboundMessage.read);
-        return {
-          ...current,
-          inboundMessages: current.inboundMessages.map((item) => (
-            item.id === inboundMessage.id ? { ...item, ...inboundMessage } : item
-          )),
-          inboundMailboxes: current.inboundMailboxes.map((mailbox) => (
-            mailbox.id === inboundMessage.mailboxId && shouldDecrementUnread
-              ? { ...mailbox, unreadCount: Math.max(0, mailbox.unreadCount - 1) }
-              : mailbox
-          ))
-        };
-      });
-    }
-    return inboundMessage;
-  }
-
-  async function createApiToken(values: { name: string; scopes: string[]; expiresAt?: string | null }) {
-    const result = await runAction(async () => api.createApiToken(values), t('tokens.createdSuccess'));
-    if (!result?.token) return null;
-    setData((current) => ({ ...current, apiTokens: [result.token, ...current.apiTokens] }));
-    return result.token;
-  }
-
-  async function updateApiToken(token: ApiToken, values: { name: string; scopes: string[]; expiresAt?: string | null }) {
-    const result = await runAction(async () => api.updateApiToken(token.id, values), t('tokens.updatedSuccess'));
-    if (!result?.token) return;
-    setData((current) => ({
-      ...current,
-      apiTokens: current.apiTokens.map((item) => item.id === result.token.id ? result.token : item)
-    }));
   }
+  return <Outlet />;
+}
 
-  async function revokeApiToken(token: ApiToken) {
-    const result = await runAction(async () => api.deleteApiToken(token.id), t('tokens.revokedSuccess'));
-    if (!result?.token) return;
-    setData((current) => ({
-      ...current,
-      apiTokens: current.apiTokens.map((item) => item.id === result.token?.id ? result.token : item)
-    }));
-  }
+function RoutePage({ children }: { children: ReactNode }) {
+  return <Suspense fallback={<PageSkeleton />}>{children}</Suspense>;
+}
 
-  async function saveSettings(values: Partial<RuntimeConfig>) {
-    const result = await runAction(async () => api.saveAdminSettings(values), t('actions.settingsSaved'));
-    if (!result?.settings) return;
-    setData((current) => ({ ...current, settings: result.settings, config: { ...current.config, ...result.settings } as RuntimeConfig }));
-  }
+function NotFound() {
+  const { t } = useI18n();
+  return (
+    <Result
+      status="404"
+      title={t('shell.notFoundTitle')}
+      subTitle={t('shell.notFoundDescription')}
+      extra={<Button type="primary" href="/overview">{t('shell.backOverview')}</Button>}
+    />
+  );
+}
 
-  async function logout() {
-    await api.logout().catch(() => null);
-    window.location.href = '/login';
-  }
+function BootstrapSkeleton() {
+  const { t } = useI18n();
+  return (
+    <div className="bootstrap-skeleton" aria-label={t('shell.loadingConsole')}>
+      <div className="bootstrap-skeleton__sidebar" />
+      <div className="bootstrap-skeleton__content">
+        <Skeleton active paragraph={{ rows: 8 }} />
+      </div>
+    </div>
+  );
+}
 
-  const breadcrumb = useMemo(() => {
-    if (activeView === 'domains' && domainMode === 'detail' && selectedDomain) return [t('nav.domains'), selectedDomain.domain];
-    return [t(viewTitleKeys[activeView])];
-  }, [activeView, domainMode, selectedDomain, t]);
+function PageSkeleton() {
+  const { t } = useI18n();
+  return (
+    <div className="route-skeleton" aria-label={t('shell.loadingPage')}>
+      <Skeleton active title={{ width: '32%' }} paragraph={{ rows: 7 }} />
+    </div>
+  );
+}
 
-  const runtimeLine = data.config
-    ? `${data.config.mailHostname} · ${data.config.sendingIp || t('common.unsetSendingIp')}`
-    : t('common.loadingConfig');
+function ScrollRestoration() {
+  const location = useLocation();
+  const navigationType = useNavigationType();
+  const previousScope = useRef<string | null>(null);
 
-  const content = renderContent();
+  useEffect(() => {
+    const storageKey = `mailhub.scroll.${location.key}`;
+    const scope = routeScrollScope(location.pathname);
+    const keepPosition = previousScope.current === scope;
+    const top = keepPosition
+      ? window.scrollY
+      : navigationType === 'POP'
+        ? Number(window.sessionStorage.getItem(storageKey) || 0)
+        : 0;
+    previousScope.current = scope;
+    const frame = window.requestAnimationFrame(() => {
+      if (!keepPosition) window.scrollTo({ top, behavior: 'auto' });
+    });
+    return () => {
+      window.cancelAnimationFrame(frame);
+      window.sessionStorage.setItem(storageKey, String(window.scrollY));
+    };
+  }, [location.key, navigationType]);
 
-  return (
-    <>
-      <AdminLayout
-        activeView={activeView}
-        breadcrumb={breadcrumb}
-        user={data.me}
-        runtimeLine={runtimeLine}
-        loading={loading}
-        onViewChange={(view) => {
-          setActiveView(view);
-          if (view === 'domains') setDomainMode('list');
-        }}
-        onRefresh={loadAll}
-        onAddDomain={() => setAddOpen(true)}
-        onLogout={logout}
-      >
-        {content}
-      </AdminLayout>
-      <AddDomainDrawer
-        open={addOpen}
-        loading={actionLoading}
-        config={data.config}
-        dnsCredentials={data.dnsCredentials}
-        smtpRelays={data.smtpRelays}
-        onClose={() => setAddOpen(false)}
-        onSubmit={createDomain}
-      />
-      <Modal
-        title={testDomain ? `${t('testMail.title')} · ${testDomain.domain}` : t('testMail.title')}
-        open={Boolean(testDomain)}
-        confirmLoading={actionLoading}
-        onCancel={() => setTestDomain(null)}
-        onOk={submitTestMail}
-      >
-        <Form form={testForm} layout="vertical">
-          <Form.Item name="from" label="From" rules={[{ required: true, message: t('testMail.fromRequired') }]}>
-            <Input />
-          </Form.Item>
-          <Form.Item name="to" label="To" rules={[{ required: true, message: t('testMail.toRequired') }]}>
-            <Input placeholder="user@example.com" />
-          </Form.Item>
-          <Form.Item name="subject" label="Subject">
-            <Input />
-          </Form.Item>
-          <Form.Item name="text" label="Text">
-            <Input.TextArea rows={5} />
-          </Form.Item>
-          <Form.Item name="html" label="HTML">
-            <Input.TextArea rows={5} />
-          </Form.Item>
-          <Form.Item name="smtpRelayId" label={t('smtpRelay.domainDefault')}>
-            <Select
-              allowClear
-              placeholder={t('smtpRelay.useResolutionOrder')}
-              options={data.smtpRelays.map((relay) => ({
-                value: relay.id,
-                label: relayLabel(relay, t)
-              }))}
-            />
-          </Form.Item>
-        </Form>
-      </Modal>
-    </>
-  );
+  return null;
+}
 
-  function renderContent() {
-    if (activeView === 'dashboard') {
-      return (
-        <Dashboard
-          analytics={data.analytics}
-          domains={data.domains}
-          events={data.events}
-          config={data.config}
-          smtpCredential={data.smtpCredential}
-        />
-      );
-    }
-    if (activeView === 'domains') {
-      if (domainMode === 'detail' && selectedDomain) {
-        return (
-          <DomainDetail
-            key={selectedDomain.id}
-            domain={selectedDomain}
-            config={data.config}
-            smtpCredential={data.smtpCredential}
-            apiTokens={data.apiTokens}
-            events={data.events}
-            dnsCredentials={data.dnsCredentials}
-            smtpRelays={data.smtpRelays}
-            actionLoading={actionLoading}
-            initialTab={initialDomainTab}
-            onBack={() => setDomainMode('list')}
-            onApplyDns={applyDns}
-            onCheck={checkDomain}
-            onSendTest={openTestModal}
-            onPatchDomain={patchDomain}
-            onCopy={copy}
-            onDelete={deleteDomain}
-          />
-        );
-      }
-      return (
-        <DomainsPage
-          domains={data.domains}
-          events={data.events}
-          dnsCredentials={data.dnsCredentials}
-          actionLoading={actionLoading}
-          onViewDetail={viewDetail}
-          onApplyDns={applyDns}
-          onCheck={checkDomain}
-          onSendTest={openTestModal}
-          onDelete={deleteDomain}
-          onAddDomain={() => setAddOpen(true)}
-        />
-      );
-    }
-    if (activeView === 'dns-api') {
-      return (
-        <DnsApi
-          credentials={data.dnsCredentials}
-          loading={actionLoading}
-          onSave={saveDnsCredential}
-          onTest={testDnsCredential}
-          onDelete={deleteDnsCredential}
-        />
-      );
-    }
-    if (activeView === 'smtp') {
-      return (
-        <SmtpCredentials
-          config={data.config}
-          credential={data.smtpCredential}
-          credentials={data.smtpCredentials}
-          relays={data.smtpRelays}
-          loading={actionLoading}
-          onCopy={copy}
-          onLoadCredential={loadSmtpLoginCredential}
-          onSaveCredential={saveSmtpLoginCredential}
-          onDeleteCredential={deleteSmtpLoginCredential}
-          onLoadRelay={loadSmtpRelay}
-          onSaveRelay={saveSmtpRelay}
-          onDeleteRelay={deleteSmtpRelay}
-        />
-      );
-    }
-    if (activeView === 'inbox') {
-      return (
-        <Inbox
-          config={data.config}
-          domains={data.domains}
-          mailboxes={data.inboundMailboxes}
-          messages={data.inboundMessages}
-          loading={actionLoading}
-          onCreateMailbox={createInboundMailbox}
-          onPatchDomain={patchDomain}
-          onLoadMessages={loadInboundMessages}
-          onLoadMessage={loadInboundMessage}
-          onCopy={copy}
-          onAddDomain={() => setAddOpen(true)}
-        />
-      );
-    }
-    if (activeView === 'tokens') {
-      return (
-        <ApiTokens
-          tokens={data.apiTokens}
-          config={data.config}
-          loading={actionLoading}
-          onCreate={createApiToken}
-          onUpdate={updateApiToken}
-          onRevoke={revokeApiToken}
-          onCopy={copy}
-        />
-      );
-    }
-    if (activeView === 'logs') {
-      return <SendingLogs events={data.events} domains={data.domains} onCopy={copy} onLoadEvent={loadSendEvent} />;
-    }
-    if (activeView === 'webhooks') {
-      return <Webhooks domains={data.domains} mailboxes={data.inboundMailboxes} onCopy={copy} />;
-    }
-    if (activeView === 'admin') {
-      return <AdminPage me={data.me} />;
-    }
-    if (activeView === 'settings') {
-      return (
-        <Settings
-          me={data.me}
-          settings={data.settings}
-          users={data.users}
-          loading={actionLoading}
-          onSave={saveSettings}
-        />
-      );
-    }
-    return <PlaceholderPage title={t(viewTitleKeys[activeView])} />;
-  }
+function routeScrollScope(pathname: string) {
+  if (/^\/activity(?:\/|$)/.test(pathname)) return '/activity';
+  if (/^\/inbox(?:\/|$)/.test(pathname)) return '/inbox';
+  return pathname;
 }
 
-function relayLabel(relay: SmtpRelay, t: (key: string) => string) {
-  return `${relay.name}${relay.isDefault ? ` · ${t('smtpRelay.default')}` : ''} · ${relay.host}:${relay.port}`;
+function loginPathForCurrentLocation() {
+  const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
+  const next = current.startsWith('/') && !current.startsWith('//') ? current : '/overview';
+  return `/login?next=${encodeURIComponent(next)}`;
 }

+ 18 - 0
src/frontend/app-context.tsx

@@ -0,0 +1,18 @@
+import { createContext, useContext } from 'react';
+
+import type { RuntimeConfig, User } from './types';
+
+export interface AppContextValue {
+  user: User | null;
+  config: RuntimeConfig | null;
+  refreshBootstrap: () => Promise<void>;
+  logout: () => Promise<void>;
+}
+
+export const AppContext = createContext<AppContextValue | null>(null);
+
+export function useAppContext() {
+  const context = useContext(AppContext);
+  if (!context) throw new Error('useAppContext must be used inside AppContext.Provider');
+  return context;
+}

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

@@ -11,7 +11,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react';
 
 import { useI18n } from '../i18n/react';
 import { api } from '../services/api';
-import { authModeFromLocation, nextAuthSuccessState } from './auth-model';
+import { authModeFromLocation, nextAuthSuccessState, safeInternalPath } from './auth-model';
 
 type AuthMode = 'login' | 'register' | 'forgot' | 'reset' | 'resend';
 type AlertKind = 'error' | 'success';
@@ -36,6 +36,7 @@ interface ResetPasswordValues {
 export function AuthApp() {
   const { locale, locales, setLocale, t } = useI18n();
   const initialAuth = authModeFromLocation(window.location.pathname, window.location.search);
+  const requestedNext = safeInternalPath(new URLSearchParams(window.location.search).get('next'), '/overview');
   const [mode, setMode] = useState<AuthMode>(initialAuth.mode as AuthMode);
   const [resetToken, setResetToken] = useState(initialAuth.token);
   const [message, setMessage] = useState('');
@@ -52,7 +53,7 @@ export function AuthApp() {
     if (error) {
       setMessage(error);
       setMessageKind('error');
-      window.history.replaceState(null, '', window.location.pathname);
+      window.history.replaceState(null, '', authPathWithNext(window.location.pathname, requestedNext));
     }
     if (initialAuth.mode === 'reset' && !initialAuth.token) {
       setMessage(t('auth.resetTokenMissing'));
@@ -73,11 +74,11 @@ export function AuthApp() {
       const response = await fetch(path, {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify(values)
+        body: JSON.stringify({ ...values, next: requestedNext })
       });
       const data = await response.json().catch(() => ({}));
       if (!response.ok) throw new Error(data.error || t('auth.requestFailed'));
-      const next = nextAuthSuccessState(path, data);
+      const next = nextAuthSuccessState(path, data, requestedNext);
       if (next.redirectTo) {
         window.location.href = next.redirectTo;
         return;
@@ -139,7 +140,7 @@ export function AuthApp() {
     setMode(nextMode);
     setMessage('');
     setMessageKind('error');
-    window.history.replaceState(null, '', authPathForMode(nextMode));
+    window.history.replaceState(null, '', authPathWithNext(authPathForMode(nextMode), requestedNext));
   }
 
   return (
@@ -271,6 +272,11 @@ function authPathForMode(mode: AuthMode) {
   }[mode];
 }
 
+function authPathWithNext(path: string, next: string) {
+  if (!next || next === '/overview' || path === '/reset-password') return path;
+  return `${path}?next=${encodeURIComponent(next)}`;
+}
+
 function authTitle(mode: AuthMode, t: (key: string) => string) {
   return {
     login: t('auth.loginTitle'),

+ 22 - 2
src/frontend/auth/auth-model.js

@@ -1,4 +1,4 @@
-export function nextAuthSuccessState(path, data = {}) {
+export function nextAuthSuccessState(path, data = {}, requestedNext = '') {
   if (isRegisterPath(path)) {
     return {
       mode: 'login',
@@ -11,10 +11,30 @@ export function nextAuthSuccessState(path, data = {}) {
     mode: 'login',
     path: '/login',
     message: '',
-    redirectTo: '/'
+    redirectTo: safeInternalPath(data.redirectTo || requestedNext, '/overview')
   };
 }
 
+export function safeInternalPath(value, fallback = '') {
+  const candidate = String(value || '').trim();
+  if (!candidate || !candidate.startsWith('/') || candidate.startsWith('//')) return fallback;
+  if (candidate.includes('\\') || /[\u0000-\u001f\u007f]/.test(candidate)) return fallback;
+
+  try {
+    const parsed = new URL(candidate, 'http://mailhub.local');
+    if (parsed.origin !== 'http://mailhub.local') return fallback;
+    if (
+      ['/login', '/register', '/forgot-password', '/resend-verification', '/reset-password', '/landing.html', '/healthz'].includes(parsed.pathname)
+      || ['/api', '/assets', '/t'].some((prefix) => parsed.pathname === prefix || parsed.pathname.startsWith(`${prefix}/`))
+    ) {
+      return fallback;
+    }
+    return `${parsed.pathname}${parsed.search}${parsed.hash}`;
+  } catch {
+    return fallback;
+  }
+}
+
 export function authModeFromLocation(pathname, search = '') {
   const path = String(pathname || '');
   if (path.endsWith('/register')) return { mode: 'register', token: '' };

+ 54 - 4
src/frontend/i18n/index.js

@@ -26,6 +26,23 @@ const messages = {
     'common.status': '状态',
     'common.user': '用户',
     'common.unsetSendingIp': '未设置发信 IP',
+    'shell.skipToMain': '跳至主要内容',
+    'shell.mainNavigation': '主导航',
+    'shell.openNavigation': '打开主导航',
+    'shell.closeNavigation': '关闭主导航',
+    'shell.accountMenu': '账户菜单',
+    'shell.envReady': '运行环境正常',
+    'shell.envNeedsSetup': '运行配置待完善',
+    'shell.loadingFailed': '控制台加载失败',
+    'shell.retry': '重试',
+    'shell.backToLogin': '返回登录',
+    'shell.forbiddenTitle': '无权访问',
+    'shell.forbiddenDescription': '该页面仅对系统管理员开放。',
+    'shell.notFoundTitle': '页面不存在',
+    'shell.notFoundDescription': '链接可能已失效,或页面地址输入有误。',
+    'shell.backOverview': '返回概览',
+    'shell.loadingConsole': '正在加载控制台',
+    'shell.loadingPage': '正在加载页面',
     'auth.email': '邮箱',
     'auth.emailPlaceholder': 'name@example.com',
     'auth.backToLogin': '返回登录',
@@ -337,7 +354,7 @@ const messages = {
     'smtp.createTitle': '新增发信登录凭据',
     'smtp.editTitle': '编辑发信登录凭据',
     'smtp.deleteConfirm': '确认删除该 SMTP 登录凭据?',
-    'smtp.passwordExtra': '留空保存时会保留原密码;当前密码可在上方连接信息中复制。',
+    'smtp.passwordExtra': '留空保存时会保留原密码;出于安全原因,已保存密码不会再次显示。',
     'smtp.regenerate': '重新生成密码',
     'smtp.resetToCopy': '请重新设置后复制',
     'smtpRelay.title': '高级:外部 SMTP 出口',
@@ -353,7 +370,7 @@ const messages = {
     'smtpRelay.password': '密码',
     'smtpRelay.passwordSet': '已设置',
     'smtpRelay.passwordEmpty': '未设置',
-    'smtpRelay.passwordExtra': '编辑时会回显已保存密码;清空该字段保存会移除密码。',
+    'smtpRelay.passwordExtra': '留空保存时会保留原密码;出于安全原因,已保存密码不会再次显示。',
     'smtpRelay.generatePassword': '生成密码',
     'smtpRelay.heloExtra': '留空时使用系统默认 HELO。',
     'smtpRelay.default': '默认',
@@ -556,6 +573,8 @@ const messages = {
     'actions.webhookTestQueued': 'Webhook 测试已入队',
     'actions.webhookDeliveryReplayed': 'Webhook 投递已重放',
     'nav.dashboard': '仪表盘',
+    'nav.overview': '概览',
+    'nav.activity': '发送活动',
     'nav.domains': '发信域名',
     'nav.dnsApi': 'DNS API',
     'nav.smtp': 'SMTP 凭据',
@@ -563,10 +582,16 @@ const messages = {
     'nav.tokens': 'API Token',
     'nav.logs': '发送记录',
     'nav.webhooks': 'Webhooks',
+    'nav.smtpIntegration': 'SMTP 与中继',
+    'nav.apiKeys': 'API 密钥',
+    'nav.dnsIntegration': 'DNS 集成',
+    'nav.adminCenter': '管理中心',
     'nav.admin': '管理员',
     'nav.settings': '系统设置',
     'nav.group.overview': '概览',
     'nav.group.delivery': '投递',
+    'nav.group.operations': '运行中心',
+    'nav.group.integrations': '接入与集成',
     'nav.group.system': '系统'
   },
   'en-US': {
@@ -593,6 +618,23 @@ const messages = {
     'common.status': 'Status',
     'common.user': 'User',
     'common.unsetSendingIp': 'Sending IP not set',
+    'shell.skipToMain': 'Skip to main content',
+    'shell.mainNavigation': 'Main navigation',
+    'shell.openNavigation': 'Open main navigation',
+    'shell.closeNavigation': 'Close main navigation',
+    'shell.accountMenu': 'Account menu',
+    'shell.envReady': 'Environment healthy',
+    'shell.envNeedsSetup': 'Environment needs setup',
+    'shell.loadingFailed': 'Console failed to load',
+    'shell.retry': 'Retry',
+    'shell.backToLogin': 'Back to login',
+    'shell.forbiddenTitle': 'Access denied',
+    'shell.forbiddenDescription': 'This page is available to system administrators only.',
+    'shell.notFoundTitle': 'Page not found',
+    'shell.notFoundDescription': 'The link may be stale or the address may be incorrect.',
+    'shell.backOverview': 'Back to overview',
+    'shell.loadingConsole': 'Loading console',
+    'shell.loadingPage': 'Loading page',
     'auth.email': 'Email',
     'auth.emailPlaceholder': 'name@example.com',
     'auth.backToLogin': 'Back to sign in',
@@ -904,7 +946,7 @@ const messages = {
     'smtp.createTitle': 'New sending login credential',
     'smtp.editTitle': 'Edit sending login credential',
     'smtp.deleteConfirm': 'Delete this SMTP login credential?',
-    'smtp.passwordExtra': 'Leave empty to keep the current password. Copy the current password from the connection section above.',
+    'smtp.passwordExtra': 'Leave empty to keep the current password. Saved passwords are never shown again.',
     'smtp.regenerate': 'Regenerate password',
     'smtp.resetToCopy': 'Reset before copying',
     'smtpRelay.title': 'Advanced: external SMTP relays',
@@ -920,7 +962,7 @@ const messages = {
     'smtpRelay.password': 'Password',
     'smtpRelay.passwordSet': 'Set',
     'smtpRelay.passwordEmpty': 'Empty',
-    'smtpRelay.passwordExtra': 'Saved passwords are shown when editing. Save an empty value to remove the password.',
+    'smtpRelay.passwordExtra': 'Leave empty to keep the current password. Saved passwords are never shown again.',
     'smtpRelay.generatePassword': 'Generate password',
     'smtpRelay.heloExtra': 'Leave empty to use the system default HELO.',
     'smtpRelay.default': 'Default',
@@ -1123,6 +1165,8 @@ const messages = {
     'actions.webhookTestQueued': 'Webhook test queued',
     'actions.webhookDeliveryReplayed': 'Webhook delivery replayed',
     'nav.dashboard': 'Dashboard',
+    'nav.overview': 'Overview',
+    'nav.activity': 'Sending activity',
     'nav.domains': 'Domains',
     'nav.dnsApi': 'DNS API',
     'nav.smtp': 'SMTP Credentials',
@@ -1130,10 +1174,16 @@ const messages = {
     'nav.tokens': 'API Tokens',
     'nav.logs': 'Sending Logs',
     'nav.webhooks': 'Webhooks',
+    'nav.smtpIntegration': 'SMTP & relays',
+    'nav.apiKeys': 'API keys',
+    'nav.dnsIntegration': 'DNS integrations',
+    'nav.adminCenter': 'Admin center',
     'nav.admin': 'Admin',
     'nav.settings': 'Settings',
     'nav.group.overview': 'Overview',
     'nav.group.delivery': 'Delivery',
+    'nav.group.operations': 'Operations',
+    'nav.group.integrations': 'Integrations',
     'nav.group.system': 'System'
   }
 };

+ 19 - 0
src/frontend/navigation-state.ts

@@ -0,0 +1,19 @@
+export interface DetailHistoryState {
+  listPath: string;
+  depth: number;
+  origin: 'list' | 'direct';
+}
+
+export function detailHistoryState(value: unknown): DetailHistoryState | null {
+  if (!value || typeof value !== 'object') return null;
+  const candidate = (value as { mailhubDetail?: unknown }).mailhubDetail;
+  if (!candidate || typeof candidate !== 'object') return null;
+  const { listPath, depth, origin } = candidate as Partial<DetailHistoryState>;
+  if (typeof listPath !== 'string' || !listPath.startsWith('/') || !Number.isInteger(depth) || Number(depth) < 1) return null;
+  if (origin !== undefined && origin !== 'list' && origin !== 'direct') return null;
+  return { listPath, depth: Number(depth), origin: origin || 'list' };
+}
+
+export function detailHistoryLocation(listPath: string, depth: number, origin: DetailHistoryState['origin'] = 'list') {
+  return { mailhubDetail: { listPath, depth, origin } };
+}

+ 34 - 9
src/frontend/services/api.ts

@@ -9,13 +9,17 @@ import type {
   Domain,
   DomainPatchPayload,
   InboundMailbox,
+  InboundFolder,
   InboundMessage,
+  InboundMessageFilters,
   MailboxClientConfig,
   RuntimeConfig,
   SendEvent,
+  SendEventFilters,
   SmtpCredential,
+  SmtpCredentialInput,
   SmtpRelay,
-  SmtpRelayPayload,
+  SmtpRelayInput,
   SystemEmailSettings,
   User,
   UserMergeOptions,
@@ -96,7 +100,12 @@ export const api = {
   me: () => request<{ user: User }>('/api/me'),
   config: () => request<RuntimeConfig>('/api/config'),
   domains: () => request<{ domains: Domain[] }>('/api/domains'),
-  events: () => request<{ events: SendEvent[] }>('/api/events'),
+  events: (filters: SendEventFilters = {}) => {
+    const query = queryString(filters);
+    return request<{ events: SendEvent[]; total: number; page: number; pageSize: number }>(
+      `/api/events${query ? `?${query}` : ''}`
+    );
+  },
   event: (id: number) => request<{ event: SendEvent | null }>(`/api/events/${id}`),
   inboundMailboxes: () => request<{ mailboxes: InboundMailbox[] }>('/api/inbound-mailboxes'),
   createInboundMailbox: (data: {
@@ -120,20 +129,27 @@ export const api = {
     method: 'PATCH',
     data
   }),
-  inboundMessages: (mailboxId?: number | null) => {
-    const query = mailboxId ? `?mailboxId=${mailboxId}` : '';
-    return request<{ messages: InboundMessage[] }>(`/api/inbound-messages${query}`);
+  inboundMessages: (filters: InboundMessageFilters | number | null = {}) => {
+    const normalized = typeof filters === 'number'
+      ? { mailboxId: filters }
+      : (filters || {});
+    const query = queryString(normalized);
+    return request<{ messages: InboundMessage[]; total: number; page: number; pageSize: number }>(
+      `/api/inbound-messages${query ? `?${query}` : ''}`
+    );
   },
+  inboundFolders: (mailboxId: number) =>
+    request<{ folders: InboundFolder[] }>(`/api/inbound-mailboxes/${mailboxId}/folders`),
   inboundMessage: (id: number) => request<{ message: InboundMessage | null }>(`/api/inbound-messages/${id}`),
   markInboundMessageRead: (id: number, read = true) =>
     request<{ message: InboundMessage | null }>(`/api/inbound-messages/${id}`, { method: 'PATCH', data: { read } }),
   analytics: (days = 7) => request<{ analytics: Analytics }>(`/api/analytics?days=${days}`),
   smtpCredential: () => request<{ credential: SmtpCredential | null }>('/api/smtp-credential'),
-  saveSmtpCredential: (data: { username: string; password?: string }) =>
+  saveSmtpCredential: (data: SmtpCredentialInput) =>
     request<{ credential: SmtpCredential }>('/api/smtp-credential', { method: 'PUT', data }),
   smtpCredentials: () => request<{ credentials: SmtpCredential[] }>('/api/smtp-credentials'),
   smtpCredentialDetail: (id: number) => request<{ credential: SmtpCredential }>(`/api/smtp-credentials/${id}`),
-  saveSmtpLoginCredential: (data: { username: string; password?: string }, id?: number) =>
+  saveSmtpLoginCredential: (data: SmtpCredentialInput, id?: number) =>
     request<{ credential: SmtpCredential }>(id ? `/api/smtp-credentials/${id}` : '/api/smtp-credentials', {
       method: id ? 'PATCH' : 'POST',
       data
@@ -142,7 +158,7 @@ export const api = {
     request<{ deleted: boolean }>(`/api/smtp-credentials/${id}`, { method: 'DELETE' }),
   smtpRelays: () => request<{ relays: SmtpRelay[] }>('/api/smtp-relays'),
   smtpRelay: (id: number) => request<{ relay: SmtpRelay }>(`/api/smtp-relays/${id}`),
-  saveSmtpRelay: (data: SmtpRelayPayload, id?: number) =>
+  saveSmtpRelay: (data: SmtpRelayInput, id?: number) =>
     request<{ relay: SmtpRelay }>(id ? `/api/smtp-relays/${id}` : '/api/smtp-relays', {
       method: id ? 'PATCH' : 'POST',
       data
@@ -150,7 +166,7 @@ export const api = {
   deleteSmtpRelay: (id: number) =>
     request<{ deleted: boolean }>(`/api/smtp-relays/${id}`, { method: 'DELETE' }),
   dnsCredentials: () => request<{ credentials: DnsCredential[] }>('/api/dns-credentials'),
-  saveDnsCredential: (data: Record<string, unknown>, id?: number) =>
+  saveDnsCredential: (data: object, id?: number) =>
     request<{ credential: DnsCredential }>(id ? `/api/dns-credentials/${id}` : '/api/dns-credentials', {
       method: id ? 'PATCH' : 'POST',
       data
@@ -280,6 +296,15 @@ export const api = {
     request<{ delivery: WebhookDelivery }>(`/api/webhook-deliveries/${id}/replay`, { method: 'POST' })
 };
 
+function queryString(values: object) {
+  const params = new URLSearchParams();
+  Object.entries(values).forEach(([key, value]) => {
+    if (value === undefined || value === null || value === '') return;
+    params.set(key, typeof value === 'boolean' ? String(value) : String(value));
+  });
+  return params.toString();
+}
+
 interface SystemMailActionResult {
   ok: boolean;
   message: string;

+ 266 - 2
src/frontend/styles.css

@@ -22,17 +22,54 @@ body,
   min-height: 100%;
 }
 
+html {
+  scrollbar-gutter: stable;
+}
+
 body {
   margin: 0;
   background: var(--mh-canvas);
   color: var(--mh-ink);
   font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+  overflow-x: hidden;
+}
+
+button,
+a,
+input,
+select,
+textarea,
+[role='button'],
+[role='menuitem'] {
+  touch-action: manipulation;
+}
+
+:where(button, a, input, select, textarea, [tabindex]):focus-visible {
+  outline: 3px solid rgba(79, 70, 229, 0.35);
+  outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *,
+  *::before,
+  *::after {
+    scroll-behavior: auto !important;
+    transition-duration: 0.01ms !important;
+    animation-duration: 0.01ms !important;
+    animation-iteration-count: 1 !important;
+  }
 }
 
 .full-width {
   width: 100%;
 }
 
+.page-header__title.ant-typography {
+  font-size: clamp(24px, 2vw, 30px);
+  letter-spacing: -0.02em;
+  line-height: 1.25;
+}
+
 .admin-layout {
   min-height: 100vh;
 }
@@ -69,12 +106,29 @@ body {
 
 .admin-sider .ant-menu-dark.ant-menu-inline .ant-menu-item {
   border-radius: 10px;
-  height: 40px;
-  line-height: 40px;
+  height: 44px;
+  line-height: 44px;
   margin-block: 2px;
   width: 100%;
 }
 
+.skip-link {
+  background: var(--mh-primary);
+  border-radius: 0 0 8px 8px;
+  color: #fff;
+  left: 16px;
+  padding: 10px 14px;
+  position: fixed;
+  top: -64px;
+  transition: transform 160ms ease-out;
+  z-index: 1100;
+}
+
+.skip-link:focus {
+  color: #fff;
+  top: 0;
+}
+
 .admin-sider .ant-menu-dark .ant-menu-item-selected {
   background: rgba(79, 70, 229, 0.22) !important;
   color: #e0e7ff !important;
@@ -170,6 +224,17 @@ body {
   margin-top: 2px;
 }
 
+.mobile-nav-close {
+  color: #e2e8f0 !important;
+  margin-inline-start: auto;
+  min-width: 44px;
+}
+
+.mobile-nav-close:hover {
+  background: rgba(255, 255, 255, 0.08) !important;
+  color: #fff !important;
+}
+
 .admin-header {
   align-items: center;
   background: var(--mh-surface);
@@ -214,9 +279,95 @@ body {
 
 .admin-content {
   background: var(--mh-canvas);
+  min-width: 0;
+  outline: none;
   padding: 28px 28px 40px;
 }
 
+.admin-main-layout {
+  min-width: 0;
+}
+
+.mobile-nav-trigger {
+  display: none;
+  min-width: 44px;
+}
+
+.environment-status {
+  align-items: center;
+  display: inline-flex;
+  font-weight: 600;
+  min-height: 32px;
+  padding-inline: 10px;
+}
+
+.account-menu-summary {
+  display: grid;
+  gap: 2px;
+  min-width: 190px;
+}
+
+.bootstrap-skeleton {
+  display: grid;
+  grid-template-columns: 252px minmax(0, 1fr);
+  min-height: 100vh;
+}
+
+.bootstrap-skeleton__sidebar {
+  background: var(--mh-ink);
+}
+
+.bootstrap-skeleton__content,
+.route-skeleton {
+  padding: 32px;
+}
+
+.route-skeleton {
+  background: var(--mh-surface);
+  border: 1px solid var(--mh-border);
+  border-radius: var(--mh-radius-card);
+  min-height: 320px;
+}
+
+.ant-btn,
+.ant-input,
+.ant-input-affix-wrapper,
+.ant-picker,
+.ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
+  min-height: 44px;
+}
+
+.ant-btn.ant-btn-sm {
+  min-height: 44px;
+}
+
+.ant-tabs-tab {
+  justify-content: center;
+  min-height: 44px;
+  min-width: 44px;
+}
+
+.ant-table-wrapper,
+.ant-table-container,
+.ant-card,
+.ant-tabs,
+.ant-tabs-content-holder {
+  max-width: 100%;
+  min-width: 0;
+}
+
+.ant-table-content {
+  overscroll-behavior-inline: contain;
+}
+
+.mono,
+.code-sample,
+.code-block,
+.inline-code-value,
+code {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
+}
+
 .ant-card {
   border-color: var(--mh-border);
   box-shadow: var(--mh-shadow-card);
@@ -957,3 +1108,116 @@ body {
     padding: 24px;
   }
 }
+
+/* Product breakpoints: phone / tablet / compact desktop / wide desktop. */
+@media (max-width: 1023px) {
+  .desktop-sider {
+    display: none !important;
+  }
+
+  .mobile-nav-trigger {
+    align-items: center;
+    display: inline-flex;
+    justify-content: center;
+  }
+
+  .admin-header {
+    align-items: center;
+    flex-direction: row;
+    gap: 12px;
+    min-height: 64px;
+    padding: 10px 20px;
+    position: sticky;
+  }
+
+  .admin-sider {
+    height: 100%;
+    max-height: none;
+  }
+
+  .admin-sider .admin-menu {
+    overflow-y: auto;
+  }
+}
+
+@media (min-width: 1024px) {
+  .desktop-sider {
+    display: block !important;
+  }
+}
+
+@media (max-width: 767px) {
+  .admin-content {
+    padding: 16px 16px 28px;
+  }
+
+  .admin-header {
+    padding-inline: 12px;
+  }
+
+  .language-select {
+    min-width: 96px;
+    width: 96px;
+  }
+
+  .user-button__name,
+  .environment-status__label {
+    display: none;
+  }
+
+  .user-button {
+    min-width: 44px;
+    padding-inline: 8px;
+  }
+
+  .environment-status {
+    margin-inline-end: 0;
+    min-width: 36px;
+    padding-inline: 8px;
+  }
+
+  .page-header,
+  .page-toolbar,
+  .domain-title-row {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .page-header__extra,
+  .page-toolbar > * {
+    width: 100%;
+  }
+
+  .page-header__extra .ant-btn-primary,
+  .page-toolbar .ant-btn-primary {
+    width: 100%;
+  }
+
+  .ant-drawer-content-wrapper {
+    max-width: 100vw;
+  }
+
+  .bootstrap-skeleton {
+    grid-template-columns: 1fr;
+  }
+
+  .bootstrap-skeleton__sidebar {
+    display: none;
+  }
+
+  .bootstrap-skeleton__content,
+  .route-skeleton {
+    padding: 20px 16px;
+  }
+}
+
+@media (max-width: 374px) {
+  .admin-content {
+    padding-inline: 12px;
+  }
+
+  .language-select {
+    min-width: 84px;
+    width: 84px;
+  }
+}

+ 4 - 1
src/frontend/theme.ts

@@ -31,6 +31,9 @@ export const mailhubTheme: ThemeConfig = {
     colorBorderSecondary: brandColors.border,
     colorText: brandColors.ink,
     colorTextSecondary: brandColors.textSecondary,
+    controlHeight: 44,
+    controlHeightLG: 48,
+    controlHeightSM: 36,
     borderRadius: 10,
     borderRadiusLG: 14,
     fontFamily:
@@ -45,7 +48,7 @@ export const mailhubTheme: ThemeConfig = {
       cellPaddingInline: 16
     },
     Button: {
-      controlHeight: 36,
+      controlHeight: 44,
       borderRadius: 10
     },
     Menu: {

+ 53 - 6
src/frontend/types.ts

@@ -154,18 +154,25 @@ export interface DnsCredential {
   updatedAt: string;
 }
 
-export interface SmtpCredential {
+export interface SmtpCredentialSummary {
   id?: number;
   userId?: number;
   username: string;
-  password?: string;
   passwordSet?: boolean;
-  passwordRecoverable?: boolean;
   createdAt?: string;
   updatedAt?: string;
 }
 
-export interface SmtpRelay {
+export interface SmtpCredentialInput {
+  username: string;
+  /** Write-only. The API never returns this value. */
+  password?: string;
+}
+
+/** Backward-compatible name for existing page and admin model imports. */
+export type SmtpCredential = SmtpCredentialSummary;
+
+export interface SmtpRelaySummary {
   id: number;
   userId?: number;
   name: string;
@@ -173,7 +180,6 @@ export interface SmtpRelay {
   port: number;
   secure: boolean;
   username: string;
-  password?: string;
   passwordSet: boolean;
   helo: string;
   isDefault: boolean;
@@ -181,7 +187,7 @@ export interface SmtpRelay {
   updatedAt?: string;
 }
 
-export interface SmtpRelayPayload {
+export interface SmtpRelayInput {
   name: string;
   host: string;
   port: number | string;
@@ -192,6 +198,10 @@ export interface SmtpRelayPayload {
   isDefault?: boolean;
 }
 
+/** Backward-compatible names for existing imports. */
+export type SmtpRelay = SmtpRelaySummary;
+export type SmtpRelayPayload = SmtpRelayInput;
+
 export interface SystemEmailSettings {
   host: string;
   port: number;
@@ -352,6 +362,7 @@ export interface InboundMessage {
   domainId: number;
   domain: string;
   mailboxAddress: string;
+  folder: string;
   sender: string;
   recipients: string[];
   subject: string;
@@ -366,6 +377,41 @@ export interface InboundMessage {
   htmlBody?: string;
 }
 
+export interface InboundFolder {
+  name: string;
+  specialUse: string | null;
+  messageCount: number;
+  unreadCount: number;
+}
+
+export interface PaginationQuery {
+  page?: number;
+  pageSize?: number;
+}
+
+export interface SendEventFilters extends PaginationQuery {
+  status?: string;
+  domainId?: number;
+  from?: string;
+  to?: string;
+  recipient?: string;
+  q?: string;
+}
+
+export interface InboundMessageFilters extends PaginationQuery {
+  mailboxId?: number;
+  folder?: string;
+  read?: boolean;
+  q?: string;
+}
+
+export interface PaginatedResult<T> {
+  total: number;
+  page: number;
+  pageSize: number;
+  items: T[];
+}
+
 export interface DeliveryLogEntry {
   at: string;
   phase: 'connect' | 'smtp' | 'auth' | 'envelope' | 'data' | 'queue' | 'quit' | 'error' | string;
@@ -465,6 +511,7 @@ export interface SendEvent {
   status: string;
   detail: string;
   queueId?: string;
+  messageId?: string;
   deliveryLog?: DeliveryLogEntry[];
   deliveryAttempts?: DeliveryAttempt[];
   webhookDeliveries?: WebhookDelivery[];

+ 223 - 102
src/layouts/AdminLayout.tsx

@@ -1,141 +1,202 @@
 import {
   ApiOutlined,
-  AppstoreOutlined,
+  CheckCircleOutlined,
   CloudServerOutlined,
+  CloseOutlined,
   DashboardOutlined,
   GlobalOutlined,
   InboxOutlined,
   KeyOutlined,
   MailOutlined,
-  ReloadOutlined,
+  MenuOutlined,
   SafetyCertificateOutlined,
   SendOutlined,
   SettingOutlined,
-  UserOutlined
+  UserOutlined,
+  WarningOutlined
 } from '@ant-design/icons';
-import { Avatar, Breadcrumb, Button, Dropdown, Layout, Menu, Select, Space, Typography } from 'antd';
-import type { ReactNode } from 'react';
+import {
+  Avatar,
+  Button,
+  Drawer,
+  Dropdown,
+  Grid,
+  Layout,
+  Menu,
+  Select,
+  Space,
+  Tag,
+  Tooltip,
+  Typography
+} from 'antd';
+import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
+import { Outlet, useLocation, useNavigate } from 'react-router-dom';
 
+import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
-import type { User, ViewKey } from '../frontend/types';
 
 const { Header, Sider, Content } = Layout;
 
-const navGroups: Array<{
+interface NavigationItem {
+  path: string;
+  labelKey: string;
+  icon: ReactNode;
+  adminOnly?: boolean;
+}
+
+interface NavigationGroup {
   key: string;
   labelKey: string;
-  items: Array<{ key: ViewKey; labelKey: string; icon: ReactNode; adminOnly?: boolean }>;
-}> = [
+  items: NavigationItem[];
+}
+
+export const navigationGroups: NavigationGroup[] = [
   {
-    key: 'overview',
-    labelKey: 'nav.group.overview',
+    key: 'operations',
+    labelKey: 'nav.group.operations',
     items: [
-      { key: 'dashboard', labelKey: 'nav.dashboard', icon: <DashboardOutlined /> },
-      { key: 'domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
-      { key: 'dns-api', labelKey: 'nav.dnsApi', icon: <CloudServerOutlined /> }
+      { path: '/overview', labelKey: 'nav.overview', icon: <DashboardOutlined /> },
+      { path: '/activity', labelKey: 'nav.activity', icon: <SendOutlined /> },
+      { path: '/domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
+      { path: '/inbox', labelKey: 'nav.inbox', icon: <InboxOutlined /> }
     ]
   },
   {
-    key: 'delivery',
-    labelKey: 'nav.group.delivery',
+    key: 'integrations',
+    labelKey: 'nav.group.integrations',
     items: [
-      { key: 'smtp', labelKey: 'nav.smtp', icon: <MailOutlined /> },
-      { key: 'inbox', labelKey: 'nav.inbox', icon: <InboxOutlined /> },
-      { key: 'tokens', labelKey: 'nav.tokens', icon: <KeyOutlined /> },
-      { key: 'logs', labelKey: 'nav.logs', icon: <SendOutlined /> },
-      { key: 'webhooks', labelKey: 'nav.webhooks', icon: <ApiOutlined /> }
+      { path: '/integrations/smtp', labelKey: 'nav.smtpIntegration', icon: <MailOutlined /> },
+      { path: '/integrations/api-keys', labelKey: 'nav.apiKeys', icon: <KeyOutlined /> },
+      { path: '/integrations/webhooks', labelKey: 'nav.webhooks', icon: <ApiOutlined /> },
+      { path: '/integrations/dns', labelKey: 'nav.dnsIntegration', icon: <CloudServerOutlined /> }
     ]
   },
   {
     key: 'system',
     labelKey: 'nav.group.system',
     items: [
-      { key: 'admin', labelKey: 'nav.admin', icon: <SafetyCertificateOutlined />, adminOnly: true },
-      { key: 'settings', labelKey: 'nav.settings', icon: <SettingOutlined /> }
+      { path: '/admin/users', labelKey: 'nav.adminCenter', icon: <SafetyCertificateOutlined />, adminOnly: true },
+      { path: '/settings', labelKey: 'nav.settings', icon: <SettingOutlined />, adminOnly: true }
     ]
   }
 ];
 
-interface AdminLayoutProps {
-  activeView: ViewKey;
-  breadcrumb: string[];
-  user: User | null;
-  runtimeLine: string;
-  loading: boolean;
-  children: ReactNode;
-  onViewChange: (view: ViewKey) => void;
-  onRefresh: () => void;
-  onAddDomain: () => void;
-  onLogout: () => void;
+export function visibleNavigation(isAdmin: boolean) {
+  return navigationGroups
+    .map((group) => ({
+      ...group,
+      items: group.items.filter((item) => !item.adminOnly || isAdmin)
+    }))
+    .filter((group) => group.items.length > 0);
+}
+
+export function navigationSelection(pathname: string) {
+  if (pathname.startsWith('/activity')) return '/activity';
+  if (pathname.startsWith('/domains')) return '/domains';
+  if (pathname.startsWith('/inbox')) return '/inbox';
+  if (pathname.startsWith('/integrations/smtp')) return '/integrations/smtp';
+  if (pathname.startsWith('/integrations/api-keys')) return '/integrations/api-keys';
+  if (pathname.startsWith('/integrations/webhooks')) return '/integrations/webhooks';
+  if (pathname.startsWith('/integrations/dns')) return '/integrations/dns';
+  if (pathname.startsWith('/admin')) return '/admin/users';
+  if (pathname.startsWith('/settings')) return '/settings';
+  return '/overview';
 }
 
-export function AdminLayout({
-  activeView,
-  breadcrumb,
-  user,
-  runtimeLine,
-  loading,
-  children,
-  onViewChange,
-  onRefresh,
-  onAddDomain,
-  onLogout
-}: AdminLayoutProps) {
+export function AdminLayout() {
   const { locale, locales, setLocale, t } = useI18n();
+  const { user, config, logout } = useAppContext();
+  const location = useLocation();
+  const navigate = useNavigate();
+  const screens = Grid.useBreakpoint();
+  const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false);
+  const mainRef = useRef<HTMLElement>(null);
   const isAdmin = user?.role === 'admin';
 
-  const menuItems = navGroups.map((group) => ({
+  useEffect(() => {
+    setMobileNavigationOpen(false);
+    const frame = window.requestAnimationFrame(() => mainRef.current?.focus({ preventScroll: true }));
+    return () => window.cancelAnimationFrame(frame);
+  }, [location.pathname]);
+
+  const menuItems = useMemo(() => visibleNavigation(isAdmin).map((group) => ({
     type: 'group' as const,
     key: group.key,
     label: t(group.labelKey),
-    children: group.items
-      .filter((item) => !item.adminOnly || isAdmin)
-      .map((item) => ({
-        key: item.key,
-        icon: item.icon,
-        label: t(item.labelKey)
-      }))
-  }));
+    children: group.items.map((item) => ({
+      key: item.path,
+      icon: item.icon,
+      label: t(item.labelKey)
+    }))
+  })), [isAdmin, t]);
+
+  const menu = (
+    <Menu
+      theme="dark"
+      mode="inline"
+      selectedKeys={[navigationSelection(location.pathname)]}
+      items={menuItems}
+      onClick={({ key }) => navigate(key)}
+      className="admin-menu"
+    />
+  );
+
+  const environmentReady = Boolean(config?.mailHostname && config?.sendingIp);
+  const environmentDetails = config
+    ? `${config.mailHostname} · ${config.sendingIp || t('common.unsetSendingIp')}`
+    : t('common.loadingConfig');
 
   return (
     <Layout className="admin-layout">
-      <Sider breakpoint="lg" collapsedWidth={0} width={248} className="admin-sider">
-        <div className="admin-sider-inner">
-          <div className="brand">
-            <div className="brand-logo">MH</div>
-            <div>
-              <div className="brand-title">MailHub</div>
-              <div className="brand-subtitle">Email Delivery</div>
-            </div>
-          </div>
-          <Menu
-            theme="dark"
-            mode="inline"
-            selectedKeys={[activeView]}
-            items={menuItems}
-            onClick={({ key }) => onViewChange(key as ViewKey)}
-            className="admin-menu"
-          />
-          {user ? (
-            <div className="admin-sider-footer">
-              <Avatar size={28} icon={<UserOutlined />} className="sider-user-avatar" />
-              <div className="sider-user-meta">
-                <div className="sider-user-name">{user.username || t('common.user')}</div>
-                <div className="sider-user-role">{user.role || '—'}</div>
-              </div>
-            </div>
-          ) : null}
-        </div>
+      <a className="skip-link" href="#main-content">{t('shell.skipToMain')}</a>
+      <Sider width={252} className="admin-sider desktop-sider" trigger={null}>
+        <NavigationPanel menu={menu} userName={user?.username} role={user?.role} />
       </Sider>
-      <Layout>
+
+      <Drawer
+        className="mobile-navigation-drawer"
+        placement="left"
+        width={288}
+        open={mobileNavigationOpen && screens.lg === false}
+        onClose={() => setMobileNavigationOpen(false)}
+        closable={false}
+        styles={{ body: { padding: 0, background: '#0F172A' } }}
+        aria-label={t('shell.mainNavigation')}
+      >
+        <NavigationPanel
+          menu={menu}
+          userName={user?.username}
+          role={user?.role}
+          onClose={() => setMobileNavigationOpen(false)}
+          closeLabel={t('shell.closeNavigation')}
+        />
+      </Drawer>
+
+      <Layout className="admin-main-layout">
         <Header className="admin-header">
-          <div className="header-title">
-            <Breadcrumb items={breadcrumb.map((title) => ({ title }))} className="header-breadcrumb" />
-            <Typography.Text type="secondary" className="runtime-line">
-              {runtimeLine}
-            </Typography.Text>
-          </div>
-          <Space wrap className="header-actions">
+          <Space size={12}>
+            <Button
+              className="mobile-nav-trigger"
+              type="text"
+              icon={<MenuOutlined />}
+              aria-label={t('shell.openNavigation')}
+              onClick={() => setMobileNavigationOpen(true)}
+            />
+            <Tooltip title={environmentDetails}>
+              <Tag
+                className="environment-status"
+                aria-label={environmentReady ? t('shell.envReady') : t('shell.envNeedsSetup')}
+                icon={environmentReady ? <CheckCircleOutlined /> : <WarningOutlined />}
+                color={environmentReady ? 'success' : 'warning'}
+              >
+                <span className="environment-status__label">
+                  {environmentReady ? t('shell.envReady') : t('shell.envNeedsSetup')}
+                </span>
+              </Tag>
+            </Tooltip>
+          </Space>
+
+          <Space size={8} className="header-actions">
             <Select
               aria-label="Language"
               value={locale}
@@ -143,31 +204,91 @@ export function AdminLayout({
               onChange={setLocale}
               className="language-select"
             />
-            <Button icon={<ReloadOutlined />} loading={loading} onClick={onRefresh}>
-              {t('common.refresh')}
-            </Button>
-            <Button type="primary" icon={<AppstoreOutlined />} onClick={onAddDomain}>
-              {t('common.addDomain')}
-            </Button>
             <Dropdown
+              trigger={['click']}
               menu={{
                 items: [
-                  { key: 'profile', label: user?.email || user?.username || t('common.account'), disabled: true },
-                  { key: 'logout', label: t('common.logout'), onClick: onLogout }
+                  {
+                    key: 'account',
+                    label: (
+                      <div className="account-menu-summary">
+                        <Typography.Text strong>{user?.username || t('common.user')}</Typography.Text>
+                        <Typography.Text type="secondary">{user?.email || '—'}</Typography.Text>
+                      </div>
+                    ),
+                    disabled: true
+                  },
+                  ...(isAdmin ? [{ key: 'settings', label: t('nav.settings'), onClick: () => navigate('/settings') }] : []),
+                  { type: 'divider' as const },
+                  { key: 'logout', label: t('common.logout'), onClick: () => void logout() }
                 ]
               }}
             >
-              <Button className="user-button">
-                <Space>
+              <Button className="user-button" aria-label={t('shell.accountMenu')}>
+                <Space size={8}>
                   <Avatar size={24} icon={<UserOutlined />} />
-                  <span>{user?.username || t('common.user')}</span>
+                  <span className="user-button__name">{user?.username || t('common.user')}</span>
                 </Space>
               </Button>
             </Dropdown>
           </Space>
         </Header>
-        <Content className="admin-content">{children}</Content>
+
+        <Content
+          id="main-content"
+          ref={mainRef}
+          tabIndex={-1}
+          className="admin-content"
+        >
+          <Outlet />
+        </Content>
       </Layout>
     </Layout>
   );
 }
+
+function NavigationPanel({
+  menu,
+  userName,
+  role,
+  onClose,
+  closeLabel
+}: {
+  menu: ReactNode;
+  userName?: string;
+  role?: string;
+  onClose?: () => void;
+  closeLabel?: string;
+}) {
+  return (
+    <div className="admin-sider-inner">
+      <div className="brand">
+        <div className="brand-logo" aria-hidden="true">MH</div>
+        <div>
+          <div className="brand-title">MailHub</div>
+          <div className="brand-subtitle">Delivery Operations</div>
+        </div>
+        {onClose ? (
+          <Button
+            type="text"
+            className="mobile-nav-close"
+            icon={<CloseOutlined />}
+            aria-label={closeLabel}
+            style={{ minHeight: 44, minWidth: 44 }}
+            onClick={onClose}
+          />
+        ) : null}
+      </div>
+      {menu}
+      {userName ? (
+        <div className="admin-sider-footer">
+          <Avatar size={28} icon={<UserOutlined />} className="sider-user-avatar" />
+          <div className="sider-user-meta">
+            <div className="sider-user-name">{userName}</div>
+            <div className="sider-user-role">{role || '—'}</div>
+          </div>
+        </div>
+      ) : null}
+    </div>
+  );
+}

+ 334 - 192
src/pages/Admin/index.tsx

@@ -24,12 +24,14 @@ import {
   Typography
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
 
 import { EmptyState } from '../../components/common/EmptyState';
 import { PageHeader } from '../../components/common/PageHeader';
 import { SectionCard } from '../../components/common/SectionCard';
 import { StatusPill } from '../../components/common/StatusPill';
+import { useAppContext } from '../../frontend/app-context';
 import {
   adminUserStatusMeta,
   buildMergeConfirmationText,
@@ -44,43 +46,58 @@ import type {
   AdminUser,
   AuditLogEntry,
   SystemEmailSettings,
-  User,
   UserMergeOptions,
   UserMergePreview,
   UserRole,
   UserStatus
 } from '../../frontend/types';
 
-interface AdminPageProps {
-  me: User | null;
-}
-
 const statusValues: UserStatus[] = ['pending_email', 'pending_review', 'active', 'disabled'];
 const roleValues: UserRole[] = ['user', 'admin'];
-
-const mergeOptionLabels: Array<[keyof UserMergeOptions, string]> = [
-  ['transferDomains', '迁移域名'],
-  ['transferDnsCredentials', '迁移 DNS 凭据'],
-  ['transferApiTokens', '迁移 API Token'],
-  ['transferSendEvents', '迁移发送记录'],
-  ['transferSmtpCredential', '迁移 SMTP 凭据'],
-  ['disableSource', '禁用源用户']
+const adminSections = ['users', 'resources', 'migration', 'system-email', 'audit-logs'] as const;
+type AdminSection = typeof adminSections[number];
+
+const mergeOptionLabels: Array<[keyof UserMergeOptions, string, string]> = [
+  ['transferDomains', '迁移域名', 'Transfer domains'],
+  ['transferDnsCredentials', '迁移 DNS 凭据', 'Transfer DNS credentials'],
+  ['transferApiTokens', '迁移 API Token', 'Transfer API tokens'],
+  ['transferSendEvents', '迁移发送记录', 'Transfer sending activity'],
+  ['transferSmtpCredential', '迁移 SMTP 凭据', 'Transfer SMTP credential'],
+  ['disableSource', '禁用源用户', 'Disable source user']
 ];
 
-export default function AdminPage({ me }: AdminPageProps) {
+export default function AdminPage() {
   const { message, modal } = AntApp.useApp();
-  const { t } = useI18n();
+  const { locale, t } = useI18n();
+  const { user: me } = useAppContext();
+  const navigate = useNavigate();
+  const params = useParams<{ section?: string }>();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const auditQuery = searchParams.toString();
+  const activeSection: AdminSection = adminSections.includes(params.section as AdminSection)
+    ? params.section as AdminSection
+    : 'users';
   const [users, setUsers] = useState<AdminUser[]>([]);
   const [inventory, setInventory] = useState<AdminResourceInventory | null>(null);
   const [systemEmail, setSystemEmail] = useState<SystemEmailSettings | null>(null);
   const [auditLogs, setAuditLogs] = useState<AuditLogEntry[]>([]);
-  const [auditQuery, setAuditQuery] = useState('');
   const [loading, setLoading] = useState(false);
-  const [actionLoading, setActionLoading] = useState(false);
+  const [actionKeys, setActionKeys] = useState<Set<string>>(() => new Set());
+  const loadedSections = useRef(new Set<AdminSection>());
+  const auditRequestId = useRef(0);
+
+  useEffect(() => {
+    if (!params.section || !adminSections.includes(params.section as AdminSection)) {
+      navigate('/admin/users', { replace: true });
+    }
+  }, [navigate, params.section]);
 
   useEffect(() => {
-    if (me?.role === 'admin') void loadAdminData();
-  }, [me?.role]);
+    if (me?.role === 'admin') void loadSection(activeSection, activeSection === 'audit-logs', auditQuery);
+    return () => {
+      if (activeSection === 'audit-logs') auditRequestId.current += 1;
+    };
+  }, [activeSection, auditQuery, me?.role]);
 
   if (me?.role !== 'admin') {
     return (
@@ -93,36 +110,54 @@ export default function AdminPage({ me }: AdminPageProps) {
     );
   }
 
-  async function loadAdminData(query = auditQuery) {
-    setLoading(true);
+  async function loadSection(section: AdminSection, force = false, query = auditQuery, showLoading = true) {
+    if (!force && loadedSections.current.has(section)) {
+      if (showLoading) setLoading(false);
+      return;
+    }
+    let currentAuditRequestId: number | null = null;
+    if (showLoading) setLoading(true);
     try {
-      const [usersResult, resourcesResult, emailResult, auditResult] = await Promise.all([
-        api.adminUsers(),
-        api.adminResources(),
-        api.adminSystemEmail(),
-        api.adminAuditLogs(query)
-      ]);
-      setUsers(usersResult.users || []);
-      setInventory(resourcesResult.inventory || null);
-      setSystemEmail(emailResult.settings || null);
-      setAuditLogs(auditResult.logs || []);
+      if (section === 'users' || section === 'migration') {
+        const result = await api.adminUsers();
+        setUsers(result.users || []);
+      } else if (section === 'resources') {
+        const [usersResult, resourcesResult] = await Promise.all([api.adminUsers(), api.adminResources()]);
+        setUsers(usersResult.users || []);
+        setInventory(resourcesResult.inventory || null);
+      } else if (section === 'system-email') {
+        const result = await api.adminSystemEmail();
+        setSystemEmail(result.settings || null);
+      } else if (section === 'audit-logs') {
+        currentAuditRequestId = ++auditRequestId.current;
+        const [usersResult, auditResult] = await Promise.all([api.adminUsers(), api.adminAuditLogs(query)]);
+        if (currentAuditRequestId !== auditRequestId.current) return;
+        setUsers(usersResult.users || []);
+        setAuditLogs(auditResult.logs || []);
+      }
+      loadedSections.current.add(section);
     } catch (error) {
-      message.error(error instanceof Error ? error.message : '管理员数据加载失败');
+      if (currentAuditRequestId !== null && currentAuditRequestId !== auditRequestId.current) return;
+      message.error(error instanceof Error ? error.message : tr(locale, '管理员数据加载失败', 'Failed to load admin data'));
     } finally {
-      setLoading(false);
+      if (showLoading && (currentAuditRequestId === null || currentAuditRequestId === auditRequestId.current)) setLoading(false);
     }
   }
 
-  async function runAction(action: () => Promise<unknown>, success: string, refresh = true) {
-    setActionLoading(true);
+  async function runAction(key: string, action: () => Promise<unknown>, success: string, refresh = true) {
+    setActionKeys((current) => new Set(current).add(key));
     try {
       await action();
       message.success(success);
-      if (refresh) await loadAdminData();
+      if (refresh) await loadSection(activeSection, true, auditQuery, false);
     } catch (error) {
-      message.error(error instanceof Error ? error.message : '操作失败');
+      message.error(error instanceof Error ? error.message : tr(locale, '操作失败', 'Operation failed'));
     } finally {
-      setActionLoading(false);
+      setActionKeys((current) => {
+        const next = new Set(current);
+        next.delete(key);
+        return next;
+      });
     }
   }
 
@@ -136,16 +171,13 @@ export default function AdminPage({ me }: AdminPageProps) {
   }
 
   async function searchAuditLogs(query: string) {
-    setAuditQuery(query);
-    setLoading(true);
-    try {
-      const result = await api.adminAuditLogs(query);
-      setAuditLogs(result.logs || []);
-    } catch (error) {
-      message.error(error instanceof Error ? error.message : '审计日志加载失败');
-    } finally {
-      setLoading(false);
+    const next = new URLSearchParams(query);
+    const nextQuery = next.toString();
+    if (nextQuery === auditQuery) {
+      await loadSection('audit-logs', true, nextQuery);
+      return;
     }
+    setSearchParams(next);
   }
 
   const tabItems = [
@@ -155,19 +187,20 @@ export default function AdminPage({ me }: AdminPageProps) {
       children: (
         <AdminUsers
           users={users}
-          loading={loading || actionLoading}
-          onApprove={(user) => confirm(`确认审批用户 ${user.username}?`, () =>
-            runAction(() => api.approveAdminUser(user.id), '用户已审批')
+          loading={loading}
+          actionKeys={actionKeys}
+          onApprove={(user) => confirm(tr(locale, `确认审批用户 ${user.username}?`, `Approve user ${user.username}?`), () =>
+            runAction(`approve:${user.id}`, () => api.approveAdminUser(user.id), tr(locale, '用户已审批', 'User approved'))
           )}
-          onResendVerification={(user) => runAction(() => api.resendAdminVerification(user.id), '验证邮件请求已提交')}
-          onPasswordReset={(user) => confirm(`确认给 ${user.email} 发送密码重置邮件?`, () =>
-            runAction(() => api.sendAdminPasswordReset(user.id), '密码重置邮件请求已提交')
+          onResendVerification={(user) => runAction(`resend:${user.id}`, () => api.resendAdminVerification(user.id), tr(locale, '验证邮件请求已提交', 'Verification email requested'))}
+          onPasswordReset={(user) => confirm(tr(locale, `确认给 ${user.email} 发送密码重置邮件?`, `Send a password reset email to ${user.email}?`), () =>
+            runAction(`reset:${user.id}`, () => api.sendAdminPasswordReset(user.id), tr(locale, '密码重置邮件请求已提交', 'Password reset email requested'))
           )}
-          onTemporaryPassword={(user, password) => confirm(`确认为用户 ${user.username} 设置临时密码?`, () =>
-            runAction(() => api.setAdminTemporaryPassword(user.id, password), '临时密码已设置')
+          onTemporaryPassword={(user, password) => confirm(tr(locale, `确认为用户 ${user.username} 设置临时密码?`, `Set a temporary password for ${user.username}?`), () =>
+            runAction(`temporary-password:${user.id}`, () => api.setAdminTemporaryPassword(user.id, password), tr(locale, '临时密码已设置', 'Temporary password set'))
           )}
-          onUpdateUser={(user, patch) => confirm(`确认更新用户 ${user.username}?`, () =>
-            runAction(() => api.updateAdminUser(user.id, patch), '用户已更新')
+          onUpdateUser={(user, patch) => confirm(tr(locale, `确认更新用户 ${user.username}?`, `Update user ${user.username}?`), () =>
+            runAction(`update:${user.id}`, () => api.updateAdminUser(user.id, patch), tr(locale, '用户已更新', 'User updated'))
           )}
         />
       )
@@ -179,15 +212,16 @@ export default function AdminPage({ me }: AdminPageProps) {
         <AdminResources
           users={users}
           inventory={inventory}
-          loading={loading || actionLoading}
+          loading={loading}
+          actionKeys={actionKeys}
           onTransferDomain={(domainId, values) =>
-            runAction(() => api.transferAdminDomain(domainId, values), '域名已迁移')
+            runAction(`transfer-domain:${domainId}`, () => api.transferAdminDomain(domainId, values), tr(locale, '域名已迁移', 'Domain transferred'))
           }
           onTransferDnsCredential={(credentialId, values) =>
-            runAction(() => api.transferAdminDnsCredential(credentialId, values), 'DNS 凭据已迁移')
+            runAction(`transfer-dns:${credentialId}`, () => api.transferAdminDnsCredential(credentialId, values), tr(locale, 'DNS 凭据已迁移', 'DNS credential transferred'))
           }
           onTransferApiTokens={(values) =>
-            runAction(() => api.transferAdminApiTokens(values), 'API Token 已迁移')
+            runAction('transfer-token', () => api.transferAdminApiTokens(values), tr(locale, 'API Token 已迁移', 'API tokens transferred'))
           }
         />
       )
@@ -198,9 +232,10 @@ export default function AdminPage({ me }: AdminPageProps) {
       children: (
         <AdminMigration
           users={users}
-          loading={loading || actionLoading}
+          loading={loading}
+          actionKeys={actionKeys}
           onPreview={(values) => api.previewUserMerge(values)}
-          onExecute={(values) => runAction(() => api.executeUserMerge(values), '用户资源已合并')}
+          onExecute={(values) => runAction('merge', () => api.executeUserMerge(values), tr(locale, '用户资源已合并', 'User resources merged'))}
         />
       )
     },
@@ -210,9 +245,10 @@ export default function AdminPage({ me }: AdminPageProps) {
       children: (
         <AdminSystemEmail
           settings={systemEmail}
-          loading={loading || actionLoading}
-          onSave={(values) => runAction(() => api.saveAdminSystemEmail(values), '系统邮件配置已保存')}
-          onTest={(to) => runAction(() => api.testAdminSystemEmail(to), '测试邮件请求已提交', false)}
+          loading={loading}
+          actionKeys={actionKeys}
+          onSave={(values) => runAction('system-email:save', () => api.saveAdminSystemEmail(values), tr(locale, '系统邮件配置已保存', 'System email settings saved'))}
+          onTest={(to) => runAction('system-email:test', () => api.testAdminSystemEmail(to), tr(locale, '测试邮件请求已提交', 'Test email requested'), false)}
         />
       )
     },
@@ -223,6 +259,7 @@ export default function AdminPage({ me }: AdminPageProps) {
         <AdminAuditLogs
           logs={auditLogs}
           users={users}
+          query={auditQuery}
           loading={loading}
           onSearch={searchAuditLogs}
         />
@@ -235,13 +272,18 @@ export default function AdminPage({ me }: AdminPageProps) {
       <PageHeader
         title={t('admin.title')}
         extra={
-          <Button icon={<ReloadOutlined />} loading={loading} onClick={() => loadAdminData()}>
+          <Button icon={<ReloadOutlined />} loading={loading} onClick={() => void loadSection(activeSection, true)}>
             {t('common.refresh')}
           </Button>
         }
       />
       <SectionCard>
-        <Tabs items={tabItems} />
+        <Tabs
+          activeKey={activeSection}
+          items={tabItems}
+          destroyOnHidden
+          onChange={(key) => navigate(`/admin/${key}`)}
+        />
       </SectionCard>
     </Space>
   );
@@ -250,6 +292,7 @@ export default function AdminPage({ me }: AdminPageProps) {
 function AdminUsers({
   users,
   loading,
+  actionKeys,
   onApprove,
   onResendVerification,
   onPasswordReset,
@@ -258,19 +301,21 @@ function AdminUsers({
 }: {
   users: AdminUser[];
   loading: boolean;
+  actionKeys: ReadonlySet<string>;
   onApprove: (user: AdminUser) => void;
   onResendVerification: (user: AdminUser) => void;
   onPasswordReset: (user: AdminUser) => void;
   onTemporaryPassword: (user: AdminUser, password: string) => void;
   onUpdateUser: (user: AdminUser, patch: { role?: UserRole; status?: UserStatus }) => void;
 }) {
+  const { locale } = useI18n();
   const [tempUser, setTempUser] = useState<AdminUser | null>(null);
   const [form] = Form.useForm<{ password: string }>();
 
   const columns: ColumnsType<AdminUser> = [
     { title: 'ID', dataIndex: 'id', width: 80 },
     {
-      title: '用户',
+      title: tr(locale, '用户', 'User'),
       render: (_, user) => (
         <Space direction="vertical" size={0}>
           <Typography.Text strong>{user.username}</Typography.Text>
@@ -279,68 +324,83 @@ function AdminUsers({
       )
     },
     {
-      title: '状态',
+      title: tr(locale, '状态', 'Status'),
       dataIndex: 'status',
       width: 180,
-      render: (_, user) => (
-        <Select
-          value={user.status}
-          options={statusValues.map((value) => ({ value, label: adminUserStatusMeta(value).label }))}
-          onChange={(status) => onUpdateUser(user, { status })}
-          className="table-select"
-        />
-      )
+      render: (_, user) => {
+        const pending = userMutationPending(actionKeys, user.id);
+        return (
+          <Select
+            value={user.status}
+            loading={actionKeys.has(`update:${user.id}`)}
+            disabled={pending}
+            options={statusValues.map((value) => ({ value, label: userStatusLabel(value, locale) }))}
+            onChange={(status) => onUpdateUser(user, { status })}
+            className="table-select"
+          />
+        );
+      }
     },
     {
-      title: '角色',
+      title: tr(locale, '角色', 'Role'),
       dataIndex: 'role',
       width: 140,
-      render: (_, user) => (
-        <Select
-          value={user.role}
-          options={roleValues.map((value) => ({ value, label: value }))}
-          onChange={(role) => onUpdateUser(user, { role })}
-          className="table-select"
-        />
-      )
+      render: (_, user) => {
+        const pending = userMutationPending(actionKeys, user.id);
+        return (
+          <Select
+            value={user.role}
+            loading={actionKeys.has(`update:${user.id}`)}
+            disabled={pending}
+            options={roleValues.map((value) => ({ value, label: value }))}
+            onChange={(role) => onUpdateUser(user, { role })}
+            className="table-select"
+          />
+        );
+      }
     },
     {
-      title: '资源',
+      title: tr(locale, '资源', 'Resources'),
       render: (_, user) => <ResourceCountTags counts={user.resourceCounts} />
     },
     {
-      title: '创建时间',
+      title: tr(locale, '创建时间', 'Created at'),
       dataIndex: 'createdAt',
       width: 190,
       render: formatDate
     },
     {
-      title: '操作',
+      title: tr(locale, '操作', 'Actions'),
       width: 380,
-      render: (_, user) => (
-        <Space wrap>
-          <Button
-            icon={<CheckCircleOutlined />}
-            disabled={user.status !== 'pending_review'}
-            onClick={() => onApprove(user)}
-          >
-            审批
-          </Button>
-          <Button
-            icon={<MailOutlined />}
-            disabled={user.status !== 'pending_email'}
-            onClick={() => onResendVerification(user)}
-          >
-            重发验证
-          </Button>
-          <Button icon={<SendOutlined />} onClick={() => onPasswordReset(user)}>
-            重置邮件
-          </Button>
-          <Button icon={<UserSwitchOutlined />} onClick={() => setTempUser(user)}>
-            临时密码
-          </Button>
-        </Space>
-      )
+      render: (_, user) => {
+        const pending = userMutationPending(actionKeys, user.id);
+        return (
+          <Space wrap>
+            <Button
+              icon={<CheckCircleOutlined />}
+              disabled={pending || user.status !== 'pending_review'}
+              loading={actionKeys.has(`approve:${user.id}`)}
+              onClick={() => onApprove(user)}
+            >
+              {tr(locale, '审批', 'Approve')}
+            </Button>
+            <Button
+              icon={<MailOutlined />}
+              disabled={pending || user.status !== 'pending_email'}
+              loading={actionKeys.has(`resend:${user.id}`)}
+              onClick={() => onResendVerification(user)}
+            >
+              {tr(locale, '重发验证', 'Resend verification')}
+            </Button>
+            <Button disabled={pending} icon={<SendOutlined />} loading={actionKeys.has(`reset:${user.id}`)} onClick={() => onPasswordReset(user)}>
+              {tr(locale, '重置邮件', 'Reset email')}
+            </Button>
+            <Button disabled={pending} icon={<UserSwitchOutlined />} loading={actionKeys.has(`temporary-password:${user.id}`)} onClick={() => setTempUser(user)}>
+              {tr(locale, '临时密码', 'Temporary password')}
+            </Button>
+          </Space>
+        );
+      }
     }
   ];
 
@@ -362,17 +422,17 @@ function AdminUsers({
         scroll={{ x: 1100 }}
       />
       <Modal
-        title={tempUser ? `设置临时密码 · ${tempUser.username}` : '设置临时密码'}
+        title={tempUser ? `${tr(locale, '设置临时密码', 'Set temporary password')} · ${tempUser.username}` : tr(locale, '设置临时密码', 'Set temporary password')}
         open={Boolean(tempUser)}
-        confirmLoading={loading}
+        confirmLoading={actionKeys.has(`temporary-password:${tempUser?.id}`)}
         onCancel={() => setTempUser(null)}
         onOk={submitTemporaryPassword}
       >
         <Form form={form} layout="vertical">
           <Form.Item
             name="password"
-            label="临时密码"
-            rules={[{ required: true, min: 8, message: '密码至少需要 8 位。' }]}
+            label={tr(locale, '临时密码', 'Temporary password')}
+            rules={[{ required: true, min: 8, message: tr(locale, '密码至少需要 8 位。', 'Password must be at least 8 characters.') }]}
           >
             <Input.Password autoComplete="new-password" />
           </Form.Item>
@@ -386,6 +446,7 @@ function AdminResources({
   users,
   inventory,
   loading,
+  actionKeys,
   onTransferDomain,
   onTransferDnsCredential,
   onTransferApiTokens
@@ -393,13 +454,17 @@ function AdminResources({
   users: AdminUser[];
   inventory: AdminResourceInventory | null;
   loading: boolean;
+  actionKeys: ReadonlySet<string>;
   onTransferDomain: (domainId: number, values: { targetUserId: number; dnsCredentialMode?: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }) => Promise<void>;
   onTransferDnsCredential: (credentialId: number, values: { targetUserId: number }) => Promise<void>;
   onTransferApiTokens: (values: { tokenIds: number[]; targetUserId: number }) => Promise<void>;
 }) {
+  const { locale } = useI18n();
   const [domainForm] = Form.useForm<{ domainId: number; targetUserId: number; dnsCredentialMode: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }>();
   const [dnsForm] = Form.useForm<{ credentialId: number; targetUserId: number }>();
   const [tokenForm] = Form.useForm<{ tokenIds: number[]; targetUserId: number }>();
+  const selectedDomainId = Form.useWatch('domainId', domainForm);
+  const selectedCredentialId = Form.useWatch('credentialId', dnsForm);
   const groups = inventory?.users || [];
   const targetOptions = users
     .filter((user) => user.status !== 'disabled')
@@ -410,7 +475,7 @@ function AdminResources({
 
   const groupColumns: ColumnsType<AdminResourceInventory['users'][number]> = [
     {
-      title: '用户',
+      title: tr(locale, '用户', 'User'),
       render: (_, group) => (
         <Space>
           <Typography.Text strong>{group.user.username}</Typography.Text>
@@ -418,23 +483,23 @@ function AdminResources({
         </Space>
       )
     },
-    { title: '资源', render: (_, group) => <ResourceCountTags counts={group.user.resourceCounts} /> },
-    { title: '发送记录', dataIndex: 'sendEventCount', width: 120 },
-    { title: '入站邮件', dataIndex: 'inboundMessageCount', width: 120 },
+    { title: tr(locale, '资源', 'Resources'), render: (_, group) => <ResourceCountTags counts={group.user.resourceCounts} /> },
+    { title: tr(locale, '发送记录', 'Sending activity'), dataIndex: 'sendEventCount', width: 120 },
+    { title: tr(locale, '入站邮件', 'Inbound mail'), dataIndex: 'inboundMessageCount', width: 120 },
     {
       title: 'SMTP',
       width: 120,
       render: (_, group) => (
         group.smtpCredential
-          ? <StatusPill tone="success">已配置</StatusPill>
-          : <StatusPill tone="neutral">无</StatusPill>
+          ? <StatusPill tone="success">{tr(locale, '已配置', 'Configured')}</StatusPill>
+          : <StatusPill tone="neutral">{tr(locale, '', 'None')}</StatusPill>
       )
     }
   ];
 
   async function submitDomainTransfer(values: { domainId: number; targetUserId: number; dnsCredentialMode: 'domain_only' | 'with_dns_credential' | 'clear_dns_credential' }) {
     Modal.confirm({
-      title: '确认迁移该域名?',
+      title: tr(locale, '确认迁移该域名?', 'Transfer this domain?'),
       onOk: async () => {
         await onTransferDomain(values.domainId, {
           targetUserId: values.targetUserId,
@@ -447,7 +512,7 @@ function AdminResources({
 
   async function submitDnsTransfer(values: { credentialId: number; targetUserId: number }) {
     Modal.confirm({
-      title: '确认迁移该 DNS 凭据?',
+      title: tr(locale, '确认迁移该 DNS 凭据?', 'Transfer this DNS credential?'),
       onOk: async () => {
         await onTransferDnsCredential(values.credentialId, { targetUserId: values.targetUserId });
         dnsForm.resetFields();
@@ -457,7 +522,7 @@ function AdminResources({
 
   async function submitTokenTransfer(values: { tokenIds: number[]; targetUserId: number }) {
     Modal.confirm({
-      title: `确认迁移 ${values.tokenIds.length} 个 API Token?`,
+      title: tr(locale, `确认迁移 ${values.tokenIds.length} 个 API Token?`, `Transfer ${values.tokenIds.length} API tokens?`),
       onOk: async () => {
         await onTransferApiTokens({ tokenIds: values.tokenIds, targetUserId: values.targetUserId });
         tokenForm.resetFields();
@@ -471,13 +536,13 @@ function AdminResources({
         <Alert
           type="warning"
           showIcon
-          message={`发现 ${inventory.warnings.length} 个 DNS 凭据归属不一致的域名`}
+          message={tr(locale, `发现 ${inventory.warnings.length} 个 DNS 凭据归属不一致的域名`, `${inventory.warnings.length} domains have mismatched DNS credential ownership`)}
         />
       ) : null}
       <div className="form-grid three">
-        <SectionCard title="迁移域名">
-          <Form form={domainForm} layout="vertical" onFinish={submitDomainTransfer} disabled={loading}>
-            <Form.Item name="domainId" label="域名" rules={[{ required: true }]}>
+        <SectionCard title={tr(locale, '迁移域名', 'Transfer domain')}>
+          <Form form={domainForm} layout="vertical" onFinish={submitDomainTransfer} disabled={loading || actionKeys.has(`transfer-domain:${selectedDomainId}`)}>
+            <Form.Item name="domainId" label={tr(locale, '域名', 'Domain')} rules={[{ required: true }]}>
               <Select
                 showSearch
                 optionFilterProp="label"
@@ -487,24 +552,24 @@ function AdminResources({
                 }))}
               />
             </Form.Item>
-            <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
+            <Form.Item name="targetUserId" label={tr(locale, '目标用户', 'Target user')} rules={[{ required: true }]}>
               <Select options={targetOptions} />
             </Form.Item>
-            <Form.Item name="dnsCredentialMode" label="DNS 凭据" initialValue="domain_only">
+            <Form.Item name="dnsCredentialMode" label={tr(locale, 'DNS 凭据', 'DNS credential')} initialValue="domain_only">
               <Select
                 options={[
-                  { value: 'domain_only', label: '仅迁移域名' },
-                  { value: 'with_dns_credential', label: '连同 DNS 凭据迁移' },
-                  { value: 'clear_dns_credential', label: '清空 DNS 凭据绑定' }
+                  { value: 'domain_only', label: tr(locale, '仅迁移域名', 'Domain only') },
+                  { value: 'with_dns_credential', label: tr(locale, '连同 DNS 凭据迁移', 'Include DNS credential') },
+                  { value: 'clear_dns_credential', label: tr(locale, '清空 DNS 凭据绑定', 'Clear DNS credential binding') }
                 ]}
               />
             </Form.Item>
-            <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
+            <Button type="primary" htmlType="submit" loading={actionKeys.has(`transfer-domain:${selectedDomainId}`)}>{tr(locale, '执行迁移', 'Transfer')}</Button>
           </Form>
         </SectionCard>
-        <SectionCard title="迁移 DNS 凭据">
-          <Form form={dnsForm} layout="vertical" onFinish={submitDnsTransfer} disabled={loading}>
-            <Form.Item name="credentialId" label="DNS 凭据" rules={[{ required: true }]}>
+        <SectionCard title={tr(locale, '迁移 DNS 凭据', 'Transfer DNS credential')}>
+          <Form form={dnsForm} layout="vertical" onFinish={submitDnsTransfer} disabled={loading || actionKeys.has(`transfer-dns:${selectedCredentialId}`)}>
+            <Form.Item name="credentialId" label={tr(locale, 'DNS 凭据', 'DNS credential')} rules={[{ required: true }]}>
               <Select
                 showSearch
                 optionFilterProp="label"
@@ -514,14 +579,14 @@ function AdminResources({
                 }))}
               />
             </Form.Item>
-            <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
+            <Form.Item name="targetUserId" label={tr(locale, '目标用户', 'Target user')} rules={[{ required: true }]}>
               <Select options={targetOptions} />
             </Form.Item>
-            <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
+            <Button htmlType="submit" loading={actionKeys.has(`transfer-dns:${selectedCredentialId}`)}>{tr(locale, '执行迁移', 'Transfer')}</Button>
           </Form>
         </SectionCard>
-        <SectionCard title="迁移 API Token">
-          <Form form={tokenForm} layout="vertical" onFinish={submitTokenTransfer} disabled={loading}>
+        <SectionCard title={tr(locale, '迁移 API Token', 'Transfer API tokens')}>
+          <Form form={tokenForm} layout="vertical" onFinish={submitTokenTransfer} disabled={loading || actionKeys.has('transfer-token')}>
             <Form.Item name="tokenIds" label="API Token" rules={[{ required: true }]}>
               <Select
                 mode="multiple"
@@ -532,32 +597,33 @@ function AdminResources({
                 }))}
               />
             </Form.Item>
-            <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
+            <Form.Item name="targetUserId" label={tr(locale, '目标用户', 'Target user')} rules={[{ required: true }]}>
               <Select options={targetOptions} />
             </Form.Item>
-            <Button type="primary" htmlType="submit" loading={loading}>执行迁移</Button>
+            <Button htmlType="submit" loading={actionKeys.has('transfer-token')}>{tr(locale, '执行迁移', 'Transfer')}</Button>
           </Form>
         </SectionCard>
       </div>
-      <SectionCard title="资源归属">
+      <SectionCard title={tr(locale, '资源归属', 'Resource ownership')}>
         <Table
           rowKey={(group) => group.user.id}
           columns={groupColumns}
           dataSource={groups}
           loading={loading}
-          expandable={{ expandedRowRender: renderResourceDetails }}
+          expandable={{ expandedRowRender: (group) => <ResourceDetails group={group} /> }}
         />
       </SectionCard>
     </Space>
   );
 }
 
-function renderResourceDetails(group: AdminResourceInventory['users'][number]) {
+function ResourceDetails({ group }: { group: AdminResourceInventory['users'][number] }) {
+  const { locale } = useI18n();
   return (
     <Space direction="vertical" size={16} className="full-width">
       <Descriptions size="small" column={2}>
-        <Descriptions.Item label="邮箱">{group.user.email}</Descriptions.Item>
-        <Descriptions.Item label="角色">{group.user.role}</Descriptions.Item>
+        <Descriptions.Item label={tr(locale, '邮箱', 'Email')}>{group.user.email}</Descriptions.Item>
+        <Descriptions.Item label={tr(locale, '角色', 'Role')}>{group.user.role}</Descriptions.Item>
       </Descriptions>
       <Table
         size="small"
@@ -565,9 +631,9 @@ function renderResourceDetails(group: AdminResourceInventory['users'][number]) {
         pagination={false}
         dataSource={group.domains}
         columns={[
-          { title: '域名', dataIndex: 'domain' },
-          { title: '发信主机', dataIndex: 'senderHost' },
-          { title: 'DNS 凭据 ID', dataIndex: 'dnsCredentialId' }
+          { title: tr(locale, '域名', 'Domain'), dataIndex: 'domain' },
+          { title: tr(locale, '发信主机', 'Sending host'), dataIndex: 'senderHost' },
+          { title: tr(locale, 'DNS 凭据 ID', 'DNS credential ID'), dataIndex: 'dnsCredentialId' }
         ]}
       />
       <Table
@@ -576,7 +642,7 @@ function renderResourceDetails(group: AdminResourceInventory['users'][number]) {
         pagination={false}
         dataSource={group.dnsCredentials}
         columns={[
-          { title: 'DNS 凭据', dataIndex: 'name' },
+          { title: tr(locale, 'DNS 凭据', 'DNS credential'), dataIndex: 'name' },
           { title: 'Provider', dataIndex: 'provider' },
           { title: 'Zone', dataIndex: 'zoneName' }
         ]}
@@ -588,8 +654,8 @@ function renderResourceDetails(group: AdminResourceInventory['users'][number]) {
         dataSource={group.apiTokens}
         columns={[
           { title: 'API Token', dataIndex: 'name' },
-          { title: '前缀', dataIndex: 'tokenPrefix' },
-          { title: '创建时间', dataIndex: 'createdAt', render: formatDate }
+          { title: tr(locale, '前缀', 'Prefix'), dataIndex: 'tokenPrefix' },
+          { title: tr(locale, '创建时间', 'Created at'), dataIndex: 'createdAt', render: formatDate }
         ]}
       />
     </Space>
@@ -599,11 +665,13 @@ function renderResourceDetails(group: AdminResourceInventory['users'][number]) {
 function AdminMigration({
   users,
   loading,
+  actionKeys,
   onPreview,
   onExecute
 }: {
   users: AdminUser[];
   loading: boolean;
+  actionKeys: ReadonlySet<string>;
   onPreview: (values: { sourceUserId: number; targetUserId: number }) => Promise<{ preview: UserMergePreview }>;
   onExecute: (values: {
     sourceUserId: number;
@@ -613,20 +681,25 @@ function AdminMigration({
   }) => Promise<void>;
 }) {
   const { message } = AntApp.useApp();
+  const { locale } = useI18n();
   const [form] = Form.useForm<{ sourceUserId: number; targetUserId: number }>();
   const [preview, setPreview] = useState<UserMergePreview | null>(null);
   const [options, setOptions] = useState<Partial<UserMergeOptions>>({});
   const [confirmation, setConfirmation] = useState('');
+  const [previewLoading, setPreviewLoading] = useState(false);
   const userOptions = users.map((user) => ({ value: user.id, label: `${user.username} (#${user.id})` }));
 
   async function submitPreview(values: { sourceUserId: number; targetUserId: number }) {
+    setPreviewLoading(true);
     try {
       const result = await onPreview(values);
       setPreview(result.preview);
       setOptions(result.preview.defaultOptions);
       setConfirmation('');
     } catch (error) {
-      message.error(error instanceof Error ? error.message : '预览失败');
+      message.error(error instanceof Error ? error.message : tr(locale, '预览失败', 'Preview failed'));
+    } finally {
+      setPreviewLoading(false);
     }
   }
 
@@ -649,15 +722,15 @@ function AdminMigration({
 
   return (
     <Space direction="vertical" size={16} className="full-width">
-      <SectionCard title="合并预览">
+      <SectionCard title={tr(locale, '合并预览', 'Merge preview')}>
         <Form form={form} layout="inline" onFinish={submitPreview} disabled={loading}>
-          <Form.Item name="sourceUserId" label="源用户" rules={[{ required: true }]}>
+          <Form.Item name="sourceUserId" label={tr(locale, '源用户', 'Source user')} rules={[{ required: true }]}>
             <Select options={userOptions} className="toolbar-select" />
           </Form.Item>
-          <Form.Item name="targetUserId" label="目标用户" rules={[{ required: true }]}>
+          <Form.Item name="targetUserId" label={tr(locale, '目标用户', 'Target user')} rules={[{ required: true }]}>
             <Select options={userOptions} className="toolbar-select" />
           </Form.Item>
-          <Button type="primary" htmlType="submit" loading={loading}>预览</Button>
+          <Button type={preview ? 'default' : 'primary'} htmlType="submit" loading={previewLoading}>{tr(locale, '预览', 'Preview')}</Button>
         </Form>
       </SectionCard>
       {preview ? (
@@ -668,22 +741,22 @@ function AdminMigration({
             ) : null}
             <Space wrap>
               {mergePreviewSummary(preview).map((item) => (
-                <StatusPill key={item.key} tone="neutral">{item.label}: {item.count}</StatusPill>
+                <StatusPill key={item.key} tone="neutral">{resourceLabel(item.key, locale)}: {item.count}</StatusPill>
               ))}
             </Space>
             <div className="form-grid two">
-              {mergeOptionLabels.map(([key, label]) => (
+              {mergeOptionLabels.map(([key, zhLabel, enLabel]) => (
                 <Checkbox
                   key={key}
                   checked={options[key] !== false}
                   onChange={(event) => setOptions((current) => ({ ...current, [key]: event.target.checked }))}
                 >
-                  {label}
+                  {tr(locale, zhLabel, enLabel)}
                 </Checkbox>
               ))}
             </div>
             <Descriptions column={1} bordered size="small">
-              <Descriptions.Item label="确认文本">
+              <Descriptions.Item label={tr(locale, '确认文本', 'Confirmation text')}>
                 <Typography.Text code>{expectedConfirmation}</Typography.Text>
               </Descriptions.Item>
             </Descriptions>
@@ -695,16 +768,16 @@ function AdminMigration({
             <Button
               danger
               type="primary"
-              loading={loading}
+              loading={actionKeys.has('merge')}
               disabled={confirmation !== expectedConfirmation}
               onClick={execute}
             >
-              执行合并
+              {tr(locale, '执行合并', 'Merge users')}
             </Button>
           </Space>
         </SectionCard>
       ) : (
-        <EmptyState description="暂无预览" />
+        <EmptyState description={tr(locale, '暂无预览', 'No preview yet')} />
       )}
     </Space>
   );
@@ -713,14 +786,17 @@ function AdminMigration({
 function AdminSystemEmail({
   settings,
   loading,
+  actionKeys,
   onSave,
   onTest
 }: {
   settings: SystemEmailSettings | null;
   loading: boolean;
+  actionKeys: ReadonlySet<string>;
   onSave: (values: Partial<SystemEmailSettings>) => Promise<void>;
   onTest: (to?: string) => Promise<void>;
 }) {
+  const { locale } = useI18n();
   const [form] = Form.useForm<SystemEmailSettings>();
 
   useEffect(() => {
@@ -733,7 +809,7 @@ function AdminSystemEmail({
   }
 
   return (
-    <SectionCard title="系统邮件服务器">
+    <SectionCard title={tr(locale, '系统邮件服务器', 'System email server')}>
       <Form form={form} layout="vertical" onFinish={submit} disabled={loading}>
         <div className="form-grid two">
           <Form.Item name="host" label="SMTP Host" rules={[{ required: true }]}>
@@ -751,7 +827,7 @@ function AdminSystemEmail({
           <Form.Item name="username" label="Username">
             <Input autoComplete="off" />
           </Form.Item>
-          <Form.Item name="password" label={settings?.passwordSet ? 'Password(留空保留)' : 'Password'}>
+          <Form.Item name="password" label={settings?.passwordSet ? tr(locale, 'Password(留空保留)', 'Password (leave blank to keep)') : 'Password'}>
             <Input.Password autoComplete="new-password" />
           </Form.Item>
           <Form.Item name="fromEmail" label="From Email" rules={[{ required: true, type: 'email' }]}>
@@ -765,8 +841,8 @@ function AdminSystemEmail({
           </Form.Item>
         </div>
         <Space wrap>
-          <Button type="primary" htmlType="submit" loading={loading}>保存配置</Button>
-          <Button onClick={() => onTest(form.getFieldValue('testRecipient'))} loading={loading}>发送测试</Button>
+          <Button type="primary" htmlType="submit" loading={actionKeys.has('system-email:save')}>{tr(locale, '保存配置', 'Save settings')}</Button>
+          <Button onClick={() => onTest(form.getFieldValue('testRecipient'))} loading={actionKeys.has('system-email:test')}>{tr(locale, '发送测试', 'Send test')}</Button>
         </Space>
       </Form>
     </SectionCard>
@@ -776,25 +852,32 @@ function AdminSystemEmail({
 function AdminAuditLogs({
   logs,
   users,
+  query,
   loading,
   onSearch
 }: {
   logs: AuditLogEntry[];
   users: AdminUser[];
+  query: string;
   loading: boolean;
   onSearch: (query: string) => Promise<void>;
 }) {
+  const { locale } = useI18n();
   const [form] = Form.useForm();
   const userOptions = users.map((user) => ({ value: user.id, label: `${user.username} (#${user.id})` }));
 
+  useEffect(() => {
+    form.setFieldsValue(auditFiltersFromQuery(query));
+  }, [form, query]);
+
   const columns: ColumnsType<AuditLogEntry> = [
-    { title: '时间', dataIndex: 'createdAt', width: 190, render: formatDate },
-    { title: '动作', dataIndex: 'action', width: 220 },
-    { title: '操作者', dataIndex: 'actorUserId', width: 130, render: (value) => value ?? 'system' },
-    { title: '目标用户', dataIndex: 'targetUserId', width: 130, render: (value) => value ?? '-' },
-    { title: '目标', render: (_, log) => `${log.targetType}:${log.targetId || '-'}`, width: 180 },
+    { title: tr(locale, '时间', 'Time'), dataIndex: 'createdAt', width: 190, render: formatDate },
+    { title: tr(locale, '动作', 'Action'), dataIndex: 'action', width: 220 },
+    { title: tr(locale, '操作者', 'Actor'), dataIndex: 'actorUserId', width: 130, render: (value) => value ?? 'system' },
+    { title: tr(locale, '目标用户', 'Target user'), dataIndex: 'targetUserId', width: 130, render: (value) => value ?? '-' },
+    { title: tr(locale, '目标', 'Target'), render: (_, log) => `${log.targetType}:${log.targetId || '-'}`, width: 180 },
     {
-      title: '摘要',
+      title: tr(locale, '摘要', 'Summary'),
       dataIndex: 'summary',
       render: (value) => (
         <Typography.Text code ellipsis>
@@ -812,22 +895,22 @@ function AdminAuditLogs({
     <Space direction="vertical" size={16} className="full-width">
       <SectionCard className="admin-audit-toolbar-card">
         <Form form={form} layout="inline" onFinish={submit} disabled={loading}>
-          <Form.Item name="actorUserId" label="操作者">
+          <Form.Item name="actorUserId" label={tr(locale, '操作者', 'Actor')}>
             <Select allowClear options={[{ value: 'system', label: 'system' }, ...userOptions]} className="toolbar-select" />
           </Form.Item>
-          <Form.Item name="targetUserId" label="目标用户">
+          <Form.Item name="targetUserId" label={tr(locale, '目标用户', 'Target user')}>
             <Select allowClear options={userOptions} className="toolbar-select" />
           </Form.Item>
-          <Form.Item name="action" label="动作">
+          <Form.Item name="action" label={tr(locale, '动作', 'Action')}>
             <Input placeholder="admin.user_merge" />
           </Form.Item>
-          <Form.Item name="from" label="开始">
+          <Form.Item name="from" label={tr(locale, '开始', 'From')}>
             <Input placeholder="2026-07-08" />
           </Form.Item>
-          <Form.Item name="to" label="结束">
+          <Form.Item name="to" label={tr(locale, '结束', 'To')}>
             <Input placeholder="2026-07-09" />
           </Form.Item>
-          <Button type="primary" htmlType="submit" loading={loading}>查询</Button>
+          <Button type="primary" htmlType="submit" loading={loading}>{tr(locale, '查询', 'Search')}</Button>
         </Form>
       </SectionCard>
       <Table rowKey="id" columns={columns} dataSource={logs} loading={loading} scroll={{ x: 1100 }} />
@@ -836,15 +919,16 @@ function AdminAuditLogs({
 }
 
 function ResourceCountTags({ counts }: { counts?: AdminUser['resourceCounts'] }) {
-  if (!counts) return <Tag>无资源</Tag>;
+  const { locale } = useI18n();
+  if (!counts) return <Tag>{tr(locale, '无资源', 'No resources')}</Tag>;
   return (
     <Space wrap>
-      <Tag>域名 {counts.domains}</Tag>
+      <Tag>{tr(locale, '域名', 'Domains')} {counts.domains}</Tag>
       <Tag>DNS {counts.dnsCredentials}</Tag>
       <Tag>Token {counts.apiTokens}</Tag>
-      <Tag>收信 {counts.inboundMailboxes}</Tag>
-      <Tag>入站 {counts.inboundMessages}</Tag>
-      <Tag>记录 {counts.sendEvents}</Tag>
+      <Tag>{tr(locale, '收信', 'Mailboxes')} {counts.inboundMailboxes}</Tag>
+      <Tag>{tr(locale, '入站', 'Inbound')} {counts.inboundMessages}</Tag>
+      <Tag>{tr(locale, '记录', 'Events')} {counts.sendEvents}</Tag>
       <Tag>SMTP {counts.smtpCredential}</Tag>
     </Space>
   );
@@ -852,8 +936,9 @@ function ResourceCountTags({ counts }: { counts?: AdminUser['resourceCounts'] })
 
 function UserStatusTag({ status }: { status: UserStatus }) {
   const meta = adminUserStatusMeta(status);
+  const { locale } = useI18n();
   const tone = statusToneFromColor(meta.color);
-  return <StatusPill tone={tone}>{meta.label}</StatusPill>;
+  return <StatusPill tone={tone}>{userStatusLabel(status, locale)}</StatusPill>;
 }
 
 function statusToneFromColor(color: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
@@ -867,3 +952,60 @@ function statusToneFromColor(color: string): 'success' | 'warning' | 'error' | '
 function formatDate(value?: string) {
   return value ? new Date(value).toLocaleString() : '-';
 }
+
+function userMutationPending(actionKeys: ReadonlySet<string>, userId: number) {
+  return [
+    `update:${userId}`,
+    `approve:${userId}`,
+    `resend:${userId}`,
+    `reset:${userId}`,
+    `temporary-password:${userId}`
+  ].some((key) => actionKeys.has(key));
+}
+
+function auditFiltersFromQuery(query: string) {
+  const params = new URLSearchParams(query);
+  const actor = params.get('actorUserId');
+  const target = params.get('targetUserId');
+  return {
+    actorUserId: actor === 'system' ? actor : positiveInteger(actor),
+    targetUserId: positiveInteger(target),
+    action: params.get('action') || undefined,
+    from: params.get('from') || undefined,
+    to: params.get('to') || undefined
+  };
+}
+
+function positiveInteger(value: string | null) {
+  const parsed = Number(value);
+  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
+}
+
+function tr(locale: string, zh: string, en: string) {
+  return locale.startsWith('en') ? en : zh;
+}
+
+function userStatusLabel(status: UserStatus, locale: string) {
+  const labels: Record<UserStatus, [string, string]> = {
+    pending_email: ['待验证邮箱', 'Email verification pending'],
+    pending_review: ['待管理员审核', 'Admin review pending'],
+    active: ['正常', 'Active'],
+    disabled: ['已禁用', 'Disabled']
+  };
+  const label = labels[status];
+  return label ? tr(locale, label[0], label[1]) : status;
+}
+
+function resourceLabel(key: string, locale: string) {
+  const labels: Record<string, [string, string]> = {
+    domains: ['域名', 'Domains'],
+    dnsCredentials: ['DNS 凭据', 'DNS credentials'],
+    apiTokens: ['API Token', 'API tokens'],
+    inboundMailboxes: ['收信邮箱', 'Mailboxes'],
+    inboundMessages: ['入站邮件', 'Inbound mail'],
+    sendEvents: ['发送记录', 'Sending activity'],
+    smtpCredential: ['SMTP 凭据', 'SMTP credential']
+  };
+  const label = labels[key];
+  return label ? tr(locale, label[0], label[1]) : key;
+}

+ 242 - 206
src/pages/ApiTokens.tsx

@@ -1,9 +1,35 @@
-import { CopyOutlined, DeleteOutlined, EditOutlined, KeyOutlined } from '@ant-design/icons';
-import { Alert, Button, Checkbox, Collapse, Descriptions, Form, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd';
+import {
+  CopyOutlined,
+  DeleteOutlined,
+  EditOutlined,
+  KeyOutlined,
+  PlusOutlined,
+  ReadOutlined,
+  ReloadOutlined
+} from '@ant-design/icons';
+import {
+  Alert,
+  App as AntApp,
+  Button,
+  Checkbox,
+  Collapse,
+  Descriptions,
+  Drawer,
+  Form,
+  Input,
+  Modal,
+  Popconfirm,
+  Skeleton,
+  Space,
+  Table,
+  Tag,
+  Typography
+} from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 
 import { CodeBlock } from '../components/common/CodeBlock';
+import { EmptyState } from '../components/common/EmptyState';
 import { PageHeader } from '../components/common/PageHeader';
 import { SectionCard } from '../components/common/SectionCard';
 import { StatusPill, type StatusTone } from '../components/common/StatusPill';
@@ -14,7 +40,9 @@ import {
   formatApiTokenPrefix,
   getCreatedApiTokenSecret
 } from '../frontend/api-token-model.js';
+import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
+import { api } from '../frontend/services/api';
 import type { ApiToken, RuntimeConfig } from '../frontend/types';
 
 interface TokenFormValues {
@@ -23,253 +51,270 @@ interface TokenFormValues {
   expiresAt?: string;
 }
 
-interface ApiTokensProps {
-  tokens: ApiToken[];
-  config: RuntimeConfig | null;
-  loading?: boolean;
-  onCreate: (values: { name: string; scopes: string[]; expiresAt?: string | null }) => Promise<ApiToken | null | void>;
-  onUpdate: (token: ApiToken, values: { name: string; scopes: string[]; expiresAt?: string | null }) => Promise<void>;
-  onRevoke: (token: ApiToken) => Promise<void>;
-  onCopy: (value: string) => void;
-}
-
 const scopeOptions = [
   { label: 'send', value: 'send' },
   { label: 'mailboxes:read', value: 'mailboxes:read' },
   { label: 'mailboxes:write', value: 'mailboxes:write' }
 ];
 
-export default function ApiTokens({ tokens, config, loading, onCreate, onUpdate, onRevoke, onCopy }: ApiTokensProps) {
+export default function ApiTokens() {
+  const { message } = AntApp.useApp();
   const { t } = useI18n();
+  const { config } = useAppContext();
   const [form] = Form.useForm<TokenFormValues>();
-  const [editForm] = Form.useForm<TokenFormValues>();
-  const [createdToken, setCreatedToken] = useState<ApiToken | null>(null);
+  const [tokens, setTokens] = useState<ApiToken[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [loadError, setLoadError] = useState('');
+  const [actionKey, setActionKey] = useState('');
+  const [editorOpen, setEditorOpen] = useState(false);
   const [editingToken, setEditingToken] = useState<ApiToken | null>(null);
-  const endpoint = `${config?.appBaseUrl || window.location.origin}/api/send`;
-  const mailboxEndpoint = `${config?.appBaseUrl || window.location.origin}/api/mailboxes`;
-  const examples = buildApiUsageExamples({
-    endpoint,
-    token: '<USER_API_TOKEN>',
-    from: 'noreply@example.com',
-    to: 'user@example.com'
-  });
-  const mailboxExamples = buildMailboxApiUsageExamples({
-    endpoint: mailboxEndpoint,
-    token: '<MAILBOX_API_TOKEN>'
-  });
+  const [selectedToken, setSelectedToken] = useState<ApiToken | null>(null);
+  const [createdToken, setCreatedToken] = useState<ApiToken | null>(null);
+  const [guideOpen, setGuideOpen] = useState(false);
+
+  const loadData = useCallback(async () => {
+    setLoading(true);
+    setLoadError('');
+    try {
+      const tokenResult = await api.apiTokens();
+      setTokens(tokenResult.tokens || []);
+    } catch (error) {
+      setLoadError(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setLoading(false);
+    }
+  }, [t]);
+
+  useEffect(() => {
+    void loadData();
+  }, [loadData]);
+
+  async function copyValue(value: string) {
+    if (!value) return;
+    await navigator.clipboard.writeText(value);
+    message.success(t('common.copied'));
+  }
+
+  function openCreate() {
+    setEditingToken(null);
+    form.resetFields();
+    form.setFieldsValue({ name: '', scopes: ['send'], expiresAt: '' });
+    setEditorOpen(true);
+  }
+
+  function openEdit(token: ApiToken) {
+    setEditingToken(token);
+    form.resetFields();
+    form.setFieldsValue({
+      name: token.name,
+      scopes: token.scopes || ['send'],
+      expiresAt: toDatetimeLocal(token.expiresAt)
+    });
+    setEditorOpen(true);
+  }
+
+  async function saveToken() {
+    const values = await form.validateFields();
+    const payload = toTokenPayload(values);
+    const key = editingToken ? `save:${editingToken.id}` : 'create';
+    setActionKey(key);
+    try {
+      if (editingToken) {
+        await api.updateApiToken(editingToken.id, payload);
+        message.success(t('tokens.updatedSuccess'));
+      } else {
+        const result = await api.createApiToken(payload);
+        message.success(t('tokens.createdSuccess'));
+        if (result.token && canCopyFullApiToken(result.token)) setCreatedToken(result.token);
+      }
+      setEditorOpen(false);
+      setEditingToken(null);
+      form.resetFields();
+      await loadData();
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setActionKey('');
+    }
+  }
+
+  async function revokeToken(token: ApiToken) {
+    setActionKey(`revoke:${token.id}`);
+    try {
+      await api.deleteApiToken(token.id);
+      message.success(t('tokens.revokedSuccess'));
+      if (selectedToken?.id === token.id) setSelectedToken(null);
+      await loadData();
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setActionKey('');
+    }
+  }
 
   const columns: ColumnsType<ApiToken> = [
-    { title: t('tokens.name'), dataIndex: 'name', width: 190 },
+    {
+      title: t('tokens.name'),
+      dataIndex: 'name',
+      render: (value: string) => <Typography.Text strong>{value}</Typography.Text>
+    },
     {
       title: t('tokens.prefix'),
       dataIndex: 'tokenPrefix',
-      width: 168,
+      width: 180,
       render: (_value, token) => <Typography.Text code>{formatApiTokenPrefix(token)}</Typography.Text>
     },
     {
       title: t('tokens.scopes'),
       dataIndex: 'scopes',
-      width: 260,
-      render: (scopes: string[]) => (
-        <Space size={[4, 4]} wrap>
-          {(scopes || []).map((scope) => <Tag key={scope}>{scope}</Tag>)}
-        </Space>
-      )
+      render: (scopes: string[]) => <Space size={[4, 4]} wrap>{(scopes || []).map((scope) => <Tag key={scope}>{scope}</Tag>)}</Space>
     },
     {
       title: t('tokens.status'),
       dataIndex: 'status',
-      width: 104,
+      width: 120,
       render: (_value, token) => <StatusPill tone={tokenStatusTone(token.status)}>{tokenStatusText(token.status, t)}</StatusPill>
     },
-    { title: t('tokens.expiresAt'), dataIndex: 'expiresAt', width: 176, render: formatOptionalTime },
-    { title: t('tokens.lastUsed'), dataIndex: 'lastUsedAt', width: 176, render: (value) => value ? formatOptionalTime(value) : t('tokens.neverUsed') },
+    { title: t('tokens.lastUsed'), dataIndex: 'lastUsedAt', width: 190, render: (value?: string) => value ? formatOptionalTime(value) : t('tokens.neverUsed') },
     {
       title: t('tokens.actions'),
-      fixed: 'right',
-      width: 142,
+      width: 180,
       render: (_value, token) => (
-        <Space size={4}>
-          <Tooltip title={t('tokens.copyPrefix')}>
-            <Button aria-label={t('tokens.copyPrefix')} icon={<CopyOutlined />} onClick={() => onCopy(token.tokenPrefix)} />
-          </Tooltip>
-          <Tooltip title={t('tokens.edit')}>
-            <Button
-              aria-label={t('tokens.edit')}
-              disabled={token.status === 'revoked'}
-              icon={<EditOutlined />}
-              onClick={() => openEdit(token)}
-            />
-          </Tooltip>
-          <Popconfirm title={t('tokens.revokeConfirm')} onConfirm={() => void onRevoke(token)} disabled={token.status === 'revoked'}>
-            <Button aria-label={t('tokens.revoke')} danger disabled={token.status === 'revoked'} icon={<DeleteOutlined />} />
+        <Space onClick={(event) => event.stopPropagation()}>
+          <Button aria-label={t('tokens.copyPrefix')} icon={<CopyOutlined />} onClick={() => void copyValue(token.tokenPrefix)} />
+          <Button aria-label={`${t('tokens.edit')} ${token.name}`} disabled={token.status === 'revoked'} icon={<EditOutlined />} onClick={() => openEdit(token)} />
+          <Popconfirm title={t('tokens.revokeConfirm')} onConfirm={() => void revokeToken(token)} disabled={token.status === 'revoked'}>
+            <Button aria-label={`${t('tokens.revoke')} ${token.name}`} danger disabled={token.status === 'revoked'} icon={<DeleteOutlined />} loading={actionKey === `revoke:${token.id}`} />
           </Popconfirm>
         </Space>
       )
     }
   ];
 
-  async function submit(values: TokenFormValues) {
-    const token = await onCreate(toTokenPayload(values));
-    if (token && canCopyFullApiToken(token)) setCreatedToken(token);
-    form.resetFields();
-    form.setFieldsValue({ scopes: ['send'] });
-  }
-
-  function openEdit(token: ApiToken) {
-    setEditingToken(token);
-    editForm.setFieldsValue({
-      name: token.name,
-      scopes: token.scopes || ['send'],
-      expiresAt: toDatetimeLocal(token.expiresAt)
-    });
-  }
-
-  async function saveEdit() {
-    if (!editingToken) return;
-    const values = await editForm.validateFields();
-    await onUpdate(editingToken, toTokenPayload(values));
-    setEditingToken(null);
-  }
-
   return (
-    <>
-      <Space direction="vertical" size={20} className="full-width">
-        <PageHeader title={t('nav.tokens')} subtitle={t('tokens.subtitle')} />
-
-        <SectionCard title={t('tokens.createTitle')}>
-          <Form form={form} layout="vertical" initialValues={{ scopes: ['send'] }} onFinish={submit}>
-            <div className="token-form-grid">
-              <Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}>
-                <Input placeholder={t('tokens.namePlaceholder')} autoComplete="off" />
-              </Form.Item>
-              <Form.Item name="expiresAt" label={t('tokens.expiresAt')} extra={t('tokens.expiresAtExtra')}>
-                <Input type="datetime-local" />
-              </Form.Item>
-            </div>
-            <Form.Item name="scopes" label={t('tokens.scopes')} rules={[{ required: true, type: 'array', min: 1, message: t('tokens.scopesRequired') }]} extra={t('tokens.scopesExtra')}>
-              <Checkbox.Group options={scopeOptions} />
-            </Form.Item>
-            <Button type="primary" htmlType="submit" loading={loading} icon={<KeyOutlined />}>
-              {t('tokens.create')}
-            </Button>
-          </Form>
-        </SectionCard>
+    <Space direction="vertical" size={20} className="full-width">
+      <PageHeader
+        title={t('nav.tokens')}
+        subtitle={t('tokens.subtitle')}
+        extra={
+          <Space wrap>
+            <Button icon={<ReadOutlined />} onClick={() => setGuideOpen(true)} style={{ minHeight: 44 }}>{t('tokens.docsTitle')}</Button>
+            <Button type="primary" icon={<PlusOutlined />} onClick={openCreate} style={{ minHeight: 44 }}>{t('tokens.create')}</Button>
+          </Space>
+        }
+      />
+      {loadError ? <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>} /> : null}
+      <SectionCard title={t('tokens.listTitle')} extra={<StatusPill tone="neutral">{tokens.length}</StatusPill>}>
+        <Alert type="info" showIcon message={t('tokens.prefixOnlyHelp')} style={{ marginBottom: 16 }} />
+        {loading ? <Skeleton active paragraph={{ rows: 6 }} /> : tokens.length ? (
+          <Table
+            rowKey="id"
+            columns={columns}
+            dataSource={tokens}
+            scroll={{ x: 980 }}
+            onRow={(token) => ({
+              onClick: () => setSelectedToken(token),
+              style: { cursor: 'pointer' },
+              tabIndex: 0,
+              onKeyDown: (event) => {
+                if (event.key === 'Enter' || event.key === ' ') setSelectedToken(token);
+              }
+            })}
+          />
+        ) : <EmptyState description={t('tokens.noTokenHint')} action={<Button icon={<PlusOutlined />} onClick={openCreate}>{t('tokens.create')}</Button>} />}
+      </SectionCard>
 
-        <SectionCard title={t('tokens.listTitle')} extra={<StatusPill tone="neutral">{tokens.length}</StatusPill>}>
-          <Alert type="info" showIcon message={t('tokens.prefixOnlyHelp')} className="token-list-alert" />
-          <Table rowKey="id" columns={columns} dataSource={tokens} scroll={{ x: 1260 }} />
-        </SectionCard>
+      <Drawer
+        title={editingToken ? t('tokens.editTitle') : t('tokens.createTitle')}
+        width={520}
+        open={editorOpen}
+        onClose={() => setEditorOpen(false)}
+        destroyOnHidden
+        footer={<Space style={{ display: 'flex', justifyContent: 'flex-end' }}><Button onClick={() => setEditorOpen(false)}>{t('common.cancel')}</Button><Button type="primary" icon={<KeyOutlined />} loading={actionKey === 'create' || actionKey.startsWith('save:')} onClick={() => void saveToken()}>{editingToken ? t('common.save') : t('tokens.create')}</Button></Space>}
+      >
+        {!editingToken ? <Alert type="warning" showIcon message={t('tokens.createdWarning')} style={{ marginBottom: 20 }} /> : null}
+        <Form form={form} layout="vertical" initialValues={{ scopes: ['send'] }}>
+          <Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}><Input placeholder={t('tokens.namePlaceholder')} autoComplete="off" /></Form.Item>
+          <Form.Item name="expiresAt" label={t('tokens.expiresAt')} extra={t('tokens.expiresAtExtra')}><Input type="datetime-local" /></Form.Item>
+          <Form.Item name="scopes" label={t('tokens.scopes')} rules={[{ required: true, type: 'array', min: 1, message: t('tokens.scopesRequired') }]} extra={t('tokens.scopesExtra')}><Checkbox.Group options={scopeOptions} /></Form.Item>
+        </Form>
+      </Drawer>
 
-        <SectionCard title={t('tokens.docsTitle')}>
-          <Space direction="vertical" size={16} className="full-width">
-            <Alert type="info" showIcon message={t('tokens.noTokenHint')} />
-            <Descriptions column={1} bordered size="small">
-              <Descriptions.Item label={t('tokens.endpoint')}>{copyable(endpoint, onCopy)}</Descriptions.Item>
-              <Descriptions.Item label={t('tokens.mailboxEndpoint')}>{copyable(mailboxEndpoint, onCopy)}</Descriptions.Item>
-              <Descriptions.Item label={t('tokens.authHeader')}>{copyable(t('tokens.authHeaderValue'), onCopy)}</Descriptions.Item>
-              <Descriptions.Item label={t('tokens.contentType')}><Typography.Text code>application/json</Typography.Text></Descriptions.Item>
+      <Drawer title={selectedToken?.name} width={520} open={Boolean(selectedToken)} onClose={() => setSelectedToken(null)}>
+        {selectedToken ? (
+          <Space direction="vertical" size={20} className="full-width">
+            <Descriptions bordered column={1} size="small">
+              <Descriptions.Item label={t('tokens.prefix')}><Typography.Text code>{formatApiTokenPrefix(selectedToken)}</Typography.Text></Descriptions.Item>
+              <Descriptions.Item label={t('tokens.status')}><StatusPill tone={tokenStatusTone(selectedToken.status)}>{tokenStatusText(selectedToken.status, t)}</StatusPill></Descriptions.Item>
+              <Descriptions.Item label={t('tokens.scopes')}><Space wrap>{selectedToken.scopes.map((scope) => <Tag key={scope}>{scope}</Tag>)}</Space></Descriptions.Item>
+              <Descriptions.Item label={t('tokens.expiresAt')}>{formatOptionalTime(selectedToken.expiresAt)}</Descriptions.Item>
+              <Descriptions.Item label={t('tokens.lastUsed')}>{selectedToken.lastUsedAt ? formatOptionalTime(selectedToken.lastUsedAt) : t('tokens.neverUsed')}</Descriptions.Item>
+              <Descriptions.Item label={t('tokens.createdAt')}>{formatOptionalTime(selectedToken.createdAt)}</Descriptions.Item>
             </Descriptions>
-            <Collapse
-              defaultActiveKey={['send']}
-              items={[
-                {
-                  key: 'send',
-                  label: t('tokens.sendApi'),
-                  children: <ApiExamples examples={examples} onCopy={onCopy} labels={{ curl: t('tokens.curlExample'), request: t('tokens.requestExample'), response: t('tokens.responseExample') }} />
-                },
-                {
-                  key: 'mailboxes',
-                  label: t('tokens.mailboxApi'),
-                  children: (
-                    <Space direction="vertical" size={12} className="full-width">
-                      <Alert type="info" showIcon message={t('tokens.mailboxApiHint')} />
-                      <Typography.Text strong>{t('tokens.permanentMailbox')}</Typography.Text>
-                      <CodeBlock value={mailboxExamples.permanentCurl} onCopy={onCopy} />
-                      <Typography.Text strong>{t('tokens.temporaryMailbox')}</Typography.Text>
-                      <CodeBlock value={mailboxExamples.temporaryCurl} onCopy={onCopy} />
-                      <Typography.Text strong>{t('tokens.mailboxResponse')}</Typography.Text>
-                      <CodeBlock value={mailboxExamples.successResponse} onCopy={onCopy} />
-                    </Space>
-                  )
-                }
-              ]}
-            />
-            <Typography.Title level={5} className="api-doc-subheading">{t('tokens.securityTips')}</Typography.Title>
-            <ul className="api-doc-list">
-              <li>{t('tokens.securityTipStore')}</li>
-              <li>{t('tokens.securityTipRotate')}</li>
-              <li>{t('tokens.securityTipScopes')}</li>
-              <li>{t('tokens.securityTipDomain')}</li>
-            </ul>
+            <Alert type="info" showIcon message={t('tokens.secretUnavailable')} />
+            <Button icon={<ReadOutlined />} onClick={() => setGuideOpen(true)}>{t('tokens.docsTitle')}</Button>
           </Space>
-        </SectionCard>
-      </Space>
+        ) : null}
+      </Drawer>
+
+      <ApiGuideDrawer open={guideOpen} config={config} onClose={() => setGuideOpen(false)} onCopy={copyValue} />
 
       <Modal
         title={t('tokens.createdTitle')}
         open={Boolean(createdToken)}
-        onCancel={() => setCreatedToken(null)}
+        closable={false}
+        maskClosable={false}
+        keyboard={false}
+        destroyOnHidden
         footer={[
-          <Button key="close" onClick={() => setCreatedToken(null)}>{t('common.cancel')}</Button>,
-          <Button key="copy" type="primary" icon={<CopyOutlined />} onClick={() => {
-            const secret = getCreatedApiTokenSecret(createdToken || {});
-            if (secret) onCopy(secret);
-          }}>{t('tokens.copyCreated')}</Button>
+          <Button key="copy" icon={<CopyOutlined />} onClick={() => void copyValue(getCreatedApiTokenSecret(createdToken || {}) || '')}>{t('tokens.copyCreated')}</Button>,
+          <Button key="done" type="primary" onClick={() => setCreatedToken(null)}>{t('common.confirm')}</Button>
         ]}
       >
-        <Space direction="vertical" size={16} className="full-width">
-          <Alert type="error" showIcon message={t('tokens.createdWarning')} className="token-secret-alert" />
-          <CodeBlock value={getCreatedApiTokenSecret(createdToken || {}) || ''} onCopy={onCopy} />
-        </Space>
-      </Modal>
-
-      <Modal
-        title={t('tokens.editTitle')}
-        open={Boolean(editingToken)}
-        confirmLoading={loading}
-        onCancel={() => setEditingToken(null)}
-        onOk={() => void saveEdit()}
-      >
-        <Form form={editForm} layout="vertical">
-          <Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}>
-            <Input autoComplete="off" />
-          </Form.Item>
-          <Form.Item name="expiresAt" label={t('tokens.expiresAt')} extra={t('tokens.expiresAtExtra')}>
-            <Input type="datetime-local" />
-          </Form.Item>
-          <Form.Item name="scopes" label={t('tokens.scopes')} rules={[{ required: true, type: 'array', min: 1, message: t('tokens.scopesRequired') }]}>
-            <Checkbox.Group options={scopeOptions} />
-          </Form.Item>
-        </Form>
+        {createdToken ? (
+          <Space direction="vertical" size={16} className="full-width">
+            <Alert type="error" showIcon message={t('tokens.createdWarning')} />
+            <CodeBlock value={getCreatedApiTokenSecret(createdToken) || ''} onCopy={copyValue} />
+          </Space>
+        ) : null}
       </Modal>
-    </>
+    </Space>
   );
 }
 
-function ApiExamples({ examples, onCopy, labels }: {
-  examples: ReturnType<typeof buildApiUsageExamples>;
-  onCopy: (value: string) => void;
-  labels: { curl: string; request: string; response: string };
-}) {
+function ApiGuideDrawer({ open, config, onClose, onCopy }: { open: boolean; config: RuntimeConfig | null; onClose: () => void; onCopy: (value: string) => void }) {
+  const { t } = useI18n();
+  const endpoint = `${config?.appBaseUrl || window.location.origin}/api/send`;
+  const mailboxEndpoint = `${config?.appBaseUrl || window.location.origin}/api/mailboxes`;
+  const examples = useMemo(() => buildApiUsageExamples({ endpoint, token: '<USER_API_TOKEN>', from: 'noreply@example.com', to: 'user@example.com' }), [endpoint]);
+  const mailboxExamples = useMemo(() => buildMailboxApiUsageExamples({ endpoint: mailboxEndpoint, token: '<MAILBOX_API_TOKEN>' }), [mailboxEndpoint]);
   return (
-    <Space direction="vertical" size={12} className="full-width">
-      <Typography.Text strong>{labels.curl}</Typography.Text>
-      <CodeBlock value={examples.curl} onCopy={onCopy} />
-      <Typography.Text strong>{labels.request}</Typography.Text>
-      <CodeBlock value={examples.requestBody} onCopy={onCopy} />
-      <Typography.Text strong>{labels.response}</Typography.Text>
-      <CodeBlock value={examples.successResponse} onCopy={onCopy} />
-    </Space>
+    <Drawer title={t('tokens.docsTitle')} width={720} open={open} onClose={onClose}>
+      <Space direction="vertical" size={16} className="full-width">
+        <Descriptions column={1} bordered size="small">
+          <Descriptions.Item label={t('tokens.endpoint')}><Typography.Text code copyable={{ onCopy: () => onCopy(endpoint) }}>{endpoint}</Typography.Text></Descriptions.Item>
+          <Descriptions.Item label={t('tokens.mailboxEndpoint')}><Typography.Text code copyable={{ onCopy: () => onCopy(mailboxEndpoint) }}>{mailboxEndpoint}</Typography.Text></Descriptions.Item>
+          <Descriptions.Item label={t('tokens.authHeader')}><Typography.Text code>{t('tokens.authHeaderValue')}</Typography.Text></Descriptions.Item>
+        </Descriptions>
+        <Collapse
+          defaultActiveKey={['send']}
+          items={[
+            {
+              key: 'send', label: t('tokens.sendApi'), children: <Space direction="vertical" size={12} className="full-width"><Typography.Text strong>{t('tokens.curlExample')}</Typography.Text><CodeBlock value={examples.curl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.requestExample')}</Typography.Text><CodeBlock value={examples.requestBody} onCopy={onCopy} /><Typography.Text strong>{t('tokens.responseExample')}</Typography.Text><CodeBlock value={examples.successResponse} onCopy={onCopy} /></Space>
+            },
+            {
+              key: 'mailboxes', label: t('tokens.mailboxApi'), children: <Space direction="vertical" size={12} className="full-width"><Alert type="info" showIcon message={t('tokens.mailboxApiHint')} /><Typography.Text strong>{t('tokens.permanentMailbox')}</Typography.Text><CodeBlock value={mailboxExamples.permanentCurl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.temporaryMailbox')}</Typography.Text><CodeBlock value={mailboxExamples.temporaryCurl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.mailboxResponse')}</Typography.Text><CodeBlock value={mailboxExamples.successResponse} onCopy={onCopy} /></Space>
+            }
+          ]}
+        />
+        <Alert type="warning" showIcon message={t('tokens.securityTipStore')} description={t('tokens.securityTipScopes')} />
+      </Space>
+    </Drawer>
   );
 }
 
 function toTokenPayload(values: TokenFormValues) {
-  return {
-    name: values.name,
-    scopes: values.scopes,
-    expiresAt: values.expiresAt ? new Date(values.expiresAt).toISOString() : null
-  };
+  return { name: values.name.trim(), scopes: values.scopes, expiresAt: values.expiresAt ? new Date(values.expiresAt).toISOString() : null };
 }
 
 function toDatetimeLocal(value?: string | null) {
@@ -291,18 +336,9 @@ function tokenStatusText(status: string | undefined, t: (key: string) => string)
   if (status === 'active') return t('tokens.statusActive');
   if (status === 'expired') return t('tokens.statusExpired');
   if (status === 'revoked') return t('tokens.statusRevoked');
-  return '-';
+  return '';
 }
 
 function formatOptionalTime(value?: string | null) {
-  return value ? new Date(value).toLocaleString() : '-';
-}
-
-function copyable(value: string, onCopy: (value: string) => void) {
-  return (
-    <Space>
-      <Typography.Text code className="inline-code-value">{value}</Typography.Text>
-      <Button aria-label="Copy" size="small" icon={<CopyOutlined />} onClick={() => onCopy(value)} />
-    </Space>
-  );
+  return value ? new Date(value).toLocaleString() : '—';
 }

+ 258 - 404
src/pages/Dashboard.tsx

@@ -1,459 +1,313 @@
-import { Area, Bar, Column, Line, Pie } from '@ant-design/plots';
-import { Alert, Col, List, Progress, Row, Space, Table, Typography } from 'antd';
-import type { ColumnsType } from 'antd/es/table';
+import {
+  ArrowRightOutlined,
+  CheckCircleOutlined,
+  ExclamationCircleOutlined
+} from '@ant-design/icons';
+import {
+  Alert,
+  Button,
+  Col,
+  List,
+  Result,
+  Row,
+  Segmented,
+  Skeleton,
+  Space,
+  Typography
+} from 'antd';
+import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
 
 import { EmptyState } from '../components/common/EmptyState';
 import { MetricCard } from '../components/common/MetricCard';
 import { PageHeader } from '../components/common/PageHeader';
 import { SectionCard } from '../components/common/SectionCard';
-import { StatusPill } from '../components/common/StatusPill';
-import {
-  buildDashboardSummary,
-  buildDeliveryFunnel,
-  buildDomainRanking,
-  buildEngagementSummary,
-  buildEngagementTrend,
-  buildHourlyHeatmap,
-  buildStatusDistribution,
-  buildTopLinks,
-  buildTrendSeries
-} from '../frontend/analytics-model.js';
+import { StatusPill, type StatusTone } from '../components/common/StatusPill';
+import { buildEngagementSummary } from '../frontend/analytics-model.js';
 import { buildDomainHealth } from '../frontend/domain-model.js';
+import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
-import { brandColors } from '../frontend/theme';
-import type { Analytics, Domain, RuntimeConfig, SendEvent, SmtpCredential } from '../frontend/types';
+import { api } from '../frontend/services/api';
+import type { Analytics, Domain, SmtpCredential, WebhookDelivery } from '../frontend/types';
 
-interface DashboardProps {
-  analytics: Analytics | null;
+const DashboardCharts = lazy(() => import('./DashboardCharts'));
+
+type RangeDays = 1 | 7 | 30;
+type AnalysisSection = 'delivery' | 'engagement';
+
+interface DashboardData {
+  analytics: Analytics;
   domains: Domain[];
-  events: SendEvent[];
-  config: RuntimeConfig | null;
   smtpCredential: SmtpCredential | null;
+  failedWebhooks: WebhookDelivery[];
 }
 
-export default function Dashboard({ analytics, domains, events, config, smtpCredential }: DashboardProps) {
-  const { t } = useI18n();
-  const summary = buildDashboardSummary({ analytics, domains, events, config, smtpCredential });
-  const trendSeries = buildTrendSeries(analytics);
-  const trendData = trendSeries.flatMap((item) => [
-    { date: item.date, type: t('metrics.total'), value: item.total },
-    { date: item.date, type: t('metrics.accepted'), value: item.accepted },
-    { date: item.date, type: t('metrics.failed'), value: item.failed }
-  ]);
-  const statusData = buildStatusDistribution(analytics).map((item) => ({
-    ...item,
-    label: statusLabel(item.status, t)
-  }));
-  const rankingData = buildDomainRanking(analytics);
-  const hourlyData = buildHourlyHeatmap(analytics);
-  const deliveryFunnel = buildDeliveryFunnel(analytics);
-  const engagement = buildEngagementSummary(analytics);
-  const engagementTrend = buildEngagementTrend(analytics);
-  const engagementTrendData = engagementTrend.flatMap((item) => [
-    { date: item.date, type: t('dashboard.opens'), value: item.opens },
-    { date: item.date, type: t('dashboard.clicks'), value: item.clicks }
-  ]);
-  const topLinks = buildTopLinks(analytics);
-  const failureReasons = analytics?.failureReasons || [];
-  const lastSentLabel = summary.lastSentAt
-    ? new Date(summary.lastSentAt).toLocaleString()
-    : t('common.notFound');
+interface ActionItem {
+  key: string;
+  title: string;
+  description: string;
+  tone: StatusTone;
+  label: string;
+  path: string;
+}
 
-  const columns: ColumnsType<SendEvent> = [
-    { title: 'Time', dataIndex: 'createdAt', render: (value) => new Date(value).toLocaleString() },
-    { title: 'Recipient', dataIndex: 'recipients', render: (value: string[]) => value.join(', ') },
-    { title: 'Domain', dataIndex: 'domain' },
-    { title: 'Subject', dataIndex: 'subject', ellipsis: true },
-    {
-      title: t('common.status'),
-      dataIndex: 'status',
-      render: (value) => (
-        <StatusPill tone={statusTone(value)}>{statusLabel(value, t)}</StatusPill>
-      )
-    }
-  ];
-  const linkColumns: ColumnsType<(typeof topLinks)[number]> = [
-    {
-      title: t('dashboard.link'),
-      dataIndex: 'target',
-      ellipsis: { showTitle: false },
-      render: (value) => <Typography.Text ellipsis={{ tooltip: value }}>{value || '-'}</Typography.Text>
-    },
-    { title: t('dashboard.uniqueClicks'), dataIndex: 'uniqueClicks', width: 110 },
-    { title: t('dashboard.totalClicks'), dataIndex: 'clicks', width: 100 },
-    {
-      title: t('dashboard.lastClicked'),
-      dataIndex: 'lastClickedAt',
-      width: 180,
-      render: (value) => value ? new Date(value).toLocaleString() : '-'
+export default function Dashboard() {
+  const { config } = useAppContext();
+  const { locale } = useI18n();
+  const navigate = useNavigate();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const range = normalizeRange(searchParams.get('range'));
+  const analysisSection = normalizeAnalysisSection(searchParams.get('analysis'));
+  const [data, setData] = useState<DashboardData | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState('');
+  const copy = locale.startsWith('en') ? enCopy : zhCopy;
+
+  const load = useCallback(async () => {
+    setLoading(true);
+    setError('');
+    try {
+      const [analyticsResult, domainsResult] = await Promise.all([
+        api.analytics(range),
+        api.domains()
+      ]);
+      const [credentialResult, webhooksResult] = await Promise.allSettled([
+        api.smtpCredential(),
+        api.webhookDeliveries({ status: 'dead', limit: 5 })
+      ]);
+      setData({
+        analytics: analyticsResult.analytics,
+        domains: domainsResult.domains,
+        smtpCredential: credentialResult.status === 'fulfilled' ? credentialResult.value.credential : null,
+        failedWebhooks: webhooksResult.status === 'fulfilled' ? webhooksResult.value.deliveries : []
+      });
+    } catch (reason) {
+      setError(reason instanceof Error ? reason.message : copy.loadFailed);
+    } finally {
+      setLoading(false);
     }
-  ];
+  }, [copy.loadFailed, range]);
+
+  useEffect(() => {
+    void load();
+  }, [load]);
+
+  const actions = useMemo(() => data ? buildActionItems(data, copy) : [], [copy, data]);
+
+  function changeRange(value: string | number) {
+    const next = new URLSearchParams(searchParams);
+    next.set('range', String(value));
+    setSearchParams(next);
+  }
+
+  function changeAnalysisSection(value: AnalysisSection) {
+    const next = new URLSearchParams(searchParams);
+    if (value === 'delivery') next.delete('analysis');
+    else next.set('analysis', value);
+    setSearchParams(next);
+  }
+
+  if (loading && !data) {
+    return (
+      <Space direction="vertical" size={20} className="full-width" aria-label={copy.loading}>
+        <Skeleton active title={{ width: '28%' }} paragraph={{ rows: 2 }} />
+        <Row gutter={[16, 16]}>{[0, 1, 2, 3].map((key) => <Col xs={24} sm={12} xl={6} key={key}><Skeleton.Node active style={{ width: '100%' }} /></Col>)}</Row>
+        <SectionCard><Skeleton active paragraph={{ rows: 8 }} /></SectionCard>
+      </Space>
+    );
+  }
+
+  if (error && !data) {
+    return (
+      <Result
+        status="error"
+        title={copy.loadFailed}
+        subTitle={error}
+        extra={<Button type="primary" onClick={() => void load()}>{copy.retry}</Button>}
+      />
+    );
+  }
+
+  if (!data) return null;
+
+  const { analytics, domains } = data;
+  const summary = analytics.summary;
+  const engagement = buildEngagementSummary(analytics);
+  const failuresAndDelays = Number(summary.terminalFailed || summary.failed || 0)
+    + Number(summary.deferred || 0)
+    + Number(summary.pending || 0);
 
   return (
     <Space direction="vertical" size={20} className="full-width">
       {config?.usingDefaultAdminPassword ? (
-        <Alert type="warning" showIcon message={t('dashboard.defaultPasswordWarning')} />
+        <Alert type="warning" showIcon message={copy.defaultPassword} />
       ) : null}
 
+      {error ? <Alert type="error" showIcon message={copy.partialFailure} description={error} action={<Button size="small" onClick={() => void load()}>{copy.retry}</Button>} /> : null}
+
       <PageHeader
-        title={t('dashboard.title')}
+        title={copy.title}
+        subtitle={copy.subtitle}
         extra={
-          <StatusPill tone={summary.smtpReady ? 'success' : 'warning'}>
-            {t('dashboard.smtpStatus')}:{' '}
-            {summary.smtpReady ? t('dashboard.smtpReady') : t('dashboard.smtpNotConfigured')}
-          </StatusPill>
+          <Segmented
+            aria-label={copy.rangeLabel}
+            value={range}
+            onChange={changeRange}
+            options={[
+              { label: copy.last24Hours, value: 1 },
+              { label: copy.last7Days, value: 7 },
+              { label: copy.last30Days, value: 30 }
+            ]}
+          />
         }
       />
 
-      <Row gutter={[16, 16]}>
-        <Col xs={24} sm={12} lg={6}>
-          <MetricCard label={t('dashboard.todaySent')} value={summary.today} />
-        </Col>
-        <Col xs={24} sm={12} lg={6}>
-          <MetricCard
-            label={t('dashboard.successRate')}
-            value={`${summary.successRate}%`}
-            hint={`${t('dashboard.bounceRate')} ${summary.bounceRate}% · ${t('dashboard.complaintRate')} ${summary.complaintRate}%`}
-          />
-        </Col>
-        <Col xs={24} sm={12} lg={6}>
-          <MetricCard label={t('dashboard.verifiedDomains')} value={summary.verifiedDomains} />
-        </Col>
-        <Col xs={24} sm={12} lg={6}>
-          <MetricCard
-            label={t('dashboard.dnsIssues')}
-            value={summary.dnsIssues}
-            tone={summary.dnsIssues > 0 ? 'warning' : 'default'}
-            hint={summary.dnsIssues > 0 ? t('dashboard.dnsActionHint') : undefined}
-          />
-        </Col>
-      </Row>
-
       <Row gutter={[16, 16]}>
         <Col xs={24} sm={12} xl={6}>
-          <MetricCard
-            label={t('dashboard.openRate')}
-            value={`${engagement.openRate}%`}
-            hint={`${engagement.uniqueOpens} ${t('dashboard.unique')} · ${engagement.totalOpens} ${t('dashboard.total')}`}
-          />
+          <MetricCard label={copy.sent} value={summary.total || 0} hint={`${summary.recipients || 0} ${copy.recipients}`} />
         </Col>
         <Col xs={24} sm={12} xl={6}>
-          <MetricCard
-            label={t('dashboard.clickRate')}
-            value={`${engagement.clickRate}%`}
-            hint={`${engagement.uniqueClicks} ${t('dashboard.unique')} · ${engagement.totalClicks} ${t('dashboard.total')}`}
-          />
+          <MetricCard label={copy.deliveryRate} value={`${summary.deliveryRate || 0}%`} hint={`${summary.delivered || 0} ${copy.delivered}`} />
         </Col>
         <Col xs={24} sm={12} xl={6}>
           <MetricCard
-            label={t('dashboard.clickToOpenRate')}
-            value={`${engagement.clickToOpenRate}%`}
-            hint={`${t('dashboard.trackedDelivered')} ${engagement.trackedDelivered}`}
+            label={copy.failuresAndDelays}
+            value={failuresAndDelays}
+            tone={failuresAndDelays ? 'danger' : 'default'}
+            hint={`${summary.terminalFailed || summary.failed || 0} ${copy.failed} · ${summary.deferred || 0} ${copy.deferred}`}
           />
         </Col>
         <Col xs={24} sm={12} xl={6}>
           <MetricCard
-            label={t('dashboard.measuredActivity')}
-            value={engagement.totalOpens + engagement.totalClicks}
-            hint={`${t('dashboard.proxyOpens')} ${engagement.proxyOpens} · ${t('dashboard.scannerEvents')} ${engagement.scannerEvents}`}
+            label={copy.opensAndClicks}
+            value={`${engagement.openRate}% / ${engagement.clickRate}%`}
+            hint={`${engagement.uniqueOpens} ${copy.opens} · ${engagement.uniqueClicks} ${copy.clicks}`}
           />
         </Col>
       </Row>
 
-      <Row gutter={[16, 16]}>
-        <Col xs={24} xl={15}>
-          <SectionCard title={t('dashboard.engagementTrend')} className="chart-card">
-            {engagementTrendData.some((item) => item.value > 0) ? (
-              <Line
-                data={engagementTrendData}
-                xField="date"
-                yField="value"
-                colorField="type"
-                height={300}
-                axis={{ y: { title: false }, x: { title: false } }}
-                scale={{ color: { range: [brandColors.chartPrimary, brandColors.chartSuccess] } }}
-                tooltip={{ title: 'date' }}
-                legend={{ color: { position: 'top' } }}
-              />
-            ) : (
-              <EmptyState description={t('dashboard.noEngagement')} />
-            )}
-          </SectionCard>
-        </Col>
-        <Col xs={24} xl={9}>
-          <SectionCard title={t('dashboard.topLinks')}>
-            {topLinks.length ? (
-              <Table
-                rowKey="fingerprint"
-                size="small"
-                columns={linkColumns}
-                dataSource={topLinks}
-                pagination={false}
-                scroll={{ x: 640 }}
-              />
-            ) : (
-              <EmptyState description={t('dashboard.noEngagement')} />
-            )}
-          </SectionCard>
-        </Col>
-      </Row>
-
-      <Row gutter={[16, 16]}>
-        <Col xs={24} xl={15}>
-          <SectionCard title={t('dashboard.deliveryFunnel')}>
-            {deliveryFunnel.length ? (
-              <div className="delivery-funnel">
-                {deliveryFunnel.map((item) => (
-                  <div className="delivery-funnel-row" key={item.stage}>
-                    <div className="delivery-funnel-row__meta">
-                      <StatusPill tone={item.tone}>{deliveryStageLabel(item.stage, t)}</StatusPill>
-                      <Typography.Text strong>{item.total}</Typography.Text>
-                    </div>
-                    <Progress percent={item.rate} size="small" showInfo={false} status={item.tone === 'error' ? 'exception' : 'normal'} />
-                    <Typography.Text type="secondary">{item.rate}%</Typography.Text>
-                  </div>
-                ))}
-              </div>
-            ) : (
-              <EmptyState description={t('dashboard.noTrend')} />
-            )}
-          </SectionCard>
-        </Col>
-        <Col xs={24} xl={9}>
-          <SectionCard title={t('dashboard.failureReasons')}>
-            {failureReasons.length ? (
-              <List
-                dataSource={failureReasons}
-                renderItem={(item) => (
-                  <List.Item>
-                    <List.Item.Meta
-                      title={<Typography.Text ellipsis>{item.reason}</Typography.Text>}
-                      description={
-                        <Typography.Text type="secondary">
-                          {t('metrics.total')}: {item.total} · {item.lastSeenAt ? new Date(item.lastSeenAt).toLocaleString() : '-'}
-                        </Typography.Text>
-                      }
-                    />
-                  </List.Item>
-                )}
-              />
-            ) : (
-              <EmptyState description={t('dashboard.noFailures')} />
-            )}
-          </SectionCard>
-        </Col>
-      </Row>
-
-      <Row gutter={[16, 16]}>
-        <Col xs={24} xl={15}>
-          <SectionCard title={t('dashboard.trend')} className="chart-card">
-            {trendData.length ? (
-              <Area
-                data={trendData}
-                xField="date"
-                yField="value"
-                colorField="type"
-                shapeField="smooth"
-                height={316}
-                axis={{ y: { title: false }, x: { title: false } }}
-                scale={{
-                  color: {
-                    range: [brandColors.chartPrimary, brandColors.chartSuccess, brandColors.chartDanger]
-                  }
-                }}
-                tooltip={{ title: 'date' }}
-                legend={{ color: { position: 'top' } }}
-              />
-            ) : (
-              <EmptyState description={t('dashboard.noTrend')} />
-            )}
-          </SectionCard>
-        </Col>
-        <Col xs={24} xl={9}>
-          <SectionCard title={t('dashboard.statusDistribution')} className="chart-card">
-            {statusData.length ? (
-              <Pie
-                data={statusData}
-                angleField="value"
-                colorField="label"
-                radius={0.68}
-                innerRadius={0.48}
-                height={316}
-                marginLeft={24}
-                marginRight={24}
-                marginTop={8}
-                marginBottom={8}
-                scale={{
-                  color: {
-                    range: [brandColors.chartSuccess, brandColors.chartDanger, brandColors.chartWarning]
-                  }
-                }}
-                label={{
-                  text: (datum: { label?: string; value?: number }) =>
-                    `${datum.label || ''}${datum.value != null ? ` ${datum.value}` : ''}`,
-                  position: 'outside',
-                  transform: [{ type: 'overlapDodgeY' }],
-                  style: {
-                    fontSize: 12,
-                    textAlign: 'center'
-                  },
-                  connector: true
-                }}
-                legend={{ color: { position: 'bottom' } }}
-                tooltip={{
-                  title: (datum: { label?: string }) => datum.label || '',
-                  items: [
-                    (datum: { value?: number }) => ({
-                      name: t('metrics.total'),
-                      value: datum.value ?? 0
-                    })
-                  ]
-                }}
-              />
-            ) : (
-              <EmptyState description={t('dashboard.noTrend')} />
-            )}
-          </SectionCard>
-        </Col>
-      </Row>
-
-      <Row gutter={[16, 16]}>
-        <Col xs={24} xl={12}>
-          <SectionCard title={t('dashboard.domainRanking')} className="chart-card">
-            {rankingData.length ? (
-              <Bar
-                data={rankingData}
-                xField="total"
-                yField="domain"
-                height={312}
-                colorField="domain"
-                scale={{ color: { range: [brandColors.chartPrimary] } }}
-                label={{ text: 'total', position: 'right' }}
-                axis={{ x: { title: false }, y: { title: false } }}
-                legend={false}
-              />
-            ) : (
-              <EmptyState description={t('dashboard.noDomains')} />
-            )}
-          </SectionCard>
-        </Col>
-        <Col xs={24} xl={12}>
-          <SectionCard title={t('dashboard.hourlyHeatmap')} className="chart-card">
-            {hourlyData.length ? (
-              <Column
-                data={hourlyData}
-                xField="hour"
-                yField="total"
-                height={312}
-                colorField="total"
-                scale={{ color: { range: [brandColors.chartTrack, brandColors.chartPrimary] } }}
-                axis={{ x: { title: false }, y: { title: false } }}
-                tooltip={{ title: 'hour' }}
-                legend={false}
-              />
-            ) : (
-              <EmptyState description={t('dashboard.noTrend')} />
-            )}
-          </SectionCard>
-        </Col>
-      </Row>
-
-      <Row gutter={[16, 16]}>
-        <Col xs={24} xl={9}>
-          <SectionCard title={t('dashboard.recentFailures')}>
-            {analytics?.recentFailures?.length ? (
-              <List
-                dataSource={analytics.recentFailures}
-                renderItem={(item) => (
-                  <List.Item>
-                    <List.Item.Meta
-                      title={<Typography.Text ellipsis>{item.subject || item.domain || '-'}</Typography.Text>}
-                      description={
-                        <Space direction="vertical" size={2}>
-                          <Typography.Text type="secondary">{new Date(item.createdAt).toLocaleString()}</Typography.Text>
-                          <Typography.Text type="danger" ellipsis>{item.detail}</Typography.Text>
-                        </Space>
-                      }
-                    />
-                  </List.Item>
-                )}
-              />
-            ) : (
-              <EmptyState description={t('dashboard.noFailures')} />
-            )}
-          </SectionCard>
-        </Col>
-        <Col xs={24} xl={15}>
-          <SectionCard title={t('dashboard.domainHealth')}>
-            <Space direction="vertical" className="full-width">
-              {domains.slice(0, 6).map((domain) => {
-                const health = buildDomainHealth(domain);
-                return (
-                  <div className="health-row" key={domain.id}>
-                    <div>
-                      <Typography.Text strong>{domain.domain}</Typography.Text>
-                      <Typography.Text type="secondary">DNS {health.passed}/{health.total}</Typography.Text>
-                    </div>
-                    <StatusPill tone={domainHealthTone(health.status)}>
-                      {domainHealthLabel(health.status, t)}
-                    </StatusPill>
-                  </div>
-                );
-              })}
-              {!domains.length ? <EmptyState description={t('dashboard.noDomains')} /> : null}
-            </Space>
-          </SectionCard>
-        </Col>
-      </Row>
-
       <SectionCard
-        title={t('dashboard.recentLogs')}
-        extra={
-          <Typography.Text type="secondary">
-            {t('dashboard.lastSentAt')}: {lastSentLabel}
-          </Typography.Text>
-        }
+        title={copy.actionNeeded}
+        extra={actions.length ? <StatusPill tone="warning">{actions.length} {copy.items}</StatusPill> : <StatusPill tone="success">{copy.allClear}</StatusPill>}
       >
-        <Table rowKey="id" columns={columns} dataSource={events.slice(0, 8)} pagination={false} scroll={{ x: 900 }} />
+        {actions.length ? (
+          <List
+            dataSource={actions}
+            renderItem={(item) => (
+              <List.Item
+                actions={[
+                  <Button key="resolve" type="link" onClick={() => navigate(item.path)}>
+                    {item.label} <ArrowRightOutlined />
+                  </Button>
+                ]}
+              >
+                <List.Item.Meta
+                  avatar={<ExclamationCircleOutlined aria-hidden style={{ color: item.tone === 'error' ? '#dc2626' : '#d97706', fontSize: 20 }} />}
+                  title={<Space wrap><Typography.Text strong>{item.title}</Typography.Text><StatusPill tone={item.tone}>{copy.needsAction}</StatusPill></Space>}
+                  description={item.description}
+                />
+              </List.Item>
+            )}
+          />
+        ) : (
+          <EmptyState description={copy.noActionNeeded} icon={<CheckCircleOutlined style={{ color: '#16a34a' }} />} />
+        )}
       </SectionCard>
+
+      {!summary.total ? <ReadinessChecklist domains={domains} credential={data.smtpCredential} onNavigate={navigate} copy={copy} /> : null}
+
+      <Suspense fallback={<SectionCard><Skeleton active paragraph={{ rows: 8 }} /></SectionCard>}>
+        <DashboardCharts
+          analytics={analytics}
+          copy={copy}
+          activeSection={analysisSection}
+          onSectionChange={changeAnalysisSection}
+        />
+      </Suspense>
     </Space>
   );
 }
 
-function statusLabel(status: string, t: (key: string) => string) {
-  if (status === 'queued') return t('logs.statusQueued');
-  if (status === 'sent') return t('logs.statusSent');
-  if (status === 'deferred') return t('logs.statusDeferred');
-  if (status === 'bounced') return t('logs.statusBounced');
-  if (status === 'failed') return t('logs.statusFailed');
-  return status || t('dashboard.statusUnknown');
+function ReadinessChecklist({
+  domains,
+  credential,
+  onNavigate,
+  copy
+}: {
+  domains: Domain[];
+  credential: SmtpCredential | null;
+  onNavigate: (path: string) => void;
+  copy: typeof zhCopy;
+}) {
+  const hasDomain = domains.length > 0;
+  const dnsReady = domains.some((domain) => buildDomainHealth(domain).status === 'success');
+  const credentialReady = Boolean(credential?.passwordSet);
+  const items = [
+    { title: copy.addDomain, done: hasDomain, path: '/domains' },
+    { title: copy.configureDns, done: dnsReady, path: hasDomain ? `/domains/${domains[0].id}/dns` : '/domains' },
+    { title: copy.createCredential, done: credentialReady, path: '/integrations/smtp' },
+    { title: copy.sendTest, done: false, path: hasDomain ? `/domains/${domains[0].id}/sending` : '/domains' }
+  ];
+  return (
+    <SectionCard title={copy.getReady} extra={<Typography.Text type="secondary">{copy.getReadyHint}</Typography.Text>}>
+      <List
+        dataSource={items}
+        renderItem={(item, index) => (
+          <List.Item actions={[<Button key="go" type={index === 0 && !item.done ? 'primary' : 'default'} onClick={() => onNavigate(item.path)}>{item.done ? copy.review : copy.start}</Button>]}>
+            <List.Item.Meta
+              avatar={item.done ? <CheckCircleOutlined style={{ color: '#16a34a', fontSize: 20 }} /> : <StatusPill tone="neutral">{index + 1}</StatusPill>}
+              title={<Typography.Text delete={item.done}>{item.title}</Typography.Text>}
+            />
+          </List.Item>
+        )}
+      />
+    </SectionCard>
+  );
 }
 
-function statusTone(status: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
-  if (status === 'queued') return 'info';
-  if (status === 'sent') return 'success';
-  if (status === 'deferred') return 'warning';
-  if (status === 'bounced' || status === 'failed') return 'error';
-  return 'neutral';
+function buildActionItems(data: DashboardData, copy: typeof zhCopy): ActionItem[] {
+  const unhealthy = data.domains.filter((domain) => buildDomainHealth(domain).status !== 'success');
+  const recentFailures = data.analytics.recentFailures || [];
+  const items: ActionItem[] = [];
+  if (unhealthy.length) {
+    items.push({ key: 'dns', title: copy.dnsNotReady(unhealthy.length), description: copy.dnsNotReadyHint, tone: 'warning', label: copy.reviewDomains, path: '/domains?status=error' });
+  }
+  if (recentFailures.length) {
+    items.push({ key: 'failures', title: copy.recentFailures(recentFailures.length), description: recentFailures[0]?.detail || copy.recentFailuresHint, tone: 'error', label: copy.inspectActivity, path: '/activity?status=failed' });
+  }
+  if (data.failedWebhooks.length) {
+    items.push({ key: 'webhooks', title: copy.webhookFailures(data.failedWebhooks.length), description: copy.webhookFailuresHint, tone: 'error', label: copy.reviewWebhooks, path: '/integrations/webhooks' });
+  }
+  if (!data.smtpCredential?.passwordSet) {
+    items.push({ key: 'credential', title: copy.missingCredential, description: copy.missingCredentialHint, tone: 'warning', label: copy.configure, path: '/integrations/smtp' });
+  }
+  return items;
 }
 
-function domainHealthLabel(status: string, t: (key: string) => string) {
-  if (status === 'success') return t('domains.healthy');
-  if (status === 'warning') return t('domains.waitingDns');
-  return t('domains.needsAction');
+function normalizeRange(value: string | null): RangeDays {
+  if (value === '1') return 1;
+  if (value === '30') return 30;
+  return 7;
 }
 
-function domainHealthTone(status: string): 'success' | 'warning' | 'error' {
-  if (status === 'success') return 'success';
-  if (status === 'warning') return 'warning';
-  return 'error';
+function normalizeAnalysisSection(value: string | null): AnalysisSection {
+  return value === 'engagement' ? 'engagement' : 'delivery';
 }
 
-function deliveryStageLabel(stage: string, t: (key: string) => string) {
-  if (stage === 'submitted') return t('dashboard.stageSubmitted');
-  if (stage === 'accepted') return t('dashboard.stageAccepted');
-  if (stage === 'delivered') return t('dashboard.stageDelivered');
-  if (stage === 'pending') return t('dashboard.stagePending');
-  if (stage === 'failed') return t('dashboard.stageFailed');
-  return stage;
-}
+const zhCopy = {
+  title: '运行概览', subtitle: '从投递健康开始,快速定位需要处理的问题。', loading: '正在加载运行概览', loadFailed: '概览加载失败', retry: '重试', rangeLabel: '统计时间范围', last24Hours: '24 小时', last7Days: '7 天', last30Days: '30 天',
+  sent: '发送量', recipients: '位收件人', deliveryRate: '送达率', delivered: '已送达', failuresAndDelays: '失败 / 延迟', failed: '失败', deferred: '延迟', opensAndClicks: '打开 / 点击', opens: '打开', clicks: '点击',
+  actionNeeded: '需要处理', items: '项', allClear: '状态正常', needsAction: '需要处理', noActionNeeded: '当前没有检测到需要处理的投递问题。', partialFailure: '部分数据刷新失败', defaultPassword: '当前仍在使用默认管理密码,请尽快在系统设置中更换。',
+  getReady: '发送就绪清单', getReadyHint: '完成以下步骤后即可进行首次测试发送', addDomain: '添加发信域名', configureDns: '配置并验证 DNS', createCredential: '创建 SMTP 或 API 凭据', sendTest: '发送测试邮件', review: '查看', start: '开始',
+  deliveryQuality: '投递质量', engagementAnalysis: '互动分析', deliveryTrend: '发送与送达趋势', engagementTrend: '打开与点击趋势', domainRanking: '域名发送排行', topLinks: '热门链接', link: '链接', uniqueClicks: '独立点击', noDeliveryData: '当前范围暂无发送数据', noDomainData: '暂无域名发送数据', noEngagementData: '暂无互动数据',
+  dnsNotReady: (count: number) => `${count} 个域名的 DNS 尚未就绪`, dnsNotReadyHint: '认证记录不完整可能导致投递失败或进入垃圾箱。', reviewDomains: '处理域名', recentFailures: (count: number) => `最近发现 ${count} 条发送失败`, recentFailuresHint: '请检查投递响应和队列日志。', inspectActivity: '查看活动', webhookFailures: (count: number) => `${count} 条 Webhook 投递已停止重试`, webhookFailuresHint: '请检查回调地址、签名校验和服务可用性。', reviewWebhooks: '检查 Webhooks', missingCredential: '尚未创建可用的 SMTP 凭据', missingCredentialHint: '创建凭据后,应用才能通过 Submission 服务发送邮件。', configure: '去配置'
+};
+
+const enCopy: typeof zhCopy = {
+  title: 'Operations overview', subtitle: 'Start with delivery health and move directly to issues that need attention.', loading: 'Loading operations overview', loadFailed: 'Unable to load overview', retry: 'Retry', rangeLabel: 'Analytics range', last24Hours: '24 hours', last7Days: '7 days', last30Days: '30 days',
+  sent: 'Sent', recipients: 'recipients', deliveryRate: 'Delivery rate', delivered: 'Delivered', failuresAndDelays: 'Failed / delayed', failed: 'Failed', deferred: 'Delayed', opensAndClicks: 'Opens / clicks', opens: 'Opens', clicks: 'Clicks',
+  actionNeeded: 'Action needed', items: 'items', allClear: 'All clear', needsAction: 'Needs action', noActionNeeded: 'No delivery issues currently require attention.', partialFailure: 'Some data could not be refreshed', defaultPassword: 'The default administrator password is still in use. Change it in system settings.',
+  getReady: 'Sending readiness', getReadyHint: 'Complete these steps before your first test send', addDomain: 'Add a sending domain', configureDns: 'Configure and verify DNS', createCredential: 'Create an SMTP or API credential', sendTest: 'Send a test email', review: 'Review', start: 'Start',
+  deliveryQuality: 'Delivery quality', engagementAnalysis: 'Engagement', deliveryTrend: 'Sending and delivery trend', engagementTrend: 'Open and click trend', domainRanking: 'Domain ranking', topLinks: 'Top links', link: 'Link', uniqueClicks: 'Unique clicks', noDeliveryData: 'No sending data in this range', noDomainData: 'No domain sending data', noEngagementData: 'No engagement data',
+  dnsNotReady: (count: number) => `${count} domains have incomplete DNS`, dnsNotReadyHint: 'Incomplete authentication records can cause delivery failures or spam placement.', reviewDomains: 'Review domains', recentFailures: (count: number) => `${count} recent sends failed`, recentFailuresHint: 'Inspect the delivery response and queue log.', inspectActivity: 'Inspect activity', webhookFailures: (count: number) => `${count} webhook deliveries stopped retrying`, webhookFailuresHint: 'Check the callback endpoint, signature validation, and service availability.', reviewWebhooks: 'Review webhooks', missingCredential: 'No usable SMTP credential', missingCredentialHint: 'Create a credential before applications can use the Submission service.', configure: 'Configure'
+};

+ 121 - 0
src/pages/DashboardCharts.tsx

@@ -0,0 +1,121 @@
+import { Area, Bar, Line } from '@ant-design/plots';
+import { Col, Row, Table, Tabs, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+
+import { EmptyState } from '../components/common/EmptyState';
+import { SectionCard } from '../components/common/SectionCard';
+import {
+  buildDomainRanking,
+  buildEngagementTrend,
+  buildTopLinks,
+  buildTrendSeries
+} from '../frontend/analytics-model.js';
+import { brandColors } from '../frontend/theme';
+import type { Analytics } from '../frontend/types';
+
+export interface DashboardChartCopy {
+  sent: string;
+  delivered: string;
+  failed: string;
+  opens: string;
+  clicks: string;
+  deliveryQuality: string;
+  engagementAnalysis: string;
+  deliveryTrend: string;
+  engagementTrend: string;
+  domainRanking: string;
+  topLinks: string;
+  link: string;
+  uniqueClicks: string;
+  noDeliveryData: string;
+  noDomainData: string;
+  noEngagementData: string;
+}
+
+export default function DashboardCharts({
+  analytics,
+  copy,
+  activeSection,
+  onSectionChange
+}: {
+  analytics: Analytics;
+  copy: DashboardChartCopy;
+  activeSection: 'delivery' | 'engagement';
+  onSectionChange: (section: 'delivery' | 'engagement') => void;
+}) {
+  const trend = buildTrendSeries(analytics);
+  const deliveryTrend = trend.flatMap((item) => [
+    { date: item.date, type: copy.sent, value: item.total },
+    { date: item.date, type: copy.delivered, value: item.accepted },
+    { date: item.date, type: copy.failed, value: item.failed }
+  ]);
+  const engagementTrend = buildEngagementTrend(analytics).flatMap((item) => [
+    { date: item.date, type: copy.opens, value: item.opens },
+    { date: item.date, type: copy.clicks, value: item.clicks }
+  ]);
+  const ranking = buildDomainRanking(analytics).slice(0, 8);
+  const topLinks = buildTopLinks(analytics).slice(0, 8);
+  const linkColumns: ColumnsType<(typeof topLinks)[number]> = [
+    { title: copy.link, dataIndex: 'target', ellipsis: true, render: (value) => <Typography.Text ellipsis={{ tooltip: value }}>{value || '-'}</Typography.Text> },
+    { title: copy.uniqueClicks, dataIndex: 'uniqueClicks', width: 110 },
+    { title: copy.clicks, dataIndex: 'clicks', width: 90 }
+  ];
+
+  return (
+    <SectionCard>
+      <Tabs
+        activeKey={activeSection}
+        onChange={(key) => onSectionChange(key === 'engagement' ? 'engagement' : 'delivery')}
+        items={[
+          {
+            key: 'delivery',
+            label: copy.deliveryQuality,
+            children: (
+              <Row gutter={[16, 16]}>
+                <Col xs={24} xl={15}>
+                  <Typography.Title level={5}>{copy.deliveryTrend}</Typography.Title>
+                  {deliveryTrend.some((item) => item.value > 0) ? (
+                    <Area
+                      data={deliveryTrend}
+                      xField="date"
+                      yField="value"
+                      colorField="type"
+                      height={300}
+                      shapeField="smooth"
+                      scale={{ color: { range: [brandColors.chartPrimary, brandColors.chartSuccess, brandColors.chartDanger] } }}
+                      axis={{ x: { title: false }, y: { title: false } }}
+                    />
+                  ) : <EmptyState description={copy.noDeliveryData} />}
+                </Col>
+                <Col xs={24} xl={9}>
+                  <Typography.Title level={5}>{copy.domainRanking}</Typography.Title>
+                  {ranking.length ? (
+                    <Bar data={ranking} xField="total" yField="domain" height={300} colorField="domain" legend={false} scale={{ color: { range: [brandColors.chartPrimary] } }} />
+                  ) : <EmptyState description={copy.noDomainData} />}
+                </Col>
+              </Row>
+            )
+          },
+          {
+            key: 'engagement',
+            label: copy.engagementAnalysis,
+            children: (
+              <Row gutter={[16, 16]}>
+                <Col xs={24} xl={15}>
+                  <Typography.Title level={5}>{copy.engagementTrend}</Typography.Title>
+                  {engagementTrend.some((item) => item.value > 0) ? (
+                    <Line data={engagementTrend} xField="date" yField="value" colorField="type" height={300} scale={{ color: { range: [brandColors.chartPrimary, brandColors.chartSuccess] } }} />
+                  ) : <EmptyState description={copy.noEngagementData} />}
+                </Col>
+                <Col xs={24} xl={9}>
+                  <Typography.Title level={5}>{copy.topLinks}</Typography.Title>
+                  {topLinks.length ? <Table rowKey="fingerprint" size="small" columns={linkColumns} dataSource={topLinks} pagination={false} scroll={{ x: 440 }} /> : <EmptyState description={copy.noEngagementData} />}
+                </Col>
+              </Row>
+            )
+          }
+        ]}
+      />
+    </SectionCard>
+  );
+}

+ 277 - 99
src/pages/DnsApi.tsx

@@ -1,59 +1,198 @@
-import { DeleteOutlined, EditOutlined, ThunderboltOutlined } from '@ant-design/icons';
-import { Button, Form, Input, InputNumber, Popconfirm, Select, Space, Table, Typography } from 'antd';
+import {
+  DeleteOutlined,
+  EditOutlined,
+  PlusOutlined,
+  ReloadOutlined,
+  ThunderboltOutlined
+} from '@ant-design/icons';
+import {
+  Alert,
+  App as AntApp,
+  Button,
+  Descriptions,
+  Drawer,
+  Form,
+  Input,
+  InputNumber,
+  Popconfirm,
+  Select,
+  Skeleton,
+  Space,
+  Table,
+  Typography
+} from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
 
+import { EmptyState } from '../components/common/EmptyState';
 import { PageHeader } from '../components/common/PageHeader';
 import { SectionCard } from '../components/common/SectionCard';
 import { StatusPill } from '../components/common/StatusPill';
 import { useI18n } from '../frontend/i18n/react';
+import { api } from '../frontend/services/api';
 import type { DnsCredential } from '../frontend/types';
 
-interface DnsApiProps {
-  credentials: DnsCredential[];
-  loading?: boolean;
-  onSave: (values: Record<string, unknown>, id?: number) => Promise<void>;
-  onTest: (credential: DnsCredential) => void;
-  onDelete: (credential: DnsCredential) => void;
+interface CredentialFormValues {
+  name: string;
+  provider: string;
+  zoneName: string;
+  defaultTtl: number;
+  apiToken?: string;
+  zoneId?: string;
+  accessKeyId?: string;
+  accessKeySecret?: string;
+  secretId?: string;
+  secretKey?: string;
 }
 
-export default function DnsApi({ credentials, loading, onSave, onTest, onDelete }: DnsApiProps) {
+export default function DnsApi() {
+  const { message } = AntApp.useApp();
   const { t } = useI18n();
-  const [form] = Form.useForm();
+  const [form] = Form.useForm<CredentialFormValues>();
+  const [credentials, setCredentials] = useState<DnsCredential[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [loadError, setLoadError] = useState('');
+  const [actionKey, setActionKey] = useState('');
+  const [editorOpen, setEditorOpen] = useState(false);
   const [editing, setEditing] = useState<DnsCredential | null>(null);
+  const [selected, setSelected] = useState<DnsCredential | null>(null);
   const provider = Form.useWatch('provider', form) || 'cloudflare';
 
-  function edit(credential: DnsCredential) {
-    setEditing(credential);
-    form.setFieldsValue(credential);
-  }
+  const loadCredentials = useCallback(async () => {
+    setLoading(true);
+    setLoadError('');
+    try {
+      const result = await api.dnsCredentials();
+      setCredentials(result.credentials || []);
+    } catch (error) {
+      setLoadError(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setLoading(false);
+    }
+  }, [t]);
 
-  async function submit(values: Record<string, unknown>) {
-    await onSave(values, editing?.id);
+  useEffect(() => {
+    void loadCredentials();
+  }, [loadCredentials]);
+
+  function openCreate() {
     setEditing(null);
     form.resetFields();
-    form.setFieldValue('provider', 'cloudflare');
-    form.setFieldValue('defaultTtl', 600);
+    form.setFieldsValue({ provider: 'cloudflare', defaultTtl: 600 } as CredentialFormValues);
+    setEditorOpen(true);
+  }
+
+  function openEdit(credential: DnsCredential) {
+    setEditing(credential);
+    form.resetFields();
+    form.setFieldsValue({
+      name: credential.name,
+      provider: credential.provider,
+      zoneName: credential.zoneName,
+      defaultTtl: credential.defaultTtl
+    });
+    setEditorOpen(true);
+  }
+
+  async function submit() {
+    const values = await form.validateFields();
+    const key = editing ? `save:${editing.id}` : 'create';
+    setActionKey(key);
+    try {
+      await api.saveDnsCredential(values, editing?.id);
+      message.success(editing ? t('actions.dnsApiUpdated') : t('actions.dnsApiCreated'));
+      setEditorOpen(false);
+      setEditing(null);
+      form.resetFields();
+      await loadCredentials();
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setActionKey('');
+    }
+  }
+
+  async function testCredential(credential: DnsCredential) {
+    setActionKey(`test:${credential.id}`);
+    try {
+      const result = await api.testDnsCredential(credential.id);
+      if (result.ok) message.success(result.detail || t('actions.dnsApiTestCompleted'));
+      else message.error(result.error || t('common.error'));
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setActionKey('');
+    }
+  }
+
+  async function deleteCredential(credential: DnsCredential) {
+    setActionKey(`delete:${credential.id}`);
+    try {
+      await api.deleteDnsCredential(credential.id);
+      message.success(t('actions.dnsApiDeleted'));
+      if (selected?.id === credential.id) setSelected(null);
+      await loadCredentials();
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setActionKey('');
+    }
   }
 
   const columns: ColumnsType<DnsCredential> = [
-    { title: t('tokens.name'), dataIndex: 'name' },
+    {
+      title: t('tokens.name'),
+      dataIndex: 'name',
+      render: (value: string) => <Typography.Text strong>{value}</Typography.Text>
+    },
     { title: 'Provider', dataIndex: 'provider', render: providerLabel },
-    { title: t('dnsApi.zone'), dataIndex: 'zoneName' },
-    { title: 'TTL', dataIndex: 'defaultTtl' },
-    { title: t('tokens.createdAt'), dataIndex: 'updatedAt', render: (value) => new Date(value).toLocaleString() },
     {
-      title: t('domains.actions'),
+      title: t('dnsApi.zone'),
+      dataIndex: 'zoneName',
+      render: (value: string) => <Typography.Text code>{value}</Typography.Text>
+    },
+    { title: 'TTL', dataIndex: 'defaultTtl', width: 100 },
+    {
+      title: t('common.status'),
+      dataIndex: 'credentialSet',
+      width: 140,
+      render: (value?: boolean) => (
+        <StatusPill tone={value === false ? 'warning' : 'success'}>
+          {value === false ? t('common.notConfigured') : t('smtp.passwordSet')}
+        </StatusPill>
+      )
+    },
+    {
+      title: t('tokens.createdAt'),
+      dataIndex: 'updatedAt',
+      width: 190,
+      render: (value: string) => value ? new Date(value).toLocaleString() : '—'
+    },
+    {
+      title: t('common.actions'),
+      width: 220,
       render: (_, credential) => (
-        <Space wrap>
-          <Button icon={<ThunderboltOutlined />} onClick={() => onTest(credential)}>
+        <Space onClick={(event) => event.stopPropagation()}>
+          <Button
+            aria-label={`${t('domains.test')} ${credential.name}`}
+            icon={<ThunderboltOutlined />}
+            loading={actionKey === `test:${credential.id}`}
+            onClick={() => void testCredential(credential)}
+          >
             {t('domains.test')}
           </Button>
-          <Button icon={<EditOutlined />} onClick={() => edit(credential)}>
-            {t('dnsApi.editTitle')}
-          </Button>
-          <Popconfirm title={t('tokens.deleteConfirm')} onConfirm={() => onDelete(credential)}>
-            <Button danger icon={<DeleteOutlined />} />
+          <Button
+            aria-label={`${t('common.edit')} ${credential.name}`}
+            icon={<EditOutlined />}
+            onClick={() => openEdit(credential)}
+          />
+          <Popconfirm title={t('tokens.deleteConfirm')} onConfirm={() => void deleteCredential(credential)}>
+            <Button
+              aria-label={`${t('common.delete')} ${credential.name}`}
+              danger
+              icon={<DeleteOutlined />}
+              loading={actionKey === `delete:${credential.id}`}
+            />
           </Popconfirm>
         </Space>
       )
@@ -62,92 +201,131 @@ export default function DnsApi({ credentials, loading, onSave, onTest, onDelete
 
   return (
     <Space direction="vertical" size={20} className="full-width">
-      <PageHeader title={t('dnsApi.title')} />
-
-      <SectionCard
+      <PageHeader
         title={t('dnsApi.title')}
-        extra={<StatusPill tone="neutral">{credentials.length}</StatusPill>}
-      >
-        <Table rowKey="id" columns={columns} dataSource={credentials} scroll={{ x: 900 }} />
+        subtitle={t('dnsApi.secretHint')}
+        extra={
+          <Button type="primary" icon={<PlusOutlined />} onClick={openCreate} style={{ minHeight: 44 }}>
+            {t('dnsApi.create')}
+          </Button>
+        }
+      />
+
+      {loadError ? (
+        <Alert
+          type="error"
+          showIcon
+          message={loadError}
+          action={<Button icon={<ReloadOutlined />} onClick={() => void loadCredentials()}>{t('common.refresh')}</Button>}
+        />
+      ) : null}
+
+      <SectionCard title={t('dnsApi.title')} extra={<StatusPill tone="neutral">{credentials.length}</StatusPill>}>
+        {loading ? <Skeleton active paragraph={{ rows: 5 }} /> : credentials.length ? (
+          <Table
+            rowKey="id"
+            columns={columns}
+            dataSource={credentials}
+            scroll={{ x: 940 }}
+            onRow={(credential) => ({
+              onClick: () => setSelected(credential),
+              style: { cursor: 'pointer' },
+              tabIndex: 0,
+              onKeyDown: (event) => {
+                if (event.key === 'Enter' || event.key === ' ') setSelected(credential);
+              }
+            })}
+          />
+        ) : (
+          <EmptyState
+            description={t('dnsApi.secretHint')}
+            action={<Button icon={<PlusOutlined />} onClick={openCreate}>{t('dnsApi.create')}</Button>}
+          />
+        )}
       </SectionCard>
 
-      <SectionCard
+      <Drawer
         title={editing ? `${t('dnsApi.editTitle')} ${editing.name}` : t('dnsApi.createTitle')}
-        extra={editing ? <Button onClick={() => { setEditing(null); form.resetFields(); }}>{t('common.cancel')}</Button> : null}
+        width={560}
+        open={editorOpen}
+        onClose={() => setEditorOpen(false)}
+        destroyOnHidden
+        footer={
+          <Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
+            <Button onClick={() => setEditorOpen(false)}>{t('common.cancel')}</Button>
+            <Button type="primary" loading={actionKey === 'create' || actionKey.startsWith('save:')} onClick={() => void submit()}>
+              {editing ? t('dnsApi.save') : t('dnsApi.create')}
+            </Button>
+          </Space>
+        }
       >
-        <Form
-          form={form}
-          layout="vertical"
-          onFinish={submit}
-          initialValues={{ provider: 'cloudflare', defaultTtl: 600 }}
-        >
-          <div className="form-grid two">
-            <Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}>
-              <Input placeholder="Primary Cloudflare" />
-            </Form.Item>
-            <Form.Item name="provider" label="Provider" rules={[{ required: true }]}>
-              <Select
-                options={[
-                  { value: 'cloudflare', label: 'Cloudflare' },
-                  { value: 'aliyun', label: 'Aliyun DNS' },
-                  { value: 'dnspod', label: 'Tencent DNSPod' }
-                ]}
-              />
-            </Form.Item>
-            <Form.Item
-              name="zoneName"
-              label={t('dnsApi.zone')}
-              rules={[{ required: true, message: t('dnsApi.zoneRequired') }]}
-              extra={t('dnsApi.zoneExtra')}
-            >
-              <Input placeholder="example.com" />
-            </Form.Item>
-            <Form.Item name="defaultTtl" label="TTL">
-              <InputNumber min={60} max={86400} className="full-width" />
-            </Form.Item>
-          </div>
+        <Alert type="info" showIcon message={t('dnsApi.secretHint')} style={{ marginBottom: 20 }} />
+        <Form form={form} layout="vertical" initialValues={{ provider: 'cloudflare', defaultTtl: 600 }}>
+          <Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}>
+            <Input placeholder="Primary Cloudflare" />
+          </Form.Item>
+          <Form.Item name="provider" label="Provider" rules={[{ required: true }]}>
+            <Select options={providerOptions} />
+          </Form.Item>
+          <Form.Item
+            name="zoneName"
+            label={t('dnsApi.zone')}
+            rules={[{ required: true, message: t('dnsApi.zoneRequired') }]}
+            extra={provider === 'cloudflare' ? t('dnsApi.cloudflareZoneExtra') : t('dnsApi.zoneExtra')}
+          >
+            <Input placeholder="example.com" />
+          </Form.Item>
+          <Form.Item name="defaultTtl" label="TTL">
+            <InputNumber min={60} max={86400} className="full-width" />
+          </Form.Item>
           {provider === 'cloudflare' ? (
-            <div className="form-grid two">
+            <>
               <Form.Item name="apiToken" label="Cloudflare API Token" extra={editing ? t('dnsApi.keepSecret') : t('dnsApi.tokenExtra')}>
-                <Input.Password autoComplete="off" />
-              </Form.Item>
-              <Form.Item name="zoneId" label="Cloudflare Zone ID">
-                <Input autoComplete="off" />
+                <Input.Password autoComplete="new-password" />
               </Form.Item>
-            </div>
+              <Form.Item name="zoneId" label="Cloudflare Zone ID"><Input autoComplete="off" /></Form.Item>
+            </>
           ) : null}
           {provider === 'aliyun' ? (
-            <div className="form-grid two">
-              <Form.Item name="accessKeyId" label="AccessKeyId" extra={editing ? t('dnsApi.keepSecret') : undefined}>
-                <Input autoComplete="off" />
-              </Form.Item>
-              <Form.Item name="accessKeySecret" label="AccessKeySecret">
-                <Input.Password autoComplete="off" />
-              </Form.Item>
-            </div>
+            <>
+              <Form.Item name="accessKeyId" label="AccessKeyId" extra={editing ? t('dnsApi.keepSecret') : undefined}><Input autoComplete="off" /></Form.Item>
+              <Form.Item name="accessKeySecret" label="AccessKeySecret" extra={editing ? t('dnsApi.keepSecret') : undefined}><Input.Password autoComplete="new-password" /></Form.Item>
+            </>
           ) : null}
           {provider === 'dnspod' ? (
-            <div className="form-grid two">
-              <Form.Item name="secretId" label="SecretId" extra={editing ? t('dnsApi.keepSecret') : undefined}>
-                <Input autoComplete="off" />
-              </Form.Item>
-              <Form.Item name="secretKey" label="SecretKey">
-                <Input.Password autoComplete="off" />
-              </Form.Item>
-            </div>
+            <>
+              <Form.Item name="secretId" label="SecretId" extra={editing ? t('dnsApi.keepSecret') : undefined}><Input autoComplete="off" /></Form.Item>
+              <Form.Item name="secretKey" label="SecretKey" extra={editing ? t('dnsApi.keepSecret') : undefined}><Input.Password autoComplete="new-password" /></Form.Item>
+            </>
           ) : null}
-          <Space>
-            <Button type="primary" htmlType="submit" loading={loading}>
-              {editing ? t('dnsApi.save') : t('dnsApi.create')}
-            </Button>
-            <Typography.Text type="secondary">{t('dnsApi.secretHint')}</Typography.Text>
-          </Space>
         </Form>
-      </SectionCard>
+      </Drawer>
+
+      <Drawer title={selected?.name} width={480} open={Boolean(selected)} onClose={() => setSelected(null)}>
+        {selected ? (
+          <Descriptions bordered column={1} size="small">
+            <Descriptions.Item label="Provider">{providerLabel(selected.provider)}</Descriptions.Item>
+            <Descriptions.Item label={t('dnsApi.zone')}><Typography.Text code>{selected.zoneName}</Typography.Text></Descriptions.Item>
+            <Descriptions.Item label="TTL">{selected.defaultTtl}</Descriptions.Item>
+            <Descriptions.Item label={t('common.status')}>
+              <StatusPill tone={selected.credentialSet === false ? 'warning' : 'success'}>
+                {selected.credentialSet === false ? t('common.notConfigured') : t('smtp.passwordSet')}
+              </StatusPill>
+            </Descriptions.Item>
+            <Descriptions.Item label={t('tokens.createdAt')}>{new Date(selected.createdAt).toLocaleString()}</Descriptions.Item>
+          </Descriptions>
+        ) : null}
+      </Drawer>
     </Space>
   );
 }
 
+const providerOptions = [
+  { value: 'cloudflare', label: 'Cloudflare' },
+  { value: 'aliyun', label: 'Aliyun DNS' },
+  { value: 'dnspod', label: 'Tencent DNSPod' }
+];
+
 function providerLabel(provider: string) {
   return {
     cloudflare: <StatusPill tone="info">Cloudflare</StatusPill>,

+ 429 - 383
src/pages/Domains/DomainDetail.tsx

@@ -1,476 +1,522 @@
-import { ArrowLeftOutlined, CopyOutlined } from '@ant-design/icons';
+import {
+  ArrowLeftOutlined,
+  CopyOutlined,
+  DeleteOutlined,
+  EditOutlined,
+  EllipsisOutlined,
+  InboxOutlined,
+  ReloadOutlined,
+  SendOutlined,
+  ThunderboltOutlined
+} from '@ant-design/icons';
 import {
   Alert,
+  App,
   Button,
-  Collapse,
+  Col,
   Descriptions,
+  Dropdown,
   Form,
   Input,
+  List,
   Modal,
+  Result,
   Row,
-  Col,
   Select,
+  Skeleton,
   Space,
   Table,
   Tabs,
   Typography
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useMemo, useState } from 'react';
+import type { MenuProps } from 'antd';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
 
-import { CodeBlock } from '../../components/common/CodeBlock';
 import { EmptyState } from '../../components/common/EmptyState';
+import { PageHeader } from '../../components/common/PageHeader';
 import { SectionCard } from '../../components/common/SectionCard';
 import { StatusPill } from '../../components/common/StatusPill';
-import { DomainHealthCard } from '../../components/domain/DomainHealthCard';
 import { DnsRecordCard } from '../../components/domain/DnsRecordCard';
-import { getVisibleDnsRecords } from '../../frontend/domain-model.js';
+import { useAppContext } from '../../frontend/app-context';
+import { buildDomainHealth, getVisibleDnsRecords } from '../../frontend/domain-model.js';
 import { useI18n } from '../../frontend/i18n/react';
+import { api } from '../../frontend/services/api';
 import type {
   ApiToken,
   DnsCredential,
-  DnsRecord,
   Domain,
   DomainPatchPayload,
-  RuntimeConfig,
+  InboundMailbox,
   SendEvent,
   SmtpCredential,
   SmtpRelay
 } from '../../frontend/types';
-import Webhooks from '../Webhooks';
-
-interface DomainDetailProps {
-  domain: Domain;
-  config: RuntimeConfig | null;
-  smtpCredential: SmtpCredential | null;
-  apiTokens: ApiToken[];
-  events: SendEvent[];
+
+const sections = ['overview', 'dns', 'sending', 'inbound', 'activity'] as const;
+type DetailSection = (typeof sections)[number];
+
+interface BaseData {
+  domain: Domain | null;
   dnsCredentials: DnsCredential[];
   smtpRelays: SmtpRelay[];
-  actionLoading?: boolean;
-  initialTab?: string;
-  onBack: () => void;
-  onApplyDns: (domain: Domain) => void;
-  onCheck: (domain: Domain) => void;
-  onSendTest: (domain: Domain) => void;
-  onPatchDomain: (domain: Domain, values: DomainPatchPayload) => Promise<void>;
-  onCopy: (value: string) => void;
-  onDelete: (domain: Domain) => void;
 }
 
-export default function DomainDetail({
-  domain,
-  config,
-  smtpCredential,
-  apiTokens,
-  events,
-  dnsCredentials,
-  smtpRelays,
-  actionLoading,
-  initialTab,
-  onBack,
-  onApplyDns,
-  onCheck,
-  onSendTest,
-  onPatchDomain,
-  onCopy,
-  onDelete
-}: DomainDetailProps) {
-  const { t } = useI18n();
-  const [activeTab, setActiveTab] = useState(initialTab || 'overview');
+export default function DomainDetail() {
+  const { id, section } = useParams<{ id: string; section: string }>();
+  const domainId = Number(id);
+  const activeSection: DetailSection = isSection(section) ? section : 'overview';
+  const navigate = useNavigate();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const { config } = useAppContext();
+  const { locale } = useI18n();
+  const copy = locale.startsWith('en') ? enCopy : zhCopy;
+  const { message } = App.useApp();
+  const [base, setBase] = useState<BaseData | null>(null);
+  const [events, setEvents] = useState<SendEvent[] | null>(null);
+  const [smtpCredential, setSmtpCredential] = useState<SmtpCredential | null | undefined>(undefined);
+  const [apiTokens, setApiTokens] = useState<ApiToken[] | null>(null);
+  const [mailboxes, setMailboxes] = useState<InboundMailbox[] | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [sectionLoading, setSectionLoading] = useState(false);
+  const [error, setError] = useState('');
+  const [sectionError, setSectionError] = useState('');
+  const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
   const [editOpen, setEditOpen] = useState(false);
-  const [form] = Form.useForm<DomainPatchPayload>();
-  const dnsApiName = dnsCredentials.find((item) => item.id === domain.dnsCredentialId)?.name;
-  const smtpRelayName = smtpRelays.find((item) => item.id === domain.smtpRelayId)?.name;
-  const domainEvents = events.filter((event) => event.domain === domain.domain);
-  const records = useMemo(() => orderedRecords(domain.status?.records || []), [domain.status?.records]);
-
-  function openEdit() {
-    form.setFieldsValue({
-      selector: domain.selector,
-      dnsCredentialId: domain.dnsCredentialId,
-      smtpRelayId: domain.smtpRelayId,
-      senderHost: domain.senderHost,
-      sendingIp: domain.sendingIp,
-      spfExtra: domain.spfExtra,
-      dmarcPolicy: domain.dmarcPolicy,
-      dmarcRua: domain.dmarcRua
+  const [deleteOpen, setDeleteOpen] = useState(false);
+  const [deleteConfirmation, setDeleteConfirmation] = useState('');
+  const [testOpen, setTestOpen] = useState(false);
+  const [editForm] = Form.useForm<DomainPatchPayload>();
+  const [testForm] = Form.useForm<{ to: string; subject: string; text: string }>();
+
+  const loadBase = useCallback(async () => {
+    setLoading(true);
+    setError('');
+    try {
+      const [domainsResult, credentialsResult, relaysResult] = await Promise.all([
+        api.domains(), api.dnsCredentials(), api.smtpRelays()
+      ]);
+      setBase({
+        domain: domainsResult.domains.find((item) => item.id === domainId) || null,
+        dnsCredentials: credentialsResult.credentials,
+        smtpRelays: relaysResult.relays
+      });
+    } catch (reason) {
+      setError(reason instanceof Error ? reason.message : copy.loadFailed);
+    } finally {
+      setLoading(false);
+    }
+  }, [copy.loadFailed, domainId]);
+
+  useEffect(() => {
+    void loadBase();
+  }, [loadBase]);
+
+  useEffect(() => {
+    if (!section || isSection(section)) return;
+    navigate(`/domains/${id}/overview`, { replace: true });
+  }, [id, navigate, section]);
+
+  useEffect(() => {
+    if (searchParams.get('edit') === '1' && base?.domain) openEdit(base.domain);
+    // The query flag is an entry action; the form itself controls subsequent visibility.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [base?.domain?.id, searchParams]);
+
+  const loadSection = useCallback(async () => {
+    if (!base?.domain) return;
+    if (activeSection === 'overview' || activeSection === 'dns') return;
+    if (activeSection === 'activity' && events !== null) return;
+    if (activeSection === 'sending' && apiTokens !== null && smtpCredential !== undefined) return;
+    if (activeSection === 'inbound' && mailboxes !== null) return;
+    setSectionLoading(true);
+    setSectionError('');
+    try {
+      if (activeSection === 'activity') {
+        const result = await api.events({ domainId: base.domain.id, page: 1, pageSize: 50 });
+        setEvents(result.events);
+      } else if (activeSection === 'sending') {
+        const [credentialResult, tokensResult] = await Promise.all([api.smtpCredential(), api.apiTokens()]);
+        setSmtpCredential(credentialResult.credential);
+        setApiTokens(tokensResult.tokens);
+      } else if (activeSection === 'inbound') {
+        const result = await api.inboundMailboxes();
+        setMailboxes(result.mailboxes.filter((mailbox) => mailbox.domainId === base.domain?.id));
+      }
+    } catch (reason) {
+      setSectionError(reason instanceof Error ? reason.message : copy.sectionLoadFailed);
+    } finally {
+      setSectionLoading(false);
+    }
+  }, [activeSection, apiTokens, base?.domain, copy.sectionLoadFailed, events, mailboxes, smtpCredential]);
+
+  useEffect(() => {
+    void loadSection();
+  }, [loadSection]);
+
+  const domain = base?.domain || null;
+  const health = useMemo(() => domain ? buildDomainHealth(domain) : null, [domain]);
+
+  function updateDomain(nextDomain: Domain) {
+    setBase((current) => current ? { ...current, domain: nextDomain } : current);
+  }
+
+  async function runAction<T>(name: string, operation: () => Promise<T>) {
+    setActionLoading((current) => ({ ...current, [name]: true }));
+    try {
+      return await operation();
+    } finally {
+      setActionLoading((current) => ({ ...current, [name]: false }));
+    }
+  }
+
+  async function checkDns() {
+    if (!domain) return;
+    try {
+      const result = await runAction('check', () => api.checkDomain(domain.id));
+      updateDomain(result.domain);
+      message.success(copy.checked);
+    } catch (reason) {
+      message.error(errorMessage(reason, copy.actionFailed));
+    }
+  }
+
+  async function applyDns() {
+    if (!domain) return;
+    try {
+      const result = await runAction('apply', () => api.applyDns(domain.id));
+      updateDomain(result.domain);
+      if (result.apply?.ok === false) message.warning(copy.dnsPartial);
+      else message.success(copy.dnsApplied);
+    } catch (reason) {
+      message.error(errorMessage(reason, copy.actionFailed));
+    }
+  }
+
+  async function saveDomain() {
+    if (!domain) return;
+    let values: DomainPatchPayload;
+    try {
+      values = await editForm.validateFields();
+    } catch {
+      return;
+    }
+    try {
+      const result = await runAction('edit', () => api.patchDomain(domain.id, {
+        ...values,
+        dnsCredentialId: values.dnsCredentialId ?? null,
+        smtpRelayId: values.smtpRelayId ?? null
+      }));
+      updateDomain(result.domain);
+      closeEdit();
+      message.success(copy.saved);
+    } catch (reason) {
+      message.error(errorMessage(reason, copy.actionFailed));
+    }
+  }
+
+  async function sendTest() {
+    if (!domain) return;
+    let values: { to: string; subject: string; text: string };
+    try {
+      values = await testForm.validateFields();
+    } catch {
+      return;
+    }
+    try {
+      await runAction('test', () => api.sendTest(domain.id, values));
+      setTestOpen(false);
+      testForm.resetFields();
+      message.success(copy.testQueued);
+    } catch (reason) {
+      message.error(errorMessage(reason, copy.actionFailed));
+    }
+  }
+
+  async function deleteDomain() {
+    if (!domain || deleteConfirmation !== domain.domain) return;
+    try {
+      await runAction('delete', () => api.deleteDomain(domain.id));
+      message.success(copy.deleted);
+      navigate('/domains', { replace: true });
+    } catch (reason) {
+      message.error(errorMessage(reason, copy.actionFailed));
+    }
+  }
+
+  function openEdit(target: Domain) {
+    editForm.setFieldsValue({
+      selector: target.selector,
+      dnsCredentialId: target.dnsCredentialId,
+      smtpRelayId: target.smtpRelayId,
+      senderHost: target.senderHost,
+      sendingIp: target.sendingIp,
+      spfExtra: target.spfExtra,
+      dmarcPolicy: target.dmarcPolicy,
+      dmarcRua: target.dmarcRua,
+      catchAllAddress: target.catchAllAddress
     });
     setEditOpen(true);
   }
 
-  async function saveEdit() {
-    const values = await form.validateFields();
-    await onPatchDomain(domain, {
-      ...values,
-      dnsCredentialId: values.dnsCredentialId ?? null,
-      smtpRelayId: values.smtpRelayId ?? null
-    });
+  function closeEdit() {
     setEditOpen(false);
+    if (searchParams.has('edit')) {
+      const next = new URLSearchParams(searchParams);
+      next.delete('edit');
+      setSearchParams(next, { replace: true });
+    }
   }
 
+  function dismissSetupNotice() {
+    if (!searchParams.has('setup')) return;
+    const next = new URLSearchParams(searchParams);
+    next.delete('setup');
+    setSearchParams(next, { replace: true });
+  }
+
+  function openTest() {
+    if (!domain) return;
+    testForm.setFieldsValue({ subject: copy.testSubject(domain.domain), text: copy.testBody(domain.domain) });
+    setTestOpen(true);
+  }
+
+  async function copyValue(value: string) {
+    try {
+      await navigator.clipboard.writeText(value);
+      message.success(copy.copied);
+    } catch {
+      message.error(copy.copyFailed);
+    }
+  }
+
+  if (loading && !base) {
+    return <Space direction="vertical" size={20} className="full-width" aria-label={copy.loading}><Skeleton active title={{ width: '36%' }} paragraph={{ rows: 3 }} /><SectionCard><Skeleton active paragraph={{ rows: 10 }} /></SectionCard></Space>;
+  }
+
+  if (error && !base) {
+    return <Result status="error" title={copy.loadFailed} subTitle={error} extra={<Space><Button onClick={() => navigate('/domains')}>{copy.back}</Button><Button type="primary" onClick={() => void loadBase()}>{copy.retry}</Button></Space>} />;
+  }
+
+  if (!Number.isInteger(domainId) || domainId <= 0 || (base && !domain)) {
+    return <Result status="404" title={copy.notFound} subTitle={copy.notFoundHint} extra={<Button type="primary" onClick={() => navigate('/domains')}>{copy.backToDomains}</Button>} />;
+  }
+
+  if (!domain || !base || !health) return null;
+
+  const menuItems: MenuProps['items'] = [
+    { key: 'check', icon: <ReloadOutlined />, label: copy.checkDns, onClick: () => void checkDns() },
+    { key: 'apply', icon: <ThunderboltOutlined />, label: copy.autoDns, disabled: !domain.dnsCredentialId, onClick: () => void applyDns() },
+    { key: 'edit', icon: <EditOutlined />, label: copy.edit, onClick: () => openEdit(domain) },
+    { type: 'divider' },
+    { key: 'delete', icon: <DeleteOutlined />, danger: true, label: copy.delete, onClick: () => { setDeleteConfirmation(''); setDeleteOpen(true); } }
+  ];
+
   return (
     <Space direction="vertical" size={20} className="full-width">
-      <Button icon={<ArrowLeftOutlined />} onClick={onBack}>
-        {t('domainDetail.back')}
-      </Button>
-      <DomainHealthCard
-        domain={domain}
-        lastSentAt={domainEvents[0] ? new Date(domainEvents[0].createdAt).toLocaleString() : undefined}
-        dnsApiName={dnsApiName}
-        loading={actionLoading}
-        onApplyDns={() => onApplyDns(domain)}
-        onCheck={() => onCheck(domain)}
-        onSendTest={() => onSendTest(domain)}
-        onEdit={openEdit}
+      <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/domains')} style={{ width: 'fit-content', minHeight: 44 }}>{copy.backToDomains}</Button>
+      <PageHeader
+        title={domain.domain}
+        subtitle={`${domain.senderHost} · ${domain.sendingIp}`}
+        extra={
+          <Space>
+            <StatusPill tone={health.status === 'success' ? 'success' : health.status === 'warning' ? 'warning' : 'error'}>{health.passed}/{health.total} {health.status === 'success' ? copy.dnsPassed : copy.needsAttention}</StatusPill>
+            <Button type="primary" icon={<SendOutlined />} style={{ minHeight: 44 }} onClick={openTest}>{copy.testSend}</Button>
+            <Dropdown menu={{ items: menuItems }} trigger={['click']}><Button aria-label={copy.moreActions} icon={<EllipsisOutlined />} style={{ minHeight: 44 }} loading={Object.values(actionLoading).some(Boolean)} /></Dropdown>
+          </Space>
+        }
       />
+
+      {searchParams.get('setup') === 'partial' ? (
+        <Alert
+          type="warning"
+          showIcon
+          closable
+          message={copy.setupPartialTitle}
+          description={copy.setupPartialDescription}
+          onClose={dismissSetupNotice}
+        />
+      ) : searchParams.get('setup') === 'complete' ? (
+        <Alert
+          type="success"
+          showIcon
+          closable
+          message={copy.setupCompleteTitle}
+          description={domain.dnsCredentialId ? copy.setupAutomaticDescription : copy.setupManualDescription}
+          onClose={dismissSetupNotice}
+        />
+      ) : null}
+
       <Tabs
-        activeKey={activeTab}
-        onChange={setActiveTab}
+        activeKey={activeSection}
+        onChange={(key) => navigate(`/domains/${domain.id}/${key}`)}
         className="domain-detail-tabs"
         items={[
-          { key: 'overview', label: 'Overview', children: <OverviewTab domain={domain} events={domainEvents} smtpRelayName={smtpRelayName} onDelete={() => onDelete(domain)} /> },
-          {
-            key: 'dns',
-            label: 'DNS Records',
-            children: (
-              <DnsRecordsTab
-                domain={domain}
-                records={records}
-                loading={actionLoading}
-                onCopy={onCopy}
-                onCheck={() => onCheck(domain)}
-              />
-            )
-          },
-          {
-            key: 'smtp-api',
-            label: 'SMTP / API',
-            children: (
-              <SmtpApiTab
-                domain={domain}
-                config={config}
-                smtpCredential={smtpCredential}
-                apiTokens={apiTokens}
-                onCopy={onCopy}
-              />
-            )
-          },
-          { key: 'logs', label: 'Sending Logs', children: <SendingLogsTab events={domainEvents} /> },
-          { key: 'guide', label: 'Integration Guide', children: <IntegrationGuideTab domain={domain} config={config} apiTokens={apiTokens} /> },
-          {
-            key: 'webhooks',
-            label: 'Webhooks',
-            children: <Webhooks domainId={domain.id} domains={[domain]} onCopy={onCopy} />
-          }
+          { key: 'overview', label: copy.overview, children: <OverviewTab domain={domain} health={health} dnsName={base.dnsCredentials.find((item) => item.id === domain.dnsCredentialId)?.name} relayName={base.smtpRelays.find((item) => item.id === domain.smtpRelayId)?.name} copy={copy} onCheck={checkDns} onReviewDns={() => navigate(`/domains/${domain.id}/dns`)} onEdit={() => openEdit(domain)} onDelete={() => setDeleteOpen(true)} loading={actionLoading} /> },
+          { key: 'dns', label: copy.dnsAndVerification, children: <DnsTab domain={domain} copy={copy} onCopy={copyValue} onCheck={checkDns} onApply={applyDns} loading={actionLoading} /> },
+          { key: 'sending', label: copy.sendingConfiguration, children: <SectionContent loading={sectionLoading} error={sectionError} retry={loadSection} copy={copy}><SendingTab domain={domain} config={config} credential={smtpCredential} tokens={apiTokens || []} relayName={base.smtpRelays.find((item) => item.id === domain.smtpRelayId)?.name} copy={copy} onCopy={copyValue} onEdit={() => openEdit(domain)} /></SectionContent> },
+          { key: 'inbound', label: copy.inboundConfiguration, children: <SectionContent loading={sectionLoading} error={sectionError} retry={loadSection} copy={copy}><InboundTab domain={domain} config={config} mailboxes={mailboxes || []} copy={copy} onNavigate={() => navigate('/inbox')} onEdit={() => openEdit(domain)} /></SectionContent> },
+          { key: 'activity', label: copy.activity, children: <SectionContent loading={sectionLoading} error={sectionError} retry={loadSection} copy={copy}><ActivityTab events={events || []} copy={copy} onView={(event) => navigate(`/activity/${event.id}?domainId=${domain.id}`)} /></SectionContent> }
         ]}
       />
-      <Modal title={t('domainDetail.editTitle')} open={editOpen} onCancel={() => setEditOpen(false)} onOk={saveEdit} confirmLoading={actionLoading}>
-        <Form form={form} layout="vertical">
-          <Form.Item name="dnsCredentialId" label={t('domains.dnsApi')}>
-            <Select
-              allowClear
-              placeholder={t('addDomain.manualDns')}
-              options={dnsCredentials.map((credential) => ({
-                value: credential.id,
-                label: credential.name
-              }))}
-            />
-          </Form.Item>
-          <Form.Item name="smtpRelayId" label={t('smtpRelay.domainDefault')}>
-            <Select
-              allowClear
-              placeholder={t('smtpRelay.useResolutionOrder')}
-              options={smtpRelays.map((relay) => ({
-                value: relay.id,
-                label: relayLabel(relay, t)
-              }))}
-            />
-          </Form.Item>
-          <Form.Item name="selector" label="DKIM selector" rules={[{ required: true, message: t('addDomain.selectorRequired') }]}>
-            <Input />
-          </Form.Item>
-          <Form.Item name="senderHost" label={t('domains.senderHost')} rules={[{ required: true, message: t('addDomain.senderHostRequired') }]}>
-            <Input />
-          </Form.Item>
-          <Form.Item name="sendingIp" label={t('domains.sendingIp')} rules={[{ required: true, message: t('addDomain.sendingIpRequired') }]}>
-            <Input />
-          </Form.Item>
-          <Form.Item name="dmarcPolicy" label="DMARC">
-            <Select options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} />
-          </Form.Item>
-          <Form.Item name="spfExtra" label={t('addDomain.spfExtra')}>
-            <Input.TextArea rows={3} />
-          </Form.Item>
-          <Form.Item name="dmarcRua" label="DMARC rua">
-            <Input placeholder="mailto:dmarc@example.com" />
-          </Form.Item>
-        </Form>
+
+      <EditDomainModal open={editOpen} domain={domain} form={editForm} dnsCredentials={base.dnsCredentials} smtpRelays={base.smtpRelays} copy={copy} loading={Boolean(actionLoading.edit)} onCancel={closeEdit} onSave={saveDomain} />
+
+      <Modal title={copy.deleteTitle} open={deleteOpen} okText={copy.delete} cancelText={copy.cancel} okButtonProps={{ danger: true, disabled: deleteConfirmation !== domain.domain, loading: Boolean(actionLoading.delete) }} onCancel={() => { setDeleteOpen(false); setDeleteConfirmation(''); }} onOk={() => void deleteDomain()}>
+        <Space direction="vertical" size={16} className="full-width"><Alert type="error" showIcon message={copy.deleteWarning} /><Typography.Text>{copy.typeDomain} <Typography.Text code>{domain.domain}</Typography.Text></Typography.Text><Input value={deleteConfirmation} onChange={(event) => setDeleteConfirmation(event.target.value)} aria-label={copy.deleteConfirmation} style={{ minHeight: 44 }} /></Space>
+      </Modal>
+
+      <Modal title={copy.testSend} open={testOpen} okText={copy.queueTest} cancelText={copy.cancel} confirmLoading={Boolean(actionLoading.test)} onCancel={() => setTestOpen(false)} onOk={() => void sendTest()}>
+        <Form form={testForm} layout="vertical"><Form.Item name="to" label={copy.recipient} rules={[{ required: true, type: 'email', message: copy.validRecipient }]}><Input placeholder="you@example.com" style={{ minHeight: 44 }} /></Form.Item><Form.Item name="subject" label={copy.subject} rules={[{ required: true }]}><Input style={{ minHeight: 44 }} /></Form.Item><Form.Item name="text" label={copy.body} rules={[{ required: true }]}><Input.TextArea rows={5} /></Form.Item></Form>
       </Modal>
     </Space>
   );
 }
 
-function OverviewTab({
-  domain,
-  events,
-  smtpRelayName,
-  onDelete
-}: {
-  domain: Domain;
-  events: SendEvent[];
-  smtpRelayName?: string;
-  onDelete: () => void;
-}) {
-  const { t } = useI18n();
+function OverviewTab({ domain, health, dnsName, relayName, copy, onCheck, onReviewDns, onEdit, onDelete, loading }: { domain: Domain; health: ReturnType<typeof buildDomainHealth>; dnsName?: string; relayName?: string; copy: typeof zhCopy; onCheck: () => void; onReviewDns: () => void; onEdit: () => void; onDelete: () => void; loading: Record<string, boolean> }) {
   return (
-    <Row gutter={[16, 16]}>
-      <Col xs={24} lg={16}>
-        <SectionCard title={t('domainDetail.overview')}>
-          <Descriptions column={1}>
-            <Descriptions.Item label={t('domains.domain')}>{domain.domain}</Descriptions.Item>
-            <Descriptions.Item label={t('domains.senderHost')}>{domain.senderHost}</Descriptions.Item>
-            <Descriptions.Item label={t('domains.sendingIp')}>{domain.sendingIp}</Descriptions.Item>
-            <Descriptions.Item label={t('smtpRelay.domainDefault')}>{smtpRelayName || t('smtpRelay.useResolutionOrder')}</Descriptions.Item>
-            <Descriptions.Item label="DKIM selector">{domain.selector}</Descriptions.Item>
-            <Descriptions.Item label={t('domains.lastSent')}>{events[0] ? new Date(events[0].createdAt).toLocaleString() : t('common.notFound')}</Descriptions.Item>
-          </Descriptions>
-        </SectionCard>
-      </Col>
-      <Col xs={24} lg={8}>
-        <SectionCard title={t('domainDetail.danger')} className="domain-danger-card">
-          <Space direction="vertical" className="full-width" size={12}>
-            <Typography.Text type="secondary">{t('domainDetail.deleteHint')}</Typography.Text>
-            <Button danger block onClick={onDelete}>
-              {t('common.delete')}
-            </Button>
-          </Space>
-        </SectionCard>
-      </Col>
-    </Row>
+    <Space direction="vertical" size={20} className="full-width">
+      <Row gutter={[16, 16]}>
+        <Col xs={24} lg={15}><SectionCard title={copy.domainSummary}><Descriptions column={1}><Descriptions.Item label={copy.domain}>{domain.domain}</Descriptions.Item><Descriptions.Item label={copy.senderHost}>{domain.senderHost}</Descriptions.Item><Descriptions.Item label={copy.sendingIp}>{domain.sendingIp}</Descriptions.Item><Descriptions.Item label="DKIM selector">{domain.selector}</Descriptions.Item><Descriptions.Item label={copy.dnsIntegration}>{dnsName || copy.manualDns}</Descriptions.Item><Descriptions.Item label={copy.relay}>{relayName || copy.defaultDelivery}</Descriptions.Item><Descriptions.Item label={copy.createdAt}>{new Date(domain.createdAt).toLocaleString()}</Descriptions.Item></Descriptions></SectionCard></Col>
+        <Col xs={24} lg={9}><SectionCard title={copy.authenticationHealth}><Space direction="vertical" size={16} className="full-width"><Typography.Title level={2} style={{ margin: 0 }}>{health.passed}/{health.total}</Typography.Title><Typography.Text type="secondary">{copy.recordsPassed}</Typography.Text><StatusPill tone={health.status === 'success' ? 'success' : health.status === 'warning' ? 'warning' : 'error'}>{health.status === 'success' ? copy.ready : copy.needsAttention}</StatusPill><Button icon={<ReloadOutlined />} loading={loading.check} onClick={onCheck}>{copy.checkDns}</Button></Space></SectionCard></Col>
+      </Row>
+      <Row gutter={[16, 16]}><Col xs={24} lg={12}><SectionCard title={copy.nextActions}><Space wrap><Button icon={<ThunderboltOutlined />} onClick={onReviewDns}>{copy.reviewDns}</Button><Button icon={<EditOutlined />} onClick={onEdit}>{copy.edit}</Button></Space></SectionCard></Col><Col xs={24} lg={12}><SectionCard title={copy.dangerZone} className="domain-danger-card"><Space direction="vertical" className="full-width"><Typography.Text type="secondary">{copy.dangerHint}</Typography.Text><Button danger icon={<DeleteOutlined />} onClick={onDelete}>{copy.delete}</Button></Space></SectionCard></Col></Row>
+    </Space>
   );
 }
 
-function DnsRecordsTab({
-  domain,
-  records,
-  loading,
-  onCopy,
-  onCheck
-}: {
-  domain: Domain;
-  records: DnsRecord[];
-  loading?: boolean;
-  onCopy: (value: string) => void;
-  onCheck: () => void;
-}) {
-  const { t } = useI18n();
-  const liveEntries = Object.entries(domain.status?.live || {});
+function DnsTab({ domain, copy, onCopy, onCheck, onApply, loading }: { domain: Domain; copy: typeof zhCopy; onCopy: (value: string) => void; onCheck: () => void; onApply: () => void; loading: Record<string, boolean> }) {
+  const records = getVisibleDnsRecords(domain.status?.records || []);
   const copyAll = records.map((record) => `${record.host}\t${record.type}\t${record.value || ''}`).join('\n');
+  return (
+    <Space direction="vertical" size={16} className="full-width">
+      <SectionCard title={copy.dnsAndVerification} extra={<Space wrap><Button icon={<ReloadOutlined />} loading={loading.check} onClick={onCheck}>{copy.recheck}</Button><Button icon={<ThunderboltOutlined />} disabled={!domain.dnsCredentialId} loading={loading.apply} onClick={onApply}>{copy.autoDns}</Button><Button icon={<CopyOutlined />} disabled={!copyAll} onClick={() => onCopy(copyAll)}>{copy.copyAll}</Button></Space>}>
+        <Alert type={domain.status?.verified ? 'success' : 'warning'} showIcon message={domain.status?.verified ? copy.domainVerified : copy.domainNotVerified} description={`${copy.lastChecked}: ${domain.status?.checkedAt ? new Date(domain.status.checkedAt).toLocaleString() : copy.never}`} />
+      </SectionCard>
+      <DnsApplyResults apply={domain.status?.apply} copy={copy} />
+      {records.length ? records.map((record) => <DnsRecordCard key={record.key} record={record} loading={loading.check} onCopy={onCopy} onRecheck={onCheck} />) : <SectionCard><EmptyState description={copy.noDnsRecords} action={<Button onClick={onCheck}>{copy.checkNow}</Button>} /></SectionCard>}
+      {domain.status?.warnings?.length ? <Alert type="warning" showIcon message={copy.warnings} description={domain.status.warnings.join('\n')} /> : null}
+    </Space>
+  );
+}
 
+function DnsApplyResults({ apply, copy }: { apply?: Domain['status']['apply']; copy: typeof zhCopy }) {
+  if (!apply?.results?.length) return null;
   return (
-    <Row gutter={[16, 16]} align="top">
-      <Col xs={24} xl={15}>
-        <Space direction="vertical" size={12} className="full-width">
-          {records.length ? records.map((record) => (
-              <DnsRecordCard key={record.key} record={record} loading={loading} onCopy={onCopy} onRecheck={onCheck} />
-          )) : (
-            <SectionCard>
-              <EmptyState
-                description={t('domainDetail.noDnsResult')}
-                action={
-                  <Button type="primary" onClick={onCheck}>{t('domainHealth.checkNow')}</Button>
-                }
-              />
-            </SectionCard>
-          )}
-        </Space>
-      </Col>
-      <Col xs={24} xl={9}>
-        <SectionCard
-          title={t('domainDetail.currentDnsResult')}
-          extra={
-            <Space>
-              <Button size="small" onClick={onCheck}>{t('domainDetail.recheckAll')}</Button>
-              <Button size="small" icon={<CopyOutlined />} disabled={!copyAll} onClick={() => onCopy(copyAll)}>
-                {t('domainDetail.copyAll')}
-              </Button>
-            </Space>
-          }
-        >
-          <Space direction="vertical" size={16} className="full-width">
-            <StatusPill tone={domain.status?.checkedAt ? 'neutral' : 'warning'}>
-              {t('domainDetail.lastCheck')}:{domain.status?.checkedAt ? new Date(domain.status.checkedAt).toLocaleString() : t('domainDetail.notChecked')}
-            </StatusPill>
-            {liveEntries.length ? (
-              <Collapse
-                items={liveEntries.map(([key, values]) => ({
-                  key,
-                  label: liveLabel(key, t),
-                  children: values.length ? (
-                    <Space direction="vertical" className="full-width" size={8}>
-                      {values.map((value) => (
-                        <CodeBlock key={value} value={value} onCopy={onCopy} />
-                      ))}
-                    </Space>
-                  ) : (
-                    <Typography.Text type="secondary">{t('domainDetail.notFoundRecord')}</Typography.Text>
-                  )
-                }))}
+    <SectionCard
+      title={copy.applyResults}
+      extra={<StatusPill tone={apply.ok ? 'success' : 'warning'}>{apply.ok ? copy.applyComplete : copy.applyPartial}</StatusPill>}
+    >
+      <List
+        dataSource={apply.results}
+        renderItem={(result) => {
+          const tone = result.skipped ? 'warning' : (result.ok ? 'success' : 'error');
+          const label = result.skipped ? copy.applySkipped : (result.ok ? copy.applySucceeded : copy.applyFailed);
+          const description = result.ok
+            ? (result.detail || copy.applySucceededHint)
+            : (result.error || copy.applyFailedHint);
+          return (
+            <List.Item>
+              <List.Item.Meta
+                title={<Space wrap><StatusPill tone={tone}>{label}</StatusPill><Typography.Text code>{result.type}</Typography.Text><Typography.Text>{result.host}</Typography.Text></Space>}
+                description={<Typography.Text type={result.ok ? 'secondary' : 'danger'}>{description}</Typography.Text>}
               />
-            ) : (
-              <EmptyState description={t('domainDetail.noPublicDns')} />
-            )}
-            {domain.status?.warnings?.length ? (
-              <Alert type="warning" showIcon message={t('domainDetail.needAttention')} description={domain.status.warnings.join('\n')} />
-            ) : null}
-          </Space>
-        </SectionCard>
-      </Col>
+            </List.Item>
+          );
+        }}
+      />
+    </SectionCard>
+  );
+}
+
+function SendingTab({ domain, config, credential, tokens, relayName, copy, onCopy, onEdit }: { domain: Domain; config: ReturnType<typeof useAppContext>['config']; credential: SmtpCredential | null | undefined; tokens: ApiToken[]; relayName?: string; copy: typeof zhCopy; onCopy: (value: string) => void; onEdit: () => void }) {
+  const endpoint = `${config?.appBaseUrl || window.location.origin}/api/send`;
+  return (
+    <Row gutter={[16, 16]}>
+      <Col xs={24} xl={12}><SectionCard title="SMTP" extra={<Button icon={<EditOutlined />} onClick={onEdit}>{copy.edit}</Button>}><Descriptions column={1}><Descriptions.Item label={copy.host}>{copyable(config?.submission?.host || '-', onCopy)}</Descriptions.Item><Descriptions.Item label={copy.ports}>{config?.submission?.ports?.map((item) => `${item.port}/${item.protocol}`).join(', ') || '-'}</Descriptions.Item><Descriptions.Item label={copy.username}>{credential?.username || config?.submission?.username || '-'}</Descriptions.Item><Descriptions.Item label={copy.password}><StatusPill tone={credential?.passwordSet ? 'success' : 'warning'}>{credential?.passwordSet ? copy.configured : copy.notConfigured}</StatusPill></Descriptions.Item><Descriptions.Item label={copy.relay}>{relayName || copy.defaultDelivery}</Descriptions.Item></Descriptions></SectionCard></Col>
+      <Col xs={24} xl={12}><SectionCard title="API" extra={<Button href="/integrations/api-keys">{copy.manageKeys}</Button>}><Descriptions column={1}><Descriptions.Item label="Endpoint">{copyable(endpoint, onCopy)}</Descriptions.Item><Descriptions.Item label={copy.availableKeys}>{tokens.filter((token) => !token.revokedAt).length}</Descriptions.Item></Descriptions>{tokens.length ? <List size="small" dataSource={tokens.slice(0, 4)} renderItem={(token) => <List.Item><List.Item.Meta title={token.name} description={<Typography.Text code>{token.tokenPrefix}••••</Typography.Text>} /></List.Item>} /> : <EmptyState description={copy.noKeys} />}</SectionCard></Col>
+      <Col span={24}><SectionCard title={copy.senderIdentity}><Descriptions column={1}><Descriptions.Item label={copy.fromDomain}>{domain.domain}</Descriptions.Item><Descriptions.Item label={copy.senderHost}>{domain.senderHost}</Descriptions.Item><Descriptions.Item label={copy.verification}><StatusPill tone={domain.status?.verified ? 'success' : 'warning'}>{domain.status?.verified ? copy.verified : copy.pending}</StatusPill></Descriptions.Item></Descriptions></SectionCard></Col>
     </Row>
   );
 }
 
-function SmtpApiTab({
-  domain,
-  config,
-  smtpCredential,
-  apiTokens,
-  onCopy
-}: {
-  domain: Domain;
-  config: RuntimeConfig | null;
-  smtpCredential: SmtpCredential | null;
-  apiTokens: ApiToken[];
-  onCopy: (value: string) => void;
-}) {
-  const { t } = useI18n();
-  const apiEndpoint = `${config?.appBaseUrl || window.location.origin}/api/send`;
+function InboundTab({ domain, config, mailboxes, copy, onNavigate, onEdit }: { domain: Domain; config: ReturnType<typeof useAppContext>['config']; mailboxes: InboundMailbox[]; copy: typeof zhCopy; onNavigate: () => void; onEdit: () => void }) {
   return (
     <Row gutter={[16, 16]}>
-      <Col xs={24} lg={12}>
-        <SectionCard title="SMTP">
-          <Descriptions column={1}>
-            <Descriptions.Item label="SMTP Host">{copyable(config?.submission?.host || '-', onCopy)}</Descriptions.Item>
-            <Descriptions.Item label="SMTP Port">
-              <Space wrap size={6}>
-                {(config?.submission?.ports || []).map((item) => (
-                  <StatusPill key={item.port} tone="info">
-                    {item.port} · {item.protocol}
-                  </StatusPill>
-                ))}
-              </Space>
-            </Descriptions.Item>
-            <Descriptions.Item label="Username">{copyable(smtpCredential?.username || config?.submission?.username || '-', onCopy)}</Descriptions.Item>
-            <Descriptions.Item label="Password">{smtpCredential?.password ? copyable(smtpCredential.password, onCopy) : t('domainDetail.noSmtpPassword')}</Descriptions.Item>
-            <Descriptions.Item label="TLS / SSL">{config?.submission?.tls ? 'TLS' : 'STARTTLS'}</Descriptions.Item>
-          </Descriptions>
-        </SectionCard>
-      </Col>
-      <Col xs={24} lg={12}>
-        <SectionCard title="API">
-          <Descriptions column={1}>
-            <Descriptions.Item label="API Endpoint">{copyable(apiEndpoint, onCopy)}</Descriptions.Item>
-            <Descriptions.Item label="API Token">{apiTokens[0] ? `${apiTokens[0].tokenPrefix}...` : t('domainDetail.noApiToken')}</Descriptions.Item>
-            <Descriptions.Item label="From">noreply@{domain.domain}</Descriptions.Item>
-          </Descriptions>
-        </SectionCard>
-      </Col>
+      <Col xs={24} xl={9}><SectionCard title={copy.inboundStatus}><Space direction="vertical" size={16} className="full-width"><StatusPill tone={config?.submission?.inboundEnabled ? 'success' : 'warning'}><InboxOutlined /> {config?.submission?.inboundEnabled ? copy.inboundEnabled : copy.inboundDisabled}</StatusPill><Typography.Text type="secondary">{copy.inboundHint}</Typography.Text><Button onClick={onNavigate}>{copy.openMailboxRouting}</Button></Space></SectionCard></Col>
+      <Col xs={24} xl={15}><SectionCard title={copy.mailboxes} extra={<Typography.Text type="secondary">{mailboxes.length}</Typography.Text>}>{mailboxes.length ? <List dataSource={mailboxes} renderItem={(mailbox) => <List.Item><List.Item.Meta title={mailbox.address} description={`${mailbox.unreadCount} ${copy.unread} · ${mailbox.messageCount} ${copy.messages}`} /><StatusPill tone={mailbox.status === 'active' ? 'success' : 'warning'}>{mailbox.status}</StatusPill></List.Item>} /> : <EmptyState description={copy.noMailboxes} action={<Button onClick={onNavigate}>{copy.configureMailbox}</Button>} />}</SectionCard></Col>
+      <Col span={24}><SectionCard title={copy.catchAll} extra={<Button icon={<EditOutlined />} onClick={onEdit}>{copy.edit}</Button>}><Descriptions column={1}><Descriptions.Item label={copy.catchAllAddress}>{domain.catchAllAddress || copy.notConfigured}</Descriptions.Item><Descriptions.Item label="IMAP">{config?.mailAccess?.imap.enabled ? config.mailAccess.imap.ports.map((item) => item.port).join(', ') : copy.disabled}</Descriptions.Item><Descriptions.Item label="POP3">{config?.mailAccess?.pop3.enabled ? config.mailAccess.pop3.ports.map((item) => item.port).join(', ') : copy.disabled}</Descriptions.Item></Descriptions></SectionCard></Col>
     </Row>
   );
 }
 
-function SendingLogsTab({ events }: { events: SendEvent[] }) {
-  const { t } = useI18n();
+function ActivityTab({ events, copy, onView }: { events: SendEvent[]; copy: typeof zhCopy; onView: (event: SendEvent) => void }) {
   const columns: ColumnsType<SendEvent> = [
-    { title: t('logs.time'), dataIndex: 'createdAt', render: (value) => new Date(value).toLocaleString() },
-    { title: t('logs.recipient'), dataIndex: 'recipients', render: (value: string[]) => value.join(', ') },
-    { title: 'Subject', dataIndex: 'subject', ellipsis: true },
-    {
-      title: t('common.status'),
-      dataIndex: 'status',
-      render: (value) => (
-        <StatusPill tone={sendStatusTone(value)}>{sendStatusLabel(value, t)}</StatusPill>
-      )
-    },
-    { title: t('logs.errorReason'), dataIndex: 'detail', ellipsis: true }
+    { title: copy.time, dataIndex: 'createdAt', width: 190, render: (value) => new Date(value).toLocaleString() },
+    { title: copy.recipient, dataIndex: 'recipients', ellipsis: true, render: (value: string[]) => value.join(', ') },
+    { title: copy.subject, dataIndex: 'subject', ellipsis: true },
+    { title: copy.status, dataIndex: 'status', width: 130, render: (value) => <StatusPill tone={eventTone(value)}>{eventStatus(value, copy)}</StatusPill> }
   ];
-  return (
-    <SectionCard title={t('logs.title')}>
-      <Table rowKey="id" columns={columns} dataSource={events} scroll={{ x: 900 }} />
-    </SectionCard>
-  );
+  return events.length ? <SectionCard><Table rowKey="id" columns={columns} dataSource={events} pagination={{ pageSize: 20 }} scroll={{ x: 760 }} onRow={(event) => ({ onClick: () => onView(event), style: { cursor: 'pointer' }, 'aria-label': `${copy.viewActivity} ${event.id}` })} /></SectionCard> : <SectionCard><EmptyState description={copy.noActivity} /></SectionCard>;
 }
 
-function IntegrationGuideTab({
-  domain,
-  config,
-  apiTokens
-}: {
-  domain: Domain;
-  config: RuntimeConfig | null;
-  apiTokens: ApiToken[];
-}) {
-  const { t } = useI18n();
-  const token = apiTokens[0] ? `${apiTokens[0].tokenPrefix}...` : '<USER_API_TOKEN>';
-  const endpoint = `${config?.appBaseUrl || window.location.origin}/api/send`;
-  const code = `curl -X POST ${endpoint} \\
-  -H 'Authorization: Bearer ${token}' \\
-  -H 'Content-Type: application/json' \\
-  -d '{
-    "from": "noreply@${domain.domain}",
-    "to": "user@example.com",
-    "subject": "Hello from MailHub",
-    "text": "Signed with DKIM and queued by MailHub."
-  }'`;
+function SectionContent({ loading, error, retry, copy, children }: { loading: boolean; error: string; retry: () => void; copy: typeof zhCopy; children: React.ReactNode }) {
+  if (loading) return <SectionCard><Skeleton active paragraph={{ rows: 7 }} /></SectionCard>;
+  if (error) return <Result status="error" title={copy.sectionLoadFailed} subTitle={error} extra={<Button type="primary" onClick={retry}>{copy.retry}</Button>} />;
+  return <>{children}</>;
+}
+
+function EditDomainModal({ open, domain, form, dnsCredentials, smtpRelays, copy, loading, onCancel, onSave }: { open: boolean; domain: Domain; form: ReturnType<typeof Form.useForm<DomainPatchPayload>>[0]; dnsCredentials: DnsCredential[]; smtpRelays: SmtpRelay[]; copy: typeof zhCopy; loading: boolean; onCancel: () => void; onSave: () => void }) {
   return (
-    <SectionCard title={t('domainDetail.apiExample')}>
-      <CodeBlock value={code} />
-    </SectionCard>
+    <Modal title={copy.editTitle} open={open} width={640} confirmLoading={loading} okText={copy.save} cancelText={copy.cancel} onCancel={onCancel} onOk={onSave}>
+      <Form form={form} layout="vertical"><Row gutter={16}><Col xs={24} md={12}><Form.Item name="dnsCredentialId" label={copy.dnsIntegration}><Select allowClear options={dnsCredentials.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="smtpRelayId" label={copy.relay}><Select allowClear options={smtpRelays.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item></Col></Row><Row gutter={16}><Col xs={24} md={12}><Form.Item name="senderHost" label={copy.senderHost} rules={[{ required: true }]}><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="sendingIp" label={copy.sendingIp} rules={[{ required: true }]}><Input /></Form.Item></Col></Row><Row gutter={16}><Col xs={24} md={12}><Form.Item name="selector" label="DKIM selector" rules={[{ required: true }]}><Input /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="dmarcPolicy" label="DMARC"><Select options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} /></Form.Item></Col></Row><Form.Item name="spfExtra" label="SPF"><Input.TextArea rows={2} /></Form.Item><Form.Item name="dmarcRua" label="DMARC rua"><Input /></Form.Item><Form.Item name="catchAllAddress" label={copy.catchAllAddress}><Input placeholder={`inbox@${domain.domain}`} /></Form.Item></Form>
+    </Modal>
   );
 }
 
-function orderedRecords(records: DnsRecord[]) {
-  return getVisibleDnsRecords(records);
+function copyable(value: string, onCopy: (value: string) => void) {
+  return <Space><Typography.Text code>{value}</Typography.Text><Button type="text" size="small" aria-label="Copy" icon={<CopyOutlined />} onClick={() => onCopy(value)} /></Space>;
 }
 
-function sendStatusLabel(status: string, t: (key: string) => string) {
-  if (status === 'queued') return t('logs.statusQueued');
-  if (status === 'sent') return t('logs.statusSent');
-  if (status === 'deferred') return t('logs.statusDeferred');
-  if (status === 'bounced') return t('logs.statusBounced');
-  if (status === 'failed') return t('logs.statusFailed');
-  return status || t('dashboard.statusUnknown');
+function isSection(value?: string): value is DetailSection {
+  return sections.includes(value as DetailSection);
 }
 
-function sendStatusTone(status: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
+function eventTone(status: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
+  if (status === 'sent' || status === 'delivered') return 'success';
   if (status === 'queued') return 'info';
-  if (status === 'sent') return 'success';
-  if (status === 'deferred') return 'warning';
-  if (status === 'bounced' || status === 'failed') return 'error';
+  if (status === 'deferred' || status === 'pending') return 'warning';
+  if (status === 'failed' || status === 'bounced') return 'error';
   return 'neutral';
 }
 
-function liveLabel(key: string, t: (key: string) => string) {
-  return {
-    rootTxt: t('dnsRecord.rootTxt'),
-    verificationTxt: t('dnsRecord.verificationTxt'),
-    dkimTxt: t('dnsRecord.dkimTxt'),
-    dmarcTxt: t('dnsRecord.dmarcTxt'),
-    senderA: t('dnsRecord.senderA'),
-    ptr: t('dnsRecord.ptr')
-  }[key] || key;
+function eventStatus(status: string, copy: typeof zhCopy) {
+  return ({ sent: copy.sent, delivered: copy.delivered, queued: copy.queued, deferred: copy.deferred, pending: copy.pending, failed: copy.failed, bounced: copy.bounced } as Record<string, string>)[status] || status;
 }
 
-function relayLabel(relay: SmtpRelay, t: (key: string) => string) {
-  return `${relay.name}${relay.isDefault ? ` · ${t('smtpRelay.default')}` : ''} · ${relay.host}:${relay.port}`;
+function errorMessage(reason: unknown, fallback: string) {
+  return reason instanceof Error ? reason.message : fallback;
 }
 
-function copyable(value: string, onCopy: (value: string) => void) {
-  return (
-    <Space>
-      <Typography.Text code>{value}</Typography.Text>
-      <Button size="small" icon={<CopyOutlined />} onClick={() => onCopy(value)} />
-    </Space>
-  );
-}
+const zhCopy = {
+  loading: '正在加载域名详情', loadFailed: '域名详情加载失败', sectionLoadFailed: '当前页签加载失败', retry: '重试', back: '返回', backToDomains: '返回域名列表', notFound: '未找到该域名', notFoundHint: '域名不存在、已被删除,或当前账户没有访问权限。',
+  moreActions: '更多操作', overview: '概览', dnsAndVerification: 'DNS 与验证', sendingConfiguration: '发送配置', inboundConfiguration: '收信配置', activity: '活动', domain: '域名', senderHost: '发信主机', sendingIp: '发信 IP', dnsIntegration: 'DNS 集成', relay: '发送中继', manualDns: '手动配置', defaultDelivery: '系统默认投递', createdAt: '创建时间',
+  dnsPassed: 'DNS 已通过', needsAttention: '需要处理', testSend: '测试发送', checkDns: '检查 DNS', autoDns: '自动写入 DNS', edit: '编辑配置', delete: '删除域名', checked: 'DNS 检查已完成', dnsApplied: 'DNS 记录写入完成', dnsPartial: '部分 DNS 记录写入失败', saved: '配置已保存', testQueued: '测试邮件已加入队列', deleted: '域名已删除', actionFailed: '操作失败', copied: '已复制', copyFailed: '复制失败', setupPartialTitle: '域名已创建,但 DNS 后续操作需要处理', setupPartialDescription: '自动写入或即时检查未完全成功。请查看下方逐条结果,修正后重新执行。', setupCompleteTitle: '域名创建完成', setupAutomaticDescription: 'DNS 自动写入流程已执行,请核对逐条结果和当前验证状态。', setupManualDescription: '请按下方记录完成手动 DNS 配置,并在记录生效后重新检查。',
+  domainSummary: '域名摘要', authenticationHealth: '认证健康度', recordsPassed: '项必需记录已通过', ready: '发送就绪', nextActions: '下一步操作', reviewDns: '查看 DNS 记录', dangerZone: '危险操作', dangerHint: '删除域名会停止相关发信配置,且不可撤销。', recheck: '重新检查', copyAll: '复制全部', domainVerified: '域名已验证,可用于发送', domainNotVerified: '域名尚未完成验证', lastChecked: '最近检查', never: '从未检查', noDnsRecords: '尚未生成 DNS 检查结果', checkNow: '立即检查', warnings: '需要注意', applyResults: 'DNS 写入结果', applyComplete: '全部完成', applyPartial: '部分失败', applySucceeded: '成功', applyFailed: '失败', applySkipped: '已跳过', applySucceededHint: '记录已写入或无需变更。', applyFailedHint: '未返回具体错误,请重新执行或改为手动配置。',
+  host: '主机', ports: '端口', username: '用户名', password: '密码', configured: '已设置(不会返回明文)', notConfigured: '未设置', manageKeys: '管理 API 密钥', availableKeys: '可用密钥', noKeys: '尚无可用 API 密钥', senderIdentity: '发件身份', fromDomain: '发件域名', verification: '验证状态', verified: '已验证', pending: '等待验证',
+  inboundStatus: '收信服务状态', inboundEnabled: '收信服务已启用', inboundDisabled: '收信服务未启用', inboundHint: '邮箱、别名和路由在独立收件箱工作区管理。', openMailboxRouting: '打开邮箱与路由', mailboxes: '邮箱', unread: '封未读', messages: '封邮件', noMailboxes: '该域名还没有邮箱', configureMailbox: '配置邮箱', catchAll: '默认收信路由', catchAllAddress: 'Catch-all 地址', disabled: '未启用',
+  time: '时间', recipient: '收件人', subject: '主题', body: '正文', status: '状态', noActivity: '该域名尚无发送活动', viewActivity: '查看发送活动', sent: '已发送', delivered: '已送达', queued: '已入队', deferred: '已延迟', failed: '失败', bounced: '退信',
+  editTitle: '编辑域名配置', save: '保存', cancel: '取消', deleteTitle: '删除域名', deleteWarning: '此操作不可撤销,域名相关发送配置将立即不可用。', typeDomain: '请输入域名以确认:', deleteConfirmation: '域名删除确认', queueTest: '加入发送队列', validRecipient: '请输入有效的收件邮箱', testSubject: (domain: string) => `MailHub ${domain} 测试邮件`, testBody: (domain: string) => `这是一封来自 ${domain} 的 MailHub 投递测试邮件。`
+};
+
+const enCopy: typeof zhCopy = {
+  loading: 'Loading domain details', loadFailed: 'Unable to load domain details', sectionLoadFailed: 'Unable to load this section', retry: 'Retry', back: 'Back', backToDomains: 'Back to domains', notFound: 'Domain not found', notFoundHint: 'The domain does not exist, was deleted, or is not available to this account.',
+  moreActions: 'More actions', overview: 'Overview', dnsAndVerification: 'DNS & verification', sendingConfiguration: 'Sending', inboundConfiguration: 'Receiving', activity: 'Activity', domain: 'Domain', senderHost: 'Sending host', sendingIp: 'Sending IP', dnsIntegration: 'DNS integration', relay: 'Sending relay', manualDns: 'Manual', defaultDelivery: 'System delivery', createdAt: 'Created',
+  dnsPassed: 'DNS passed', needsAttention: 'Needs attention', testSend: 'Send test', checkDns: 'Check DNS', autoDns: 'Apply DNS', edit: 'Edit configuration', delete: 'Delete domain', checked: 'DNS check completed', dnsApplied: 'DNS records applied', dnsPartial: 'Some DNS records could not be applied', saved: 'Configuration saved', testQueued: 'Test email queued', deleted: 'Domain deleted', actionFailed: 'Action failed', copied: 'Copied', copyFailed: 'Unable to copy', setupPartialTitle: 'Domain created, but DNS follow-up needs attention', setupPartialDescription: 'Automatic application or the immediate check did not fully complete. Review each result below, correct it, and retry.', setupCompleteTitle: 'Domain created', setupAutomaticDescription: 'The automatic DNS flow ran. Review each result and the current verification status.', setupManualDescription: 'Add the records below at your DNS provider, then recheck after they propagate.',
+  domainSummary: 'Domain summary', authenticationHealth: 'Authentication health', recordsPassed: 'required records passed', ready: 'Ready to send', nextActions: 'Next actions', reviewDns: 'Review DNS records', dangerZone: 'Danger zone', dangerHint: 'Deleting the domain stops its sending configuration and cannot be undone.', recheck: 'Recheck', copyAll: 'Copy all', domainVerified: 'Domain verified and ready to send', domainNotVerified: 'Domain verification is incomplete', lastChecked: 'Last checked', never: 'Never', noDnsRecords: 'No DNS check results yet', checkNow: 'Check now', warnings: 'Attention needed', applyResults: 'DNS apply results', applyComplete: 'Complete', applyPartial: 'Partially failed', applySucceeded: 'Succeeded', applyFailed: 'Failed', applySkipped: 'Skipped', applySucceededHint: 'The record was written or already matched.', applyFailedHint: 'No detailed error was returned. Retry or configure this record manually.',
+  host: 'Host', ports: 'Ports', username: 'Username', password: 'Password', configured: 'Set (never returned in plaintext)', notConfigured: 'Not set', manageKeys: 'Manage API keys', availableKeys: 'Available keys', noKeys: 'No API keys available', senderIdentity: 'Sender identity', fromDomain: 'From domain', verification: 'Verification', verified: 'Verified', pending: 'Pending',
+  inboundStatus: 'Inbound service', inboundEnabled: 'Inbound service enabled', inboundDisabled: 'Inbound service disabled', inboundHint: 'Manage mailboxes, aliases, and routes in the dedicated Inbox workspace.', openMailboxRouting: 'Open mailboxes & routing', mailboxes: 'Mailboxes', unread: 'unread', messages: 'messages', noMailboxes: 'No mailboxes for this domain', configureMailbox: 'Configure mailbox', catchAll: 'Default inbound route', catchAllAddress: 'Catch-all address', disabled: 'Disabled',
+  time: 'Time', recipient: 'Recipient', subject: 'Subject', body: 'Body', status: 'Status', noActivity: 'No sending activity for this domain', viewActivity: 'View sending activity', sent: 'Sent', delivered: 'Delivered', queued: 'Queued', deferred: 'Deferred', failed: 'Failed', bounced: 'Bounced',
+  editTitle: 'Edit domain configuration', save: 'Save', cancel: 'Cancel', deleteTitle: 'Delete domain', deleteWarning: 'This cannot be undone. Sending configuration will stop immediately.', typeDomain: 'Type the domain to confirm:', deleteConfirmation: 'Domain deletion confirmation', queueTest: 'Queue test', validRecipient: 'Enter a valid recipient email', testSubject: (domain: string) => `MailHub ${domain} test email`, testBody: (domain: string) => `This is a MailHub delivery test from ${domain}.`
+};

+ 501 - 135
src/pages/Domains/index.tsx

@@ -1,209 +1,575 @@
-import { DeleteOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
-import { Button, Input, Popconfirm, Select, Space, Table, Typography } from 'antd';
+import {
+  DeleteOutlined,
+  EditOutlined,
+  EllipsisOutlined,
+  EyeOutlined,
+  GlobalOutlined,
+  MailOutlined,
+  ReloadOutlined,
+  SearchOutlined,
+  SendOutlined,
+  ThunderboltOutlined
+} from '@ant-design/icons';
+import {
+  App,
+  Button,
+  Card,
+  Dropdown,
+  Form,
+  Grid,
+  Input,
+  List,
+  Modal,
+  Pagination,
+  Result,
+  Select,
+  Skeleton,
+  Space,
+  Table,
+  Tooltip,
+  Typography
+} from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useMemo, useState } from 'react';
+import type { MenuProps } from 'antd';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
 
+import { EmptyState } from '../../components/common/EmptyState';
 import { PageHeader } from '../../components/common/PageHeader';
 import { SectionCard } from '../../components/common/SectionCard';
 import { StatusPill } from '../../components/common/StatusPill';
-import { StatusTag } from '../../components/common/StatusTag';
-import { buildDomainHealth } from '../../frontend/domain-model.js';
+import { AddDomainDrawer } from '../../components/domain/AddDomainDrawer';
+import { useAppContext } from '../../frontend/app-context';
+import { buildDomainHealth, getRecordStatusMeta } from '../../frontend/domain-model.js';
 import { useI18n } from '../../frontend/i18n/react';
-import type { DnsCredential, Domain, SendEvent } from '../../frontend/types';
+import { api } from '../../frontend/services/api';
+import type { AddDomainPayload, DnsCredential, DnsRecord, Domain, SendEvent, SmtpRelay } from '../../frontend/types';
 
-interface DomainsPageProps {
+interface DomainsData {
   domains: Domain[];
   events: SendEvent[];
   dnsCredentials: DnsCredential[];
-  actionLoading?: boolean;
-  onViewDetail: (domain: Domain) => void;
-  onApplyDns: (domain: Domain) => void;
-  onCheck: (domain: Domain) => void;
-  onSendTest: (domain: Domain) => void;
-  onDelete: (domain: Domain) => void;
-  onAddDomain: () => void;
+  smtpRelays: SmtpRelay[];
 }
 
-export default function DomainsPage({
-  domains,
-  events,
-  dnsCredentials,
-  actionLoading,
-  onViewDetail,
-  onApplyDns,
-  onCheck,
-  onSendTest,
-  onDelete,
-  onAddDomain
-}: DomainsPageProps) {
-  const { t } = useI18n();
-  const [query, setQuery] = useState('');
-  const [status, setStatus] = useState<string>();
-  const credentialName = new Map(dnsCredentials.map((item) => [item.id, item.name]));
+const DEFAULT_PAGE = 1;
+const DEFAULT_PAGE_SIZE = 20;
+
+interface DomainSetupOperations {
+  createDomain: (values: AddDomainPayload) => Promise<{ domain: Domain }>;
+  applyDns: (id: number) => Promise<{ domain: Domain; apply?: Domain['status']['apply'] }>;
+  checkDomain: (id: number) => Promise<{ domain: Domain }>;
+}
+
+export interface DomainSetupResult {
+  domain: Domain;
+  setup: 'complete' | 'partial';
+  followUp: 'apply' | 'check' | 'none';
+  error?: string;
+}
+
+export async function createDomainWithSetup(
+  values: AddDomainPayload,
+  operations: DomainSetupOperations
+): Promise<DomainSetupResult> {
+  const created = await operations.createDomain(values);
+  const credentialId = Number(values.dnsCredentialId || 0);
+  const followUp = credentialId > 0 ? 'apply' : (values.immediateCheck ? 'check' : 'none');
+
+  if (followUp === 'none') {
+    return { domain: created.domain, setup: 'complete', followUp };
+  }
+
+  try {
+    if (followUp === 'apply') {
+      const result = await operations.applyDns(created.domain.id);
+      const apply = result.apply || result.domain.status?.apply;
+      if (apply?.ok === false) {
+        return {
+          domain: result.domain,
+          setup: 'partial',
+          followUp,
+          error: firstDnsApplyError(apply)
+        };
+      }
+      return { domain: result.domain, setup: 'complete', followUp };
+    }
+
+    const result = await operations.checkDomain(created.domain.id);
+    return { domain: result.domain, setup: 'complete', followUp };
+  } catch (reason) {
+    return {
+      domain: created.domain,
+      setup: 'partial',
+      followUp,
+      error: reason instanceof Error ? reason.message : undefined
+    };
+  }
+}
+
+export function readDomainPagination(searchParams: URLSearchParams) {
+  const requestedPageSize = positiveInteger(searchParams.get('pageSize'), DEFAULT_PAGE_SIZE);
+  return {
+    page: positiveInteger(searchParams.get('page'), DEFAULT_PAGE),
+    pageSize: [10, 20, 50, 100].includes(requestedPageSize) ? requestedPageSize : DEFAULT_PAGE_SIZE
+  };
+}
+
+export function paginateDomains<T>(items: T[], page: number, pageSize: number) {
+  const start = (page - 1) * pageSize;
+  return items.slice(start, start + pageSize);
+}
+
+export default function DomainsPage() {
+  const { config } = useAppContext();
+  const { locale } = useI18n();
+  const copy = locale.startsWith('en') ? enCopy : zhCopy;
+  const navigate = useNavigate();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const screens = Grid.useBreakpoint();
+  const { message } = App.useApp();
+  const [data, setData] = useState<DomainsData | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState('');
+  const [deleteTarget, setDeleteTarget] = useState<Domain | null>(null);
+  const [deleteConfirmation, setDeleteConfirmation] = useState('');
+  const [testTarget, setTestTarget] = useState<Domain | null>(null);
+  const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
+  const [testForm] = Form.useForm<{ to: string; subject: string; text: string }>();
+
+  const query = searchParams.get('q') || '';
+  const status = searchParams.get('status') || undefined;
+  const addOpen = searchParams.get('create') === '1';
+  const { page, pageSize } = readDomainPagination(searchParams);
+
+  const load = useCallback(async () => {
+    setLoading(true);
+    setError('');
+    try {
+      const [domainsResult, credentialsResult, relaysResult, eventsResult] = await Promise.all([
+        api.domains(),
+        api.dnsCredentials(),
+        api.smtpRelays(),
+        api.events({ page: 1, pageSize: 100 })
+      ]);
+      setData({
+        domains: domainsResult.domains,
+        dnsCredentials: credentialsResult.credentials,
+        smtpRelays: relaysResult.relays,
+        events: eventsResult.events
+      });
+    } catch (reason) {
+      setError(reason instanceof Error ? reason.message : copy.loadFailed);
+    } finally {
+      setLoading(false);
+    }
+  }, [copy.loadFailed]);
+
+  useEffect(() => {
+    void load();
+  }, [load]);
 
   const filtered = useMemo(() => {
-    return domains.filter((domain) => {
+    if (!data) return [];
+    const normalizedQuery = query.trim().toLowerCase();
+    return data.domains.filter((domain) => {
       const health = buildDomainHealth(domain);
-      const matchesQuery = !query || domain.domain.includes(query) || domain.senderHost.includes(query);
-      const matchesStatus = !status || health.status === status;
-      return matchesQuery && matchesStatus;
+      const matchesQuery = !normalizedQuery
+        || domain.domain.toLowerCase().includes(normalizedQuery)
+        || domain.senderHost.toLowerCase().includes(normalizedQuery);
+      return matchesQuery && (!status || health.status === status);
     });
-  }, [domains, query, status]);
+  }, [data, query, status]);
+  const pagedDomains = useMemo(() => paginateDomains(filtered, page, pageSize), [filtered, page, pageSize]);
+
+  useEffect(() => {
+    if (!data) return;
+    const maxPage = Math.max(DEFAULT_PAGE, Math.ceil(filtered.length / pageSize));
+    const normalizedPage = Math.min(page, maxPage);
+    if (searchParams.get('page') === String(normalizedPage) && searchParams.get('pageSize') === String(pageSize)) return;
+    const next = new URLSearchParams(searchParams);
+    next.set('page', String(normalizedPage));
+    next.set('pageSize', String(pageSize));
+    setSearchParams(next, { replace: true });
+  }, [data, filtered.length, page, pageSize, searchParams, setSearchParams]);
+
+  const credentialNames = useMemo(() => new Map(data?.dnsCredentials.map((item) => [item.id, item.name]) || []), [data?.dnsCredentials]);
+  const lastActivity = useMemo(() => {
+    const map = new Map<string, SendEvent>();
+    for (const event of data?.events || []) {
+      if (event.domain && !map.has(event.domain)) map.set(event.domain, event);
+    }
+    return map;
+  }, [data?.events]);
+
+  function updateFilter(key: 'q' | 'status', value?: string) {
+    const next = new URLSearchParams(searchParams);
+    if (value) next.set(key, value);
+    else next.delete(key);
+    next.set('page', String(DEFAULT_PAGE));
+    setSearchParams(next, { replace: key === 'q' });
+  }
+
+  function setCreateOpen(open: boolean) {
+    const next = new URLSearchParams(searchParams);
+    if (open) next.set('create', '1');
+    else next.delete('create');
+    setSearchParams(next, { replace: !open });
+  }
+
+  function updatePagination(nextPage: number, nextPageSize: number) {
+    const next = new URLSearchParams(searchParams);
+    next.set('page', String(nextPageSize === pageSize ? nextPage : DEFAULT_PAGE));
+    next.set('pageSize', String(nextPageSize));
+    setSearchParams(next);
+  }
+
+  async function runAction<T>(domain: Domain, action: string, operation: () => Promise<T>) {
+    const key = `${domain.id}:${action}`;
+    setActionLoading((current) => ({ ...current, [key]: true }));
+    try {
+      return await operation();
+    } finally {
+      setActionLoading((current) => ({ ...current, [key]: false }));
+    }
+  }
+
+  async function createDomain(values: AddDomainPayload) {
+    const key = 'new:create';
+    setActionLoading((current) => ({ ...current, [key]: true }));
+    try {
+      const result = await createDomainWithSetup(values, api);
+      setData((current) => current ? { ...current, domains: [result.domain, ...current.domains] } : current);
+      setCreateOpen(false);
+      if (result.setup === 'partial') message.warning(result.error ? `${copy.createdPartial}:${result.error}` : copy.createdPartial);
+      else message.success(copy.created);
+      navigate(`/domains/${result.domain.id}/dns?setup=${result.setup}`);
+    } catch (reason) {
+      message.error(reason instanceof Error ? reason.message : copy.createFailed);
+      throw reason;
+    } finally {
+      setActionLoading((current) => ({ ...current, [key]: false }));
+    }
+  }
+
+  async function checkDomain(domain: Domain) {
+    try {
+      const result = await runAction(domain, 'check', () => api.checkDomain(domain.id));
+      replaceDomain(result.domain);
+      message.success(copy.checked);
+    } catch (reason) {
+      message.error(reason instanceof Error ? reason.message : copy.actionFailed);
+    }
+  }
+
+  async function applyDns(domain: Domain) {
+    try {
+      const result = await runAction(domain, 'dns', () => api.applyDns(domain.id));
+      replaceDomain(result.domain);
+      if (result.apply?.ok === false) message.warning(copy.dnsPartiallyApplied);
+      else message.success(copy.dnsApplied);
+    } catch (reason) {
+      message.error(reason instanceof Error ? reason.message : copy.actionFailed);
+    }
+  }
+
+  async function deleteDomain() {
+    if (!deleteTarget || deleteConfirmation !== deleteTarget.domain) return;
+    try {
+      await runAction(deleteTarget, 'delete', () => api.deleteDomain(deleteTarget.id));
+      setData((current) => current ? { ...current, domains: current.domains.filter((item) => item.id !== deleteTarget.id) } : current);
+      setDeleteTarget(null);
+      setDeleteConfirmation('');
+      message.success(copy.deleted);
+    } catch (reason) {
+      message.error(reason instanceof Error ? reason.message : copy.actionFailed);
+    }
+  }
+
+  async function sendTest() {
+    if (!testTarget) return;
+    let values: { to: string; subject: string; text: string };
+    try {
+      values = await testForm.validateFields();
+    } catch {
+      return;
+    }
+    try {
+      await runAction(testTarget, 'test', () => api.sendTest(testTarget.id, values));
+      setTestTarget(null);
+      testForm.resetFields();
+      message.success(copy.testQueued);
+    } catch (reason) {
+      message.error(reason instanceof Error ? reason.message : copy.actionFailed);
+    }
+  }
+
+  function replaceDomain(domain: Domain) {
+    setData((current) => current ? { ...current, domains: current.domains.map((item) => item.id === domain.id ? domain : item) } : current);
+  }
+
+  function openTest(domain: Domain) {
+    setTestTarget(domain);
+    testForm.setFieldsValue({
+      to: '',
+      subject: copy.testSubject(domain.domain),
+      text: copy.testBody(domain.domain)
+    });
+  }
+
+  function menuItems(domain: Domain): MenuProps['items'] {
+    return [
+      { key: 'check', icon: <ReloadOutlined />, label: copy.checkDns, onClick: () => void checkDomain(domain) },
+      { key: 'apply', icon: <ThunderboltOutlined />, label: copy.autoDns, disabled: !domain.dnsCredentialId, onClick: () => void applyDns(domain) },
+      { key: 'test', icon: <SendOutlined />, label: copy.sendTest, onClick: () => openTest(domain) },
+      { key: 'edit', icon: <EditOutlined />, label: copy.edit, onClick: () => navigate(`/domains/${domain.id}/sending?edit=1`) },
+      { type: 'divider' },
+      { key: 'delete', icon: <DeleteOutlined />, danger: true, label: copy.delete, onClick: () => { setDeleteTarget(domain); setDeleteConfirmation(''); } }
+    ];
+  }
+
+  if (loading && !data) {
+    return <Space direction="vertical" size={20} className="full-width" aria-label={copy.loading}><Skeleton active title={{ width: '28%' }} paragraph={{ rows: 2 }} /><SectionCard><Skeleton active paragraph={{ rows: 9 }} /></SectionCard></Space>;
+  }
+
+  if (error && !data) {
+    return <Result status="error" title={copy.loadFailed} subTitle={error} extra={<Button type="primary" onClick={() => void load()}>{copy.retry}</Button>} />;
+  }
+
+  if (!data) return null;
 
   const columns: ColumnsType<Domain> = [
     {
-      title: t('domains.domain'),
+      title: copy.domain,
       dataIndex: 'domain',
-      fixed: 'left',
-      width: 190,
-      render: (value, domain) => (
-        <Button type="link" className="table-link" onClick={() => onViewDetail(domain)}>
-          {value}
-        </Button>
+      width: 220,
+      render: (value: string, domain) => (
+        <Button type="link" className="table-link" onClick={() => navigate(`/domains/${domain.id}/overview`)}>{value}</Button>
       )
     },
-    { title: t('domains.senderHost'), dataIndex: 'senderHost', width: 190, ellipsis: true },
-    { title: t('domains.sendingIp'), dataIndex: 'sendingIp', width: 140 },
     {
-      title: t('domains.dnsApi'),
-      dataIndex: 'dnsCredentialId',
-      width: 150,
-      render: (value: number | null) => {
-        if (!value) {
-          return <StatusPill tone="neutral">{t('common.manual')}</StatusPill>;
-        }
-        const name = credentialName.get(value);
-        return (
-          <StatusPill tone={name ? 'info' : 'warning'}>
-            {name || t('common.notConfigured')}
-          </StatusPill>
-        );
-      }
+      title: copy.capabilities,
+      width: 220,
+      render: (_, domain) => <CapabilitySummary domain={domain} copy={copy} />
     },
-    recordColumn('DKIM', 'dkim'),
-    recordColumn('SPF', 'spf'),
-    recordColumn('DMARC', 'dmarc'),
     {
-      title: t('domains.smtp'),
-      width: 110,
-      render: (_, domain) => (
-        <StatusPill tone={domain.status?.verified ? 'success' : 'warning'}>
-          {domain.status?.verified ? t('domains.sendable') : t('domains.waitingVerify')}
-        </StatusPill>
-      )
+      title: copy.authentication,
+      width: 160,
+      render: (_, domain) => <AuthenticationHealth domain={domain} copy={copy} />
     },
     {
-      title: t('domains.lastSent'),
+      title: copy.dnsIntegration,
       width: 180,
-      render: (_, domain) => {
-        const event = events.find((item) => item.domain === domain.domain);
-        return event ? new Date(event.createdAt).toLocaleString() : t('common.notFound');
-      }
+      render: (_, domain) => domain.dnsCredentialId
+        ? <StatusPill tone={credentialNames.has(domain.dnsCredentialId) ? 'info' : 'warning'}>{credentialNames.get(domain.dnsCredentialId) || copy.credentialUnavailable}</StatusPill>
+        : <StatusPill tone="neutral">{copy.manualDns}</StatusPill>
     },
     {
-      title: t('domains.overallStatus'),
-      width: 130,
+      title: copy.lastActivity,
+      width: 190,
       render: (_, domain) => {
-        const health = buildDomainHealth(domain);
-        return (
-          <StatusPill tone={healthTone(health.status)}>
-            {domainHealthLabel(health.status, t)}
-          </StatusPill>
-        );
+        const event = lastActivity.get(domain.domain);
+        return event ? <Space direction="vertical" size={0}><Typography.Text>{new Date(event.createdAt).toLocaleString()}</Typography.Text><Typography.Text type="secondary">{event.subject || event.status}</Typography.Text></Space> : <Typography.Text type="secondary">{copy.noActivity}</Typography.Text>;
       }
     },
     {
-      title: t('domains.actions'),
-      width: 360,
+      title: copy.actions,
+      width: 132,
       fixed: 'right',
       render: (_, domain) => (
-        <Space size={8} wrap>
-          <Button icon={<EyeOutlined />} onClick={() => onViewDetail(domain)}>
-            {t('common.details')}
-          </Button>
-          <Button type="primary" disabled={!domain.dnsCredentialId} loading={actionLoading} onClick={() => onApplyDns(domain)}>
-            {t('domains.oneClickDns')}
-          </Button>
-          <Button loading={actionLoading} onClick={() => onCheck(domain)}>
-            {t('domains.check')}
-          </Button>
-          <Button onClick={() => onSendTest(domain)}>{t('domains.test')}</Button>
-          <Popconfirm title={t('domains.deleteConfirm')} onConfirm={() => onDelete(domain)}>
-            <Button danger icon={<DeleteOutlined />} />
-          </Popconfirm>
+        <Space size={4}>
+          <Tooltip title={copy.view}>
+            <Button aria-label={`${copy.view} ${domain.domain}`} icon={<EyeOutlined />} onClick={() => navigate(`/domains/${domain.id}/overview`)} />
+          </Tooltip>
+          <Dropdown menu={{ items: menuItems(domain) }} trigger={['click']}>
+            <Button aria-label={`${copy.moreActions} ${domain.domain}`} icon={<EllipsisOutlined />} loading={Object.entries(actionLoading).some(([key, value]) => key.startsWith(`${domain.id}:`) && value)} />
+          </Dropdown>
         </Space>
       )
     }
   ];
+  const pagination = {
+    current: page,
+    pageSize,
+    total: filtered.length,
+    showSizeChanger: true,
+    pageSizeOptions: [10, 20, 50, 100],
+    onChange: updatePagination,
+    showTotal: (total: number) => copy.paginationTotal(total)
+  };
 
   return (
     <Space direction="vertical" size={20} className="full-width">
-      <PageHeader title={t('domains.title')} />
+      <PageHeader title={copy.title} subtitle={copy.subtitle} extra={<Button type="primary" style={{ minHeight: 44 }} onClick={() => setCreateOpen(true)}>{copy.addDomain}</Button>} />
 
       <SectionCard className="domains-toolbar-card">
         <div className="page-toolbar">
           <Space wrap>
             <Input
               allowClear
+              aria-label={copy.search}
               prefix={<SearchOutlined />}
-              placeholder={t('domains.searchPlaceholder')}
+              placeholder={copy.searchPlaceholder}
               value={query}
-              onChange={(event) => setQuery(event.target.value)}
+              onChange={(event) => updateFilter('q', event.target.value)}
               className="toolbar-search"
+              style={{ minHeight: 44 }}
             />
             <Select
               allowClear
-              placeholder={t('domains.statusPlaceholder')}
+              aria-label={copy.statusFilter}
+              placeholder={copy.statusFilter}
               value={status}
-              onChange={setStatus}
+              onChange={(value) => updateFilter('status', value)}
               options={[
-                { value: 'success', label: t('domains.healthy') },
-                { value: 'warning', label: t('domains.pending') },
-                { value: 'error', label: t('domains.needsAction') }
+                { value: 'success', label: copy.healthy },
+                { value: 'warning', label: copy.pending },
+                { value: 'error', label: copy.needsAction }
               ]}
               className="toolbar-select"
+              style={{ minHeight: 44 }}
             />
           </Space>
-          <Button type="primary" onClick={onAddDomain}>
-            {t('common.addDomain')}
-          </Button>
+          <Typography.Text type="secondary">{copy.resultCount(filtered.length, data.domains.length)}</Typography.Text>
         </div>
       </SectionCard>
 
-      <SectionCard
-        title={t('domains.title')}
-        extra={
-          <Typography.Text type="secondary">
-            {filtered.length} / {domains.length}
-          </Typography.Text>
-        }
+      {filtered.length ? screens.md ? (
+        <SectionCard>
+          <Table rowKey="id" columns={columns} dataSource={pagedDomains} pagination={pagination} scroll={{ x: 1120 }} />
+        </SectionCard>
+      ) : (
+        <Space direction="vertical" size={16} className="full-width">
+          <List
+            dataSource={pagedDomains}
+            renderItem={(domain) => (
+              <List.Item>
+                <DomainCard domain={domain} credentialName={domain.dnsCredentialId ? credentialNames.get(domain.dnsCredentialId) : undefined} event={lastActivity.get(domain.domain)} menuItems={menuItems(domain)} onView={() => navigate(`/domains/${domain.id}/overview`)} copy={copy} />
+              </List.Item>
+            )}
+          />
+          <Pagination {...pagination} responsive />
+        </Space>
+      ) : (
+        <SectionCard><EmptyState description={query || status ? copy.noMatches : copy.noDomains} action={!data.domains.length ? <Button onClick={() => setCreateOpen(true)}>{copy.addFirstDomain}</Button> : undefined} /></SectionCard>
+      )}
+
+      <AddDomainDrawer
+        open={addOpen}
+        loading={Boolean(actionLoading['new:create'])}
+        config={config}
+        dnsCredentials={data.dnsCredentials}
+        smtpRelays={data.smtpRelays}
+        onClose={() => setCreateOpen(false)}
+        onSubmit={createDomain}
+      />
+
+      <Modal
+        title={copy.deleteTitle}
+        open={Boolean(deleteTarget)}
+        okText={copy.delete}
+        okButtonProps={{ danger: true, disabled: !deleteTarget || deleteConfirmation !== deleteTarget.domain, loading: Boolean(deleteTarget && actionLoading[`${deleteTarget.id}:delete`]) }}
+        cancelText={copy.cancel}
+        onCancel={() => { setDeleteTarget(null); setDeleteConfirmation(''); }}
+        onOk={() => void deleteDomain()}
       >
-        <Table rowKey="id" columns={columns} dataSource={filtered} scroll={{ x: 1800 }} />
-      </SectionCard>
+        <Space direction="vertical" size={16} className="full-width">
+          <Typography.Paragraph>{copy.deleteWarning}</Typography.Paragraph>
+          <Typography.Text>{copy.typeDomain} <Typography.Text code>{deleteTarget?.domain}</Typography.Text></Typography.Text>
+          <Input autoFocus value={deleteConfirmation} onChange={(event) => setDeleteConfirmation(event.target.value)} aria-label={copy.deleteConfirmation} style={{ minHeight: 44 }} />
+        </Space>
+      </Modal>
+
+      <Modal
+        title={testTarget ? copy.testTitle(testTarget.domain) : copy.sendTest}
+        open={Boolean(testTarget)}
+        okText={copy.queueTest}
+        cancelText={copy.cancel}
+        confirmLoading={Boolean(testTarget && actionLoading[`${testTarget.id}:test`])}
+        onCancel={() => { setTestTarget(null); testForm.resetFields(); }}
+        onOk={() => void sendTest()}
+      >
+        <Form form={testForm} layout="vertical">
+          <Form.Item name="to" label={copy.recipient} rules={[{ required: true, type: 'email', message: copy.validRecipient }]}><Input placeholder="you@example.com" style={{ minHeight: 44 }} /></Form.Item>
+          <Form.Item name="subject" label={copy.subject} rules={[{ required: true }]}><Input style={{ minHeight: 44 }} /></Form.Item>
+          <Form.Item name="text" label={copy.body} rules={[{ required: true }]}><Input.TextArea rows={5} /></Form.Item>
+        </Form>
+      </Modal>
     </Space>
   );
 }
 
-function recordColumn(title: string, key: string): ColumnsType<Domain>[number] {
-  return {
-    title,
-    width: 110,
-    render: (_, domain) => {
-      const record = domain.status?.records?.find((item) => item.key === key);
-      if (!record) return <StatusTag status="missing" />;
-      return <StatusTag record={record} />;
-    }
-  };
+function AuthenticationHealth({ domain, copy }: { domain: Domain; copy: typeof zhCopy }) {
+  const records = domain.status?.records || [];
+  const keys = ['spf', 'dkim', 'dmarc'];
+  const passed = keys.filter((key) => {
+    const record = records.find((item) => item.key === key);
+    return record && getRecordStatusMeta(record).key === 'success';
+  }).length;
+  const tone = passed === 3 ? 'success' : passed ? 'warning' : 'error';
+  return (
+    <Tooltip title={keys.map((key) => `${key.toUpperCase()}: ${recordState(records.find((item) => item.key === key), copy)}`).join('\n')}>
+      <span><StatusPill tone={tone}>{passed}/3 {passed === 3 ? copy.passed : copy.incomplete}</StatusPill></span>
+    </Tooltip>
+  );
+}
+
+function CapabilitySummary({ domain, copy }: { domain: Domain; copy: typeof zhCopy }) {
+  return (
+    <Space direction="vertical" size={4}>
+      <StatusPill tone={domain.status?.verified ? 'success' : 'warning'}><SendOutlined /> {domain.status?.verified ? copy.sendReady : copy.sendPending}</StatusPill>
+      <StatusPill tone={domain.catchAllAddress ? 'info' : 'neutral'}><MailOutlined /> {domain.catchAllAddress ? copy.receiveRouted : copy.receiveConfigurable}</StatusPill>
+    </Space>
+  );
+}
+
+function DomainCard({ domain, credentialName, event, menuItems, onView, copy }: { domain: Domain; credentialName?: string; event?: SendEvent; menuItems: MenuProps['items']; onView: () => void; copy: typeof zhCopy }) {
+  return (
+    <Card
+      style={{ width: '100%' }}
+      title={<Space><GlobalOutlined /><Typography.Text strong>{domain.domain}</Typography.Text></Space>}
+      extra={<Dropdown menu={{ items: menuItems }} trigger={['click']}><Button aria-label={`${copy.moreActions} ${domain.domain}`} icon={<EllipsisOutlined />} /></Dropdown>}
+      actions={[<Button key="view" type="link" icon={<EyeOutlined />} onClick={onView}>{copy.view}</Button>]}
+    >
+      <Space direction="vertical" size={12} className="full-width">
+        <CapabilitySummary domain={domain} copy={copy} />
+        <Space wrap><Typography.Text type="secondary">{copy.authentication}</Typography.Text><AuthenticationHealth domain={domain} copy={copy} /></Space>
+        <Space wrap><Typography.Text type="secondary">{copy.dnsIntegration}</Typography.Text><StatusPill tone={credentialName ? 'info' : 'neutral'}>{credentialName || copy.manualDns}</StatusPill></Space>
+        <Typography.Text type="secondary">{copy.lastActivity}: {event ? new Date(event.createdAt).toLocaleString() : copy.noActivity}</Typography.Text>
+      </Space>
+    </Card>
+  );
+}
+
+function firstDnsApplyError(apply: NonNullable<Domain['status']['apply']>) {
+  return apply.results?.find((result) => !result.ok && result.error)?.error;
 }
 
-function healthTone(status: string): 'success' | 'warning' | 'error' {
-  if (status === 'success') return 'success';
-  if (status === 'warning') return 'warning';
-  return 'error';
+function positiveInteger(value: string | null, fallback: number) {
+  const parsed = Number(value);
+  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
 }
 
-function domainHealthLabel(status: string, t: (key: string) => string) {
-  if (status === 'success') return t('domains.healthy');
-  if (status === 'warning') return t('domains.waitingDns');
-  return t('domains.needsAction');
+function recordState(record: DnsRecord | undefined, copy: typeof zhCopy) {
+  if (!record) return copy.missing;
+  const state = getRecordStatusMeta(record).key;
+  if (state === 'success') return copy.passed;
+  if (state === 'pending') return copy.pending;
+  return copy.needsAction;
 }
+
+const zhCopy = {
+  title: '发信域名', subtitle: '统一管理域名认证、发送能力和最近投递状态。', loading: '正在加载域名', loadFailed: '域名列表加载失败', retry: '重试', domain: '域名', capabilities: '发送 / 接收能力', authentication: '认证健康度', dnsIntegration: 'DNS 集成', lastActivity: '最近活动', actions: '操作', view: '查看', moreActions: '更多操作',
+  addDomain: '添加域名', addFirstDomain: '添加第一个域名', search: '搜索域名', searchPlaceholder: '搜索域名或发信主机', statusFilter: '认证状态', healthy: '健康', pending: '等待生效', needsAction: '需要处理', noMatches: '没有符合当前条件的域名', noDomains: '还没有发信域名', resultCount: (filtered: number, total: number) => `${filtered} / ${total} 个域名`,
+  checkDns: '检查 DNS', autoDns: '自动写入 DNS', sendTest: '测试发送', edit: '编辑配置', delete: '删除域名', manualDns: '手动配置', credentialUnavailable: '凭据不可用', noActivity: '尚无发送活动', passed: '已通过', incomplete: '未完整', missing: '未配置', sendReady: '发送可用', sendPending: '发送待验证', receiveRouted: '接收已路由', receiveConfigurable: '接收可配置',
+  created: '域名已创建,后续 DNS 操作已完成', createdPartial: '域名已创建,但后续 DNS 操作未完全成功,请在详情页继续处理', createFailed: '创建域名失败', checked: 'DNS 检查已完成', dnsApplied: 'DNS 记录写入已完成', dnsPartiallyApplied: '部分 DNS 记录写入失败,请查看详情', deleted: '域名已删除', testQueued: '测试邮件已加入队列', actionFailed: '操作失败', paginationTotal: (total: number) => `共 ${total} 个域名`,
+  deleteTitle: '删除域名', deleteWarning: '删除后,该域名的发送配置将立即不可用。此操作不可撤销。', typeDomain: '请输入域名以确认:', deleteConfirmation: '域名删除确认', cancel: '取消', testTitle: (domain: string) => `通过 ${domain} 测试发送`, queueTest: '加入发送队列', recipient: '收件人', validRecipient: '请输入有效的收件邮箱', subject: '主题', body: '正文', testSubject: (domain: string) => `MailHub ${domain} 测试邮件`, testBody: (domain: string) => `这是一封来自 ${domain} 的 MailHub 投递测试邮件。`
+};
+
+const enCopy: typeof zhCopy = {
+  title: 'Sending domains', subtitle: 'Manage authentication, sending capability, and recent delivery health.', loading: 'Loading domains', loadFailed: 'Unable to load domains', retry: 'Retry', domain: 'Domain', capabilities: 'Send / receive', authentication: 'Authentication', dnsIntegration: 'DNS integration', lastActivity: 'Last activity', actions: 'Actions', view: 'View', moreActions: 'More actions',
+  addDomain: 'Add domain', addFirstDomain: 'Add your first domain', search: 'Search domains', searchPlaceholder: 'Search domain or sending host', statusFilter: 'Authentication status', healthy: 'Healthy', pending: 'Pending', needsAction: 'Needs action', noMatches: 'No domains match these filters', noDomains: 'No sending domains yet', resultCount: (filtered: number, total: number) => `${filtered} of ${total} domains`,
+  checkDns: 'Check DNS', autoDns: 'Apply DNS automatically', sendTest: 'Send test', edit: 'Edit configuration', delete: 'Delete domain', manualDns: 'Manual', credentialUnavailable: 'Credential unavailable', noActivity: 'No activity yet', passed: 'passed', incomplete: 'incomplete', missing: 'Missing', sendReady: 'Sending ready', sendPending: 'Sending pending', receiveRouted: 'Receiving routed', receiveConfigurable: 'Receiving available',
+  created: 'Domain created and DNS follow-up completed', createdPartial: 'Domain created, but the DNS follow-up needs attention', createFailed: 'Unable to create domain', checked: 'DNS check completed', dnsApplied: 'DNS records applied', dnsPartiallyApplied: 'Some DNS records could not be applied', deleted: 'Domain deleted', testQueued: 'Test email queued', actionFailed: 'Action failed', paginationTotal: (total: number) => `${total} domains`,
+  deleteTitle: 'Delete domain', deleteWarning: 'Sending configuration for this domain will stop working immediately. This cannot be undone.', typeDomain: 'Type the domain to confirm:', deleteConfirmation: 'Domain deletion confirmation', cancel: 'Cancel', testTitle: (domain: string) => `Test sending from ${domain}`, queueTest: 'Queue test', recipient: 'Recipient', validRecipient: 'Enter a valid recipient email', subject: 'Subject', body: 'Body', testSubject: (domain: string) => `MailHub ${domain} test email`, testBody: (domain: string) => `This is a MailHub delivery test from ${domain}.`
+};

+ 550 - 628
src/pages/Inbox.tsx

@@ -1,64 +1,58 @@
 import {
+  ContainerOutlined,
   CopyOutlined,
-  KeyOutlined,
+  DeleteOutlined,
+  FileTextOutlined,
+  FolderOutlined,
   InboxOutlined,
+  MailOutlined,
   PlusOutlined,
   ReloadOutlined,
   SearchOutlined,
+  SendOutlined,
   SettingOutlined,
-  ThunderboltOutlined
+  WarningOutlined
 } from '@ant-design/icons';
 import {
   Alert,
+  App as AntApp,
+  Badge,
   Button,
+  Card,
   Checkbox,
-  Collapse,
   Descriptions,
   Drawer,
   Form,
+  Grid,
   Input,
   InputNumber,
+  List,
   Modal,
+  Pagination,
   Select,
+  Skeleton,
   Space,
-  Spin,
   Table,
   Tabs,
   Tag,
   Typography
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
 
+import { CodeBlock } from '../components/common/CodeBlock';
 import { EmptyState } from '../components/common/EmptyState';
 import { PageHeader } from '../components/common/PageHeader';
 import { SectionCard } from '../components/common/SectionCard';
 import { StatusPill } from '../components/common/StatusPill';
+import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
-import type { Domain, DomainPatchPayload, InboundMailbox, InboundMessage, MailboxClientConfig, RuntimeConfig } from '../frontend/types';
-import Webhooks from './Webhooks';
-
-interface InboxProps {
-  config: RuntimeConfig | null;
-  domains: Domain[];
-  mailboxes: InboundMailbox[];
-  messages: InboundMessage[];
-  loading?: boolean;
-  onCreateMailbox: (values: {
-    address: string;
-    displayName?: string;
-    password: string;
-    aliases?: string;
-    forwardTo?: string;
-    keepForwarded?: boolean;
-    quotaMb?: number | string | null;
-  }) => Promise<{ mailbox: InboundMailbox; clientConfig?: MailboxClientConfig } | null>;
-  onPatchDomain: (domain: Domain, values: DomainPatchPayload) => Promise<void>;
-  onLoadMessages: (mailboxId?: number | null) => Promise<InboundMessage[]>;
-  onLoadMessage: (id: number) => Promise<InboundMessage | null>;
-  onCopy: (value: string) => void;
-  onAddDomain: () => void;
-}
+import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
+import { api } from '../frontend/services/api';
+import type { Domain, InboundFolder, InboundMailbox, InboundMessage, MailboxClientConfig, RuntimeConfig } from '../frontend/types';
+
+type MailMessage = InboundMessage & { folder?: string };
 
 interface MailboxFormValues {
   localPart: string;
@@ -71,544 +65,306 @@ interface MailboxFormValues {
   keepForwarded?: boolean;
 }
 
-interface CatchAllFormValues {
-  catchAllAddress?: string;
-}
-
-export default function Inbox({
-  config,
-  domains,
-  mailboxes,
-  messages,
-  loading,
-  onCreateMailbox,
-  onPatchDomain,
-  onLoadMessages,
-  onLoadMessage,
-  onCopy,
-  onAddDomain
-}: InboxProps) {
-  const { t } = useI18n();
-  const [form] = Form.useForm<MailboxFormValues>();
-  const [catchAllForm] = Form.useForm<CatchAllFormValues>();
+const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
+type MessageTab = 'text' | 'html' | 'raw';
+
+export default function Inbox() {
+  const { message } = AntApp.useApp();
+  const screens = Grid.useBreakpoint();
+  const { locale, t } = useI18n();
+  const { config } = useAppContext();
+  const location = useLocation();
+  const navigate = useNavigate();
+  const params = useParams<{ messageId?: string }>();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [mailboxForm] = Form.useForm<MailboxFormValues>();
+  const [catchAllForm] = Form.useForm<{ catchAllAddress?: string }>();
+  const [domains, setDomains] = useState<Domain[]>([]);
+  const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
+  const [folders, setFolders] = useState<InboundFolder[]>(fallbackFolders());
+  const [messages, setMessages] = useState<MailMessage[]>([]);
+  const [total, setTotal] = useState(0);
+  const [selectedMessage, setSelectedMessage] = useState<MailMessage | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [messagesLoading, setMessagesLoading] = useState(false);
+  const [detailLoading, setDetailLoading] = useState(false);
+  const [loadError, setLoadError] = useState('');
+  const [messagesError, setMessagesError] = useState('');
+  const [detailError, setDetailError] = useState('');
+  const [actionKey, setActionKey] = useState('');
+  const [searchDraft, setSearchDraft] = useState(searchParams.get('q') || '');
   const [mailboxOpen, setMailboxOpen] = useState(false);
-  const [mailboxLoading, setMailboxLoading] = useState(false);
   const [clientConfig, setClientConfig] = useState<MailboxClientConfig | null>(null);
-  const [webhookMailbox, setWebhookMailbox] = useState<InboundMailbox | null>(null);
   const [catchAllDomain, setCatchAllDomain] = useState<Domain | null>(null);
-  const [catchAllLoading, setCatchAllLoading] = useState(false);
-  const [selectedMailboxId, setSelectedMailboxId] = useState<number | null>(null);
-  const [query, setQuery] = useState('');
-  const [selectedMessage, setSelectedMessage] = useState<InboundMessage | null>(null);
-  const [detailLoading, setDetailLoading] = useState(false);
-  const [detailError, setDetailError] = useState('');
-
-  const filteredMessages = useMemo(() => {
-    const cleanQuery = query.trim().toLowerCase();
-    if (!cleanQuery) return messages;
-    return messages.filter((message) => [
-      message.sender,
-      message.mailboxAddress,
-      message.subject,
-      message.preview,
-      message.recipients.join(', ')
-    ].some((value) => String(value || '').toLowerCase().includes(cleanQuery)));
-  }, [messages, query]);
-
-  const domainMailboxCounts = useMemo(() => {
-    const counts = new Map<number, number>();
-    for (const mailbox of mailboxes) counts.set(mailbox.domainId, (counts.get(mailbox.domainId) || 0) + 1);
-    return counts;
-  }, [mailboxes]);
-
-  const domainColumns: ColumnsType<Domain> = [
-    {
-      title: t('domains.domain'),
-      dataIndex: 'domain',
-      render: (value: string, domain) => (
-        <Space wrap>
-          <Typography.Text strong>{value}</Typography.Text>
-          <Typography.Text type="secondary">{domainMailboxCounts.get(domain.id) || 0} {t('inbox.mailboxUnit')}</Typography.Text>
-        </Space>
-      )
-    },
-    {
-      title: t('inbox.catchAllAddress'),
-      dataIndex: 'catchAllAddress',
-      render: (value: string) => value ? <Tag color={value === '/dev/null' ? 'default' : 'blue'}>{value}</Tag> : <Tag>{t('inbox.catchAllDisabled')}</Tag>
-    },
-    {
-      title: t('common.actions'),
-      width: 130,
-      render: (_value, domain) => (
-        <Button icon={<SettingOutlined />} onClick={() => openCatchAllModal(domain)}>
-          {t('common.edit')}
-        </Button>
-      )
+  const pendingDirectClose = useRef<string | null>(null);
+
+  const workspace = searchParams.get('workspace') === 'routing' ? 'routing' : 'messages';
+  const selectedMailboxId = Number(searchParams.get('mailboxId') || 0) || null;
+  const folder = searchParams.get('folder') || 'INBOX';
+  const readFilter = searchParams.get('read') || 'all';
+  const query = searchParams.get('q') || '';
+  const page = Math.max(1, Number(searchParams.get('page') || 1) || 1);
+  const pageSize = 25;
+  const routeMessageId = Number(params.messageId || 0) || null;
+  const messageTab = normalizeMessageTab(searchParams.get('tab'));
+  const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId) || null;
+
+  const loadBase = useCallback(async () => {
+    setLoading(true);
+    setLoadError('');
+    try {
+      const [domainResult, mailboxResult] = await Promise.all([
+        api.domains(), api.inboundMailboxes()
+      ]);
+      setDomains(domainResult.domains || []);
+      setMailboxes(mailboxResult.mailboxes || []);
+    } catch (error) {
+      setLoadError(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setLoading(false);
     }
-  ];
-
-  const mailboxColumns: ColumnsType<InboundMailbox> = [
-    {
-      title: t('inbox.mailboxAddress'),
-      dataIndex: 'address',
-      render: (value: string, mailbox) => (
-        <Space wrap>
-          <Typography.Text strong>{value}</Typography.Text>
-          {mailbox.displayName ? <Typography.Text type="secondary">{mailbox.displayName}</Typography.Text> : null}
-        </Space>
-      )
-    },
-    {
-      title: t('inbox.forwardTo'),
-      dataIndex: 'forwardTo',
-      width: 240,
-      render: (value: string[], mailbox) => value?.length ? (
-        <Space direction="vertical" size={2}>
-          <Typography.Text ellipsis>{value.join(', ')}</Typography.Text>
-          <Tag color={mailbox.keepForwarded ? 'blue' : 'orange'}>
-            {mailbox.keepForwarded ? t('inbox.keepForwarded') : t('inbox.forwardOnly')}
-          </Tag>
-        </Space>
-      ) : '-'
-    },
-    {
-      title: t('inbox.quotaMb'),
-      dataIndex: 'quotaMb',
-      width: 120,
-      render: (value: number | null) => value === null ? t('inbox.unlimited') : `${value} MB`
-    },
-    {
-      title: t('inbox.unread'),
-      dataIndex: 'unreadCount',
-      width: 100,
-      render: (value: number) => (
-        <StatusPill tone={value > 0 ? 'warning' : 'neutral'}>{String(value)}</StatusPill>
-      )
-    },
-    { title: t('inbox.messageCount'), dataIndex: 'messageCount', width: 120 },
-    {
-      title: t('inbox.lastMessageAt'),
-      dataIndex: 'lastMessageAt',
-      width: 190,
-      render: formatOptionalTime
-    },
-    {
-      title: t('common.actions'),
-      width: 230,
-      render: (_value, mailbox) => (
-        <Space size={4} wrap>
-          <Button icon={<KeyOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(mailbox, config))}>
-            {t('inbox.clientConfig')}
-          </Button>
-          <Button icon={<ThunderboltOutlined />} onClick={() => setWebhookMailbox(mailbox)}>
-            {t('inbox.mailboxWebhooks')}
-          </Button>
-        </Space>
-      )
+  }, [t]);
+
+  useEffect(() => {
+    void loadBase();
+  }, [loadBase]);
+
+  useEffect(() => {
+    if (!mailboxes.length) return;
+    if (routeMessageId && (!searchParams.has('mailboxId') || !searchParams.has('folder'))) return;
+    if (selectedMailboxId && mailboxes.some((item) => item.id === selectedMailboxId)) return;
+    const next = new URLSearchParams(searchParams);
+    next.set('mailboxId', String(mailboxes[0].id));
+    next.set('folder', 'INBOX');
+    next.set('page', '1');
+    setSearchParams(next, { replace: true });
+  }, [mailboxes, routeMessageId, searchParams, selectedMailboxId, setSearchParams]);
+
+  const loadFolders = useCallback(async () => {
+    if (!selectedMailboxId) {
+      setFolders(fallbackFolders());
+      return;
     }
-  ];
+    try {
+      const result = await api.inboundFolders(selectedMailboxId);
+      setFolders(result.folders?.length ? result.folders : fallbackFolders(selectedMailbox || undefined));
+    } catch {
+      setFolders(fallbackFolders(selectedMailbox || undefined));
+    }
+  }, [selectedMailbox, selectedMailboxId]);
 
-  const messageColumns: ColumnsType<InboundMessage> = [
-    {
-      title: t('inbox.receivedAt'),
-      dataIndex: 'receivedAt',
-      width: 190,
-      render: (value: string) => new Date(value).toLocaleString()
-    },
-    {
-      title: t('inbox.subject'),
-      dataIndex: 'subject',
-      ellipsis: true,
-      render: (value: string, message) => (
-        <Button type="link" className="table-link" onClick={() => void openMessage(message)}>
-          {value || t('inbox.noSubject')}
-        </Button>
-      )
-    },
-    { title: t('inbox.sender'), dataIndex: 'sender', width: 220, ellipsis: true },
-    { title: t('inbox.mailbox'), dataIndex: 'mailboxAddress', width: 220, ellipsis: true },
-    {
-      title: t('common.status'),
-      dataIndex: 'read',
-      width: 100,
-      render: (read: boolean) => (
-        <Tag color={read ? 'default' : 'blue'}>{read ? t('inbox.read') : t('inbox.unread')}</Tag>
-      )
-    },
-    { title: t('inbox.preview'), dataIndex: 'preview', ellipsis: true }
-  ];
+  useEffect(() => {
+    void loadFolders();
+  }, [loadFolders]);
 
-  return (
-    <>
-      <Space direction="vertical" size={20} className="full-width">
-        <PageHeader
-          title={t('inbox.title')}
-          subtitle={t('inbox.subtitle')}
-          extra={
-            <Space wrap>
-              <Button icon={<ReloadOutlined />} loading={loading} onClick={() => void onLoadMessages(selectedMailboxId)}>
-                {t('common.refresh')}
-              </Button>
-              <Button type="primary" icon={<PlusOutlined />} disabled={!domains.length} onClick={openMailboxModal}>
-                {t('inbox.createMailbox')}
-              </Button>
-            </Space>
-          }
-        />
-
-        {config?.submission?.inboundEnabled === false ? (
-          <Alert type="warning" showIcon message={t('inbox.inboundDisabled')} />
-        ) : null}
-
-        <Collapse
-          className="inbox-help"
-          size="small"
-          defaultActiveKey={['client']}
-          items={[
-            {
-              key: 'client',
-              label: t('inbox.clientHelpTitle'),
-              children: (
-                <Space direction="vertical" size={8} className="full-width">
-                  <Typography.Paragraph type="secondary" className="inbox-help-intro">
-                    {t('inbox.clientHelpIntro')}
-                  </Typography.Paragraph>
-                  <ul className="inbox-help-list">
-                    <li>{t('inbox.clientHelpImap')}</li>
-                    <li>{t('inbox.clientHelpPop3')}</li>
-                    <li>{t('inbox.clientHelpAuth')}</li>
-                    <li>{t('inbox.clientHelpSecurity')}</li>
-                    <li>{t('inbox.clientHelpPorts')}</li>
-                  </ul>
-                </Space>
-              )
-            }
-          ]}
-        />
-
-        <SectionCard
-          title={t('inbox.domainRoutes')}
-          extra={<Typography.Text type="secondary">{domains.length}</Typography.Text>}
-        >
-          {domains.length ? (
-            <Table
-              rowKey="id"
-              columns={domainColumns}
-              dataSource={domains}
-              pagination={false}
-              scroll={{ x: 720 }}
-            />
-          ) : (
-            <EmptyState
-              icon={<InboxOutlined />}
-              description={t('inbox.noDomain')}
-              action={<Button type="primary" onClick={onAddDomain}>{t('common.addDomain')}</Button>}
-            />
-          )}
-        </SectionCard>
-
-        <SectionCard
-          title={t('inbox.mailboxes')}
-          extra={
-            <Typography.Text type="secondary">
-              {mailboxes.length}
-            </Typography.Text>
-          }
-        >
-          {domains.length ? (
-            <Table
-              rowKey="id"
-              columns={mailboxColumns}
-              dataSource={mailboxes}
-              pagination={{ pageSize: 5 }}
-              scroll={{ x: 1180 }}
-            />
-          ) : (
-            <EmptyState
-              icon={<InboxOutlined />}
-              description={t('inbox.noDomain')}
-              action={<Button type="primary" onClick={onAddDomain}>{t('common.addDomain')}</Button>}
-            />
-          )}
-        </SectionCard>
-
-        <SectionCard
-          title={t('inbox.messages')}
-          extra={
-            <Typography.Text type="secondary">
-              {filteredMessages.length} / {messages.length}
-            </Typography.Text>
-          }
-        >
-          <div className="page-toolbar inbox-toolbar">
-            <Space wrap>
-              <Select
-                allowClear
-                placeholder={t('inbox.mailboxFilter')}
-                value={selectedMailboxId || undefined}
-                onChange={(value) => void selectMailbox(value || null)}
-                options={mailboxes.map((mailbox) => ({ value: mailbox.id, label: mailbox.address }))}
-                className="toolbar-select"
-              />
-              <Input
-                allowClear
-                prefix={<SearchOutlined />}
-                placeholder={t('inbox.searchPlaceholder')}
-                value={query}
-                onChange={(event) => setQuery(event.target.value)}
-                className="toolbar-search"
-              />
-            </Space>
-          </div>
-          <Table
-            rowKey="id"
-            columns={messageColumns}
-            dataSource={filteredMessages}
-            loading={loading}
-            scroll={{ x: 1180 }}
-          />
-        </SectionCard>
-      </Space>
+  const loadMessages = useCallback(async () => {
+    if (!selectedMailboxId || workspace !== 'messages') {
+      setMessages([]);
+      setTotal(0);
+      return;
+    }
+    setMessagesLoading(true);
+    setMessagesError('');
+    try {
+      const result = await api.inboundMessages({
+        mailboxId: selectedMailboxId,
+        folder,
+        page,
+        pageSize,
+        q: query || undefined,
+        read: readFilter === 'read' ? true : readFilter === 'unread' ? false : undefined
+      });
+      setMessages(result.messages || []);
+      setTotal(result.total ?? result.messages?.length ?? 0);
+    } catch (error) {
+      setMessagesError(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setMessagesLoading(false);
+    }
+  }, [folder, page, query, readFilter, selectedMailboxId, t, workspace]);
 
-      <Modal
-        title={t('inbox.createMailbox')}
-        open={mailboxOpen}
-        confirmLoading={mailboxLoading}
-        onOk={saveMailbox}
-        onCancel={closeMailboxModal}
-        width={760}
-      >
-        <Form form={form} layout="vertical">
-          <div className="inbox-form-grid">
-            <Form.Item
-              name="localPart"
-              label={t('inbox.localPart')}
-              rules={[
-                { required: true, message: t('inbox.localPartRequired') },
-                { pattern: /^[^@\s]+$/, message: t('inbox.localPartInvalid') }
-              ]}
-            >
-              <Input placeholder="support" />
-            </Form.Item>
-            <Form.Item name="domain" label={t('domains.domain')} rules={[{ required: true, message: t('inbox.domainRequired') }]}>
-              <Select
-                options={domains.map((domain) => ({ value: domain.domain, label: domain.domain }))}
-                placeholder="example.com"
-              />
-            </Form.Item>
-          </div>
-          <Form.Item
-            name="password"
-            label={t('inbox.password')}
-            rules={[
-              { required: true, message: t('inbox.passwordRequired') },
-              { min: 8, message: t('inbox.passwordMin') }
-            ]}
-          >
-            <Input.Password
-              autoComplete="new-password"
-              addonAfter={<Button type="link" size="small" onClick={generatePassword}>{t('inbox.generatePassword')}</Button>}
-            />
-          </Form.Item>
-          <div className="inbox-form-grid">
-            <Form.Item name="displayName" label={t('inbox.displayName')}>
-              <Input placeholder="Support" />
-            </Form.Item>
-            <Form.Item name="quotaMb" label={t('inbox.quotaMb')}>
-              <InputNumber min={0} precision={0} className="full-width" placeholder={t('inbox.unlimited')} addonAfter="MB" />
-            </Form.Item>
-          </div>
-          <Form.Item name="aliases" label={t('inbox.aliases')} extra={t('inbox.aliasesExtra')}>
-            <Input.TextArea rows={3} placeholder={'sales\nhelp'} />
-          </Form.Item>
-          <Form.Item name="forwardTo" label={t('inbox.forwardTo')} extra={t('inbox.forwardToExtra')}>
-            <Input.TextArea rows={3} placeholder={'archive@example.net\nops@example.net'} />
-          </Form.Item>
-          <Form.Item name="keepForwarded" valuePropName="checked">
-            <Checkbox>{t('inbox.keepForwarded')}</Checkbox>
-          </Form.Item>
-        </Form>
-      </Modal>
+  useEffect(() => {
+    void loadMessages();
+  }, [loadMessages]);
 
-      <Modal
-        title={catchAllDomain ? `${t('inbox.catchAllTitle')} · ${catchAllDomain.domain}` : t('inbox.catchAllTitle')}
-        open={Boolean(catchAllDomain)}
-        confirmLoading={catchAllLoading}
-        onOk={saveCatchAll}
-        onCancel={() => setCatchAllDomain(null)}
-      >
-        <Form form={catchAllForm} layout="vertical">
-          <Form.Item name="catchAllAddress" label={t('inbox.catchAllAddress')} extra={t('inbox.catchAllExtra')}>
-            <Input placeholder={`share@${catchAllDomain?.domain || 'example.com'} 或 /dev/null`} />
-          </Form.Item>
-        </Form>
-      </Modal>
+  useEffect(() => {
+    if (!routeMessageId) {
+      setSelectedMessage(null);
+      setDetailError('');
+      return;
+    }
+    let active = true;
+    setDetailLoading(true);
+    setDetailError('');
+    void api.inboundMessage(routeMessageId)
+      .then(async (result) => {
+        if (!active) return;
+        const detail = result.message as MailMessage | null;
+        if (!detail) {
+          setDetailError(t('inbox.messageNotFound'));
+          return;
+        }
+        setSelectedMessage(detail);
+        if (!detail.read) {
+          await api.markInboundMessageRead(detail.id, true);
+          if (!active) return;
+          setSelectedMessage({ ...detail, read: true });
+          setMessages((items) => items.map((item) => item.id === detail.id ? { ...item, read: true } : item));
+          void loadFolders();
+        }
+      })
+      .catch((error) => {
+        if (active) setDetailError(error instanceof Error ? error.message : t('inbox.detailLoadFailed'));
+      })
+      .finally(() => {
+        if (active) setDetailLoading(false);
+      });
+    return () => { active = false; };
+  }, [loadFolders, routeMessageId, t]);
+
+  useEffect(() => {
+    if (!routeMessageId || selectedMessage?.id !== routeMessageId) return;
+    if (searchParams.has('mailboxId') && searchParams.has('folder')) return;
+    const next = new URLSearchParams(searchParams);
+    next.set('mailboxId', String(selectedMessage.mailboxId));
+    next.set('folder', selectedMessage.folder || 'INBOX');
+    setSearchParams(next, { replace: true });
+  }, [routeMessageId, searchParams, selectedMessage, setSearchParams]);
+
+  useEffect(() => {
+    const target = pendingDirectClose.current;
+    if (!target || detailHistoryState(location.state)?.origin === 'direct') return;
+    pendingDirectClose.current = null;
+    navigate(target, { replace: true });
+  }, [location.key, location.state, navigate]);
+
+  function updateSearch(patch: Record<string, string | number | null>) {
+    const next = new URLSearchParams(searchParams);
+    Object.entries(patch).forEach(([key, value]) => {
+      if (value === null || value === '') next.delete(key);
+      else next.set(key, String(value));
+    });
+    setSearchParams(next);
+  }
 
-      <Modal
-        title={t('inbox.clientConfig')}
-        open={Boolean(clientConfig)}
-        footer={null}
-        onCancel={() => setClientConfig(null)}
-        width={760}
-      >
-        {clientConfig ? (
-          <Space direction="vertical" size={16} className="full-width">
-            <Alert type="info" showIcon message={t('inbox.clientConfigHelpSummary')} />
-            <Descriptions bordered size="small" column={1}>
-              <Descriptions.Item label={t('inbox.configUsername')}>
-                <ConfigValue value={clientConfig.username} onCopy={onCopy} />
-              </Descriptions.Item>
-              <Descriptions.Item label={t('inbox.configPassword')}>
-                <ConfigValue value={clientConfig.password || t('inbox.passwordNotShown')} onCopy={clientConfig.password ? onCopy : undefined} />
-              </Descriptions.Item>
-            </Descriptions>
-            <Descriptions bordered size="small" column={1} title={t('inbox.incomingConfig')}>
-              <Descriptions.Item label={t('inbox.configProtocol')}>{clientConfig.incoming.protocol}</Descriptions.Item>
-              <Descriptions.Item label={t('inbox.configHost')}>
-                <ConfigValue value={clientConfig.incoming.host} onCopy={onCopy} />
-              </Descriptions.Item>
-              <Descriptions.Item label={t('inbox.configPort')}>{clientConfig.incoming.port}</Descriptions.Item>
-              <Descriptions.Item label={t('inbox.configSecurity')}>{clientConfig.incoming.security}</Descriptions.Item>
-              <Descriptions.Item label={t('inbox.configAuthMethod')}>{clientConfig.incoming.authMethod}</Descriptions.Item>
-            </Descriptions>
-            {clientConfig.pop3 ? (
-              <Descriptions bordered size="small" column={1} title={t('inbox.pop3Config')}>
-                <Descriptions.Item label={t('inbox.configProtocol')}>{clientConfig.pop3.protocol}</Descriptions.Item>
-                <Descriptions.Item label={t('inbox.configHost')}>
-                  <ConfigValue value={clientConfig.pop3.host} onCopy={onCopy} />
-                </Descriptions.Item>
-                <Descriptions.Item label={t('inbox.configPort')}>{clientConfig.pop3.port}</Descriptions.Item>
-                <Descriptions.Item label={t('inbox.configSecurity')}>{clientConfig.pop3.security}</Descriptions.Item>
-                <Descriptions.Item label={t('inbox.configAuthMethod')}>{clientConfig.pop3.authMethod}</Descriptions.Item>
-              </Descriptions>
-            ) : null}
-            <Descriptions bordered size="small" column={1} title={t('inbox.outgoingConfig')}>
-              <Descriptions.Item label={t('inbox.configProtocol')}>{clientConfig.outgoing.protocol}</Descriptions.Item>
-              <Descriptions.Item label={t('inbox.configHost')}>
-                <ConfigValue value={clientConfig.outgoing.host} onCopy={onCopy} />
-              </Descriptions.Item>
-              <Descriptions.Item label={t('inbox.configPort')}>{clientConfig.outgoing.port}</Descriptions.Item>
-              <Descriptions.Item label={t('inbox.configSecurity')}>{clientConfig.outgoing.security}</Descriptions.Item>
-              <Descriptions.Item label={t('inbox.configAuthMethod')}>{clientConfig.outgoing.authMethod}</Descriptions.Item>
-            </Descriptions>
-          </Space>
-        ) : null}
-      </Modal>
+  function switchWorkspace(key: string) {
+    const next = new URLSearchParams(searchParams);
+    if (key === 'routing') next.set('workspace', 'routing');
+    else next.delete('workspace');
+    const suffix = next.toString();
+    navigate(`/inbox${suffix ? `?${suffix}` : ''}`);
+  }
 
-      <Drawer
-        title={webhookMailbox ? `${t('inbox.mailboxWebhooks')} · ${webhookMailbox.address}` : t('inbox.mailboxWebhooks')}
-        open={Boolean(webhookMailbox)}
-        width="min(1240px, 100vw)"
-        destroyOnHidden
-        onClose={() => setWebhookMailbox(null)}
-      >
-        {webhookMailbox ? (
-          <Webhooks
-            mailboxId={webhookMailbox.id}
-            domains={domains}
-            mailboxes={mailboxes}
-            onCopy={onCopy}
-          />
-        ) : null}
-      </Drawer>
+  function selectMailbox(id: number) {
+    const next = new URLSearchParams(searchParams);
+    next.set('mailboxId', String(id));
+    next.set('folder', 'INBOX');
+    next.set('page', '1');
+    const suffix = next.toString();
+    navigate(`/inbox${suffix ? `?${suffix}` : ''}`);
+  }
 
-      <Drawer
-        title={selectedMessage ? `${t('inbox.messageDetail')} · mh-in-${selectedMessage.id}` : t('inbox.messageDetail')}
-        open={Boolean(selectedMessage)}
-        width="min(820px, 100vw)"
-        onClose={() => setSelectedMessage(null)}
-        extra={selectedMessage?.rawMessage ? (
-          <Button icon={<CopyOutlined />} onClick={() => onCopy(selectedMessage.rawMessage || '')}>
-            {t('inbox.copyRaw')}
-          </Button>
-        ) : null}
-      >
-        <Spin spinning={detailLoading}>
-          {selectedMessage ? (
-            <Space direction="vertical" size={16} className="full-width">
-              {detailError ? <Alert type="error" showIcon message={detailError} /> : null}
-              <Descriptions bordered size="small" column={1}>
-                <Descriptions.Item label={t('inbox.receivedAt')}>{formatOptionalTime(selectedMessage.receivedAt)}</Descriptions.Item>
-                <Descriptions.Item label={t('inbox.sender')}>{selectedMessage.sender || '-'}</Descriptions.Item>
-                <Descriptions.Item label={t('inbox.recipients')}>{selectedMessage.recipients.join(', ') || '-'}</Descriptions.Item>
-                <Descriptions.Item label={t('inbox.mailbox')}>{selectedMessage.mailboxAddress || '-'}</Descriptions.Item>
-                <Descriptions.Item label={t('inbox.subject')}>{selectedMessage.subject || '-'}</Descriptions.Item>
-                <Descriptions.Item label={t('logs.messageId')}>
-                  <Typography.Text code>{selectedMessage.messageId || '-'}</Typography.Text>
-                </Descriptions.Item>
-              </Descriptions>
-              <Tabs
-                items={[
-                  {
-                    key: 'text',
-                    label: t('inbox.textBody'),
-                    children: <MessageBody value={selectedMessage.textBody} empty={t('inbox.noTextBody')} />
-                  },
-                  {
-                    key: 'html',
-                    label: t('inbox.htmlBody'),
-                    children: <MessageBody value={selectedMessage.htmlBody} empty={t('inbox.noHtmlBody')} />
-                  },
-                  {
-                    key: 'raw',
-                    label: t('inbox.rawMessage'),
-                    children: <MessageBody value={selectedMessage.rawMessage} empty={t('inbox.noRawMessage')} />
-                  }
-                ]}
-              />
-            </Space>
-          ) : null}
-        </Spin>
-      </Drawer>
-    </>
-  );
+  function selectFolder(name: string) {
+    const next = new URLSearchParams(searchParams);
+    next.set('folder', name);
+    next.set('page', '1');
+    const suffix = next.toString();
+    navigate(`/inbox${suffix ? `?${suffix}` : ''}`);
+  }
 
-  function openMailboxModal() {
-    form.setFieldsValue({
-      localPart: '',
-      domain: domains[0]?.domain || '',
-      password: generateMailboxPassword(),
-      displayName: '',
-      quotaMb: null,
-      aliases: '',
-      forwardTo: '',
-      keepForwarded: true
+  function openMessage(item: MailMessage) {
+    setSelectedMessage(item);
+    const suffix = searchParams.toString();
+    const target = `/inbox/messages/${item.id}${suffix ? `?${suffix}` : ''}`;
+    const historyState = detailHistoryState(location.state);
+    if (historyState) {
+      navigate(target, {
+        state: detailHistoryLocation(historyState.listPath, historyState.depth + 1, historyState.origin)
+      });
+      return;
+    }
+    if (routeMessageId) {
+      navigate(target, { replace: true });
+      return;
+    }
+    navigate(target, {
+      state: detailHistoryLocation(`${location.pathname}${location.search}`, 1)
     });
-    setMailboxOpen(true);
   }
 
-  function closeMailboxModal() {
-    setMailboxOpen(false);
-    form.resetFields();
+  function closeMessage() {
+    const historyState = detailHistoryState(location.state);
+    if (historyState?.origin === 'list') {
+      navigate(-historyState.depth);
+      return;
+    }
+    const detail = selectedMessage?.id === routeMessageId ? selectedMessage : null;
+    const listPath = inboxListPath(searchParams, detail);
+    if (historyState?.origin === 'direct') {
+      pendingDirectClose.current = listPath;
+      navigate(-historyState.depth);
+      return;
+    }
+    navigate(listPath, { replace: true });
+  }
+
+  function changeMessageTab(tab: MessageTab) {
+    const next = new URLSearchParams(searchParams);
+    if (tab === 'text') next.delete('tab');
+    else next.set('tab', tab);
+    const historyState = detailHistoryState(location.state);
+    const detail = selectedMessage?.id === routeMessageId ? selectedMessage : null;
+    const search = next.toString();
+    navigate(
+      { pathname: location.pathname, search: search ? `?${search}` : '' },
+      {
+        state: detailHistoryLocation(
+          historyState?.listPath || inboxListPath(searchParams, detail),
+          (historyState?.depth || 0) + 1,
+          historyState?.origin || 'direct'
+        )
+      }
+    );
+  }
+
+  async function copyValue(value: string) {
+    if (!value) return;
+    await navigator.clipboard.writeText(value);
+    message.success(t('common.copied'));
   }
 
-  async function saveMailbox() {
-    const values = await form.validateFields();
-    setMailboxLoading(true);
+  function openCreateMailbox() {
+    mailboxForm.resetFields();
+    mailboxForm.setFieldsValue({ domain: domains[0]?.domain, password: generateMailboxPassword(), quotaMb: 1024, keepForwarded: true });
+    setMailboxOpen(true);
+  }
+
+  async function createMailbox() {
+    const values = await mailboxForm.validateFields();
+    setActionKey('mailbox:create');
     try {
-      const result = await onCreateMailbox({
-        address: `${values.localPart.trim()}@${values.domain}`,
-        displayName: values.displayName?.trim(),
+      const result = await api.createInboundMailbox({
+        address: `${values.localPart}@${values.domain}`,
+        displayName: values.displayName,
         password: values.password,
         aliases: values.aliases,
         forwardTo: values.forwardTo,
-        keepForwarded: values.keepForwarded !== false,
-        quotaMb: values.quotaMb ?? null
+        keepForwarded: values.keepForwarded,
+        quotaMb: values.quotaMb
       });
-      if (!result?.mailbox) return;
+      message.success(t('actions.inboundMailboxCreated'));
+      setMailboxOpen(false);
+      mailboxForm.resetFields();
       setClientConfig(result.clientConfig || buildMailboxClientConfig(result.mailbox, config, values.password));
-      closeMailboxModal();
+      await loadBase();
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
     } finally {
-      setMailboxLoading(false);
+      setActionKey('');
     }
   }
 
-  function generatePassword() {
-    form.setFieldValue('password', generateMailboxPassword());
-  }
-
-  function openCatchAllModal(domain: Domain) {
+  function openCatchAll(domain: Domain) {
     setCatchAllDomain(domain);
     catchAllForm.setFieldsValue({ catchAllAddress: domain.catchAllAddress || '' });
   }
@@ -616,127 +372,293 @@ export default function Inbox({
   async function saveCatchAll() {
     if (!catchAllDomain) return;
     const values = await catchAllForm.validateFields();
-    setCatchAllLoading(true);
+    setActionKey(`catch-all:${catchAllDomain.id}`);
     try {
-      await onPatchDomain(catchAllDomain, {
-        catchAllAddress: String(values.catchAllAddress || '').trim()
-      });
+      const result = await api.patchDomain(catchAllDomain.id, { catchAllAddress: String(values.catchAllAddress || '').trim() });
+      setDomains((items) => items.map((item) => item.id === result.domain.id ? result.domain : item));
       setCatchAllDomain(null);
-      catchAllForm.resetFields();
+      message.success(t('actions.domainSaved'));
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
     } finally {
-      setCatchAllLoading(false);
+      setActionKey('');
     }
   }
 
-  async function selectMailbox(mailboxId: number | null) {
-    setSelectedMailboxId(mailboxId);
-    await onLoadMessages(mailboxId);
-  }
-
-  async function openMessage(message: InboundMessage) {
-    setSelectedMessage(message);
-    setDetailError('');
-    setDetailLoading(true);
-    try {
-      const detail = await onLoadMessage(message.id);
-      if (detail) setSelectedMessage(detail);
-      if (!detail) setDetailError(t('inbox.messageNotFound'));
-    } catch (error) {
-      setDetailError(error instanceof Error ? error.message : t('inbox.detailLoadFailed'));
-    } finally {
-      setDetailLoading(false);
+  const mailboxColumns: ColumnsType<InboundMailbox> = [
+    {
+      title: t('inbox.mailboxAddress'), dataIndex: 'address', render: (value: string, item) => <Space direction="vertical" size={0}><Typography.Text strong>{value}</Typography.Text>{item.displayName ? <Typography.Text type="secondary">{item.displayName}</Typography.Text> : null}</Space>
+    },
+    { title: t('common.status'), dataIndex: 'status', width: 120, render: (value: string) => <StatusPill tone={value === 'active' ? 'success' : 'warning'}>{value}</StatusPill> },
+    { title: t('inbox.forwardTo'), dataIndex: 'forwardTo', render: (value: string[], item) => value?.length ? <Space direction="vertical" size={2}><Typography.Text>{value.join(', ')}</Typography.Text><Tag>{item.keepForwarded ? t('inbox.keepForwarded') : t('inbox.forwardOnly')}</Tag></Space> : '—' },
+    { title: t('inbox.unread'), dataIndex: 'unreadCount', width: 90 },
+    { title: t('inbox.messageCount'), dataIndex: 'messageCount', width: 100 },
+    {
+      title: t('common.actions'), width: 230, render: (_, item) => <Space wrap><Button icon={<SettingOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button icon={<MailOutlined />} onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space>
     }
+  ];
+
+  const routeColumns: ColumnsType<Domain> = [
+    { title: t('domains.domain'), dataIndex: 'domain', render: (value: string) => <Typography.Text strong>{value}</Typography.Text> },
+    { title: t('inbox.catchAllAddress'), dataIndex: 'catchAllAddress', render: (value?: string) => value ? <Tag color={value === '/dev/null' ? 'default' : 'blue'}>{value}</Tag> : <Tag>{t('inbox.catchAllDisabled')}</Tag> },
+    { title: t('common.actions'), width: 130, render: (_, domain) => <Button icon={<SettingOutlined />} onClick={() => openCatchAll(domain)}>{t('common.edit')}</Button> }
+  ];
+
+  if (loading) return <SectionCard><Skeleton active paragraph={{ rows: 12 }} /></SectionCard>;
+
+  return (
+    <Space direction="vertical" size={20} className="full-width">
+      <PageHeader
+        title={t('inbox.title')}
+        subtitle={t('inbox.subtitle')}
+        extra={workspace === 'routing' ? <Button type="primary" icon={<PlusOutlined />} disabled={!domains.length} onClick={openCreateMailbox} style={{ minHeight: 44 }}>{t('inbox.createMailbox')}</Button> : null}
+      />
+      {loadError ? <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadBase()}>{t('common.refresh')}</Button>} /> : null}
+      {config?.submission?.inboundEnabled === false ? <Alert type="warning" showIcon message={t('inbox.inboundDisabled')} /> : null}
+      <Tabs
+        activeKey={workspace}
+        onChange={switchWorkspace}
+        items={[
+          { key: 'messages', label: <Space><MailOutlined />{locale.startsWith('en') ? 'Mail' : '邮件'}</Space> },
+          { key: 'routing', label: <Space><SettingOutlined />{locale.startsWith('en') ? 'Mailboxes & routing' : '邮箱与路由'}</Space> }
+        ]}
+      />
+      {workspace === 'messages' ? (
+        mailboxes.length ? (
+          <div style={{ display: 'grid', gridTemplateColumns: screens.lg ? '220px minmax(320px, 380px) minmax(0, 1fr)' : screens.md ? '220px minmax(0, 1fr)' : 'minmax(0, 1fr)', gap: 16, minWidth: 0 }}>
+            {screens.md ? <FolderPane mailboxes={mailboxes} selectedMailboxId={selectedMailboxId} folders={folders} activeFolder={folder} onMailbox={selectMailbox} onFolder={selectFolder} locale={locale} /> : null}
+            <Card styles={{ body: { padding: 0, minWidth: 0 } }}>
+              <div style={{ padding: 12, borderBottom: '1px solid #EAECF0' }}>
+                {!screens.md ? (
+                  <Space direction="vertical" size={8} className="full-width" style={{ marginBottom: 8 }}>
+                    <Select value={selectedMailboxId || undefined} onChange={selectMailbox} options={mailboxes.map((item) => ({ value: item.id, label: item.address }))} className="full-width" aria-label={t('inbox.mailboxFilter')} />
+                    <Select value={folder} onChange={selectFolder} options={folders.map((item) => ({ value: item.name, label: `${folderLabel(item.name, locale)} (${item.unreadCount})` }))} className="full-width" aria-label={locale.startsWith('en') ? 'Folder' : '文件夹'} />
+                  </Space>
+                ) : null}
+                <Space.Compact block>
+                  <Input value={searchDraft} allowClear prefix={<SearchOutlined />} placeholder={t('inbox.searchPlaceholder')} onChange={(event) => setSearchDraft(event.target.value)} onPressEnter={() => updateSearch({ q: searchDraft.trim(), page: 1 })} />
+                  <Button aria-label={t('common.refresh')} icon={<ReloadOutlined />} loading={messagesLoading} onClick={() => void loadMessages()} />
+                </Space.Compact>
+                <Select
+                  value={readFilter}
+                  onChange={(value) => updateSearch({ read: value === 'all' ? null : value, page: 1 })}
+                  style={{ width: '100%', marginTop: 8 }}
+                  aria-label={locale.startsWith('en') ? 'Read state' : '阅读状态'}
+                  options={[{ value: 'all', label: locale.startsWith('en') ? 'All mail' : '全部邮件' }, { value: 'unread', label: t('inbox.unread') }, { value: 'read', label: t('inbox.read') }]}
+                />
+              </div>
+              {messagesError ? <Alert type="error" showIcon message={messagesError} action={<Button onClick={() => void loadMessages()}>{t('common.refresh')}</Button>} /> : null}
+              <MessageList items={messages} loading={messagesLoading} activeId={routeMessageId} onOpen={openMessage} locale={locale} t={t} />
+              {total > pageSize ? <div style={{ padding: 12, display: 'flex', justifyContent: 'center' }}><Pagination size="small" current={page} pageSize={pageSize} total={total} showSizeChanger={false} onChange={(value) => updateSearch({ page: value })} /></div> : null}
+            </Card>
+            {screens.lg ? <Card styles={{ body: { padding: 20, minWidth: 0 } }}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Card> : null}
+          </div>
+        ) : <EmptyState description={locale.startsWith('en') ? 'No receiving mailbox has been created yet.' : '尚未创建收信邮箱。'} action={<Button icon={<PlusOutlined />} disabled={!domains.length} onClick={() => { switchWorkspace('routing'); openCreateMailbox(); }}>{t('inbox.createMailbox')}</Button>} />
+      ) : (
+        <Space direction="vertical" size={20} className="full-width">
+          <SectionCard title={t('inbox.mailboxes')} extra={<StatusPill tone="neutral">{mailboxes.length}</StatusPill>}>
+            {mailboxes.length ? (screens.md ? <Table rowKey="id" columns={mailboxColumns} dataSource={mailboxes} scroll={{ x: 940 }} /> : <List dataSource={mailboxes} renderItem={(item) => <List.Item><Card size="small" className="full-width" title={item.address}><Space direction="vertical" className="full-width"><Typography.Text type="secondary">{item.messageCount} {t('inbox.messageCount')} · {item.unreadCount} {t('inbox.unread')}</Typography.Text><Space wrap><Button onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space></Space></Card></List.Item>} />) : <EmptyState description={t('inbox.noDomain')} action={<Button icon={<PlusOutlined />} disabled={!domains.length} onClick={openCreateMailbox}>{t('inbox.createMailbox')}</Button>} />}
+          </SectionCard>
+          <SectionCard title={t('inbox.domainRoutes')} extra={<StatusPill tone="neutral">{domains.length}</StatusPill>}>
+            {domains.length ? (screens.md ? <Table rowKey="id" columns={routeColumns} dataSource={domains} /> : <List dataSource={domains} renderItem={(domain) => <List.Item actions={[<Button key="edit" onClick={() => openCatchAll(domain)}>{t('common.edit')}</Button>]}><List.Item.Meta title={domain.domain} description={domain.catchAllAddress || t('inbox.catchAllDisabled')} /></List.Item>} />) : <EmptyState description={t('inbox.noDomain')} action={<Button onClick={() => navigate('/domains?create=1')}>{t('common.addDomain')}</Button>} />}
+          </SectionCard>
+        </Space>
+      )}
+
+      {!screens.lg ? <Drawer title={t('inbox.messageDetail')} width={screens.md ? 680 : '100%'} open={Boolean(routeMessageId)} onClose={closeMessage}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Drawer> : null}
+
+      <Drawer title={t('inbox.createMailbox')} width={560} open={mailboxOpen} onClose={() => setMailboxOpen(false)} destroyOnHidden footer={<Space style={{ display: 'flex', justifyContent: 'flex-end' }}><Button onClick={() => setMailboxOpen(false)}>{t('common.cancel')}</Button><Button type="primary" loading={actionKey === 'mailbox:create'} onClick={() => void createMailbox()}>{t('inbox.createMailbox')}</Button></Space>}>
+        {!domains.length ? <Alert type="warning" showIcon message={t('inbox.noDomain')} /> : (
+          <Form form={mailboxForm} layout="vertical">
+            <Space.Compact block>
+              <Form.Item name="localPart" label={t('inbox.localPart')} rules={[{ required: true, message: t('inbox.localPartRequired') }, { pattern: /^[^@\s]+$/, message: t('inbox.localPartInvalid') }]} style={{ flex: 1 }}><Input autoComplete="off" /></Form.Item>
+              <Form.Item name="domain" label={t('domains.domain')} rules={[{ required: true, message: t('inbox.domainRequired') }]} style={{ minWidth: 220 }}><Select options={domains.map((domain) => ({ value: domain.domain, label: `@${domain.domain}` }))} /></Form.Item>
+            </Space.Compact>
+            <Form.Item name="displayName" label={t('inbox.displayName')}><Input /></Form.Item>
+            <Form.Item name="password" label={t('inbox.password')} rules={[{ required: true, message: t('inbox.passwordRequired') }, { min: 8, message: t('inbox.passwordMin') }]}><Input.Password autoComplete="new-password" /></Form.Item>
+            <Button onClick={() => mailboxForm.setFieldValue('password', generateMailboxPassword())}>{t('inbox.generatePassword')}</Button>
+            <Form.Item name="quotaMb" label={`${t('inbox.quotaMb')} (MB)`} style={{ marginTop: 20 }}><InputNumber min={1} className="full-width" /></Form.Item>
+            <Form.Item name="aliases" label={t('inbox.aliases')} extra={t('inbox.aliasesExtra')}><Input.TextArea rows={3} /></Form.Item>
+            <Form.Item name="forwardTo" label={t('inbox.forwardTo')} extra={t('inbox.forwardToExtra')}><Input.TextArea rows={3} /></Form.Item>
+            <Form.Item name="keepForwarded" valuePropName="checked"><Checkbox>{t('inbox.keepForwarded')}</Checkbox></Form.Item>
+          </Form>
+        )}
+      </Drawer>
+
+      <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>
+
+      <Modal title={t('inbox.clientConfig')} open={Boolean(clientConfig)} width={720} footer={<Button type="primary" onClick={() => setClientConfig(null)}>{t('common.confirm')}</Button>} onCancel={() => setClientConfig(null)}>
+        {clientConfig ? <ClientConfigView config={clientConfig} onCopy={copyValue} t={t} /> : null}
+      </Modal>
+
+    </Space>
+  );
+}
+
+function FolderPane({ mailboxes, selectedMailboxId, folders, activeFolder, onMailbox, onFolder, locale }: { mailboxes: InboundMailbox[]; selectedMailboxId: number | null; folders: InboundFolder[]; activeFolder: string; onMailbox: (id: number) => void; onFolder: (name: string) => void; locale: string }) {
+  return (
+    <Card styles={{ body: { padding: 8 } }}>
+      <Select value={selectedMailboxId || undefined} onChange={onMailbox} options={mailboxes.map((item) => ({ value: item.id, label: item.address }))} className="full-width" style={{ marginBottom: 12 }} />
+      <Space direction="vertical" size={2} className="full-width">
+        {folders.map((item) => (
+          <Button key={item.name} type={activeFolder === item.name ? 'primary' : 'text'} icon={folderIcon(item.name)} onClick={() => onFolder(item.name)} style={{ width: '100%', minHeight: 44, display: 'flex', alignItems: 'center', justifyContent: 'flex-start' }}>
+            <span style={{ flex: 1, textAlign: 'left' }}>{folderLabel(item.name, locale)}</span>
+            {item.unreadCount ? <Badge count={item.unreadCount} size="small" /> : <Typography.Text type="secondary">{item.messageCount}</Typography.Text>}
+          </Button>
+        ))}
+      </Space>
+    </Card>
+  );
+}
+
+function MessageList({ items, loading, activeId, onOpen, locale, t }: { items: MailMessage[]; loading: boolean; activeId: number | null; onOpen: (item: MailMessage) => void; locale: string; t: (key: string) => string }) {
+  if (loading) return <div style={{ padding: 16 }}><Skeleton active paragraph={{ rows: 8 }} /></div>;
+  if (!items.length) return <EmptyState description={locale.startsWith('en') ? 'No messages match this folder and filter.' : '当前文件夹和筛选条件下没有邮件。'} />;
+  return (
+    <List dataSource={items} split renderItem={(item) => (
+      <List.Item style={{ padding: 0 }}>
+        <button
+          type="button"
+          aria-label={`${item.subject || t('inbox.noSubject')} · ${item.sender}`}
+          onClick={() => onOpen(item)}
+          style={{ width: '100%', minHeight: 88, padding: '12px 16px', border: 0, textAlign: 'left', background: activeId === item.id ? '#EEF2FF' : item.read ? '#FFFFFF' : '#F8FAFF', cursor: 'pointer' }}
+        >
+          <Space direction="vertical" size={4} style={{ width: '100%' }}>
+            <Space style={{ width: '100%', justifyContent: 'space-between' }}>
+              <Typography.Text strong={!item.read} ellipsis style={{ maxWidth: '65%' }}>{item.sender || '—'}</Typography.Text>
+              <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatCompactTime(item.receivedAt)}</Typography.Text>
+            </Space>
+            <Typography.Text strong={!item.read} ellipsis>{item.subject || t('inbox.noSubject')}</Typography.Text>
+            <Typography.Text type="secondary" ellipsis>{item.preview || '—'}</Typography.Text>
+          </Space>
+        </button>
+      </List.Item>
+    )} />
+  );
+}
+
+function MessageDetail({ message, loading, error, activeTab, onTabChange, onCopy, t }: { message: MailMessage | null; loading: boolean; error: string; activeTab: MessageTab; onTabChange: (tab: MessageTab) => void; onCopy: (value: string) => void; t: (key: string) => string }) {
+  if (loading) return <Skeleton active paragraph={{ rows: 12 }} />;
+  if (error) return <Alert type="error" showIcon message={error} />;
+  if (!message) return <EmptyState description={t('inbox.messageDetail')} />;
+  return (
+    <Space direction="vertical" size={20} className="full-width">
+      <div><Typography.Title level={4} style={{ marginBottom: 4 }}>{message.subject || t('inbox.noSubject')}</Typography.Title><Typography.Text type="secondary">{formatOptionalTime(message.receivedAt)}</Typography.Text></div>
+      <Descriptions column={1} size="small">
+        <Descriptions.Item label={t('inbox.sender')}>{message.sender || '—'}</Descriptions.Item>
+        <Descriptions.Item label={t('inbox.recipients')}>{message.recipients.join(', ') || '—'}</Descriptions.Item>
+        <Descriptions.Item label={t('logs.messageId')}><Typography.Text code copyable={{ onCopy: () => onCopy(message.messageId) }}>{message.messageId || '—'}</Typography.Text></Descriptions.Item>
+      </Descriptions>
+      <Tabs activeKey={activeTab} onChange={(key) => onTabChange(normalizeMessageTab(key))} items={[
+        { key: 'text', label: t('inbox.textBody'), children: message.textBody ? <pre className="inbox-message-body">{message.textBody}</pre> : <EmptyState description={t('inbox.noTextBody')} /> },
+        { key: 'html', label: t('inbox.htmlBody'), children: message.htmlBody ? <CodeBlock value={message.htmlBody} onCopy={onCopy} /> : <EmptyState description={t('inbox.noHtmlBody')} /> },
+        { key: 'raw', label: t('inbox.rawMessage'), children: message.rawMessage ? <CodeBlock value={message.rawMessage} onCopy={onCopy} /> : <EmptyState description={t('inbox.noRawMessage')} /> }
+      ]} />
+    </Space>
+  );
+}
+
+function normalizeMessageTab(value: string | null): MessageTab {
+  return value === 'html' || value === 'raw' ? value : 'text';
+}
+
+function inboxListPath(searchParams: URLSearchParams, message: MailMessage | null) {
+  const next = new URLSearchParams(searchParams);
+  next.delete('tab');
+  if (message && (!next.has('mailboxId') || !next.has('folder'))) {
+    next.set('mailboxId', String(message.mailboxId));
+    next.set('folder', message.folder || 'INBOX');
   }
+  const suffix = next.toString();
+  return `/inbox${suffix ? `?${suffix}` : ''}`;
 }
 
-function ConfigValue({ value, onCopy }: { value: string | number; onCopy?: (value: string) => void }) {
+function ClientConfigView({ config, onCopy, t }: { config: MailboxClientConfig; onCopy: (value: string) => void; t: (key: string) => string }) {
+  const sections = [
+    { key: 'imap', label: t('inbox.incomingConfig'), value: config.incoming },
+    ...(config.pop3 ? [{ key: 'pop3', label: t('inbox.pop3Config'), value: config.pop3 }] : []),
+    { key: 'smtp', label: t('inbox.outgoingConfig'), value: config.outgoing }
+  ];
   return (
-    <Space>
-      <Typography.Text code>{value}</Typography.Text>
-      {onCopy ? (
-        <Button size="small" icon={<CopyOutlined />} aria-label="Copy" onClick={() => onCopy(String(value))} />
-      ) : null}
+    <Space direction="vertical" size={16} className="full-width">
+      <Alert type="info" showIcon message={t('inbox.clientConfigHelpSummary')} />
+      <Descriptions bordered column={1} size="small"><Descriptions.Item label={t('inbox.configUsername')}>{configValue(config.username, onCopy)}</Descriptions.Item><Descriptions.Item label={t('inbox.configPassword')}>{config.password ? configValue(config.password, onCopy) : <Typography.Text type="secondary">{t('inbox.passwordNotShown')}</Typography.Text>}</Descriptions.Item></Descriptions>
+      <Tabs items={sections.map((section) => ({ key: section.key, label: section.label, children: <Descriptions bordered column={1} size="small"><Descriptions.Item label={t('inbox.configHost')}>{configValue(section.value.host, onCopy)}</Descriptions.Item><Descriptions.Item label={t('inbox.configPort')}>{configValue(section.value.port, onCopy)}</Descriptions.Item><Descriptions.Item label={t('inbox.configSecurity')}>{section.value.security}</Descriptions.Item><Descriptions.Item label={t('inbox.configAuthMethod')}>{section.value.authMethod}</Descriptions.Item></Descriptions> }))} />
     </Space>
   );
 }
 
-function buildMailboxClientConfig(
-  mailbox: InboundMailbox,
-  config: RuntimeConfig | null,
-  password = ''
-): MailboxClientConfig {
-  const smtpPort = preferredSubmissionPort(config, ['SMTP + STARTTLS', 'SMTPS']);
-  const imapPort = preferredAccessPort(config?.mailAccess?.imap.ports || [], ['IMAPS', 'IMAP + STARTTLS', 'IMAP']);
-  const pop3Port = preferredAccessPort(config?.mailAccess?.pop3.ports || [], ['POP3S', 'POP3 + STLS', 'POP3']);
+function configValue(value: string | number, onCopy: (value: string) => void) {
+  return <Space><Typography.Text code>{value}</Typography.Text><Button aria-label="Copy" icon={<CopyOutlined />} onClick={() => void onCopy(String(value))} /></Space>;
+}
+
+function fallbackFolders(mailbox?: InboundMailbox): InboundFolder[] {
+  return standardFolders.map((name) => ({ name, specialUse: folderSpecialUse(name), messageCount: name === 'INBOX' ? mailbox?.messageCount || 0 : 0, unreadCount: name === 'INBOX' ? mailbox?.unreadCount || 0 : 0 }));
+}
+
+function folderSpecialUse(name: string) {
+  const values: Record<string, string | null> = {
+    INBOX: null,
+    Sent: '\\Sent',
+    Drafts: '\\Drafts',
+    Trash: '\\Trash',
+    Junk: '\\Junk',
+    Archive: '\\Archive'
+  };
+  return values[name] ?? null;
+}
+
+function folderLabel(name: string, locale: string) {
+  if (locale.startsWith('en')) return name;
+  return { INBOX: '收件箱', Sent: '已发送', Drafts: '草稿', Trash: '已删除', Junk: '垃圾邮件', Archive: '归档' }[name] || name;
+}
+
+function folderIcon(name: string) {
+  if (name === 'INBOX') return <InboxOutlined />;
+  if (name === 'Sent') return <SendOutlined />;
+  if (name === 'Drafts') return <FileTextOutlined />;
+  if (name === 'Trash') return <DeleteOutlined />;
+  if (name === 'Junk') return <WarningOutlined />;
+  if (name === 'Archive') return <ContainerOutlined />;
+  return <FolderOutlined />;
+}
+
+function buildMailboxClientConfig(mailbox: InboundMailbox, config: RuntimeConfig | null, password = ''): MailboxClientConfig {
+  const smtpPort = preferredPort(config?.submission?.ports || [], [587, 465]);
+  const imapPort = preferredPort(config?.mailAccess?.imap.ports || [], [993, 143]);
+  const pop3Port = preferredPort(config?.mailAccess?.pop3.ports || [], [995, 110]);
   const accessHost = config?.mailAccess?.host || config?.submission?.host || config?.mailHostname || mailbox.domain;
   return {
     username: mailbox.address,
     password,
-    incoming: {
-      protocol: 'IMAP',
-      host: accessHost,
-      port: imapPort?.port || 143,
-      security: imapPort?.protocol || 'IMAP + STARTTLS',
-      authMethod: 'Normal password',
-      username: mailbox.address,
-      password
-    },
-    pop3: {
-      protocol: 'POP3',
-      host: accessHost,
-      port: pop3Port?.port || 110,
-      security: pop3Port?.protocol || 'POP3 + STLS',
-      authMethod: 'Normal password',
-      username: mailbox.address,
-      password
-    },
-    outgoing: {
-      protocol: 'SMTP',
-      host: config?.submission?.host || config?.mailHostname || mailbox.domain,
-      port: smtpPort?.port || 587,
-      security: smtpPort?.protocol || 'SMTP + STARTTLS',
-      authMethod: 'Normal password',
-      username: mailbox.address,
-      password
-    }
+    incoming: { protocol: 'IMAP', host: accessHost, port: imapPort?.port || 143, security: imapPort?.protocol || 'IMAP + STARTTLS', authMethod: 'Normal password', username: mailbox.address, password },
+    pop3: { protocol: 'POP3', host: accessHost, port: pop3Port?.port || 110, security: pop3Port?.protocol || 'POP3 + STLS', authMethod: 'Normal password', username: mailbox.address, password },
+    outgoing: { protocol: 'SMTP', host: config?.submission?.host || config?.mailHostname || mailbox.domain, port: smtpPort?.port || 587, security: smtpPort?.protocol || 'SMTP + STARTTLS', authMethod: 'Normal password', username: mailbox.address, password }
   };
 }
 
-function preferredAccessPort(ports: Array<{ port: number; protocol: string }>, protocols: string[]) {
-  for (const protocol of protocols) {
-    const match = ports.find((port) => port.protocol === protocol && [993, 995].includes(port.port)) ||
-      ports.find((port) => port.protocol === protocol);
-    if (match) return match;
-  }
-  return ports[0] || null;
-}
-
-function preferredSubmissionPort(config: RuntimeConfig | null, protocols: string[]) {
-  const ports = config?.submission?.ports || [];
-  for (const protocol of protocols) {
-    const match = ports.find((port) => port.protocol === protocol && port.port === 587) ||
-      ports.find((port) => port.protocol === protocol && port.port === 465) ||
-      ports.find((port) => port.protocol === protocol);
+function preferredPort(ports: Array<{ port: number; protocol: string }>, preferred: number[]) {
+  for (const port of preferred) {
+    const match = ports.find((item) => item.port === port);
     if (match) return match;
   }
   return ports[0] || null;
 }
 
 function generateMailboxPassword() {
-  const bytes = new Uint8Array(10);
-  if (globalThis.crypto?.getRandomValues) {
-    globalThis.crypto.getRandomValues(bytes);
-  } else {
-    for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256);
-  }
+  const bytes = new Uint8Array(14);
+  globalThis.crypto.getRandomValues(bytes);
   return Array.from(bytes, (value) => (value % 36).toString(36)).join('');
 }
 
-function MessageBody({ value, empty }: { value?: string; empty: string }) {
-  if (!value) return <EmptyState description={empty} />;
-  return <pre className="inbox-message-body">{value}</pre>;
+function formatCompactTime(value: string) {
+  const date = new Date(value);
+  const now = new Date();
+  return date.toDateString() === now.toDateString() ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : date.toLocaleDateString();
 }
 
 function formatOptionalTime(value?: string | null) {
-  return value ? new Date(value).toLocaleString() : '-';
+  return value ? new Date(value).toLocaleString() : '';
 }

+ 482 - 493
src/pages/SendingLogs.tsx

@@ -1,554 +1,543 @@
-import { CopyOutlined, SearchOutlined } from '@ant-design/icons';
-import { Alert, Button, DatePicker, Descriptions, Drawer, Input, Select, Space, Spin, Table, Tag, Timeline, Typography } from 'antd';
+import {
+  ClearOutlined,
+  CopyOutlined,
+  ReloadOutlined,
+  SearchOutlined
+} from '@ant-design/icons';
+import {
+  Alert,
+  App,
+  Button,
+  Card,
+  Descriptions,
+  Drawer,
+  Grid,
+  Input,
+  List,
+  Pagination,
+  Result,
+  Select,
+  Skeleton,
+  Space,
+  Table,
+  Tabs,
+  Timeline,
+  Typography
+} from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
 
+import { CodeBlock } from '../components/common/CodeBlock';
 import { EmptyState } from '../components/common/EmptyState';
 import { PageHeader } from '../components/common/PageHeader';
 import { SectionCard } from '../components/common/SectionCard';
 import { StatusPill } from '../components/common/StatusPill';
 import { buildEventTimeline } from '../frontend/analytics-model.js';
 import { useI18n } from '../frontend/i18n/react';
-import type { DeliveryAttempt, DeliveryLogEntry, Domain, SendEvent, SendEventTimelineEntry, TrackingLink, WebhookDelivery } from '../frontend/types';
+import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
+import { api } from '../frontend/services/api';
+import type {
+  DeliveryAttempt,
+  DeliveryLogEntry,
+  Domain,
+  SendEvent,
+  SendEventTimelineEntry,
+  TrackingLink,
+  WebhookDelivery
+} from '../frontend/types';
 
-const { RangePicker } = DatePicker;
+interface EventFilters {
+  page: number;
+  pageSize: number;
+  status?: string;
+  domainId?: number;
+  from?: string;
+  to?: string;
+  recipient?: string;
+  q?: string;
+}
 
-interface SendingLogsProps {
+interface EventPage {
   events: SendEvent[];
-  domains: Domain[];
-  onCopy: (value: string) => void;
-  onLoadEvent?: (id: number) => Promise<SendEvent | null>;
+  total?: number;
+  page?: number;
+  pageSize?: number;
+}
+
+interface FilterDraft {
+  from: string;
+  to: string;
+  domainId?: string;
+  status?: string;
+  recipient: string;
+  q: string;
 }
 
-export default function SendingLogs({ events, domains, onCopy, onLoadEvent }: SendingLogsProps) {
-  const { t } = useI18n();
-  const [domain, setDomain] = useState<string>();
-  const [status, setStatus] = useState<string>();
-  const [recipient, setRecipient] = useState('');
-  const [range, setRange] = useState<[number, number] | null>(null);
+type DetailTab = 'attempts' | 'tracking' | 'webhooks' | 'raw';
+
+export default function SendingLogs() {
+  const { eventId } = useParams<{ eventId: string }>();
+  const location = useLocation();
+  const navigate = useNavigate();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const queryKey = searchParams.toString();
+  const { locale } = useI18n();
+  const copy = locale.startsWith('en') ? enCopy : zhCopy;
+  const { message } = App.useApp();
+  const screens = Grid.useBreakpoint();
+  const [domains, setDomains] = useState<Domain[]>([]);
+  const [pageData, setPageData] = useState<EventPage | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState('');
+  const [draft, setDraft] = useState<FilterDraft>(() => draftFromParams(searchParams));
   const [selected, setSelected] = useState<SendEvent | null>(null);
   const [detailLoading, setDetailLoading] = useState(false);
   const [detailError, setDetailError] = useState('');
+  const pendingDirectClose = useRef<string | null>(null);
+  const detailTab = normalizeDetailTab(searchParams.get('tab'));
+
+  const filters = useMemo(() => filtersFromParams(searchParams), [queryKey]);
+
+  const loadEvents = useCallback(async () => {
+    setLoading(true);
+    setError('');
+    try {
+      const result = await api.events(filters);
+      setPageData({
+        events: result.events,
+        total: result.total ?? result.events.length,
+        page: result.page ?? filters.page,
+        pageSize: result.pageSize ?? filters.pageSize
+      });
+    } catch (reason) {
+      setError(reason instanceof Error ? reason.message : copy.loadFailed);
+    } finally {
+      setLoading(false);
+    }
+  }, [copy.loadFailed, filters]);
+
+  useEffect(() => {
+    void loadEvents();
+  }, [loadEvents]);
+
+  useEffect(() => {
+    api.domains().then((result) => setDomains(result.domains)).catch(() => setDomains([]));
+  }, []);
+
+  useEffect(() => {
+    setDraft(draftFromParams(searchParams));
+  }, [queryKey]);
+
+  useEffect(() => {
+    if (!eventId) {
+      setSelected(null);
+      setDetailError('');
+      return;
+    }
+    const id = Number(eventId);
+    if (!Number.isInteger(id) || id <= 0) {
+      setSelected(null);
+      setDetailError(copy.detailNotFound);
+      return;
+    }
+    const preview = pageData?.events.find((event) => event.id === id) || null;
+    setSelected(preview);
+    setDetailLoading(true);
+    setDetailError('');
+    api.event(id)
+      .then((result) => {
+        setSelected(result.event);
+        if (!result.event) setDetailError(copy.detailNotFound);
+      })
+      .catch((reason) => setDetailError(reason instanceof Error ? reason.message : copy.detailLoadFailed))
+      .finally(() => setDetailLoading(false));
+  }, [copy.detailLoadFailed, copy.detailNotFound, eventId, pageData?.events]);
+
+  useEffect(() => {
+    const target = pendingDirectClose.current;
+    if (!target || detailHistoryState(location.state)?.origin === 'direct') return;
+    pendingDirectClose.current = null;
+    navigate(target, { replace: true });
+  }, [location.key, location.state, navigate]);
+
+  function applyFilters() {
+    const next = new URLSearchParams();
+    if (draft.from) next.set('from', toIso(draft.from));
+    if (draft.to) next.set('to', toIso(draft.to));
+    if (draft.domainId) next.set('domainId', draft.domainId);
+    if (draft.status) next.set('status', draft.status);
+    if (draft.recipient.trim()) next.set('recipient', draft.recipient.trim());
+    if (draft.q.trim()) next.set('q', draft.q.trim());
+    next.set('page', '1');
+    next.set('pageSize', String(filters.pageSize));
+    setSearchParams(next);
+  }
+
+  function clearFilters() {
+    const next = new URLSearchParams({ page: '1', pageSize: String(filters.pageSize) });
+    setSearchParams(next);
+  }
+
+  function changePage(page: number, pageSize: number) {
+    const next = new URLSearchParams(searchParams);
+    next.set('page', String(pageSize === filters.pageSize ? page : 1));
+    next.set('pageSize', String(pageSize));
+    setSearchParams(next);
+  }
+
+  function openDetail(event: SendEvent) {
+    navigate(
+      { pathname: `/activity/${event.id}`, search: queryKey ? `?${queryKey}` : '' },
+      { state: detailHistoryLocation(`${location.pathname}${location.search}`, 1) }
+    );
+  }
+
+  function closeDetail() {
+    const historyState = detailHistoryState(location.state);
+    if (historyState?.origin === 'list') {
+      navigate(-historyState.depth);
+      return;
+    }
+    if (historyState?.origin === 'direct') {
+      pendingDirectClose.current = historyState.listPath;
+      navigate(-historyState.depth);
+      return;
+    }
+    navigate(activityListPath(searchParams), { replace: true });
+  }
+
+  function changeDetailTab(tab: DetailTab) {
+    const next = new URLSearchParams(searchParams);
+    if (tab === 'attempts') next.delete('tab');
+    else next.set('tab', tab);
+    const historyState = detailHistoryState(location.state);
+    const search = next.toString();
+    navigate(
+      { pathname: location.pathname, search: search ? `?${search}` : '' },
+      {
+        state: detailHistoryLocation(
+          historyState?.listPath || activityListPath(searchParams),
+          (historyState?.depth || 0) + 1,
+          historyState?.origin || 'direct'
+        )
+      }
+    );
+  }
+
+  async function retryDetail() {
+    const id = Number(eventId);
+    if (!Number.isInteger(id) || id <= 0) return;
+    setDetailLoading(true);
+    setDetailError('');
+    try {
+      const result = await api.event(id);
+      setSelected(result.event);
+      if (!result.event) setDetailError(copy.detailNotFound);
+    } catch (reason) {
+      setDetailError(reason instanceof Error ? reason.message : copy.detailLoadFailed);
+    } finally {
+      setDetailLoading(false);
+    }
+  }
 
-  const filtered = useMemo(() => {
-    return events.filter((event) => {
-      const time = new Date(event.createdAt).getTime();
-      const matchesRange = !range || (time >= range[0] && time <= range[1]);
-      const matchesDomain = !domain || event.domain === domain;
-      const matchesStatus = !status || event.status === status;
-      const matchesRecipient = !recipient || event.recipients.join(',').includes(recipient);
-      return matchesRange && matchesDomain && matchesStatus && matchesRecipient;
-    });
-  }, [domain, events, range, recipient, status]);
+  async function copyDiagnostics(event: SendEvent) {
+    try {
+      await navigator.clipboard.writeText(formatDiagnostics(event));
+      message.success(copy.diagnosticsCopied);
+    } catch {
+      message.error(copy.copyFailed);
+    }
+  }
 
   const columns: ColumnsType<SendEvent> = [
-    { title: t('logs.time'), dataIndex: 'createdAt', render: (value) => new Date(value).toLocaleString(), width: 190 },
-    { title: t('logs.recipient'), dataIndex: 'recipients', render: (value: string[]) => value.join(', '), ellipsis: true },
-    { title: t('logs.domain'), dataIndex: 'domain', width: 180 },
-    { title: 'Subject', dataIndex: 'subject', ellipsis: true },
+    { title: copy.time, dataIndex: 'createdAt', width: 190, render: (value) => new Date(value).toLocaleString() },
+    { title: copy.recipient, dataIndex: 'recipients', ellipsis: true, render: (value: string[]) => value.join(', ') },
+    { title: copy.domain, dataIndex: 'domain', width: 180, render: (value) => value || '-' },
+    { title: copy.subject, dataIndex: 'subject', ellipsis: true },
+    { title: copy.status, dataIndex: 'status', width: 130, render: (value) => <EventStatus status={value} copy={copy} /> },
+    { title: 'Message ID', dataIndex: 'messageId', width: 220, ellipsis: true, render: (_, event) => <Typography.Text code>{messageIdentifier(event)}</Typography.Text> },
+    { title: 'Queue ID', dataIndex: 'queueId', width: 150, ellipsis: true, render: (value) => value ? <Typography.Text code>{value}</Typography.Text> : '-' },
     {
-      title: t('common.status'),
-      dataIndex: 'status',
-      render: (value) => <StatusTag status={value} />,
-      width: 120
-    },
-    {
-      title: t('logs.opens'),
-      width: 110,
-      render: (_, event) => trackingMetric(event, 'opens')
-    },
-    {
-      title: t('logs.clicks'),
-      width: 110,
-      render: (_, event) => trackingMetric(event, 'clicks')
-    },
-    { title: 'Message ID', dataIndex: 'id', render: (value) => <span>mh-{value}</span>, width: 140 },
-    { title: t('logs.errorReason'), dataIndex: 'detail', ellipsis: true },
-    { title: t('domains.actions'), render: (_, event) => <Button onClick={() => void openDetail(event)}>{t('logs.viewDetail')}</Button>, width: 120 }
+      title: copy.failureReason,
+      dataIndex: 'detail',
+      ellipsis: true,
+      width: 260,
+      render: (value, event) => (
+        <Space size={4}>
+          <Typography.Text type={isFailure(event.status) ? 'danger' : undefined} ellipsis={{ tooltip: value }}>{value || '-'}</Typography.Text>
+          {isFailure(event.status) && value ? <Button type="text" size="small" aria-label={copy.copyDiagnostics} icon={<CopyOutlined />} onClick={(click) => { click.stopPropagation(); void copyDiagnostics(event); }} /> : null}
+        </Space>
+      )
+    }
   ];
 
   return (
     <>
       <Space direction="vertical" size={20} className="full-width">
-        <PageHeader title={t('logs.title')} />
+        <PageHeader title={copy.title} subtitle={copy.subtitle} />
 
         <SectionCard className="logs-toolbar-card">
-          <div className="page-toolbar">
+          <Space direction="vertical" size={12} className="full-width">
+            <Space wrap align="end">
+              <FilterField label={copy.from}><Input type="datetime-local" aria-label={copy.from} value={draft.from} onChange={(event) => setDraft((current) => ({ ...current, from: event.target.value }))} style={{ minHeight: 44 }} /></FilterField>
+              <FilterField label={copy.to}><Input type="datetime-local" aria-label={copy.to} value={draft.to} onChange={(event) => setDraft((current) => ({ ...current, to: event.target.value }))} style={{ minHeight: 44 }} /></FilterField>
+              <FilterField label={copy.domain}><Select allowClear showSearch aria-label={copy.domain} placeholder={copy.allDomains} value={draft.domainId} onChange={(value) => setDraft((current) => ({ ...current, domainId: value }))} options={domains.map((domain) => ({ value: String(domain.id), label: domain.domain }))} style={{ width: 190, minHeight: 44 }} /></FilterField>
+              <FilterField label={copy.status}><Select allowClear aria-label={copy.status} placeholder={copy.allStatuses} value={draft.status} onChange={(value) => setDraft((current) => ({ ...current, status: value }))} options={statusOptions(copy)} style={{ width: 160, minHeight: 44 }} /></FilterField>
+              <FilterField label={copy.recipient}><Input allowClear prefix={<SearchOutlined />} aria-label={copy.recipient} placeholder="user@example.com" value={draft.recipient} onChange={(event) => setDraft((current) => ({ ...current, recipient: event.target.value }))} style={{ width: 220, minHeight: 44 }} /></FilterField>
+              <FilterField label={copy.messageSearch}><Input allowClear prefix={<SearchOutlined />} aria-label={copy.messageSearch} placeholder={copy.messageSearchPlaceholder} value={draft.q} onChange={(event) => setDraft((current) => ({ ...current, q: event.target.value }))} onPressEnter={applyFilters} style={{ width: 270, minHeight: 44 }} /></FilterField>
+            </Space>
             <Space wrap>
-              <RangePicker
-                showTime
-                onChange={(value) => {
-                  if (!value?.[0] || !value?.[1]) return setRange(null);
-                  setRange([value[0].valueOf(), value[1].valueOf()]);
-                }}
-              />
-              <Select
-                allowClear
-                placeholder={t('logs.domainPlaceholder')}
-                value={domain}
-                onChange={setDomain}
-                options={domains.map((item) => ({ value: item.domain, label: item.domain }))}
-                className="toolbar-select"
-              />
-              <Select
-                allowClear
-                placeholder={t('logs.statusPlaceholder')}
-                value={status}
-                onChange={setStatus}
-                options={[
-                  { value: 'queued', label: t('logs.statusQueued') },
-                  { value: 'sent', label: t('logs.statusSent') },
-                  { value: 'deferred', label: t('logs.statusDeferred') },
-                  { value: 'bounced', label: t('logs.statusBounced') },
-                  { value: 'failed', label: t('logs.statusFailed') }
-                ]}
-                className="toolbar-select"
-              />
-              <Input
-                allowClear
-                prefix={<SearchOutlined />}
-                placeholder={t('logs.recipientPlaceholder')}
-                value={recipient}
-                onChange={(event) => setRecipient(event.target.value)}
-                className="toolbar-search"
-              />
+              <Button type="primary" icon={<SearchOutlined />} style={{ minHeight: 44 }} onClick={applyFilters}>{copy.search}</Button>
+              <Button icon={<ClearOutlined />} style={{ minHeight: 44 }} onClick={clearFilters}>{copy.clear}</Button>
+              <Button icon={<ReloadOutlined />} style={{ minHeight: 44 }} loading={loading} onClick={() => void loadEvents()}>{copy.refresh}</Button>
             </Space>
-          </div>
+          </Space>
         </SectionCard>
 
-        <SectionCard
-          title={t('logs.title')}
-          extra={
-            <Typography.Text type="secondary">
-              {filtered.length} / {events.length}
-            </Typography.Text>
-          }
-        >
-          <Table rowKey="id" columns={columns} dataSource={filtered} scroll={{ x: 1480 }} />
-        </SectionCard>
+        {error && !pageData ? (
+          <Result status="error" title={copy.loadFailed} subTitle={error} extra={<Button type="primary" onClick={() => void loadEvents()}>{copy.retry}</Button>} />
+        ) : (
+          <SectionCard title={copy.activityList} extra={<Typography.Text type="secondary">{copy.total(pageData?.total || 0)}</Typography.Text>}>
+            {error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={() => void loadEvents()}>{copy.retry}</Button>} style={{ marginBottom: 16 }} /> : null}
+            {loading && !pageData ? <Skeleton active paragraph={{ rows: 9 }} /> : pageData?.events.length ? screens.md ? (
+              <Table
+                rowKey="id"
+                columns={columns}
+                dataSource={pageData.events}
+                loading={loading}
+                pagination={{ current: pageData.page, pageSize: pageData.pageSize, total: pageData.total, showSizeChanger: true, onChange: changePage }}
+                scroll={{ x: 1480 }}
+                onRow={(event) => ({ onClick: () => openDetail(event), style: { cursor: 'pointer' }, 'aria-label': `${copy.viewDetail} ${messageIdentifier(event)}` })}
+                rowClassName={(event) => isFailure(event.status) ? 'activity-row--failed' : event.status === 'deferred' ? 'activity-row--deferred' : ''}
+              />
+            ) : (
+              <Space direction="vertical" size={16} className="full-width">
+                <List dataSource={pageData.events} renderItem={(event) => <List.Item><EventCard event={event} copy={copy} onView={() => openDetail(event)} onCopy={() => void copyDiagnostics(event)} /></List.Item>} />
+                <Pagination current={pageData.page} pageSize={pageData.pageSize} total={pageData.total} showSizeChanger onChange={changePage} />
+              </Space>
+            ) : <EmptyState description={hasFilters(searchParams) ? copy.noMatches : copy.noActivity} />}
+          </SectionCard>
+        )}
       </Space>
 
-      <DeliveryLogDrawer
+      <DeliveryDrawer
+        open={Boolean(eventId)}
         event={selected}
         loading={detailLoading}
         error={detailError}
-        onClose={() => setSelected(null)}
-        onCopy={onCopy}
+        copy={copy}
+        activeTab={detailTab}
+        onClose={closeDetail}
+        onTabChange={changeDetailTab}
+        onRetry={() => void retryDetail()}
+        onCopy={copyDiagnostics}
       />
     </>
   );
+}
 
-  async function openDetail(event: SendEvent) {
-    setSelected(event);
-    setDetailError('');
-    if (!onLoadEvent) return;
-    setDetailLoading(true);
-    try {
-      const detail = await onLoadEvent(event.id);
-      if (detail) setSelected(detail);
-      if (!detail) setDetailError(t('logs.detailNotFound'));
-    } catch (error) {
-      setDetailError(error instanceof Error ? error.message : t('logs.detailLoadFailed'));
-    } finally {
-      setDetailLoading(false);
-    }
-  }
+function FilterField({ label, children }: { label: string; children: React.ReactNode }) {
+  return <Space direction="vertical" size={4}><Typography.Text type="secondary">{label}</Typography.Text>{children}</Space>;
+}
 
-  function trackingMetric(event: SendEvent, type: 'opens' | 'clicks') {
-    if (!event.tracking?.enabled) return <Tag>{t('logs.trackingDisabledShort')}</Tag>;
-    return type === 'opens'
-      ? event.tracking.summary?.totalOpens ?? 0
-      : event.tracking.summary?.totalClicks ?? 0;
-  }
+function EventCard({ event, copy, onView, onCopy }: { event: SendEvent; copy: typeof zhCopy; onView: () => void; onCopy: () => void }) {
+  return (
+    <Card style={{ width: '100%' }} title={<Space><Typography.Text code>{messageIdentifier(event)}</Typography.Text><EventStatus status={event.status} copy={copy} /></Space>} actions={[<Button key="view" type="link" onClick={onView}>{copy.viewDetail}</Button>, ...(isFailure(event.status) ? [<Button key="copy" type="link" icon={<CopyOutlined />} onClick={onCopy}>{copy.copyDiagnostics}</Button>] : [])]}>
+      <Descriptions size="small" column={1}><Descriptions.Item label={copy.time}>{new Date(event.createdAt).toLocaleString()}</Descriptions.Item><Descriptions.Item label={copy.recipient}>{event.recipients.join(', ')}</Descriptions.Item><Descriptions.Item label={copy.domain}>{event.domain || '-'}</Descriptions.Item><Descriptions.Item label={copy.subject}>{event.subject || '-'}</Descriptions.Item>{event.detail ? <Descriptions.Item label={copy.failureReason}><Typography.Text type={isFailure(event.status) ? 'danger' : undefined}>{event.detail}</Typography.Text></Descriptions.Item> : null}</Descriptions>
+    </Card>
+  );
+}
 
-  function DeliveryLogDrawer({
-    event,
-    loading,
-    error,
-    onClose,
-    onCopy
-  }: {
-    event: SendEvent | null;
-    loading: boolean;
-    error: string;
-    onClose: () => void;
-    onCopy: (value: string) => void;
-  }) {
-    const deliveryLog = event?.deliveryLog || [];
-    const trackingTimeline = buildEventTimeline(event);
-    return (
-      <Drawer
-        title={event ? `${t('logs.detailTitle')} · mh-${event.id}` : t('logs.detailTitle')}
-        open={Boolean(event)}
-        width="min(760px, 100vw)"
-        onClose={onClose}
-        extra={event ? (
-          <Button icon={<CopyOutlined />} onClick={() => onCopy(formatDeliveryLog(event))}>
-            {t('logs.copyDeliveryLog')}
-          </Button>
-        ) : null}
-      >
-        {event ? (
-          <Spin spinning={loading}>
-            <Space direction="vertical" size={16} className="full-width">
-              {error ? <Alert type="error" showIcon message={error} /> : null}
-              <Descriptions bordered size="small" column={1}>
-                <Descriptions.Item label={t('logs.time')}>{new Date(event.createdAt).toLocaleString()}</Descriptions.Item>
-                <Descriptions.Item label={t('logs.sender')}>{event.sender}</Descriptions.Item>
-                <Descriptions.Item label={t('logs.recipient')}>{event.recipients.join(', ')}</Descriptions.Item>
-                <Descriptions.Item label={t('logs.domain')}>{event.domain || '-'}</Descriptions.Item>
-                <Descriptions.Item label={t('logs.subject')}>{event.subject || '-'}</Descriptions.Item>
-                <Descriptions.Item label={t('common.status')}>
-                  <StatusTag status={event.status} />
-                </Descriptions.Item>
-                <Descriptions.Item label={t('logs.messageId')}>mh-{event.id}</Descriptions.Item>
-                <Descriptions.Item label={t('logs.queueId')}>
-                  <Typography.Text code>{event.queueId || '-'}</Typography.Text>
-                </Descriptions.Item>
-                <Descriptions.Item label={t('logs.deliveredAt')}>
-                  {event.deliveredAt ? new Date(event.deliveredAt).toLocaleString() : '-'}
-                </Descriptions.Item>
-                <Descriptions.Item label={t('logs.finalResponse')}>
-                  <Typography.Text code className="inline-code-value">{event.detail || '-'}</Typography.Text>
-                </Descriptions.Item>
-              </Descriptions>
-              {event.tracking?.enabled ? (
-                <SectionCard title={t('logs.engagement')} className="delivery-log-card">
-                  <Descriptions bordered size="small" column={2}>
-                    <Descriptions.Item label={t('logs.trackingScope')} span={2}>
-                      <StatusPill tone={event.tracking.messageLevel ? 'warning' : 'info'}>
-                        {event.tracking.messageLevel ? t('logs.messageLevel') : t('logs.recipientLevel')}
-                      </StatusPill>
-                    </Descriptions.Item>
-                    <Descriptions.Item label={t('logs.opens')}>
-                      {event.tracking.summary?.totalOpens || 0}
-                    </Descriptions.Item>
-                    <Descriptions.Item label={t('logs.clicks')}>
-                      {event.tracking.summary?.totalClicks || 0}
-                    </Descriptions.Item>
-                    <Descriptions.Item label={t('logs.firstOpened')}>
-                      {formatOptionalTime(event.tracking.summary?.firstOpenedAt)}
-                    </Descriptions.Item>
-                    <Descriptions.Item label={t('logs.lastOpened')}>
-                      {formatOptionalTime(event.tracking.summary?.lastOpenedAt)}
-                    </Descriptions.Item>
-                    <Descriptions.Item label={t('logs.firstClicked')}>
-                      {formatOptionalTime(event.tracking.summary?.firstClickedAt)}
-                    </Descriptions.Item>
-                    <Descriptions.Item label={t('logs.lastClicked')}>
-                      {formatOptionalTime(event.tracking.summary?.lastClickedAt)}
-                    </Descriptions.Item>
-                    <Descriptions.Item label={t('logs.proxyOpens')}>
-                      {event.tracking.summary?.proxyOpens || 0}
-                    </Descriptions.Item>
-                    <Descriptions.Item label={t('logs.scannerEvents')}>
-                      {event.tracking.summary?.scannerEvents || 0}
-                    </Descriptions.Item>
-                  </Descriptions>
-                  {event.tracking.linksTruncated ? (
-                    <Alert type="warning" showIcon message={t('logs.trackingLinksTruncated')} />
-                  ) : null}
-                  {event.tracking.links?.length ? <TrackingLinksTable links={event.tracking.links} /> : null}
-                </SectionCard>
-              ) : (
-                <SectionCard title={t('logs.engagement')} className="delivery-log-card">
-                  <Alert type="info" showIcon message={t('logs.trackingDisabled')} />
-                </SectionCard>
-              )}
-              <SectionCard title={t('logs.trackingTimeline')} className="delivery-log-card">
-                {event.tracking?.eventsTruncated ? (
-                  <Alert type="warning" showIcon message={t('logs.trackingTimelineTruncated')} />
-                ) : null}
-                {trackingTimeline.length ? (
-                  <Timeline
-                    items={trackingTimeline.map((item, index) => ({
-                      key: `${item.stage}-${item.at}-${index}`,
-                      color: timelineToneColor(item.tone),
-                      children: <TrackingTimelineItem item={item} />
-                    }))}
-                  />
-                ) : (
-                  <EmptyState description={t('logs.noTrackingTimeline')} />
-                )}
-              </SectionCard>
-              <SectionCard title={t('logs.deliveryAttempts')} className="delivery-log-card">
-                {event.deliveryAttempts?.length ? (
-                  <Timeline
-                    items={event.deliveryAttempts.map((attempt, index) => ({
-                      key: `${attempt.raw || attempt.at}-${index}`,
-                      color: deliveryAttemptColor(attempt.status),
-                      children: <DeliveryAttemptTimelineItem attempt={attempt} />
-                    }))}
-                  />
-                ) : (
-                  <EmptyState description={t('logs.noDeliveryAttempts')} />
-                )}
-              </SectionCard>
-              <SectionCard title={t('logs.webhookDeliveries')} className="delivery-log-card">
-                {event.webhookDeliveries?.length ? (
-                  <WebhookDeliveriesTable deliveries={event.webhookDeliveries} />
-                ) : (
-                  <EmptyState description={t('logs.noWebhookDeliveries')} />
-                )}
-              </SectionCard>
-              <SectionCard title={t('logs.deliveryLog')} className="delivery-log-card">
-                {deliveryLog.length ? (
-                  <Timeline
-                    items={deliveryLog.map((entry, index) => ({
-                      key: `${entry.at}-${index}`,
-                      color: timelineColor(entry),
-                      children: <DeliveryLogTimelineItem entry={entry} />
-                    }))}
-                  />
-                ) : (
-                  <EmptyState description={t('logs.noDeliveryLog')} />
-                )}
-              </SectionCard>
-            </Space>
-          </Spin>
-        ) : null}
-      </Drawer>
-    );
-  }
+function DeliveryDrawer({ open, event, loading, error, copy, activeTab, onClose, onTabChange, onRetry, onCopy }: { open: boolean; event: SendEvent | null; loading: boolean; error: string; copy: typeof zhCopy; activeTab: DetailTab; onClose: () => void; onTabChange: (tab: DetailTab) => void; onRetry: () => void; onCopy: (event: SendEvent) => Promise<void> }) {
+  const timeline = buildEventTimeline(event);
+  return (
+    <Drawer
+      title={event ? `${copy.detailTitle} · ${messageIdentifier(event)}` : copy.detailTitle}
+      open={open}
+      width="min(840px, 100vw)"
+      onClose={onClose}
+      extra={event ? <Button icon={<CopyOutlined />} onClick={() => void onCopy(event)}>{copy.copyDiagnostics}</Button> : null}
+    >
+      {loading && !event ? <Skeleton active paragraph={{ rows: 12 }} /> : error && !event ? <Result status="error" title={copy.detailLoadFailed} subTitle={error} extra={<Button type="primary" onClick={onRetry}>{copy.retry}</Button>} /> : event ? (
+        <Space direction="vertical" size={20} className="full-width">
+          {error ? <Alert type="error" showIcon message={error} /> : null}
+          <SectionCard title={copy.statusSummary}>
+            <Descriptions bordered size="small" column={1}>
+              <Descriptions.Item label={copy.status}><EventStatus status={event.status} copy={copy} /></Descriptions.Item>
+              <Descriptions.Item label={copy.time}>{new Date(event.createdAt).toLocaleString()}</Descriptions.Item>
+              <Descriptions.Item label={copy.sender}>{event.sender}</Descriptions.Item>
+              <Descriptions.Item label={copy.recipient}>{event.recipients.join(', ')}</Descriptions.Item>
+              <Descriptions.Item label={copy.subject}>{event.subject || '-'}</Descriptions.Item>
+              <Descriptions.Item label="Message ID"><Typography.Text code>{messageIdentifier(event)}</Typography.Text></Descriptions.Item>
+              <Descriptions.Item label="Queue ID"><Typography.Text code>{event.queueId || '-'}</Typography.Text></Descriptions.Item>
+              <Descriptions.Item label={copy.finalResponse}><Space align="start"><Typography.Text code>{event.detail || '-'}</Typography.Text>{event.detail ? <Button type="text" size="small" aria-label={copy.copyDiagnostics} icon={<CopyOutlined />} onClick={() => void onCopy(event)} /> : null}</Space></Descriptions.Item>
+            </Descriptions>
+          </SectionCard>
+
+          <SectionCard title={copy.eventTimeline}>
+            {timeline.length ? <Timeline items={timeline.map((item, index) => ({ key: `${item.stage}-${item.at}-${index}`, color: timelineColor(item.tone), children: <TimelineEntry item={item} copy={copy} /> }))} /> : <EmptyState description={copy.noTimeline} />}
+          </SectionCard>
 
-  function TrackingTimelineItem({ item }: { item: SendEventTimelineEntry }) {
-    return (
-      <div className="delivery-log-entry">
-        <Space wrap size={8}>
-          <Typography.Text strong>{timelineStageLabel(item.stage)}</Typography.Text>
-          {item.status ? <StatusPill tone={item.tone}>{webhookStatusLabel(item.status)}</StatusPill> : null}
-          <Typography.Text type="secondary">{item.at ? new Date(item.at).toLocaleString() : '-'}</Typography.Text>
+          <SectionCard>
+            <Tabs
+              activeKey={activeTab}
+              onChange={(key) => onTabChange(normalizeDetailTab(key))}
+              items={[
+                { key: 'attempts', label: copy.deliveryAttempts, children: <DeliveryAttempts attempts={event.deliveryAttempts || []} copy={copy} /> },
+                { key: 'tracking', label: copy.engagement, children: <TrackingDetail event={event} copy={copy} /> },
+                { key: 'webhooks', label: 'Webhook', children: <WebhookDetail deliveries={event.webhookDeliveries || []} copy={copy} /> },
+                { key: 'raw', label: copy.rawLogs, children: <RawLogs entries={event.deliveryLog || []} copy={copy} /> }
+              ]}
+            />
+          </SectionCard>
         </Space>
-        {item.queueId ? <LogLine label="Q" value={item.queueId} /> : null}
-        {item.recipient ? <LogLine label="To" value={item.recipient} /> : null}
-        {item.relay ? <LogLine label="MX" value={item.relay} /> : null}
-        {item.response ? <LogLine label="S" value={item.response} /> : null}
-        {item.targetOrigin ? <LogLine label="URL" value={item.targetOrigin} /> : null}
-        {item.webhookId ? <LogLine label="WH" value={`#${item.webhookId}${item.responseStatus ? ` · HTTP ${item.responseStatus}` : ''}`} /> : null}
-      </div>
-    );
-  }
+      ) : null}
+    </Drawer>
+  );
+}
 
-  function TrackingLinksTable({ links }: { links: TrackingLink[] }) {
-    const columns: ColumnsType<TrackingLink> = [
-      {
-        title: t('dashboard.link'),
-        dataIndex: 'target',
-        ellipsis: { showTitle: false },
-        render: (value) => <Typography.Text ellipsis={{ tooltip: value }}>{value}</Typography.Text>
-      },
-      { title: t('logs.clicks'), dataIndex: 'clicks', width: 80 },
-      {
-        title: t('logs.lastClicked'),
-        dataIndex: 'lastClickedAt',
-        width: 180,
-        render: formatOptionalTime
-      }
-    ];
-    return <Table rowKey="id" size="small" columns={columns} dataSource={links} pagination={false} scroll={{ x: 620 }} />;
-  }
+function TimelineEntry({ item, copy }: { item: SendEventTimelineEntry; copy: typeof zhCopy }) {
+  return <Space direction="vertical" size={2}><Space wrap><Typography.Text strong>{stageLabel(item.stage, copy)}</Typography.Text>{item.status ? <StatusPill tone={item.tone}>{eventStatus(item.status, copy)}</StatusPill> : null}<Typography.Text type="secondary">{item.at ? new Date(item.at).toLocaleString() : '-'}</Typography.Text></Space>{item.recipient ? <Typography.Text type="secondary">To: {item.recipient}</Typography.Text> : null}{item.relay ? <Typography.Text code>{item.relay}</Typography.Text> : null}{item.response ? <Typography.Text code>{item.response}</Typography.Text> : null}</Space>;
+}
 
-  function WebhookDeliveriesTable({ deliveries }: { deliveries: WebhookDelivery[] }) {
-    const columns: ColumnsType<WebhookDelivery> = [
-      { title: t('webhooks.events'), dataIndex: 'eventType', width: 120 },
-      {
-        title: t('common.status'),
-        dataIndex: 'status',
-        width: 130,
-        render: (value: string) => <StatusPill tone={webhookTone(value)}>{webhookStatusLabel(value)}</StatusPill>
-      },
-      { title: t('webhooks.attemptCount'), dataIndex: 'attemptCount', width: 90 },
-      { title: 'HTTP', dataIndex: 'responseStatus', width: 90, render: (value) => value ?? '-' },
-      {
-        title: t('logs.time'),
-        dataIndex: 'lastAttemptAt',
-        width: 180,
-        render: (value, record) => new Date(value || record.createdAt).toLocaleString()
-      },
-      { title: t('logs.errorReason'), dataIndex: 'error', ellipsis: true, render: (value) => value || '-' }
-    ];
-    return <Table rowKey="id" size="small" columns={columns} dataSource={deliveries} pagination={false} scroll={{ x: 760 }} />;
-  }
+function DeliveryAttempts({ attempts, copy }: { attempts: DeliveryAttempt[]; copy: typeof zhCopy }) {
+  if (!attempts.length) return <EmptyState description={copy.noAttempts} />;
+  return <Timeline items={attempts.map((attempt, index) => ({ key: `${attempt.at}-${index}`, color: timelineColor(eventTone(attempt.status)), children: <Space direction="vertical" size={2}><Space wrap><EventStatus status={attempt.status} copy={copy} /><Typography.Text type="secondary">{attempt.at ? new Date(attempt.at).toLocaleString() : '-'}</Typography.Text>{attempt.dsn ? <Typography.Text code>DSN {attempt.dsn}</Typography.Text> : null}</Space>{attempt.recipient ? <Typography.Text>To: {attempt.recipient}</Typography.Text> : null}{attempt.relay ? <Typography.Text code>{attempt.relay}</Typography.Text> : null}{attempt.response ? <Typography.Text code>{attempt.response}</Typography.Text> : null}</Space> }))} />;
+}
 
-  function DeliveryLogTimelineItem({ entry }: { entry: DeliveryLogEntry }) {
-    return (
-      <div className="delivery-log-entry">
-        <Space wrap size={8}>
-          <Typography.Text strong>{entry.phase}</Typography.Text>
-          {entry.direction ? <Tag>{entry.direction}</Tag> : null}
-          {entry.code ? (
-            <StatusPill tone={entry.ok === false ? 'error' : 'info'}>{entry.code}</StatusPill>
-          ) : null}
-          <Typography.Text type="secondary">{entry.at ? new Date(entry.at).toLocaleString() : '-'}</Typography.Text>
-        </Space>
-        {entry.command ? <LogLine label="C" value={entry.command} /> : null}
-        {entry.response ? <LogLine label="S" value={entry.response} /> : null}
-        {entry.message ? <LogLine label="-" value={entry.message} /> : null}
-        {entry.messageBytes !== undefined ? (
-          <Typography.Text type="secondary">{t('logs.messageBytes')}: {entry.messageBytes}</Typography.Text>
-        ) : null}
-      </div>
-    );
-  }
+function TrackingDetail({ event, copy }: { event: SendEvent; copy: typeof zhCopy }) {
+  if (!event.tracking?.enabled) return <Alert type="info" showIcon message={copy.trackingDisabled} />;
+  const summary = event.tracking.summary;
+  return <Space direction="vertical" size={16} className="full-width"><Descriptions bordered size="small" column={2}><Descriptions.Item label={copy.opens}>{summary?.totalOpens || 0}</Descriptions.Item><Descriptions.Item label={copy.clicks}>{summary?.totalClicks || 0}</Descriptions.Item><Descriptions.Item label={copy.firstOpened}>{formatTime(summary?.firstOpenedAt)}</Descriptions.Item><Descriptions.Item label={copy.lastClicked}>{formatTime(summary?.lastClickedAt)}</Descriptions.Item><Descriptions.Item label={copy.proxyOpens}>{summary?.proxyOpens || 0}</Descriptions.Item><Descriptions.Item label={copy.scannerEvents}>{summary?.scannerEvents || 0}</Descriptions.Item></Descriptions>{event.tracking.links?.length ? <TrackingLinks links={event.tracking.links} copy={copy} /> : <EmptyState description={copy.noLinks} />}</Space>;
+}
 
-  function DeliveryAttemptTimelineItem({ attempt }: { attempt: DeliveryAttempt }) {
-    return (
-      <div className="delivery-log-entry">
-        <Space wrap size={8}>
-          <StatusTag status={attempt.status} />
-          {attempt.dsn ? <Tag>dsn {attempt.dsn}</Tag> : null}
-          <Typography.Text type="secondary">{attempt.at ? new Date(attempt.at).toLocaleString() : '-'}</Typography.Text>
-        </Space>
-        {attempt.recipient ? <LogLine label="To" value={attempt.recipient} /> : null}
-        {attempt.relay ? <LogLine label="MX" value={attempt.relay} /> : null}
-        {attempt.response ? <LogLine label="S" value={attempt.response} /> : null}
-      </div>
-    );
-  }
+function TrackingLinks({ links, copy }: { links: TrackingLink[]; copy: typeof zhCopy }) {
+  const columns: ColumnsType<TrackingLink> = [{ title: copy.link, dataIndex: 'target', ellipsis: true }, { title: copy.clicks, dataIndex: 'clicks', width: 90 }, { title: copy.lastClicked, dataIndex: 'lastClickedAt', width: 190, render: formatTime }];
+  return <Table rowKey="id" size="small" columns={columns} dataSource={links} pagination={false} scroll={{ x: 620 }} />;
+}
 
-  function LogLine({ label, value }: { label: string; value: string }) {
-    return (
-      <div className="delivery-log-line">
-        <Typography.Text type="secondary">{label}</Typography.Text>
-        <Typography.Text code>{value}</Typography.Text>
-      </div>
-    );
-  }
+function WebhookDetail({ deliveries, copy }: { deliveries: WebhookDelivery[]; copy: typeof zhCopy }) {
+  const columns: ColumnsType<WebhookDelivery> = [{ title: copy.event, dataIndex: 'eventType', width: 130 }, { title: copy.status, dataIndex: 'status', width: 130, render: (value) => <StatusPill tone={value === 'success' ? 'success' : value === 'dead' ? 'error' : 'warning'}>{value}</StatusPill> }, { title: copy.attempts, dataIndex: 'attemptCount', width: 90 }, { title: 'HTTP', dataIndex: 'responseStatus', width: 90, render: (value) => value ?? '-' }, { title: copy.failureReason, dataIndex: 'error', ellipsis: true, render: (value) => value || '-' }];
+  return deliveries.length ? <Table rowKey="id" size="small" columns={columns} dataSource={deliveries} pagination={false} scroll={{ x: 720 }} /> : <EmptyState description={copy.noWebhooks} />;
+}
 
-  function formatDeliveryLog(event: SendEvent) {
-    const lines = [
-      `${t('logs.messageId')}: mh-${event.id}`,
-      `${t('logs.time')}: ${new Date(event.createdAt).toLocaleString()}`,
-      `${t('logs.sender')}: ${event.sender}`,
-      `${t('logs.recipient')}: ${event.recipients.join(', ')}`,
-      `${t('logs.subject')}: ${event.subject || '-'}`,
-      `${t('common.status')}: ${statusLabel(event.status)}`,
-      `${t('logs.queueId')}: ${event.queueId || '-'}`,
-      `${t('logs.deliveredAt')}: ${event.deliveredAt ? new Date(event.deliveredAt).toLocaleString() : '-'}`,
-      `${t('logs.finalResponse')}: ${event.detail || '-'}`,
-      ''
-    ];
-    if (event.deliveryAttempts?.length) {
-      lines.push(t('logs.deliveryAttempts'));
-      for (const attempt of event.deliveryAttempts) {
-        lines.push(`[${attempt.at || '-'}] ${statusLabel(attempt.status)} ${attempt.recipient || ''}`);
-        if (attempt.relay) lines.push(`MX: ${attempt.relay}`);
-        if (attempt.dsn) lines.push(`DSN: ${attempt.dsn}`);
-        if (attempt.response) lines.push(`S: ${attempt.response}`);
-        lines.push('');
-      }
-    }
-    if (event.webhookDeliveries?.length) {
-      lines.push(t('logs.webhookDeliveries'));
-      for (const delivery of event.webhookDeliveries) {
-        lines.push(`[${delivery.lastAttemptAt || delivery.createdAt || '-'}] ${delivery.eventType} ${webhookStatusLabel(delivery.status)}`);
-        lines.push(`Webhook: #${delivery.webhookId}`);
-        lines.push(`Attempts: ${delivery.attemptCount}`);
-        if (delivery.responseStatus) lines.push(`HTTP: ${delivery.responseStatus}`);
-        if (delivery.error) lines.push(`${t('logs.errorReason')}: ${delivery.error}`);
-        lines.push('');
-      }
-    }
-    if (event.tracking?.enabled) {
-      lines.push(t('logs.engagement'));
-      lines.push(`${t('logs.opens')}: ${event.tracking.summary?.totalOpens || 0}`);
-      lines.push(`${t('logs.clicks')}: ${event.tracking.summary?.totalClicks || 0}`);
-      lines.push(`${t('logs.proxyOpens')}: ${event.tracking.summary?.proxyOpens || 0}`);
-      lines.push(`${t('logs.scannerEvents')}: ${event.tracking.summary?.scannerEvents || 0}`);
-      lines.push('');
-    }
-    const entries = event.deliveryLog?.length ? event.deliveryLog : [{
-      at: event.createdAt,
-      phase: 'legacy',
-      direction: 'system',
-      message: event.detail || t('logs.noDeliveryLog')
-    }];
-    for (const entry of entries) {
-      lines.push(`[${entry.at || '-'}] ${entry.phase}${entry.direction ? `/${entry.direction}` : ''}`);
-      if (entry.command) lines.push(`C: ${entry.command}`);
-      if (entry.response) lines.push(`S: ${entry.response}`);
-      if (entry.message) lines.push(`- ${entry.message}`);
-      if (entry.messageBytes !== undefined) lines.push(`${t('logs.messageBytes')}: ${entry.messageBytes}`);
-      lines.push('');
-    }
-    return lines.join('\n').trim();
-  }
+function RawLogs({ entries, copy }: { entries: DeliveryLogEntry[]; copy: typeof zhCopy }) {
+  if (!entries.length) return <EmptyState description={copy.noRawLogs} />;
+  return <Space direction="vertical" size={12} className="full-width">{entries.map((entry, index) => <Card size="small" key={`${entry.at}-${index}`} title={<Space wrap><Typography.Text strong>{entry.phase}</Typography.Text>{entry.code ? <StatusPill tone={entry.ok === false ? 'error' : 'info'}>{entry.code}</StatusPill> : null}<Typography.Text type="secondary">{formatTime(entry.at)}</Typography.Text></Space>}>{entry.command ? <CodeBlock value={`C: ${entry.command}`} /> : null}{entry.response ? <CodeBlock value={`S: ${entry.response}`} /> : null}{entry.message ? <Typography.Paragraph>{entry.message}</Typography.Paragraph> : null}</Card>)}</Space>;
+}
 
-  function StatusTag({ status }: { status: string }) {
-    return <StatusPill tone={statusTone(status)}>{statusLabel(status)}</StatusPill>;
-  }
+function EventStatus({ status, copy }: { status: string; copy: typeof zhCopy }) {
+  return <StatusPill tone={eventTone(status)}>{eventStatus(status, copy)}</StatusPill>;
+}
 
-  function statusLabel(status: string) {
-    return {
-      queued: t('logs.statusQueued'),
-      sent: t('logs.statusSent'),
-      deferred: t('logs.statusDeferred'),
-      bounced: t('logs.statusBounced'),
-      failed: t('logs.statusFailed')
-    }[status] || status;
-  }
+function filtersFromParams(params: URLSearchParams): EventFilters {
+  const domainId = Number(params.get('domainId'));
+  return {
+    page: positiveInteger(params.get('page'), 1),
+    pageSize: Math.min(100, positiveInteger(params.get('pageSize'), 20)),
+    status: params.get('status') || undefined,
+    domainId: Number.isInteger(domainId) && domainId > 0 ? domainId : undefined,
+    from: validIso(params.get('from')),
+    to: validIso(params.get('to')),
+    recipient: params.get('recipient') || undefined,
+    q: params.get('q') || undefined
+  };
+}
 
-  function statusTone(status: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
-    return {
-      queued: 'info',
-      sent: 'success',
-      deferred: 'warning',
-      bounced: 'error',
-      failed: 'error'
-    }[status] as 'success' | 'warning' | 'error' | 'info' | 'neutral' || 'neutral';
-  }
+function normalizeDetailTab(value: string | null): DetailTab {
+  return value === 'tracking' || value === 'webhooks' || value === 'raw' ? value : 'attempts';
+}
 
-  function timelineStageLabel(stage: string) {
-    return {
-      submitted: t('logs.stageSubmitted'),
-      accepted: t('logs.stageAccepted'),
-      delivered: t('logs.stageDelivered'),
-      pending: t('logs.stagePending'),
-      failed: t('logs.stageFailed'),
-      opened: t('logs.stageOpened'),
-      clicked: t('logs.stageClicked'),
-      webhook: t('logs.stageWebhook')
-    }[stage] || stage;
-  }
+function draftFromParams(params: URLSearchParams): FilterDraft {
+  return { from: toDatetimeLocal(params.get('from')), to: toDatetimeLocal(params.get('to')), domainId: params.get('domainId') || undefined, status: params.get('status') || undefined, recipient: params.get('recipient') || '', q: params.get('q') || '' };
+}
 
-  function webhookStatusLabel(status: string) {
-    return {
-      pending: t('webhooks.statusPending'),
-      processing: t('webhooks.statusProcessing'),
-      success: t('webhooks.statusSuccess'),
-      dead: t('webhooks.statusDead')
-    }[status] || statusLabel(status);
-  }
+function positiveInteger(value: string | null, fallback: number) {
+  const parsed = Number(value);
+  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
+}
 
-  function webhookTone(status: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
-    if (status === 'success') return 'success';
-    if (status === 'dead') return 'error';
-    if (status === 'pending' || status === 'processing') return 'warning';
-    return statusTone(status);
-  }
+function validIso(value: string | null) {
+  if (!value || Number.isNaN(Date.parse(value))) return undefined;
+  return value;
+}
 
-  function timelineToneColor(tone: string) {
-    if (tone === 'success') return 'green';
-    if (tone === 'warning') return 'gold';
-    if (tone === 'error') return 'red';
-    return 'blue';
-  }
+function toIso(value: string) {
+  const date = new Date(value);
+  return Number.isNaN(date.getTime()) ? '' : date.toISOString();
+}
 
-  function timelineColor(entry: DeliveryLogEntry) {
-    if (entry.ok === false || entry.phase === 'error') return 'red';
-    if (entry.phase === 'queue') return 'green';
-    if (entry.phase === 'auth') return 'gold';
-    return 'blue';
-  }
+function toDatetimeLocal(value: string | null) {
+  if (!value || Number.isNaN(Date.parse(value))) return '';
+  const date = new Date(value);
+  const offset = date.getTimezoneOffset() * 60_000;
+  return new Date(date.getTime() - offset).toISOString().slice(0, 16);
+}
 
-  function deliveryAttemptColor(status: string) {
-    if (status === 'sent') return 'green';
-    if (status === 'deferred') return 'gold';
-    if (status === 'bounced' || status === 'failed') return 'red';
-    return 'blue';
-  }
+function hasFilters(params: URLSearchParams) {
+  return ['from', 'to', 'domainId', 'status', 'recipient', 'q'].some((key) => params.has(key));
+}
 
-  function formatOptionalTime(value?: string | null) {
-    return value ? new Date(value).toLocaleString() : '-';
-  }
+function statusOptions(copy: typeof zhCopy) {
+  return ['queued', 'sent', 'deferred', 'bounced', 'failed'].map((value) => ({ value, label: eventStatus(value, copy) }));
+}
+
+function isFailure(status: string) {
+  return status === 'failed' || status === 'bounced';
 }
+
+function eventTone(status: string): 'success' | 'warning' | 'error' | 'info' | 'neutral' {
+  if (status === 'sent' || status === 'delivered' || status === 'success') return 'success';
+  if (status === 'queued' || status === 'processing') return 'info';
+  if (status === 'deferred' || status === 'pending') return 'warning';
+  if (status === 'failed' || status === 'bounced' || status === 'dead') return 'error';
+  return 'neutral';
+}
+
+function eventStatus(status: string, copy: typeof zhCopy) {
+  return ({ queued: copy.queued, sent: copy.sent, delivered: copy.delivered, deferred: copy.deferred, bounced: copy.bounced, failed: copy.failed, pending: copy.pending } as Record<string, string>)[status] || status;
+}
+
+function stageLabel(stage: string, copy: typeof zhCopy) {
+  return ({ submitted: copy.submitted, accepted: copy.accepted, delivered: copy.delivered, pending: copy.pending, failed: copy.failed, opened: copy.opened, clicked: copy.clicked, webhook: 'Webhook' } as Record<string, string>)[stage] || stage;
+}
+
+function timelineColor(tone: ReturnType<typeof eventTone> | SendEventTimelineEntry['tone']) {
+  return ({ success: 'green', warning: 'orange', error: 'red', info: 'blue', neutral: 'gray' } as const)[tone];
+}
+
+function formatTime(value?: string | null) {
+  return value ? new Date(value).toLocaleString() : '-';
+}
+
+function formatDiagnostics(event: SendEvent) {
+  const attempts = (event.deliveryAttempts || []).map((attempt) => `${attempt.at || '-'} ${attempt.status} ${attempt.dsn || ''} ${attempt.recipient || ''} ${attempt.response || ''}`.trim());
+  const logs = (event.deliveryLog || []).map((entry) => `${entry.at || '-'} [${entry.phase}] ${entry.command || entry.response || entry.message || ''}`.trim());
+  return [`Message ID: ${messageIdentifier(event)}`, `Queue ID: ${event.queueId || '-'}`, `Status: ${event.status}`, `Created: ${event.createdAt}`, `Sender: ${event.sender}`, `Recipients: ${event.recipients.join(', ')}`, `Response: ${event.detail || '-'}`, '', 'Delivery attempts:', ...(attempts.length ? attempts : ['-']), '', 'Raw log:', ...(logs.length ? logs : ['-'])].join('\n');
+}
+
+function messageIdentifier(event: SendEvent) {
+  return event.messageId || `mh-${event.id}`;
+}
+
+function activityListPath(searchParams: URLSearchParams) {
+  const next = new URLSearchParams(searchParams);
+  next.delete('tab');
+  const search = next.toString();
+  return `/activity${search ? `?${search}` : ''}`;
+}
+
+const zhCopy = {
+  title: '发送活动', subtitle: '按服务端数据定位发送、延迟、退信和失败事件。', loadFailed: '发送活动加载失败', retry: '重试', from: '开始时间', to: '结束时间', domain: '发信域名', allDomains: '全部域名', status: '状态', allStatuses: '全部状态', recipient: '收件人', messageSearch: '消息检索', messageSearchPlaceholder: '主题 / Message ID / Queue ID', search: '查询', clear: '清除', refresh: '刷新', activityList: '活动列表', total: (count: number) => `共 ${count} 条`, noMatches: '没有符合当前条件的发送活动', noActivity: '尚无发送活动',
+  time: '时间', subject: '主题', failureReason: '投递响应', copyDiagnostics: '复制诊断信息', viewDetail: '查看发送详情', detailTitle: '发送详情', detailLoadFailed: '发送详情加载失败', detailNotFound: '未找到该发送记录,或当前账户没有访问权限。', diagnosticsCopied: '诊断信息已复制', copyFailed: '复制失败', statusSummary: '状态摘要', sender: '发件人', finalResponse: '最终响应', eventTimeline: '事件时间线', noTimeline: '暂无可用事件时间线',
+  deliveryAttempts: '投递尝试', noAttempts: '暂无最终投递回执,邮件可能仍在队列中。', engagement: '互动跟踪', trackingDisabled: '这封邮件发送时未启用打开或点击追踪。', opens: '打开', clicks: '点击', firstOpened: '首次打开', lastClicked: '最近点击', proxyOpens: '代理打开', scannerEvents: '扫描器事件', noLinks: '暂无链接点击数据', link: '链接', event: '事件', attempts: '尝试次数', noWebhooks: '暂无关联 Webhook 投递', rawLogs: '原始日志', noRawLogs: '暂无结构化原始日志,可能是历史发送记录。',
+  queued: '已入队', sent: '已发送', delivered: '已送达', deferred: '已延迟', bounced: '已退信', failed: '失败', pending: '等待投递', submitted: '已提交', accepted: '队列已接收', opened: '已打开', clicked: '已点击'
+};
+
+const enCopy: typeof zhCopy = {
+  title: 'Sending activity', subtitle: 'Use server-side data to investigate sends, delays, bounces, and failures.', loadFailed: 'Unable to load sending activity', retry: 'Retry', from: 'From', to: 'To', domain: 'Sending domain', allDomains: 'All domains', status: 'Status', allStatuses: 'All statuses', recipient: 'Recipient', messageSearch: 'Message search', messageSearchPlaceholder: 'Subject / Message ID / Queue ID', search: 'Search', clear: 'Clear', refresh: 'Refresh', activityList: 'Activity', total: (count: number) => `${count} events`, noMatches: 'No sending activity matches these filters', noActivity: 'No sending activity yet',
+  time: 'Time', subject: 'Subject', failureReason: 'Delivery response', copyDiagnostics: 'Copy diagnostics', viewDetail: 'View sending details', detailTitle: 'Sending details', detailLoadFailed: 'Unable to load sending details', detailNotFound: 'The sending event was not found or is not available to this account.', diagnosticsCopied: 'Diagnostics copied', copyFailed: 'Unable to copy', statusSummary: 'Status summary', sender: 'Sender', finalResponse: 'Final response', eventTimeline: 'Event timeline', noTimeline: 'No event timeline available',
+  deliveryAttempts: 'Delivery attempts', noAttempts: 'No final delivery receipt yet. The message may still be queued.', engagement: 'Engagement', trackingDisabled: 'Open and click tracking was not enabled for this message.', opens: 'Opens', clicks: 'Clicks', firstOpened: 'First opened', lastClicked: 'Last clicked', proxyOpens: 'Proxy opens', scannerEvents: 'Scanner events', noLinks: 'No link click data', link: 'Link', event: 'Event', attempts: 'Attempts', noWebhooks: 'No related webhook delivery', rawLogs: 'Raw logs', noRawLogs: 'No structured raw log is available. This may be a historical event.',
+  queued: 'Queued', sent: 'Sent', delivered: 'Delivered', deferred: 'Deferred', bounced: 'Bounced', failed: 'Failed', pending: 'Pending', submitted: 'Submitted', accepted: 'Accepted by queue', opened: 'Opened', clicked: 'Clicked'
+};

+ 184 - 142
src/pages/Settings.tsx

@@ -1,73 +1,148 @@
-import { Button, Form, Input, Select, Space, Switch, Table, Typography } from 'antd';
+import { ReloadOutlined, SaveOutlined, WarningOutlined } from '@ant-design/icons';
+import {
+  Alert,
+  App as AntApp,
+  Button,
+  Form,
+  Input,
+  Select,
+  Skeleton,
+  Space,
+  Switch,
+  Table,
+  Typography
+} from 'antd';
 import type { ColumnsType } from 'antd/es/table';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { type BlockerFunction, useBeforeUnload, useBlocker } from 'react-router-dom';
 
 import { PageHeader } from '../components/common/PageHeader';
 import { SectionCard } from '../components/common/SectionCard';
-import { StatusPill, type StatusTone } from '../components/common/StatusPill';
 import { StatusTag } from '../components/common/StatusTag';
+import { useAppContext } from '../frontend/app-context';
 import { getDnsCurrentValues } from '../frontend/domain-model.js';
 import { useI18n } from '../frontend/i18n/react';
-import type { DnsRecord, RuntimeConfig, User } from '../frontend/types';
-import { adminUserStatusMeta } from './Admin/admin-model.js';
-
-interface SettingsProps {
-  me: User | null;
-  settings: RuntimeConfig | null;
-  users: User[];
-  loading?: boolean;
-  onSave: (values: Partial<RuntimeConfig>) => Promise<void>;
-}
+import { api } from '../frontend/services/api';
+import type { DnsRecord, RuntimeConfig } from '../frontend/types';
+
+export default function Settings() {
+  const { message, modal } = AntApp.useApp();
+  const { locale, t } = useI18n();
+  const { user: me } = useAppContext();
+  const [form] = Form.useForm<Partial<RuntimeConfig>>();
+  const [settings, setSettings] = useState<RuntimeConfig | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [saving, setSaving] = useState(false);
+  const [loadError, setLoadError] = useState('');
+  const [dirty, setDirty] = useState(false);
+  const leaveConfirmation = useRef<{ destroy: () => void } | null>(null);
+  const leaveCopy = useMemo(() => locale.startsWith('en')
+    ? {
+        title: 'Discard unsaved changes?',
+        content: 'You have unsaved settings. Leaving this page will discard them.',
+        confirm: 'Leave page',
+        cancel: 'Keep editing'
+      }
+    : {
+        title: '放弃未保存的更改?',
+        content: '当前设置尚未保存,离开此页面将丢失这些更改。',
+        confirm: '离开页面',
+        cancel: '继续编辑'
+      }, [locale]);
+
+  const shouldBlock = useCallback<BlockerFunction>(({ currentLocation, nextLocation }) => (
+    dirty && locationIdentity(currentLocation) !== locationIdentity(nextLocation)
+  ), [dirty]);
+  const blocker = useBlocker(shouldBlock);
+
+  useBeforeUnload(useCallback((event) => {
+    if (!dirty) return;
+    event.preventDefault();
+    event.returnValue = '';
+  }, [dirty]), { capture: true });
 
-export default function Settings({ me, settings, users, loading, onSave }: SettingsProps) {
-  const { t } = useI18n();
-  if (me?.role !== 'admin') {
+  const loadSettings = useCallback(async () => {
+    setLoading(true);
+    setLoadError('');
+    try {
+      if (me?.role !== 'admin') return;
+      const result = await api.adminSettings();
+      setSettings(result.settings);
+      form.setFieldsValue(result.settings);
+      setDirty(false);
+    } catch (error) {
+      setLoadError(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setLoading(false);
+    }
+  }, [form, me?.role, t]);
+
+  useEffect(() => {
+    void loadSettings();
+  }, [loadSettings]);
+
+  useEffect(() => {
+    if (blocker.state !== 'blocked' || leaveConfirmation.current) return;
+    leaveConfirmation.current = modal.confirm({
+      title: leaveCopy.title,
+      content: leaveCopy.content,
+      okText: leaveCopy.confirm,
+      cancelText: leaveCopy.cancel,
+      okButtonProps: { danger: true },
+      onOk: () => {
+        leaveConfirmation.current = null;
+        setDirty(false);
+        blocker.proceed();
+      },
+      onCancel: () => {
+        leaveConfirmation.current = null;
+        blocker.reset();
+      },
+      afterClose: () => {
+        leaveConfirmation.current = null;
+      }
+    });
+  }, [blocker, leaveCopy, modal]);
+
+  useEffect(() => () => {
+    leaveConfirmation.current?.destroy();
+    leaveConfirmation.current = null;
+  }, []);
+
+  async function save(values: Partial<RuntimeConfig>) {
+    setSaving(true);
+    try {
+      const result = await api.saveAdminSettings(values);
+      setSettings(result.settings);
+      form.setFieldsValue(result.settings);
+      setDirty(false);
+      message.success(t('actions.settingsSaved'));
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  if (!loading && me?.role !== 'admin') {
     return (
       <Space direction="vertical" size={20} className="full-width">
         <PageHeader title={t('nav.settings')} />
-        <SectionCard>
-          <Typography.Text type="secondary">{t('settings.noPermission')}</Typography.Text>
-        </SectionCard>
+        <Alert type="warning" showIcon message={t('settings.noPermission')} />
       </Space>
     );
   }
 
-  const columns: ColumnsType<User> = [
-    { title: 'Username', dataIndex: 'username' },
-    { title: 'Email', dataIndex: 'email' },
-    {
-      title: 'Role',
-      dataIndex: 'role',
-      render: (value: string) => (
-        <StatusPill tone={value === 'admin' ? 'info' : 'neutral'}>
-          {roleLabel(value)}
-        </StatusPill>
-      )
-    },
-    {
-      title: 'Status',
-      dataIndex: 'status',
-      render: (value: string) => {
-        const meta = adminUserStatusMeta(value);
-        return <StatusPill tone={userStatusTone(meta.color)}>{meta.label}</StatusPill>;
-      }
-    }
-  ];
   const checkColumns: ColumnsType<DnsRecord> = [
     { title: t('settings.checkItem'), dataIndex: 'label', width: 160 },
-    { title: t('dnsRecord.hostname'), dataIndex: 'host', width: 180, render: (value) => <Typography.Text code>{value || '-'}</Typography.Text> },
-    { title: t('dnsRecord.targetValue'), dataIndex: 'value', width: 220, render: (value) => <Typography.Text code>{value || '-'}</Typography.Text> },
+    { title: t('dnsRecord.hostname'), dataIndex: 'host', width: 180, render: (value: string) => <Typography.Text code>{value || '—'}</Typography.Text> },
+    { title: t('dnsRecord.targetValue'), dataIndex: 'value', width: 220, render: (value: string) => <Typography.Text code>{value || '—'}</Typography.Text> },
     {
       title: t('dnsRecord.currentValue'),
       width: 260,
       render: (_, record) => {
         const values: string[] = getDnsCurrentValues(record);
-        return values.length
-          ? (
-              <Space direction="vertical" size={4}>
-                {values.map((value) => <Typography.Text key={value} code>{value}</Typography.Text>)}
-              </Space>
-            )
-          : <Typography.Text type="secondary">{t('dnsRecord.emptyCurrent')}</Typography.Text>;
+        return values.length ? <Space direction="vertical" size={4}>{values.map((value) => <Typography.Text key={value} code>{value}</Typography.Text>)}</Space> : <Typography.Text type="secondary">{t('dnsRecord.emptyCurrent')}</Typography.Text>;
       }
     },
     { title: t('common.status'), width: 120, render: (_, record) => <StatusTag record={record} /> }
@@ -76,105 +151,72 @@ export default function Settings({ me, settings, users, loading, onSave }: Setti
 
   return (
     <Space direction="vertical" size={20} className="full-width">
-      <PageHeader title={t('nav.settings')} />
-
-      <SectionCard title="System">
-        <Form
-          layout="vertical"
-          initialValues={settings || undefined}
-          onFinish={onSave}
-          disabled={loading}
-        >
-          <div className="form-grid two">
-            <Form.Item name="appBaseUrl" label="APP_BASE_URL">
-              <Input />
-            </Form.Item>
-            <Form.Item name="mailHostname" label="MAIL_HOSTNAME">
-              <Input />
-            </Form.Item>
-            <Form.Item name="sendingIp" label="SENDING_IP">
-              <Input />
-            </Form.Item>
-            <Form.Item name="defaultSpfMechanisms" label="DEFAULT_SPF_MECHANISMS">
-              <Input />
-            </Form.Item>
-            <Form.Item name="dmarcPolicy" label="DMARC_POLICY">
-              <Select options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} />
-            </Form.Item>
-            <Form.Item name="dmarcRua" label="DMARC_RUA">
-              <Input />
-            </Form.Item>
-            <Form.Item name="listUnsubscribeMailto" label="LIST_UNSUBSCRIBE_MAILTO">
-              <Input placeholder="unsubscribe@example.com" />
-            </Form.Item>
-            <Form.Item name="listUnsubscribeUrl" label="LIST_UNSUBSCRIBE_URL">
-              <Input placeholder="https://example.com/unsubscribe/{eventId}" />
-            </Form.Item>
-            <Form.Item name="reportAbuseTo" label="REPORT_ABUSE_TO">
-              <Input placeholder="abuse@example.com" />
-            </Form.Item>
-            <Form.Item name="csaComplaintsTo" label="CSA_COMPLAINTS_TO">
-              <Input placeholder="csa-complaints@example.com" />
-            </Form.Item>
-            <Form.Item name="bounceAddress" label="BOUNCE_ADDRESS">
-              <Input placeholder="bounce@example.com" />
-            </Form.Item>
-          </div>
-          <Form.Item name="sendRequiresVerified" label="SEND_REQUIRES_VERIFIED" valuePropName="checked">
-            <Switch />
-          </Form.Item>
-          <Form.Item name="engagementTrackingEnabled" label={t('settings.engagementTracking')} valuePropName="checked">
-            <Switch />
-          </Form.Item>
-          <Form.Item name="listUnsubscribePostEnabled" label="LIST_UNSUBSCRIBE_POST_ENABLED" valuePropName="checked">
-            <Switch />
-          </Form.Item>
-          <Form.Item name="feedbackIdEnabled" label="FEEDBACK_ID_ENABLED" valuePropName="checked">
-            <Switch />
-          </Form.Item>
-          <Form.Item name="bounceEnvelopeEnabled" label="BOUNCE_ENVELOPE_ENABLED" valuePropName="checked">
-            <Switch />
-          </Form.Item>
-          <Button type="primary" htmlType="submit" loading={loading}>
+      <PageHeader
+        title={t('nav.settings')}
+        subtitle={locale.startsWith('en') ? 'Runtime and delivery defaults for this MailHub instance.' : '配置当前 MailHub 实例的运行参数和投递默认行为。'}
+        extra={
+          <Button type="primary" icon={<SaveOutlined />} loading={saving} disabled={!dirty || loading} onClick={() => form.submit()} style={{ minHeight: 44 }}>
             {t('settings.save')}
           </Button>
-        </Form>
-      </SectionCard>
-
-      <SectionCard
-        title={t('settings.deliveryChecks')}
-        extra={
-          settings?.systemChecks?.checkedAt
-            ? <Typography.Text type="secondary">{new Date(settings.systemChecks.checkedAt).toLocaleString()}</Typography.Text>
-            : null
         }
-      >
-        <Table
-          rowKey="key"
-          columns={checkColumns}
-          dataSource={checkData}
-          pagination={false}
-          scroll={{ x: 940 }}
-        />
-      </SectionCard>
-
-      <SectionCard title="Users">
-        <Table rowKey="id" columns={columns} dataSource={users} />
-      </SectionCard>
+      />
+      {loadError ? <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadSettings()}>{t('common.refresh')}</Button>} /> : null}
+      {loading ? <SectionCard><Skeleton active paragraph={{ rows: 10 }} /></SectionCard> : (
+        <Form form={form} layout="vertical" onFinish={save} onValuesChange={() => setDirty(true)}>
+          <Space direction="vertical" size={20} className="full-width">
+            <SectionCard title={locale.startsWith('en') ? 'Instance' : '实例配置'}>
+              <div className="form-grid two">
+                <Form.Item name="appBaseUrl" label="APP_BASE_URL"><Input inputMode="url" /></Form.Item>
+                <Form.Item name="mailHostname" label="MAIL_HOSTNAME"><Input /></Form.Item>
+                <Form.Item name="sendingIp" label="SENDING_IP"><Input inputMode="decimal" /></Form.Item>
+                <Form.Item name="defaultSpfMechanisms" label="DEFAULT_SPF_MECHANISMS"><Input /></Form.Item>
+                <Form.Item name="dmarcPolicy" label="DMARC_POLICY"><Select options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} /></Form.Item>
+                <Form.Item name="dmarcRua" label="DMARC_RUA"><Input inputMode="email" /></Form.Item>
+              </div>
+            </SectionCard>
+
+            <SectionCard title={locale.startsWith('en') ? 'Message and tracking defaults' : '邮件与跟踪默认值'}>
+              <div className="form-grid two">
+                <Form.Item name="listUnsubscribeMailto" label="LIST_UNSUBSCRIBE_MAILTO"><Input placeholder="unsubscribe@example.com" /></Form.Item>
+                <Form.Item name="listUnsubscribeUrl" label="LIST_UNSUBSCRIBE_URL"><Input placeholder="https://example.com/unsubscribe/{eventId}" /></Form.Item>
+                <Form.Item name="reportAbuseTo" label="REPORT_ABUSE_TO"><Input placeholder="abuse@example.com" /></Form.Item>
+                <Form.Item name="csaComplaintsTo" label="CSA_COMPLAINTS_TO"><Input placeholder="csa-complaints@example.com" /></Form.Item>
+                <Form.Item name="bounceAddress" label="BOUNCE_ADDRESS"><Input placeholder="bounce@example.com" /></Form.Item>
+              </div>
+              <Space direction="vertical" size={16}>
+                <Form.Item name="engagementTrackingEnabled" label={t('settings.engagementTracking')} valuePropName="checked" style={{ marginBottom: 0 }}><Switch /></Form.Item>
+                <Form.Item name="listUnsubscribePostEnabled" label="LIST_UNSUBSCRIBE_POST_ENABLED" valuePropName="checked" style={{ marginBottom: 0 }}><Switch /></Form.Item>
+                <Form.Item name="feedbackIdEnabled" label="FEEDBACK_ID_ENABLED" valuePropName="checked" style={{ marginBottom: 0 }}><Switch /></Form.Item>
+              </Space>
+            </SectionCard>
+
+            <SectionCard
+              title={t('settings.deliveryChecks')}
+              extra={settings?.systemChecks?.checkedAt ? <Typography.Text type="secondary">{new Date(settings.systemChecks.checkedAt).toLocaleString()}</Typography.Text> : null}
+            >
+              <Table rowKey="key" columns={checkColumns} dataSource={checkData} pagination={false} scroll={{ x: 940 }} />
+            </SectionCard>
+
+            <SectionCard title={<Space><WarningOutlined style={{ color: '#D92D20' }} />{locale.startsWith('en') ? 'Danger zone' : '高风险设置'}</Space>}>
+              <Alert
+                type="warning"
+                showIcon
+                message={locale.startsWith('en') ? 'Changes here alter the sending security boundary.' : '以下设置会改变发信安全边界,请在保存前确认影响。'}
+                description={settings?.usingDefaultAdminPassword ? (locale.startsWith('en') ? 'The default administrator password is still in use. Change it before exposing this instance.' : '当前仍在使用默认管理员密码,请在对外开放前完成修改。') : undefined}
+                style={{ marginBottom: 20 }}
+              />
+              <Space direction="vertical" size={16}>
+                <Form.Item name="sendRequiresVerified" label="SEND_REQUIRES_VERIFIED" valuePropName="checked" style={{ marginBottom: 0 }}><Switch /></Form.Item>
+                <Form.Item name="bounceEnvelopeEnabled" label="BOUNCE_ENVELOPE_ENABLED" valuePropName="checked" style={{ marginBottom: 0 }}><Switch /></Form.Item>
+              </Space>
+            </SectionCard>
+          </Space>
+        </Form>
+      )}
     </Space>
   );
 }
 
-function roleLabel(role: string) {
-  if (role === 'admin') return '管理员';
-  if (role === 'user') return '用户';
-  return role || '-';
-}
-
-function userStatusTone(color: string): StatusTone {
-  if (color === 'green' || color === 'success') return 'success';
-  if (color === 'gold' || color === 'orange' || color === 'warning') return 'warning';
-  if (color === 'red' || color === 'error') return 'error';
-  if (color === 'blue' || color === 'processing') return 'info';
-  return 'neutral';
+function locationIdentity(location: { pathname: string; search: string; hash: string }) {
+  return `${location.pathname}${location.search}${location.hash}`;
 }

+ 275 - 273
src/pages/SmtpCredentials.tsx

@@ -1,164 +1,209 @@
-import { CopyOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
-import { Button, Descriptions, Form, Input, InputNumber, Modal, Popconfirm, Space, Switch, Table, Typography } from 'antd';
+import {
+  CopyOutlined,
+  DeleteOutlined,
+  EditOutlined,
+  PlusOutlined,
+  ReloadOutlined
+} from '@ant-design/icons';
+import {
+  Alert,
+  App as AntApp,
+  Button,
+  Descriptions,
+  Drawer,
+  Form,
+  Input,
+  InputNumber,
+  Popconfirm,
+  Skeleton,
+  Space,
+  Switch,
+  Table,
+  Tabs,
+  Typography
+} from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
 
+import { EmptyState } from '../components/common/EmptyState';
 import { PageHeader } from '../components/common/PageHeader';
 import { SectionCard } from '../components/common/SectionCard';
 import { StatusPill } from '../components/common/StatusPill';
+import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
-import type { RuntimeConfig, SmtpCredential, SmtpRelay, SmtpRelayPayload } from '../frontend/types';
-
-interface SmtpCredentialsProps {
-  config: RuntimeConfig | null;
-  credential: SmtpCredential | null;
-  credentials: SmtpCredential[];
-  relays: SmtpRelay[];
-  loading?: boolean;
-  onCopy: (value: string) => void;
-  onLoadCredential: (id: number) => Promise<SmtpCredential | null>;
-  onSaveCredential: (values: { username: string; password?: string }, id?: number) => Promise<SmtpCredential | null>;
-  onDeleteCredential: (credential: SmtpCredential) => Promise<void>;
-  onLoadRelay: (id: number) => Promise<SmtpRelay | null>;
-  onSaveRelay: (values: SmtpRelayPayload, id?: number) => Promise<SmtpRelay | null>;
-  onDeleteRelay: (relay: SmtpRelay) => Promise<void>;
-}
+import { api } from '../frontend/services/api';
+import type { SmtpCredential, SmtpRelay, SmtpRelayPayload } from '../frontend/types';
 
 interface CredentialFormValues {
   username: string;
   password?: string;
 }
 
-export default function SmtpCredentials({
-  config,
-  credential,
-  credentials,
-  relays,
-  loading,
-  onCopy,
-  onLoadCredential,
-  onSaveCredential,
-  onDeleteCredential,
-  onLoadRelay,
-  onSaveRelay,
-  onDeleteRelay
-}: SmtpCredentialsProps) {
-  const { t } = useI18n();
+type SmtpTab = 'credentials' | 'relays' | 'connection';
+
+export default function SmtpCredentials() {
+  const { message } = AntApp.useApp();
+  const { locale, t } = useI18n();
+  const { config } = useAppContext();
+  const [searchParams, setSearchParams] = useSearchParams();
   const [credentialForm] = Form.useForm<CredentialFormValues>();
   const [relayForm] = Form.useForm<SmtpRelayPayload>();
+  const [primaryCredential, setPrimaryCredential] = useState<SmtpCredential | null>(null);
+  const [credentials, setCredentials] = useState<SmtpCredential[]>([]);
+  const [relays, setRelays] = useState<SmtpRelay[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [loadError, setLoadError] = useState('');
+  const [actionKey, setActionKey] = useState('');
   const [credentialOpen, setCredentialOpen] = useState(false);
-  const [credentialLoading, setCredentialLoading] = useState(false);
   const [editingCredential, setEditingCredential] = useState<SmtpCredential | null>(null);
   const [relayOpen, setRelayOpen] = useState(false);
-  const [relayLoading, setRelayLoading] = useState(false);
   const [editingRelay, setEditingRelay] = useState<SmtpRelay | null>(null);
+  const [detail, setDetail] = useState<{ type: 'credential'; value: SmtpCredential } | { type: 'relay'; value: SmtpRelay } | null>(null);
+  const requestedTab = searchParams.get('tab');
+  const activeTab: SmtpTab = requestedTab === 'relays' || requestedTab === 'connection' ? requestedTab : 'credentials';
 
-  function generateCredentialPassword() {
-    credentialForm.setFieldValue('password', randomPassword());
+  const loadData = useCallback(async () => {
+    setLoading(true);
+    setLoadError('');
+    try {
+      const [primaryResult, credentialResult, relayResult] = await Promise.all([
+        api.smtpCredential(),
+        api.smtpCredentials(),
+        api.smtpRelays()
+      ]);
+      setPrimaryCredential(primaryResult.credential || null);
+      setCredentials(credentialResult.credentials || []);
+      setRelays(relayResult.relays || []);
+    } catch (error) {
+      setLoadError(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setLoading(false);
+    }
+  }, [t]);
+
+  useEffect(() => {
+    void loadData();
+  }, [loadData]);
+
+  function changeTab(key: string) {
+    const next = new URLSearchParams(searchParams);
+    next.set('tab', key);
+    setSearchParams(next);
   }
 
-  function generateRelayPassword() {
-    relayForm.setFieldValue('password', randomPassword());
+  async function copyValue(value: string) {
+    if (!value || value === '—') return;
+    await navigator.clipboard.writeText(value);
+    message.success(t('common.copied'));
   }
 
   function openCreateCredential() {
     setEditingCredential(null);
+    credentialForm.resetFields();
     credentialForm.setFieldsValue({
-      username: credentials.length === 0 ? credential?.username || config?.submission?.username || '' : '',
+      username: credentials.length === 0 ? primaryCredential?.username || config?.submission?.username || '' : '',
       password: ''
     });
     setCredentialOpen(true);
   }
 
-  async function openEditCredential(item: SmtpCredential) {
-    if (!item.id) return;
+  function openEditCredential(item: SmtpCredential) {
     setEditingCredential(item);
+    credentialForm.resetFields();
+    credentialForm.setFieldsValue({ username: item.username, password: '' });
     setCredentialOpen(true);
-    setCredentialLoading(true);
-    try {
-      const detail = await onLoadCredential(item.id);
-      if (!detail) {
-        setCredentialOpen(false);
-        return;
-      }
-      credentialForm.setFieldsValue({
-        username: detail.username,
-        password: detail.password || ''
-      });
-    } finally {
-      setCredentialLoading(false);
-    }
   }
 
   async function saveCredential() {
     const values = await credentialForm.validateFields();
-    setCredentialLoading(true);
+    const key = editingCredential?.id ? `credential:save:${editingCredential.id}` : 'credential:create';
+    setActionKey(key);
     try {
-      const saved = await onSaveCredential(values, editingCredential?.id);
-      if (!saved) return;
+      await api.saveSmtpLoginCredential(values, editingCredential?.id);
+      message.success(editingCredential ? t('actions.smtpUpdated') : t('actions.smtpCreated'));
       setCredentialOpen(false);
+      setEditingCredential(null);
       credentialForm.resetFields();
+      await loadData();
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
     } finally {
-      setCredentialLoading(false);
+      setActionKey('');
     }
   }
 
-  function closeCredentialModal() {
-    setCredentialOpen(false);
-    setEditingCredential(null);
-    credentialForm.resetFields();
+  async function deleteCredential(item: SmtpCredential) {
+    if (!item.id) return;
+    setActionKey(`credential:delete:${item.id}`);
+    try {
+      await api.deleteSmtpCredential(item.id);
+      message.success(t('actions.smtpDeleted'));
+      setDetail(null);
+      await loadData();
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setActionKey('');
+    }
   }
 
   function openCreateRelay() {
     setEditingRelay(null);
+    relayForm.resetFields();
     relayForm.setFieldsValue({
-      name: '',
-      host: '',
-      port: 587,
-      secure: false,
-      username: '',
-      password: '',
-      helo: '',
-      isDefault: relays.length === 0
+      name: '', host: '', port: 587, secure: false, username: '', password: '', helo: '', isDefault: relays.length === 0
     });
     setRelayOpen(true);
   }
 
-  async function openEditRelay(relay: SmtpRelay) {
+  function openEditRelay(relay: SmtpRelay) {
     setEditingRelay(relay);
+    relayForm.resetFields();
+    relayForm.setFieldsValue({
+      name: relay.name,
+      host: relay.host,
+      port: relay.port,
+      secure: relay.secure,
+      username: relay.username,
+      password: '',
+      helo: relay.helo,
+      isDefault: relay.isDefault
+    });
     setRelayOpen(true);
-    setRelayLoading(true);
-    try {
-      const detail = await onLoadRelay(relay.id);
-      if (!detail) {
-        setRelayOpen(false);
-        return;
-      }
-      relayForm.setFieldsValue({
-        name: detail.name,
-        host: detail.host,
-        port: detail.port,
-        secure: detail.secure,
-        username: detail.username,
-        password: detail.password || '',
-        helo: detail.helo,
-        isDefault: detail.isDefault
-      });
-    } finally {
-      setRelayLoading(false);
-    }
   }
 
   async function saveRelay() {
     const values = await relayForm.validateFields();
-    setRelayLoading(true);
+    const payload: SmtpRelayPayload = { ...values };
+    if (editingRelay && !payload.password) delete payload.password;
+    const key = editingRelay ? `relay:save:${editingRelay.id}` : 'relay:create';
+    setActionKey(key);
     try {
-      const saved = await onSaveRelay(values, editingRelay?.id);
-      if (!saved) return;
+      await api.saveSmtpRelay(payload, editingRelay?.id);
+      message.success(editingRelay ? t('actions.smtpRelayUpdated') : t('actions.smtpRelayCreated'));
       setRelayOpen(false);
+      setEditingRelay(null);
       relayForm.resetFields();
+      await loadData();
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
     } finally {
-      setRelayLoading(false);
+      setActionKey('');
+    }
+  }
+
+  async function deleteRelay(relay: SmtpRelay) {
+    setActionKey(`relay:delete:${relay.id}`);
+    try {
+      await api.deleteSmtpRelay(relay.id);
+      message.success(t('actions.smtpRelayDeleted'));
+      setDetail(null);
+      await loadData();
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setActionKey('');
     }
   }
 
@@ -166,44 +211,25 @@ export default function SmtpCredentials({
     {
       title: t('smtp.username'),
       dataIndex: 'username',
-      width: 240,
-      render: (value: string) => copyable(value, onCopy)
+      render: (value: string) => <Typography.Text code>{value}</Typography.Text>
     },
     {
       title: t('smtp.password'),
-      dataIndex: 'password',
-      width: 280,
-      render: (value: string | undefined) => (
-        value
-          ? copyable(value, onCopy)
-          : <Typography.Text type="secondary">{t('smtp.passwordUnavailable')}</Typography.Text>
-      )
-    },
-    {
-      title: t('common.status'),
       dataIndex: 'passwordSet',
-      width: 120,
-      render: (value: boolean) => (
-        <StatusPill tone={value ? 'success' : 'neutral'}>
-          {value ? t('smtp.passwordSet') : t('smtp.passwordEmpty')}
-        </StatusPill>
+      width: 140,
+      render: (value?: boolean) => (
+        <StatusPill tone={value ? 'success' : 'warning'}>{value ? t('smtp.passwordSet') : t('smtp.passwordEmpty')}</StatusPill>
       )
     },
+    { title: t('tokens.createdAt'), dataIndex: 'createdAt', width: 190, render: formatDate },
     {
-      title: t('tokens.createdAt'),
-      dataIndex: 'createdAt',
-      width: 190,
-      render: formatDate
-    },
-    {
-      title: t('domains.actions'),
-      fixed: 'right',
-      width: 150,
+      title: t('common.actions'),
+      width: 140,
       render: (_, item) => (
-        <Space>
-          <Button icon={<EditOutlined />} onClick={() => openEditCredential(item)} disabled={!item.id} />
-          <Popconfirm title={t('smtp.deleteConfirm')} onConfirm={() => onDeleteCredential(item)} disabled={!item.id}>
-            <Button danger icon={<DeleteOutlined />} disabled={!item.id} />
+        <Space onClick={(event) => event.stopPropagation()}>
+          <Button aria-label={`${t('common.edit')} ${item.username}`} icon={<EditOutlined />} onClick={() => openEditCredential(item)} disabled={!item.id} />
+          <Popconfirm title={t('smtp.deleteConfirm')} onConfirm={() => void deleteCredential(item)} disabled={!item.id}>
+            <Button aria-label={`${t('common.delete')} ${item.username}`} danger icon={<DeleteOutlined />} loading={actionKey === `credential:delete:${item.id}`} disabled={!item.id} />
           </Popconfirm>
         </Space>
       )
@@ -214,198 +240,174 @@ export default function SmtpCredentials({
     {
       title: t('smtpRelay.name'),
       dataIndex: 'name',
-      width: 190,
-      render: (value, relay) => (
-        <Space wrap>
-          <Typography.Text strong>{value}</Typography.Text>
-          {relay.isDefault ? <StatusPill tone="success">{t('smtpRelay.default')}</StatusPill> : null}
-        </Space>
+      render: (value: string, relay) => (
+        <Space wrap><Typography.Text strong>{value}</Typography.Text>{relay.isDefault ? <StatusPill tone="success">{t('smtpRelay.default')}</StatusPill> : null}</Space>
       )
     },
     {
       title: t('smtpRelay.server'),
-      width: 220,
-      render: (_, relay) => (
-        <Space direction="vertical" size={0}>
-          <Typography.Text code>{relay.host}:{relay.port}</Typography.Text>
-          <Typography.Text type="secondary">{relay.secure ? 'SSL/TLS' : 'STARTTLS / Plain'}</Typography.Text>
-        </Space>
-      )
+      render: (_, relay) => <Typography.Text code>{relay.host}:{relay.port}</Typography.Text>
     },
-    { title: t('smtpRelay.username'), dataIndex: 'username', width: 180, render: (value) => value || '-' },
+    { title: t('smtpRelay.username'), dataIndex: 'username', render: (value?: string) => value || '—' },
     {
       title: t('smtpRelay.password'),
       dataIndex: 'passwordSet',
-      width: 120,
-      render: (value: boolean) => (
-        <StatusPill tone={value ? 'success' : 'neutral'}>
-          {value ? t('smtpRelay.passwordSet') : t('smtpRelay.passwordEmpty')}
-        </StatusPill>
-      )
+      width: 140,
+      render: (value: boolean) => <StatusPill tone={value ? 'success' : 'warning'}>{value ? t('smtpRelay.passwordSet') : t('smtpRelay.passwordEmpty')}</StatusPill>
     },
-    { title: 'HELO', dataIndex: 'helo', width: 180, render: (value) => value || '-' },
     {
-      title: t('domains.actions'),
-      fixed: 'right',
-      width: 150,
+      title: t('common.actions'),
+      width: 140,
       render: (_, relay) => (
-        <Space>
-          <Button icon={<EditOutlined />} onClick={() => openEditRelay(relay)} />
-          <Popconfirm title={t('smtpRelay.deleteConfirm')} onConfirm={() => onDeleteRelay(relay)}>
-            <Button danger icon={<DeleteOutlined />} />
+        <Space onClick={(event) => event.stopPropagation()}>
+          <Button aria-label={`${t('common.edit')} ${relay.name}`} icon={<EditOutlined />} onClick={() => openEditRelay(relay)} />
+          <Popconfirm title={t('smtpRelay.deleteConfirm')} onConfirm={() => void deleteRelay(relay)}>
+            <Button aria-label={`${t('common.delete')} ${relay.name}`} danger icon={<DeleteOutlined />} loading={actionKey === `relay:delete:${relay.id}`} />
           </Popconfirm>
         </Space>
       )
     }
   ];
 
+  const primaryAction = activeTab === 'credentials'
+    ? <Button type="primary" icon={<PlusOutlined />} onClick={openCreateCredential} style={{ minHeight: 44 }}>{t('smtp.create')}</Button>
+    : activeTab === 'relays'
+      ? <Button type="primary" icon={<PlusOutlined />} onClick={openCreateRelay} style={{ minHeight: 44 }}>{t('smtpRelay.create')}</Button>
+      : null;
+
   return (
     <Space direction="vertical" size={20} className="full-width">
-      <PageHeader title={t('nav.smtp')} />
-
-      <SectionCard title={t('smtp.connectionTitle')}>
-        <Descriptions column={1}>
-          <Descriptions.Item label="SMTP Host">{copyable(config?.submission?.host || '-', onCopy)}</Descriptions.Item>
-          <Descriptions.Item label="SMTP Port">
-            <Space wrap size={8}>
-              {(config?.submission?.ports || []).map((item) => (
-                <StatusPill key={item.port} tone="info">
-                  {item.port} · {item.protocol}
-                </StatusPill>
-              ))}
-            </Space>
-          </Descriptions.Item>
-          <Descriptions.Item label="TLS / SSL">{config?.submission?.tls ? 'TLS' : 'STARTTLS'}</Descriptions.Item>
-          <Descriptions.Item label={t('smtp.username')}>{copyable(credential?.username || config?.submission?.username || '-', onCopy)}</Descriptions.Item>
-          <Descriptions.Item label={t('smtp.password')}>
-            {credential?.password ? copyable(credential.password, onCopy) : <Typography.Text type="secondary">{t('smtp.resetToCopy')}</Typography.Text>}
-          </Descriptions.Item>
-        </Descriptions>
-      </SectionCard>
-
-      <SectionCard
-        title={t('smtp.loginCredentialsTitle')}
-        extra={
-          <Button type="primary" icon={<PlusOutlined />} onClick={openCreateCredential}>
-            {t('smtp.create')}
-          </Button>
-        }
-      >
-        <Table
-          rowKey={(item) => item.id || item.username}
-          columns={credentialColumns}
-          dataSource={credentials}
-          scroll={{ x: 980 }}
-          pagination={credentials.length > 10 ? { pageSize: 10 } : false}
+      <PageHeader title={t('nav.smtp')} subtitle={t('smtp.connectionTitle')} extra={primaryAction} />
+      {loadError ? (
+        <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>} />
+      ) : null}
+      <SectionCard>
+        <Tabs
+          activeKey={activeTab}
+          onChange={changeTab}
+          items={[
+            {
+              key: 'credentials',
+              label: t('smtp.loginCredentialsTitle'),
+              children: loading ? <Skeleton active paragraph={{ rows: 5 }} /> : credentials.length ? (
+                <Table
+                  rowKey={(item) => item.id || item.username}
+                  columns={credentialColumns}
+                  dataSource={credentials}
+                  scroll={{ x: 760 }}
+                  onRow={(item) => ({ onClick: () => setDetail({ type: 'credential', value: item }), style: { cursor: 'pointer' } })}
+                />
+              ) : <EmptyState description={t('smtp.passwordUnavailable')} action={<Button icon={<PlusOutlined />} onClick={openCreateCredential}>{t('smtp.create')}</Button>} />
+            },
+            {
+              key: 'relays',
+              label: t('smtpRelay.title'),
+              children: loading ? <Skeleton active paragraph={{ rows: 5 }} /> : relays.length ? (
+                <Table
+                  rowKey="id"
+                  columns={relayColumns}
+                  dataSource={relays}
+                  scroll={{ x: 860 }}
+                  onRow={(relay) => ({ onClick: () => setDetail({ type: 'relay', value: relay }), style: { cursor: 'pointer' } })}
+                />
+              ) : <EmptyState description={t('common.notConfigured')} action={<Button icon={<PlusOutlined />} onClick={openCreateRelay}>{t('smtpRelay.create')}</Button>} />
+            },
+            {
+              key: 'connection',
+              label: t('smtp.connectionTitle'),
+              children: loading ? <Skeleton active /> : (
+                <Descriptions bordered column={1} size="small">
+                  <Descriptions.Item label="SMTP Host">{copyable(config?.submission?.host || '—', copyValue, t('common.copy'))}</Descriptions.Item>
+                  <Descriptions.Item label="SMTP Port">
+                    <Space wrap>{(config?.submission?.ports || []).map((item) => <StatusPill key={item.port} tone="info">{item.port} · {item.protocol}</StatusPill>)}</Space>
+                  </Descriptions.Item>
+                  <Descriptions.Item label="TLS / SSL">{config?.submission?.tls ? 'TLS' : 'STARTTLS'}</Descriptions.Item>
+                  <Descriptions.Item label={t('smtp.username')}>{copyable(primaryCredential?.username || config?.submission?.username || '—', copyValue, t('common.copy'))}</Descriptions.Item>
+                  <Descriptions.Item label={t('smtp.password')}>
+                    <Typography.Text type="secondary">{t('smtp.resetToCopy')}</Typography.Text>
+                  </Descriptions.Item>
+                </Descriptions>
+              )
+            }
+          ]}
         />
       </SectionCard>
 
-      <SectionCard
-        title={t('smtpRelay.title')}
-        extra={
-          <Button type="primary" icon={<PlusOutlined />} onClick={openCreateRelay}>
-            {t('smtpRelay.create')}
-          </Button>
-        }
-      >
-        <Table
-          rowKey="id"
-          columns={relayColumns}
-          dataSource={relays}
-          scroll={{ x: 1040 }}
-          pagination={false}
-        />
-      </SectionCard>
-
-      <Modal
+      <Drawer
         title={editingCredential ? t('smtp.editTitle') : t('smtp.createTitle')}
-        open={credentialOpen}
-        confirmLoading={loading || credentialLoading}
-        onCancel={closeCredentialModal}
-        onOk={saveCredential}
         width={560}
+        open={credentialOpen}
+        onClose={() => setCredentialOpen(false)}
         destroyOnHidden
+        footer={<DrawerFooter t={t} loading={actionKey === 'credential:create' || actionKey.startsWith('credential:save:')} onCancel={() => setCredentialOpen(false)} onSave={() => void saveCredential()} />}
       >
+        <Alert type="info" showIcon message={locale.startsWith('en') ? 'Saved SMTP passwords are never returned. Leave the password blank when editing to keep it unchanged.' : '已保存的 SMTP 密码不会返回。编辑时留空即可保留原密码。'} style={{ marginBottom: 20 }} />
         <Form form={credentialForm} layout="vertical">
-          <Form.Item name="username" label={t('smtp.username')} rules={[{ required: true, message: t('smtp.usernameRequired') }]}>
-            <Input autoComplete="off" />
-          </Form.Item>
-          <Form.Item
-            name="password"
-            label={t('smtp.password')}
-            extra={editingCredential ? t('smtp.passwordExtra') : undefined}
-            rules={[{ required: !editingCredential, message: t('smtp.passwordRequired') }]}
-          >
-            <Input autoComplete="new-password" />
-          </Form.Item>
-          <Form.Item>
-            <Button icon={<ReloadOutlined />} onClick={generateCredentialPassword}>
-              {t('smtp.regenerate')}
-            </Button>
+          <Form.Item name="username" label={t('smtp.username')} rules={[{ required: true, message: t('smtp.usernameRequired') }]}><Input autoComplete="off" /></Form.Item>
+          <Form.Item name="password" label={t('smtp.password')} rules={[{ required: !editingCredential, message: t('smtp.passwordRequired') }]}>
+            <Input.Password autoComplete="new-password" />
           </Form.Item>
+          <Button icon={<ReloadOutlined />} onClick={() => credentialForm.setFieldValue('password', randomPassword())}>{t('smtp.regenerate')}</Button>
         </Form>
-      </Modal>
+      </Drawer>
 
-      <Modal
+      <Drawer
         title={editingRelay ? t('smtpRelay.editTitle') : t('smtpRelay.createTitle')}
+        width={600}
         open={relayOpen}
-        confirmLoading={loading || relayLoading}
-        onCancel={() => setRelayOpen(false)}
-        onOk={saveRelay}
-        width={640}
+        onClose={() => setRelayOpen(false)}
         destroyOnHidden
+        footer={<DrawerFooter t={t} loading={actionKey === 'relay:create' || actionKey.startsWith('relay:save:')} onCancel={() => setRelayOpen(false)} onSave={() => void saveRelay()} />}
       >
+        <Alert type="info" showIcon message={locale.startsWith('en') ? 'Saved relay passwords are write-only. Leave the password blank when editing to keep it unchanged.' : '已保存的中继密码为只写字段。编辑时留空即可保留原密码。'} style={{ marginBottom: 20 }} />
         <Form form={relayForm} layout="vertical">
-          <Form.Item name="name" label={t('smtpRelay.name')} rules={[{ required: true, message: t('smtpRelay.nameRequired') }]}>
-            <Input autoComplete="off" placeholder="Amazon SES" />
-          </Form.Item>
-          <Form.Item name="host" label="SMTP Host" rules={[{ required: true, message: t('smtpRelay.hostRequired') }]}>
-            <Input autoComplete="off" placeholder="email-smtp.us-east-1.amazonaws.com" />
-          </Form.Item>
-          <Form.Item name="port" label="SMTP Port" rules={[{ required: true, message: t('smtpRelay.portRequired') }]}>
-            <InputNumber min={1} max={65535} className="full-width" />
-          </Form.Item>
-          <Form.Item name="secure" label="SSL/TLS" valuePropName="checked">
-            <Switch />
-          </Form.Item>
-          <Form.Item name="username" label={t('smtpRelay.username')}>
-            <Input autoComplete="off" />
-          </Form.Item>
-          <Form.Item name="password" label={t('smtpRelay.password')} extra={t('smtpRelay.passwordExtra')}>
-            <Input autoComplete="new-password" />
-          </Form.Item>
-          <Form.Item>
-            <Button icon={<ReloadOutlined />} onClick={generateRelayPassword}>
-              {t('smtpRelay.generatePassword')}
-            </Button>
-          </Form.Item>
-          <Form.Item name="helo" label="HELO" extra={t('smtpRelay.heloExtra')}>
-            <Input autoComplete="off" placeholder={config?.mailHostname || 'mail.example.com'} />
-          </Form.Item>
-          <Form.Item name="isDefault" label={t('smtpRelay.default')} valuePropName="checked">
-            <Switch />
-          </Form.Item>
+          <Form.Item name="name" label={t('smtpRelay.name')} rules={[{ required: true, message: t('smtpRelay.nameRequired') }]}><Input autoComplete="off" placeholder="Amazon SES" /></Form.Item>
+          <Form.Item name="host" label="SMTP Host" rules={[{ required: true, message: t('smtpRelay.hostRequired') }]}><Input autoComplete="off" placeholder="email-smtp.us-east-1.amazonaws.com" /></Form.Item>
+          <Form.Item name="port" label="SMTP Port" rules={[{ required: true, message: t('smtpRelay.portRequired') }]}><InputNumber min={1} max={65535} className="full-width" /></Form.Item>
+          <Form.Item name="secure" label="SSL/TLS" valuePropName="checked"><Switch /></Form.Item>
+          <Form.Item name="username" label={t('smtpRelay.username')}><Input autoComplete="off" /></Form.Item>
+          <Form.Item name="password" label={t('smtpRelay.password')}><Input.Password autoComplete="new-password" /></Form.Item>
+          <Button icon={<ReloadOutlined />} onClick={() => relayForm.setFieldValue('password', randomPassword())}>{t('smtpRelay.generatePassword')}</Button>
+          <Form.Item name="helo" label="HELO" extra={t('smtpRelay.heloExtra')} style={{ marginTop: 20 }}><Input autoComplete="off" placeholder={config?.mailHostname || 'mail.example.com'} /></Form.Item>
+          <Form.Item name="isDefault" label={t('smtpRelay.default')} valuePropName="checked"><Switch /></Form.Item>
         </Form>
-      </Modal>
+      </Drawer>
+
+      <Drawer title={detail?.type === 'credential' ? detail.value.username : detail?.value.name} width={480} open={Boolean(detail)} onClose={() => setDetail(null)}>
+        {detail?.type === 'credential' ? (
+          <Descriptions bordered column={1} size="small">
+            <Descriptions.Item label={t('smtp.username')}><Typography.Text code>{detail.value.username}</Typography.Text></Descriptions.Item>
+            <Descriptions.Item label={t('smtp.password')}><StatusPill tone={detail.value.passwordSet ? 'success' : 'warning'}>{detail.value.passwordSet ? t('smtp.passwordSet') : t('smtp.passwordEmpty')}</StatusPill></Descriptions.Item>
+            <Descriptions.Item label={t('tokens.createdAt')}>{formatDate(detail.value.createdAt)}</Descriptions.Item>
+          </Descriptions>
+        ) : detail?.type === 'relay' ? (
+          <Descriptions bordered column={1} size="small">
+            <Descriptions.Item label={t('smtpRelay.server')}><Typography.Text code>{detail.value.host}:{detail.value.port}</Typography.Text></Descriptions.Item>
+            <Descriptions.Item label="TLS">{detail.value.secure ? 'SSL/TLS' : 'STARTTLS / Plain'}</Descriptions.Item>
+            <Descriptions.Item label={t('smtpRelay.username')}>{detail.value.username || '—'}</Descriptions.Item>
+            <Descriptions.Item label={t('smtpRelay.password')}><StatusPill tone={detail.value.passwordSet ? 'success' : 'warning'}>{detail.value.passwordSet ? t('smtpRelay.passwordSet') : t('smtpRelay.passwordEmpty')}</StatusPill></Descriptions.Item>
+            <Descriptions.Item label="HELO">{detail.value.helo || '—'}</Descriptions.Item>
+          </Descriptions>
+        ) : null}
+      </Drawer>
     </Space>
   );
 }
 
+function DrawerFooter({ t, loading, onCancel, onSave }: { t: (key: string) => string; loading: boolean; onCancel: () => void; onSave: () => void }) {
+  return <Space style={{ display: 'flex', justifyContent: 'flex-end' }}><Button onClick={onCancel}>{t('common.cancel')}</Button><Button type="primary" loading={loading} onClick={onSave}>{t('common.save')}</Button></Space>;
+}
+
 function randomPassword() {
   const bytes = new Uint8Array(24);
   crypto.getRandomValues(bytes);
   return btoa(String.fromCharCode(...bytes)).replace(/[+/=]/g, '').slice(0, 28);
 }
 
-function copyable(value: string, onCopy: (value: string) => void) {
-  return (
-    <Space>
-      <Typography.Text code className="inline-code-value">{value}</Typography.Text>
-      <Button size="small" icon={<CopyOutlined />} onClick={() => onCopy(value)} />
-    </Space>
-  );
+function copyable(value: string, onCopy: (value: string) => void, label: string) {
+  return <Space><Typography.Text code className="inline-code-value">{value}</Typography.Text><Button aria-label={`${label} ${value}`} icon={<CopyOutlined />} onClick={() => void onCopy(value)} /></Space>;
 }
 
 function formatDate(value?: string) {
-  return value ? new Date(value).toLocaleString() : '-';
+  return value ? new Date(value).toLocaleString() : '—';
 }

+ 234 - 129
src/pages/Webhooks.tsx

@@ -12,12 +12,14 @@ import {
   App as AntApp,
   Button,
   Checkbox,
+  Descriptions,
   Drawer,
   Form,
   Input,
   Modal,
   Popconfirm,
   Select,
+  Skeleton,
   Space,
   Switch,
   Table,
@@ -25,7 +27,8 @@ import {
   Typography
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
 
 import { CodeBlock } from '../components/common/CodeBlock';
 import { EmptyState } from '../components/common/EmptyState';
@@ -34,6 +37,7 @@ import { SectionCard } from '../components/common/SectionCard';
 import { StatusPill } from '../components/common/StatusPill';
 import type { StatusTone } from '../components/common/StatusPill';
 import { useI18n } from '../frontend/i18n/react';
+import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
 import { api } from '../frontend/services/api';
 import type {
   Domain,
@@ -72,47 +76,81 @@ interface SecretReveal {
   mode: 'created' | 'rotated';
 }
 
-export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes = [], onCopy }: WebhooksProps) {
+export default function Webhooks({ domainId: domainIdProp, mailboxId: mailboxIdProp, domains = [], mailboxes = [], onCopy }: WebhooksProps) {
   const { message } = AntApp.useApp();
   const { t } = useI18n();
+  const location = useLocation();
+  const navigate = useNavigate();
+  const [searchParams, setSearchParams] = useSearchParams();
+  const queryDomainId = Number(searchParams.get('domainId') || 0) || undefined;
+  const queryMailboxId = Number(searchParams.get('mailboxId') || 0) || undefined;
+  const requestedWebhookId = positiveInteger(searchParams.get('webhookId'));
+  const deliveryStatus = deliveryStatusFromParam(searchParams.get('deliveryStatus'));
+  const deliveryEvent = deliveryEventFromParam(searchParams.get('deliveryEvent'));
+  const domainId = domainIdProp ?? queryDomainId;
+  const mailboxId = mailboxIdProp ?? queryMailboxId;
+  const embedded = domainIdProp != null || mailboxIdProp != null;
   const [webhooks, setWebhooks] = useState<Webhook[]>([]);
-  const [deliveries, setDeliveries] = useState<WebhookDelivery[]>([]);
-  const [loading, setLoading] = useState(false);
-  const [actionLoading, setActionLoading] = useState(false);
+  const [recentDeliveries, setRecentDeliveries] = useState<WebhookDelivery[]>([]);
+  const [detailDeliveries, setDetailDeliveries] = useState<WebhookDelivery[]>([]);
+  const [detailLoading, setDetailLoading] = useState(false);
+  const [detailError, setDetailError] = useState('');
+  const [availableDomains, setAvailableDomains] = useState<Domain[]>(domains);
+  const [availableMailboxes, setAvailableMailboxes] = useState<InboundMailbox[]>(mailboxes);
+  const [loading, setLoading] = useState(true);
+  const [actionKey, setActionKey] = useState('');
   const [drawerOpen, setDrawerOpen] = useState(false);
   const [editing, setEditing] = useState<Webhook | null>(null);
   const [secretReveal, setSecretReveal] = useState<SecretReveal | null>(null);
   const [form] = Form.useForm<WebhookFormValues>();
-  const [filterWebhookId, setFilterWebhookId] = useState<number | 'all'>('all');
-  const [filterStatus, setFilterStatus] = useState<WebhookDeliveryStatus | 'all'>('all');
-  const [filterEvent, setFilterEvent] = useState<WebhookEvent | 'all'>('all');
+  const detailRequestId = useRef(0);
 
   const mailboxScoped = mailboxId != null;
   const scoped = domainId != null || mailboxScoped;
   const selectableEvents: WebhookEvent[] = mailboxScoped ? ['received'] : DELIVERY_EVENTS;
-  const domainMap = useMemo(() => new Map(domains.map((d) => [d.id, d.domain])), [domains]);
-  const mailboxMap = useMemo(() => new Map(mailboxes.map((m) => [m.id, m.address])), [mailboxes]);
+  const domainMap = useMemo(() => new Map(availableDomains.map((d) => [d.id, d.domain])), [availableDomains]);
+  const mailboxMap = useMemo(() => new Map(availableMailboxes.map((m) => [m.id, m.address])), [availableMailboxes]);
+  const selectedWebhook = useMemo(
+    () => requestedWebhookId ? webhooks.find((webhook) => webhook.id === requestedWebhookId) || null : null,
+    [requestedWebhookId, webhooks]
+  );
 
   const loadData = useCallback(async () => {
     setLoading(true);
     try {
-      const [webhooksResult, deliveriesResult] = await Promise.all([
+      const resourceRequests = !domains.length || !mailboxes.length
+        ? [api.domains(), api.inboundMailboxes()] as const
+        : null;
+      const [webhooksResult, deliveriesResult, resources] = await Promise.all([
         api.webhooks(mailboxScoped ? undefined : (domainId != null ? domainId : undefined), mailboxId),
-        api.webhookDeliveries({ limit: 100 })
+        api.webhookDeliveries({ limit: 100 }),
+        resourceRequests ? Promise.all(resourceRequests) : Promise.resolve(null)
       ]);
+      if (resources) {
+        setAvailableDomains(resources[0].domains || []);
+        setAvailableMailboxes(resources[1].mailboxes || []);
+      }
       const nextWebhooks = (webhooksResult.webhooks || []).filter((webhook) => scoped || webhook.mailboxId == null);
       setWebhooks(nextWebhooks);
       const webhookIds = new Set(nextWebhooks.map((w) => w.id));
       const nextDeliveries = (deliveriesResult.deliveries || []).filter((d) =>
         scoped ? webhookIds.has(d.webhookId) : true
       );
-      setDeliveries(nextDeliveries);
+      setRecentDeliveries(nextDeliveries);
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
     } finally {
       setLoading(false);
     }
-  }, [domainId, mailboxId, mailboxScoped, message, scoped, t]);
+  }, [domainId, domains.length, mailboxId, mailboxScoped, mailboxes.length, message, scoped, t]);
+
+  useEffect(() => {
+    if (domains.length) setAvailableDomains(domains);
+  }, [domains]);
+
+  useEffect(() => {
+    if (mailboxes.length) setAvailableMailboxes(mailboxes);
+  }, [mailboxes]);
 
   useEffect(() => {
     void loadData();
@@ -120,26 +158,46 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
 
   const lastDeliveryByWebhook = useMemo(() => {
     const map = new Map<number, WebhookDelivery>();
-    for (const delivery of deliveries) {
+    for (const delivery of recentDeliveries) {
       if (!map.has(delivery.webhookId)) map.set(delivery.webhookId, delivery);
     }
     return map;
-  }, [deliveries]);
-
-  const filteredDeliveries = useMemo(() => {
-    return deliveries.filter((delivery) => {
-      if (filterWebhookId !== 'all' && delivery.webhookId !== filterWebhookId) return false;
-      if (filterStatus !== 'all' && delivery.status !== filterStatus) return false;
-      if (filterEvent !== 'all' && delivery.eventType !== filterEvent) return false;
-      return true;
-    });
-  }, [deliveries, filterEvent, filterStatus, filterWebhookId]);
+  }, [recentDeliveries]);
+
+  const loadDetailDeliveries = useCallback(async () => {
+    const requestId = ++detailRequestId.current;
+    if (!requestedWebhookId) {
+      setDetailDeliveries([]);
+      setDetailError('');
+      setDetailLoading(false);
+      return;
+    }
 
-  const webhookNameById = useMemo(() => {
-    const map = new Map<number, string>();
-    for (const webhook of webhooks) map.set(webhook.id, webhook.name);
-    return map;
-  }, [webhooks]);
+    setDetailDeliveries([]);
+    setDetailError('');
+    setDetailLoading(true);
+    try {
+      const result = await api.webhookDeliveries({
+        webhookId: requestedWebhookId,
+        status: deliveryStatus === 'all' ? undefined : deliveryStatus,
+        eventType: deliveryEvent === 'all' ? undefined : deliveryEvent,
+        limit: 200
+      });
+      if (requestId === detailRequestId.current) {
+        setDetailDeliveries(result.deliveries || []);
+      }
+    } catch (error) {
+      if (requestId === detailRequestId.current) {
+        setDetailError(error instanceof Error ? error.message : t('common.error'));
+      }
+    } finally {
+      if (requestId === detailRequestId.current) setDetailLoading(false);
+    }
+  }, [deliveryEvent, deliveryStatus, requestedWebhookId, t]);
+
+  useEffect(() => {
+    void loadDetailDeliveries();
+  }, [loadDetailDeliveries]);
 
   async function copyValue(value: string) {
     if (!value) return;
@@ -183,7 +241,8 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
 
   async function submitForm() {
     const values = await form.validateFields();
-    setActionLoading(true);
+    const key = editing ? `save:${editing.id}` : 'create';
+    setActionKey(key);
     try {
       const payload: WebhookPayload = {
         name: values.name.trim(),
@@ -210,12 +269,12 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
     } finally {
-      setActionLoading(false);
+      setActionKey('');
     }
   }
 
   async function toggleEnabled(webhook: Webhook, enabled: boolean) {
-    setActionLoading(true);
+    setActionKey(`toggle:${webhook.id}`);
     try {
       await api.updateWebhook(webhook.id, { enabled });
       setWebhooks((current) =>
@@ -224,26 +283,26 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
     } finally {
-      setActionLoading(false);
+      setActionKey('');
     }
   }
 
   async function deleteWebhook(webhook: Webhook) {
-    setActionLoading(true);
+    setActionKey(`delete:${webhook.id}`);
     try {
       await api.deleteWebhook(webhook.id);
       message.success(t('actions.webhookDeleted'));
-      if (filterWebhookId === webhook.id) setFilterWebhookId('all');
+      if (requestedWebhookId === webhook.id) closeDeliveries();
       await loadData();
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
     } finally {
-      setActionLoading(false);
+      setActionKey('');
     }
   }
 
   async function rotateSecret(webhook: Webhook) {
-    setActionLoading(true);
+    setActionKey(`rotate:${webhook.id}`);
     try {
       const result = await api.rotateWebhookSecret(webhook.id);
       message.success(t('actions.webhookSecretRotated'));
@@ -254,41 +313,74 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
     } finally {
-      setActionLoading(false);
+      setActionKey('');
     }
   }
 
   async function testWebhook(webhook: Webhook) {
-    setActionLoading(true);
+    setActionKey(`test:${webhook.id}`);
     try {
       await api.testWebhook(webhook.id);
       message.success(t('actions.webhookTestQueued'));
-      setFilterWebhookId(webhook.id);
       await loadData();
+      viewDeliveries(webhook);
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
     } finally {
-      setActionLoading(false);
+      setActionKey('');
     }
   }
 
   async function replayDelivery(delivery: WebhookDelivery) {
-    setActionLoading(true);
+    setActionKey(`replay:${delivery.id}`);
     try {
       await api.replayWebhookDelivery(delivery.id);
       message.success(t('actions.webhookDeliveryReplayed'));
-      await loadData();
+      await Promise.all([loadData(), loadDetailDeliveries()]);
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
     } finally {
-      setActionLoading(false);
+      setActionKey('');
     }
   }
 
   function viewDeliveries(webhook: Webhook) {
-    setFilterWebhookId(webhook.id);
-    setFilterStatus('all');
-    setFilterEvent('all');
+    const next = new URLSearchParams(searchParams);
+    next.set('webhookId', String(webhook.id));
+    next.delete('deliveryStatus');
+    next.delete('deliveryEvent');
+    setSearchParams(next, {
+      state: detailHistoryLocation(`${location.pathname}${location.search}`, 1)
+    });
+  }
+
+  function closeDeliveries() {
+    const historyState = detailHistoryState(location.state);
+    if (historyState) {
+      navigate(-historyState.depth);
+      return;
+    }
+    const next = new URLSearchParams(searchParams);
+    next.delete('webhookId');
+    next.delete('deliveryStatus');
+    next.delete('deliveryEvent');
+    setSearchParams(next, { replace: true, state: null });
+  }
+
+  function updateDeliveryFilter(key: 'deliveryStatus' | 'deliveryEvent', value: string) {
+    const next = new URLSearchParams(searchParams);
+    if (value === 'all') next.delete(key);
+    else next.set(key, value);
+    const historyState = detailHistoryState(location.state);
+    if (!historyState) {
+      setSearchParams(next, { replace: true });
+      return;
+    }
+    const search = next.toString();
+    navigate(
+      { pathname: location.pathname, search: search ? `?${search}` : '' },
+      { state: detailHistoryLocation(historyState.listPath, historyState.depth + 1) }
+    );
   }
 
   function scopeLabel(webhook: Webhook) {
@@ -390,7 +482,7 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
       render: (enabled: boolean, webhook) => (
         <Switch
           checked={enabled}
-          loading={actionLoading}
+          loading={actionKey === `toggle:${webhook.id}`}
           onChange={(checked) => void toggleEnabled(webhook, checked)}
           checkedChildren={t('webhooks.enabled')}
           unCheckedChildren={t('webhooks.disabled')}
@@ -407,7 +499,7 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
       width: 280,
       render: (_, webhook) => (
         <Space wrap size={4}>
-          <Button size="small" icon={<ThunderboltOutlined />} onClick={() => void testWebhook(webhook)}>
+          <Button size="small" icon={<ThunderboltOutlined />} loading={actionKey === `test:${webhook.id}`} onClick={() => void testWebhook(webhook)}>
             {t('webhooks.test')}
           </Button>
           <Button size="small" onClick={() => viewDeliveries(webhook)}>
@@ -415,10 +507,10 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
           </Button>
           <Button size="small" icon={<EditOutlined />} onClick={() => openEdit(webhook)} />
           <Popconfirm title={t('webhooks.rotateConfirm')} onConfirm={() => void rotateSecret(webhook)}>
-            <Button size="small" icon={<KeyOutlined />} />
+            <Button aria-label={t('webhooks.rotateSecret')} size="small" icon={<KeyOutlined />} loading={actionKey === `rotate:${webhook.id}`} />
           </Popconfirm>
           <Popconfirm title={t('webhooks.deleteConfirm')} onConfirm={() => void deleteWebhook(webhook)}>
-            <Button size="small" danger icon={<DeleteOutlined />} />
+            <Button aria-label={t('common.delete')} size="small" danger icon={<DeleteOutlined />} loading={actionKey === `delete:${webhook.id}`} />
           </Popconfirm>
         </Space>
       )
@@ -432,11 +524,6 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
       width: 170,
       render: (value: string) => (value ? new Date(value).toLocaleString() : '—')
     },
-    {
-      title: t('webhooks.name'),
-      dataIndex: 'webhookId',
-      render: (id: number) => webhookNameById.get(id) || `#${id}`
-    },
     {
       title: t('webhooks.events'),
       dataIndex: 'eventType',
@@ -480,6 +567,7 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
         <Button
           size="small"
           disabled={delivery.status === 'processing'}
+          loading={actionKey === `replay:${delivery.id}`}
           onClick={() => void replayDelivery(delivery)}
         >
           {t('webhooks.replay')}
@@ -493,7 +581,7 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
 
   return (
     <Space direction="vertical" size={20} className="full-width">
-      {!scoped ? (
+      {!embedded ? (
         <PageHeader
           title={t('webhooks.title')}
           subtitle={t('webhooks.subtitle')}
@@ -522,8 +610,8 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
         </Space>
       )}
 
-      {!scoped ? (
-        <Alert type="info" showIcon message={t('webhooks.domainOverrideHelp')} />
+      {!embedded ? (
+        <Alert type="info" showIcon message={mailboxScoped ? t('webhooks.mailboxReceiptHelp') : t('webhooks.domainOverrideHelp')} />
       ) : null}
 
       <SectionCard
@@ -543,7 +631,7 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
           <EmptyState
             description={t('webhooks.empty')}
             action={
-              <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
+              <Button icon={<PlusOutlined />} onClick={openCreate}>
                 {t('webhooks.create')}
               </Button>
             }
@@ -551,67 +639,6 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
         )}
       </SectionCard>
 
-      <SectionCard title={t('webhooks.deliveriesTitle')}>
-        <Space wrap className="full-width" style={{ marginBottom: 16 }}>
-          <Select
-            style={{ minWidth: 180 }}
-            value={filterWebhookId}
-            onChange={setFilterWebhookId}
-            options={[
-              { value: 'all', label: t('webhooks.allWebhooks') },
-              ...webhooks.map((w) => ({ value: w.id, label: w.name }))
-            ]}
-            placeholder={t('webhooks.deliveriesFilterWebhook')}
-          />
-          <Select
-            style={{ minWidth: 140 }}
-            value={filterStatus}
-            onChange={setFilterStatus}
-            options={[
-              { value: 'all', label: t('webhooks.allStatuses') },
-              ...DELIVERY_STATUSES.map((status) => ({
-                value: status,
-                label: deliveryStatusLabel(status)
-              }))
-            ]}
-            placeholder={t('webhooks.deliveriesFilterStatus')}
-          />
-          <Select
-            style={{ minWidth: 140 }}
-            value={filterEvent}
-            onChange={setFilterEvent}
-            options={[
-              { value: 'all', label: t('webhooks.allEvents') },
-              ...ALL_EVENTS.map((event) => ({ value: event, label: eventLabel(event) }))
-            ]}
-            placeholder={t('webhooks.deliveriesFilterEvent')}
-          />
-        </Space>
-        {filteredDeliveries.length ? (
-          <Table
-            rowKey="id"
-            columns={deliveryColumns}
-            dataSource={filteredDeliveries}
-            loading={loading}
-            scroll={{ x: 1100 }}
-            pagination={{ pageSize: 10 }}
-          />
-        ) : (
-          <EmptyState description={t('webhooks.deliveriesEmpty')} />
-        )}
-      </SectionCard>
-
-      {!scoped ? (
-        <SectionCard title={t('webhooks.docsTitle')}>
-          <Space direction="vertical" size={12} className="full-width">
-            <Typography.Text>{t('webhooks.docsSignature')}</Typography.Text>
-            <Typography.Text>{t('webhooks.docsEvents')}</Typography.Text>
-            <Typography.Text type="secondary">{t('webhooks.urlHint')}</Typography.Text>
-            <CodeBlock value={buildSignatureSample('whsec_your_secret')} onCopy={copyValue} />
-          </Space>
-        </SectionCard>
-      ) : null}
-
       <Drawer
         title={editing ? t('webhooks.editTitle') : t('webhooks.createTitle')}
         width={520}
@@ -621,7 +648,7 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
         footer={
           <div className="drawer-footer">
             <Button onClick={closeDrawer}>{t('common.cancel')}</Button>
-            <Button type="primary" loading={actionLoading} onClick={() => void submitForm()}>
+            <Button type="primary" loading={actionKey === 'create' || actionKey.startsWith('save:')} onClick={() => void submitForm()}>
               {editing ? t('common.save') : t('webhooks.create')}
             </Button>
           </div>
@@ -661,7 +688,7 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
               <Select
                 allowClear
                 placeholder={t('webhooks.domainAccount')}
-                options={domains.map((domain) => ({
+                options={availableDomains.map((domain) => ({
                   value: domain.id,
                   label: domain.domain
                 }))}
@@ -674,6 +701,67 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
         </Form>
       </Drawer>
 
+      <Drawer
+        title={selectedWebhook ? `${selectedWebhook.name} · ${t('webhooks.deliveriesTitle')}` : t('webhooks.deliveriesTitle')}
+        width={760}
+        open={Boolean(requestedWebhookId)}
+        onClose={closeDeliveries}
+      >
+        {loading && !selectedWebhook ? (
+          <div role="status" aria-label={t('webhooks.deliveriesTitle')}>
+            <Skeleton active paragraph={{ rows: 8 }} />
+          </div>
+        ) : selectedWebhook ? (
+          <Space direction="vertical" size={20} className="full-width">
+            <Descriptions bordered column={1} size="small">
+              <Descriptions.Item label={t('webhooks.url')}>
+                <Typography.Text code copyable={{ onCopy: () => void copyValue(selectedWebhook.url) }}>{selectedWebhook.url}</Typography.Text>
+              </Descriptions.Item>
+              <Descriptions.Item label={t('webhooks.scope')}>{scopeLabel(selectedWebhook)}</Descriptions.Item>
+              <Descriptions.Item label={t('webhooks.events')}>
+                <Space wrap>{selectedWebhook.events.map((event) => <Tag key={event}>{eventLabel(event)}</Tag>)}</Space>
+              </Descriptions.Item>
+              <Descriptions.Item label={t('webhooks.secretPrefix')}><Typography.Text code>{selectedWebhook.secretPrefix}…</Typography.Text></Descriptions.Item>
+              <Descriptions.Item label={t('common.status')}>
+                <StatusPill tone={selectedWebhook.enabled ? 'success' : 'neutral'}>{selectedWebhook.enabled ? t('webhooks.enabled') : t('webhooks.disabled')}</StatusPill>
+              </Descriptions.Item>
+            </Descriptions>
+            <Space wrap>
+              <Select
+                aria-label={t('webhooks.deliveriesFilterStatus')}
+                style={{ minWidth: 160 }}
+                value={deliveryStatus}
+                onChange={(value) => updateDeliveryFilter('deliveryStatus', value)}
+                options={[{ value: 'all', label: t('webhooks.allStatuses') }, ...DELIVERY_STATUSES.map((status) => ({ value: status, label: deliveryStatusLabel(status) }))]}
+              />
+              <Select
+                aria-label={t('webhooks.deliveriesFilterEvent')}
+                style={{ minWidth: 160 }}
+                value={deliveryEvent}
+                onChange={(value) => updateDeliveryFilter('deliveryEvent', value)}
+                options={[{ value: 'all', label: t('webhooks.allEvents') }, ...ALL_EVENTS.map((event) => ({ value: event, label: eventLabel(event) }))]}
+              />
+            </Space>
+            {detailError ? (
+              <Alert
+                type="error"
+                showIcon
+                message={detailError}
+                action={<Button icon={<ReloadOutlined />} onClick={() => void loadDetailDeliveries()}>{t('common.refresh')}</Button>}
+              />
+            ) : detailLoading && !detailDeliveries.length ? (
+              <div role="status" aria-label={t('webhooks.deliveriesTitle')}>
+                <Skeleton active paragraph={{ rows: 6 }} />
+              </div>
+            ) : detailDeliveries.length ? (
+              <Table rowKey="id" columns={deliveryColumns} dataSource={detailDeliveries} loading={detailLoading} scroll={{ x: 920 }} pagination={{ pageSize: 10 }} />
+            ) : <EmptyState description={t('webhooks.deliveriesEmpty')} />}
+            <Alert type="info" showIcon message={t('webhooks.docsSignature')} description={t('webhooks.docsEvents')} />
+            <CodeBlock value={buildSignatureSample('whsec_your_secret')} onCopy={copyValue} />
+          </Space>
+        ) : requestedWebhookId ? <EmptyState description={t('common.notFound')} /> : null}
+      </Drawer>
+
       <Modal
         title={
           secretReveal?.mode === 'rotated'
@@ -681,18 +769,20 @@ export default function Webhooks({ domainId, mailboxId, domains = [], mailboxes
             : t('webhooks.secretCreatedTitle')
         }
         open={Boolean(secretReveal)}
-        onCancel={() => setSecretReveal(null)}
+        closable={false}
+        maskClosable={false}
+        keyboard={false}
+        destroyOnHidden
         footer={[
-          <Button key="close" onClick={() => setSecretReveal(null)}>
-            {t('common.cancel')}
-          </Button>,
           <Button
             key="copy"
-            type="primary"
             icon={<CopyOutlined />}
             onClick={() => void copyValue(secret)}
           >
             {t('webhooks.copySecret')}
+          </Button>,
+          <Button key="done" type="primary" onClick={() => setSecretReveal(null)}>
+            {t('common.confirm')}
           </Button>
         ]}
       >
@@ -718,6 +808,21 @@ function truncateUrl(url: string, max = 48) {
   return `${url.slice(0, max - 1)}…`;
 }
 
+function positiveInteger(value: string | null) {
+  const parsed = Number(value);
+  return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
+}
+
+function deliveryStatusFromParam(value: string | null): WebhookDeliveryStatus | 'all' {
+  return DELIVERY_STATUSES.includes(value as WebhookDeliveryStatus)
+    ? value as WebhookDeliveryStatus
+    : 'all';
+}
+
+function deliveryEventFromParam(value: string | null): WebhookEvent | 'all' {
+  return ALL_EVENTS.includes(value as WebhookEvent) ? value as WebhookEvent : 'all';
+}
+
 function buildSignatureSample(secret: string) {
   return `// Verify X-MailHub-Signature (Node.js)
 const crypto = require('crypto');

+ 149 - 21
src/server.js

@@ -46,8 +46,9 @@ import {
   listDnsCredentials,
   listDomains,
   listInboundMailboxes,
-  listInboundMessages,
-  listSendEvents,
+  listInboundMailboxFolders,
+  searchInboundMessages,
+  searchSendEvents,
   listSmtpCredentials,
   listSmtpRelays,
   listUsersWithResourceCounts,
@@ -269,13 +270,16 @@ const server = http.createServer(async (req, res) => {
     }
 
     if (isLoginAsset(url.pathname)) {
-      if ((url.pathname === '/login' || url.pathname === '/register') && user) return redirect(res, '/');
+      if ((url.pathname === '/login' || url.pathname === '/register') && user) {
+        return redirect(res, normalizeInternalPath(url.searchParams.get('next')) || '/');
+      }
       return await serveStatic(req, res, url);
     }
 
     if (!user) {
       if (url.pathname.startsWith('/api/')) return sendJson(res, 401, { error: 'Authentication required.' });
-      return redirect(res, '/login');
+      const next = normalizeInternalPath(`${url.pathname}${url.search}`) || '/';
+      return redirect(res, `/login?next=${encodeURIComponent(next)}`);
     }
 
     if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url, user);
@@ -383,7 +387,11 @@ async function handleApi(req, res, url, user) {
     }
   }
   if (method === 'GET' && pathname === '/api/events') {
-    return sendJson(res, 200, { events: listSendEvents(user.id) });
+    try {
+      return sendJson(res, 200, searchSendEvents(user.id, sendEventListFilters(url.searchParams)));
+    } catch (error) {
+      return sendJson(res, 400, { error: error.message || '发送记录查询参数不正确。' });
+    }
   }
   if (method === 'GET' && pathname === '/api/inbound-mailboxes') {
     return sendJson(res, 200, { mailboxes: listInboundMailboxes(user.id) });
@@ -421,11 +429,16 @@ async function handleApi(req, res, url, user) {
     }
   }
   if (method === 'GET' && pathname === '/api/inbound-messages') {
-    return sendJson(res, 200, {
-      messages: listInboundMessages(user.id, {
-        mailboxId: Number(url.searchParams.get('mailboxId') || 0) || null
-      })
-    });
+    try {
+      return sendJson(res, 200, searchInboundMessages(user.id, inboundMessageListFilters(url.searchParams)));
+    } catch (error) {
+      return sendJson(res, 400, { error: error.message || '收件查询参数不正确。' });
+    }
+  }
+  const inboundMailboxFoldersMatch = pathname.match(/^\/api\/inbound-mailboxes\/(\d+)\/folders$/);
+  if (inboundMailboxFoldersMatch && method === 'GET') {
+    const folders = listInboundMailboxFolders(user.id, Number(inboundMailboxFoldersMatch[1]));
+    return sendJson(res, folders ? 200 : 404, { folders: folders || [] });
   }
   const inboundMessageMatch = pathname.match(/^\/api\/inbound-messages\/(\d+)$/);
   if (inboundMessageMatch) {
@@ -454,7 +467,7 @@ async function handleApi(req, res, url, user) {
     });
   }
   if (method === 'GET' && pathname === '/api/smtp-credential') {
-    return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
+    return sendJson(res, 200, { credential: getSmtpCredential(user.id) });
   }
   if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && pathname === '/api/smtp-credential') {
     const body = await readJson(req);
@@ -469,10 +482,10 @@ async function handleApi(req, res, url, user) {
       if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
       throw error;
     }
-    return sendJson(res, 200, { credential: getSmtpCredential(user.id, { includePassword: true }) });
+    return sendJson(res, 200, { credential: getSmtpCredential(user.id) });
   }
   if (method === 'GET' && pathname === '/api/smtp-credentials') {
-    return sendJson(res, 200, { credentials: listSmtpCredentials(user.id, { includePassword: true }) });
+    return sendJson(res, 200, { credentials: listSmtpCredentials(user.id) });
   }
   if (method === 'POST' && pathname === '/api/smtp-credentials') {
     const body = await readJson(req);
@@ -481,7 +494,7 @@ async function handleApi(req, res, url, user) {
         username: String(body.username || '').trim(),
         password: String(body.password || '')
       });
-      return sendJson(res, 201, { credential: getSmtpCredential(credential.id, user.id, { includePassword: true }) });
+      return sendJson(res, 201, { credential: getSmtpCredential(credential.id, user.id) });
     } catch (error) {
       if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
       throw error;
@@ -491,7 +504,7 @@ async function handleApi(req, res, url, user) {
   if (smtpCredentialMatch) {
     const id = Number(smtpCredentialMatch[1]);
     if (method === 'GET') {
-      const credential = getSmtpCredential(id, user.id, { includePassword: true });
+      const credential = getSmtpCredential(id, user.id);
       return sendJson(res, credential ? 200 : 404, { credential });
     }
     if (method === 'PATCH' || method === 'PUT') {
@@ -503,7 +516,7 @@ async function handleApi(req, res, url, user) {
           password: String(body.password || '')
         });
         return sendJson(res, credential ? 200 : 404, {
-          credential: credential ? getSmtpCredential(credential.id, user.id, { includePassword: true }) : null
+          credential: credential ? getSmtpCredential(credential.id, user.id) : null
         });
       } catch (error) {
         if (isUniqueError(error)) return sendJson(res, 409, { error: 'SMTP 用户名已被占用。' });
@@ -527,7 +540,7 @@ async function handleApi(req, res, url, user) {
   if (smtpRelayMatch) {
     const id = Number(smtpRelayMatch[1]);
     if (method === 'GET') {
-      const relay = getSmtpRelay(id, user.id, { includePassword: true });
+      const relay = getSmtpRelay(id, user.id);
       return sendJson(res, relay ? 200 : 404, { relay });
     }
     if (method === 'PATCH' || method === 'PUT') {
@@ -1100,6 +1113,93 @@ async function handleAdminApi(req, res, url, user) {
   return sendJson(res, 404, { error: 'Not found.' });
 }
 
+function sendEventListFilters(searchParams) {
+  const page = boundedIntegerParam(searchParams, 'page', { fallback: 1, min: 1, max: 1_000_000 });
+  const pageSize = boundedIntegerParam(searchParams, 'pageSize', { fallback: 30, min: 1, max: 100 });
+  const domainId = optionalPositiveIntegerParam(searchParams, 'domainId');
+  const status = optionalTextParam(searchParams, 'status', { maxLength: 32 });
+  if (status && !/^[a-z][a-z0-9_-]*$/i.test(status)) throw new Error('status 参数不正确。');
+  const from = optionalDateParam(searchParams, 'from', { endOfDay: false });
+  const to = optionalDateParam(searchParams, 'to', { endOfDay: true });
+  if (from && to && from > to) throw new Error('from 不能晚于 to。');
+  return {
+    page,
+    pageSize,
+    status,
+    domainId,
+    from,
+    to,
+    recipient: optionalTextParam(searchParams, 'recipient', { maxLength: 320 }),
+    q: optionalTextParam(searchParams, 'q', { maxLength: 500 })
+  };
+}
+
+function inboundMessageListFilters(searchParams) {
+  const read = optionalBooleanParam(searchParams, 'read');
+  const folder = searchParams.has('folder')
+    ? optionalTextParam(searchParams, 'folder', { maxLength: 255, required: true })
+    : 'INBOX';
+  return {
+    page: boundedIntegerParam(searchParams, 'page', { fallback: 1, min: 1, max: 1_000_000 }),
+    pageSize: boundedIntegerParam(searchParams, 'pageSize', { fallback: 30, min: 1, max: 100 }),
+    mailboxId: optionalPositiveIntegerParam(searchParams, 'mailboxId'),
+    folder,
+    read,
+    q: optionalTextParam(searchParams, 'q', { maxLength: 500 })
+  };
+}
+
+function boundedIntegerParam(searchParams, name, { fallback, min, max }) {
+  if (!searchParams.has(name)) return fallback;
+  const raw = String(searchParams.get(name) || '').trim();
+  if (!/^\d+$/.test(raw)) throw new Error(`${name} 参数必须是整数。`);
+  const value = Number(raw);
+  if (!Number.isSafeInteger(value) || value < min || value > max) {
+    throw new Error(`${name} 参数必须在 ${min} 到 ${max} 之间。`);
+  }
+  return value;
+}
+
+function optionalPositiveIntegerParam(searchParams, name) {
+  if (!searchParams.has(name)) return null;
+  return boundedIntegerParam(searchParams, name, { fallback: null, min: 1, max: Number.MAX_SAFE_INTEGER });
+}
+
+function optionalBooleanParam(searchParams, name) {
+  if (!searchParams.has(name)) return undefined;
+  const value = String(searchParams.get(name) || '').trim().toLowerCase();
+  if (value === 'true') return true;
+  if (value === 'false') return false;
+  throw new Error(`${name} 参数必须是 true 或 false。`);
+}
+
+function optionalTextParam(searchParams, name, { maxLength, required = false }) {
+  if (!searchParams.has(name)) return undefined;
+  const value = String(searchParams.get(name) || '').trim();
+  if (!value) {
+    if (required) throw new Error(`${name} 参数不能为空。`);
+    return undefined;
+  }
+  if (value.length > maxLength || /[\u0000-\u001f\u007f]/.test(value)) {
+    throw new Error(`${name} 参数不正确。`);
+  }
+  return value;
+}
+
+function optionalDateParam(searchParams, name, { endOfDay }) {
+  if (!searchParams.has(name)) return undefined;
+  const value = optionalTextParam(searchParams, name, { maxLength: 64, required: true });
+  const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value);
+  const timestamp = Date.parse(value);
+  if (!Number.isFinite(timestamp)) throw new Error(`${name} 参数必须是有效日期。`);
+  if (dateOnly) {
+    const date = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}Z`);
+    if (date.toISOString().slice(0, 10) !== value) throw new Error(`${name} 参数必须是有效日期。`);
+    return date.toISOString();
+  }
+  return new Date(timestamp).toISOString();
+}
+
 function adminAuditFilters(searchParams) {
   const requested = {
     actorUserId: auditUserIdParam(searchParams.get('actorUserId'), { allowSystem: true }),
@@ -1537,7 +1637,7 @@ async function handleLogin(req, res) {
   const user = verifyUserCredentials(body.username || body.email, body.password);
   if (!user) return sendAuthError(req, res, 401, '账号或密码不正确。', '/login');
   if (user.status !== 'active') return sendAuthError(req, res, 403, loginStatusMessage(user.status), '/login');
-  return sendAuthSuccess(req, res, 200, user);
+  return sendAuthSuccess(req, res, 200, user, normalizeInternalPath(body.next) || '/');
 }
 
 function sendRegisterSuccess(req, res, user, emailResult = { ok: false }) {
@@ -1552,15 +1652,15 @@ function sendRegisterSuccess(req, res, user, emailResult = { ok: false }) {
   });
 }
 
-function sendAuthSuccess(req, res, status, user) {
+function sendAuthSuccess(req, res, status, user, redirectTo = '/') {
   const token = createSessionToken(user);
   const cookie = sessionCookie(token);
-  if (wantsHtmlRedirect(req)) return redirect(res, '/', 303, { 'Set-Cookie': cookie });
+  if (wantsHtmlRedirect(req)) return redirect(res, redirectTo, 303, { 'Set-Cookie': cookie });
   res.writeHead(status, {
     'Content-Type': 'application/json; charset=utf-8',
     'Set-Cookie': cookie
   });
-  res.end(JSON.stringify({ user }));
+  res.end(JSON.stringify({ user, redirectTo }));
 }
 
 function sendAuthError(req, res, status, message, fallbackPath) {
@@ -2196,6 +2296,34 @@ function resolveStaticPathname(pathname) {
   return pathname;
 }
 
+export function normalizeInternalPath(value) {
+  const candidate = String(value || '').trim();
+  if (!candidate || !candidate.startsWith('/') || candidate.startsWith('//')) return '';
+  if (candidate.includes('\\') || /[\u0000-\u001f\u007f]/.test(candidate)) return '';
+  let parsed;
+  try {
+    parsed = new URL(candidate, 'http://mailhub.internal');
+  } catch {
+    return '';
+  }
+  if (parsed.origin !== 'http://mailhub.internal') return '';
+  let decoded;
+  try {
+    decoded = decodeURIComponent(`${parsed.pathname}${parsed.search}`);
+  } catch {
+    return '';
+  }
+  if (decoded.includes('\\') || /[\u0000-\u001f\u007f]/.test(decoded)) return '';
+  if (decoded.startsWith('//')) return '';
+  if (
+    ['/login', '/register', '/forgot-password', '/resend-verification', '/reset-password', '/landing.html', '/healthz'].includes(parsed.pathname)
+    || ['/api', '/assets', '/t'].some((prefix) => parsed.pathname === prefix || parsed.pathname.startsWith(`${prefix}/`))
+  ) {
+    return '';
+  }
+  return `${parsed.pathname}${parsed.search}`;
+}
+
 function loadDotEnv() {
   const file = path.join(process.cwd(), '.env');
   if (!existsSync(file)) return;

+ 3 - 0
test/db.test.js

@@ -189,6 +189,9 @@ test('migrates legacy send events with engagement tracking disabled', () => {
   assert.equal(columns.includes('tracking_token_hash'), true);
   assert.equal(columns.includes('tracking_opens'), true);
   assert.equal(columns.includes('tracking_clicks'), true);
+  assert.equal(columns.includes('message_id'), false);
+  const indexes = database.prepare("PRAGMA index_list('send_events')").all().map((index) => index.name);
+  assert.equal(indexes.includes('idx_events_message_id'), false);
   const tables = database
     .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('tracking_links', 'tracking_events') ORDER BY name")
     .all()

+ 25 - 2
test/frontend-auth-model.test.js

@@ -1,7 +1,7 @@
 import assert from 'node:assert/strict';
 import { test } from 'node:test';
 
-import { authModeFromLocation, nextAuthSuccessState } from '../src/frontend/auth/auth-model.js';
+import { authModeFromLocation, nextAuthSuccessState, safeInternalPath } from '../src/frontend/auth/auth-model.js';
 
 test('registration success returns to login without navigating to the protected app', () => {
   assert.deepEqual(
@@ -27,11 +27,34 @@ test('login success still redirects to the protected app', () => {
       mode: 'login',
       path: '/login',
       message: '',
-      redirectTo: '/'
+      redirectTo: '/overview'
     }
   );
 });
 
+test('login success returns to a safe internal deep link', () => {
+  assert.equal(
+    nextAuthSuccessState('/api/login', {}, '/activity?status=failed#event-12').redirectTo,
+    '/activity?status=failed#event-12'
+  );
+});
+
+test('rejects external and ambiguous auth next destinations', () => {
+  for (const value of [
+    'https://example.com',
+    '//example.com/path',
+    '/\\example.com',
+    '/login',
+    '/register?next=/overview',
+    '/api/events',
+    'javascript:alert(1)',
+    '/overview\nLocation:https://example.com'
+  ]) {
+    assert.equal(safeInternalPath(value, '/overview'), '/overview');
+  }
+  assert.equal(safeInternalPath('/domains/12/dns?tab=records'), '/domains/12/dns?tab=records');
+});
+
 test('detects account recovery modes from auth routes', () => {
   assert.deepEqual(authModeFromLocation('/forgot-password'), { mode: 'forgot', token: '' });
   assert.deepEqual(authModeFromLocation('/resend-verification'), { mode: 'resend', token: '' });

+ 4 - 0
test/frontend-i18n.test.js

@@ -53,6 +53,10 @@ test('nav group chrome strings exist', () => {
   assert.equal(en('nav.group.overview'), 'Overview');
   assert.equal(en('nav.group.delivery'), 'Delivery');
   assert.equal(en('nav.group.system'), 'System');
+  assert.equal(zh('nav.group.operations'), '运行中心');
+  assert.equal(zh('nav.group.integrations'), '接入与集成');
+  assert.equal(en('nav.activity'), 'Sending activity');
+  assert.equal(en('nav.apiKeys'), 'API keys');
 });
 
 test('translates admin panel labels', () => {

+ 1 - 0
test/frontend-theme.test.js

@@ -12,4 +12,5 @@ test('layout canvas and ink tokens match redesign spec', () => {
   assert.equal(brandColors.canvas, '#F4F6FB');
   assert.equal(brandColors.ink, '#0F172A');
   assert.equal(mailhubTheme.token.colorBgLayout, brandColors.canvas);
+  assert.equal(mailhubTheme.token.controlHeight, 44);
 });

+ 18 - 6
test/server-admin-api.test.js

@@ -118,14 +118,18 @@ test('users can manage multiple smtp login credentials', async () => {
     });
 
     assert.equal(first.username, 'admin-smtp-main');
-    assert.equal(first.password, 'main-secret');
+    assert.equal(first.passwordSet, true);
+    assert.equal('password' in first, false);
     assert.equal(second.username, 'admin-smtp-app');
+    assert.equal(second.passwordSet, true);
+    assert.equal('password' in second, false);
 
     const list = await fetch(`${baseUrl}/api/smtp-credentials`, { headers: { Cookie: cookie } });
     assert.equal(list.status, 200);
     const listPayload = await list.json();
     assert.deepEqual(listPayload.credentials.map((credential) => credential.username), ['admin-smtp-app', 'admin-smtp-main']);
-    assert.equal(listPayload.credentials[0].password, 'app-secret');
+    assert.equal(listPayload.credentials[0].passwordSet, true);
+    assert.equal('password' in listPayload.credentials[0], false);
 
     const update = await fetch(`${baseUrl}/api/smtp-credentials/${second.id}`, {
       method: 'PATCH',
@@ -136,7 +140,9 @@ test('users can manage multiple smtp login credentials', async () => {
       body: JSON.stringify({ username: 'admin-smtp-app-renamed' })
     });
     assert.equal(update.status, 200);
-    assert.equal((await update.json()).credential.password, 'app-secret');
+    const updatedCredential = (await update.json()).credential;
+    assert.equal(updatedCredential.passwordSet, true);
+    assert.equal('password' in updatedCredential, false);
 
     const deleted = await fetch(`${baseUrl}/api/smtp-credentials/${first.id}`, {
       method: 'DELETE',
@@ -371,7 +377,8 @@ test('users can manage outbound smtp relays with recoverable passwords and send
     const detail = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
     assert.equal(detail.status, 200);
     const detailBody = await detail.json();
-    assert.equal(detailBody.relay.password, 'relay-password');
+    assert.equal(detailBody.relay.passwordSet, true);
+    assert.equal('password' in detailBody.relay, false);
     assert.equal('passwordSecret' in detailBody.relay, false);
 
     const missingPatch = await fetch(`${baseUrl}/api/smtp-relays/999999`, {
@@ -406,9 +413,14 @@ test('users can manage outbound smtp relays with recoverable passwords and send
       })
     });
     assert.equal(updateWithoutPassword.status, 200);
-    assert.equal((await updateWithoutPassword.json()).relay.isDefault, true);
+    const updatedRelay = (await updateWithoutPassword.json()).relay;
+    assert.equal(updatedRelay.isDefault, true);
+    assert.equal(updatedRelay.passwordSet, true);
+    assert.equal('password' in updatedRelay, false);
     const detailAfterPatch = await fetch(`${baseUrl}/api/smtp-relays/${created.relay.id}`, { headers: { Cookie: cookie } });
-    assert.equal((await detailAfterPatch.json()).relay.password, 'relay-password');
+    const detailAfterPatchBody = await detailAfterPatch.json();
+    assert.equal(detailAfterPatchBody.relay.passwordSet, true);
+    assert.equal('password' in detailAfterPatchBody.relay, false);
 
     const invalidRelaySend = await fetch(`${baseUrl}/api/send`, {
       method: 'POST',

+ 564 - 0
test/server-listing-api.test.js

@@ -0,0 +1,564 @@
+import assert from 'node:assert/strict';
+import { spawn, spawnSync } from 'node:child_process';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import net from 'node:net';
+import path from 'node:path';
+import { test } from 'node:test';
+
+test('listing APIs paginate, filter, isolate users, and expose folder counts', async () => {
+  const fixture = await startTestServer();
+  try {
+    const seeded = seedListingFixtures(fixture.dataDir, fixture.sessionSecret);
+    const aliceCookie = await login(fixture.baseUrl, 'list-alice', 'password123');
+    const bobCookie = await login(fixture.baseUrl, 'list-bob', 'password123');
+
+    const defaultEvents = await getJson(fixture.baseUrl, '/api/events', aliceCookie);
+    assert.equal(defaultEvents.status, 200);
+    assert.equal(defaultEvents.body.total, 5);
+    assert.equal(defaultEvents.body.page, 1);
+    assert.equal(defaultEvents.body.pageSize, 30);
+    assert.equal(defaultEvents.body.events.length, 5);
+
+    const firstPage = await getJson(fixture.baseUrl, '/api/events?page=1&pageSize=2', aliceCookie);
+    const secondPage = await getJson(fixture.baseUrl, '/api/events?page=2&pageSize=2', aliceCookie);
+    assert.equal(firstPage.body.total, 5);
+    assert.equal(firstPage.body.events.length, 2);
+    assert.equal(secondPage.body.events.length, 2);
+    assert.equal(new Set([...firstPage.body.events, ...secondPage.body.events].map((event) => event.id)).size, 4);
+
+    assert.equal((await getJson(fixture.baseUrl, '/api/events?status=failed', aliceCookie)).body.total, 1);
+    assert.equal(
+      (await getJson(fixture.baseUrl, `/api/events?domainId=${seeded.aliceSecondaryDomainId}`, aliceCookie)).body.total,
+      1
+    );
+    assert.equal(
+      (await getJson(fixture.baseUrl, '/api/events?recipient=target%2Bfilter%40example.net', aliceCookie)).body.total,
+      1
+    );
+    const byQueue = await getJson(fixture.baseUrl, '/api/events?q=QUEUE-ALICE-FAILED', aliceCookie);
+    assert.equal(byQueue.body.total, 1);
+    assert.equal(byQueue.body.events[0].status, 'failed');
+    const byMessageId = await getJson(fixture.baseUrl, `/api/events?q=mh-${seeded.specialEventId}`, aliceCookie);
+    assert.equal(byMessageId.body.total, 1);
+    assert.equal(byMessageId.body.events[0].messageId, `mh-${seeded.specialEventId}`);
+    const byNumericId = await getJson(fixture.baseUrl, `/api/events?q=${seeded.specialEventId}`, aliceCookie);
+    assert.equal(byNumericId.body.total, 1);
+    assert.equal(byNumericId.body.events[0].id, seeded.specialEventId);
+    const bySubject = await getJson(fixture.baseUrl, '/api/events?q=Message%20identifier%20event', aliceCookie);
+    assert.equal(bySubject.body.total, 1);
+    assert.equal(bySubject.body.events[0].id, seeded.specialEventId);
+    assert.equal((await getJson(fixture.baseUrl, '/api/events?from=2000-01-01', aliceCookie)).body.total, 5);
+    assert.equal((await getJson(fixture.baseUrl, '/api/events?to=2000-01-01', aliceCookie)).body.total, 0);
+
+    const bobEvents = await getJson(fixture.baseUrl, '/api/events?q=QUEUE-BOB-ONLY', bobCookie);
+    assert.equal(bobEvents.body.total, 1);
+    assert.equal((await getJson(fixture.baseUrl, '/api/events?q=QUEUE-BOB-ONLY', aliceCookie)).body.total, 0);
+
+    const inboxPage = await getJson(
+      fixture.baseUrl,
+      `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&page=1&pageSize=1`,
+      aliceCookie
+    );
+    assert.equal(inboxPage.status, 200);
+    assert.equal(inboxPage.body.total, 2);
+    assert.equal(inboxPage.body.messages.length, 1);
+    assert.equal(inboxPage.body.page, 1);
+    assert.equal(inboxPage.body.pageSize, 1);
+
+    const unread = await getJson(
+      fixture.baseUrl,
+      `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=INBOX&read=false`,
+      aliceCookie
+    );
+    assert.equal(unread.body.total, 1);
+    assert.equal(unread.body.messages[0].read, false);
+    const sent = await getJson(
+      fixture.baseUrl,
+      `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=Sent`,
+      aliceCookie
+    );
+    assert.equal(sent.body.total, 1);
+    assert.equal(sent.body.messages[0].folder, 'Sent');
+    const custom = await getJson(
+      fixture.baseUrl,
+      `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}&folder=Projects`,
+      aliceCookie
+    );
+    assert.equal(custom.body.total, 1);
+    assert.equal(custom.body.messages[0].folder, 'Projects');
+    const byInboundMessageId = await getJson(
+      fixture.baseUrl,
+      '/api/inbound-messages?folder=Projects&q=inbound-project-special',
+      aliceCookie
+    );
+    assert.equal(byInboundMessageId.body.total, 1);
+    assert.equal(byInboundMessageId.body.messages[0].messageId, '<inbound-project-special@example.net>');
+
+    const isolatedMessages = await getJson(
+      fixture.baseUrl,
+      `/api/inbound-messages?mailboxId=${seeded.aliceMailboxId}`,
+      bobCookie
+    );
+    assert.equal(isolatedMessages.body.total, 0);
+
+    const folders = await getJson(
+      fixture.baseUrl,
+      `/api/inbound-mailboxes/${seeded.aliceMailboxId}/folders`,
+      aliceCookie
+    );
+    assert.equal(folders.status, 200);
+    assert.deepEqual(folderSummary(folders.body.folders, 'INBOX'), {
+      name: 'INBOX',
+      specialUse: null,
+      messageCount: 2,
+      unreadCount: 1
+    });
+    assert.deepEqual(folderSummary(folders.body.folders, 'Sent'), {
+      name: 'Sent',
+      specialUse: '\\Sent',
+      messageCount: 1,
+      unreadCount: 0
+    });
+    assert.deepEqual(folderSummary(folders.body.folders, 'Projects'), {
+      name: 'Projects',
+      specialUse: null,
+      messageCount: 1,
+      unreadCount: 1
+    });
+    assert.ok(folders.body.folders.some((folder) => folder.name === 'Drafts' && folder.messageCount === 0));
+
+    const isolatedFolders = await getJson(
+      fixture.baseUrl,
+      `/api/inbound-mailboxes/${seeded.aliceMailboxId}/folders`,
+      bobCookie
+    );
+    assert.equal(isolatedFolders.status, 404);
+    assert.deepEqual(isolatedFolders.body.folders, []);
+
+    for (const pathName of [
+      '/api/events?page=0',
+      '/api/events?page=abc',
+      '/api/events?pageSize=101',
+      '/api/events?domainId=-1',
+      '/api/events?status=sent%20OR%201%3D1',
+      '/api/events?from=not-a-date',
+      '/api/events?from=2026-07-02&to=2026-07-01',
+      '/api/inbound-messages?page=0',
+      '/api/inbound-messages?pageSize=101',
+      '/api/inbound-messages?mailboxId=nope',
+      '/api/inbound-messages?folder=',
+      '/api/inbound-messages?read=1'
+    ]) {
+      const response = await getJson(fixture.baseUrl, pathName, aliceCookie);
+      assert.equal(response.status, 400, pathName);
+      assert.equal(typeof response.body.error, 'string');
+    }
+  } finally {
+    fixture.child.kill('SIGTERM');
+    await waitForExit(fixture.child, 1000);
+  }
+});
+
+test('SMTP API responses keep passwords write-only while internal relay auth still works', async () => {
+  const relayServer = await startFakeSmtpServer();
+  const fixture = await startTestServer();
+  try {
+    const cookie = await login(fixture.baseUrl, 'admin', 'password123');
+    const singularSave = await requestJson(fixture.baseUrl, '/api/smtp-credential', cookie, {
+      method: 'PUT',
+      body: { username: 'legacy-smtp', password: 'legacy-secret' }
+    });
+    assertSecretSummary(singularSave.body.credential);
+    const singularGet = await getJson(fixture.baseUrl, '/api/smtp-credential', cookie);
+    assertSecretSummary(singularGet.body.credential);
+
+    const credentialCreate = await requestJson(fixture.baseUrl, '/api/smtp-credentials', cookie, {
+      method: 'POST',
+      body: { username: 'app-smtp', password: 'app-secret' }
+    });
+    assert.equal(credentialCreate.status, 201);
+    assertSecretSummary(credentialCreate.body.credential);
+    const credentialId = credentialCreate.body.credential.id;
+    const credentialList = await getJson(fixture.baseUrl, '/api/smtp-credentials', cookie);
+    credentialList.body.credentials.forEach(assertSecretSummary);
+    assertSecretSummary((await getJson(fixture.baseUrl, `/api/smtp-credentials/${credentialId}`, cookie)).body.credential);
+    const credentialPatch = await requestJson(fixture.baseUrl, `/api/smtp-credentials/${credentialId}`, cookie, {
+      method: 'PATCH',
+      body: { username: 'app-smtp-renamed' }
+    });
+    assertSecretSummary(credentialPatch.body.credential);
+
+    const domain = await requestJson(fixture.baseUrl, '/api/domains', cookie, {
+      method: 'POST',
+      body: {
+        domain: 'write-only-relay.example',
+        selector: 'mh',
+        senderHost: 'mail.write-only-relay.example',
+        sendingIp: '127.0.0.1'
+      }
+    });
+    assert.equal(domain.status, 201);
+    const relayCreate = await requestJson(fixture.baseUrl, '/api/smtp-relays', cookie, {
+      method: 'POST',
+      body: {
+        name: 'Write-only relay',
+        host: '127.0.0.1',
+        port: relayServer.port,
+        secure: false,
+        username: 'relay-user',
+        password: 'relay-secret',
+        helo: 'mail.write-only-relay.example',
+        isDefault: true
+      }
+    });
+    assert.equal(relayCreate.status, 201);
+    assertSecretSummary(relayCreate.body.relay);
+    const relayId = relayCreate.body.relay.id;
+    (await getJson(fixture.baseUrl, '/api/smtp-relays', cookie)).body.relays.forEach(assertSecretSummary);
+    assertSecretSummary((await getJson(fixture.baseUrl, `/api/smtp-relays/${relayId}`, cookie)).body.relay);
+    const relayPatch = await requestJson(fixture.baseUrl, `/api/smtp-relays/${relayId}`, cookie, {
+      method: 'PATCH',
+      body: { name: 'Write-only relay renamed' }
+    });
+    assertSecretSummary(relayPatch.body.relay);
+
+    const send = await requestJson(fixture.baseUrl, '/api/send', cookie, {
+      method: 'POST',
+      body: {
+        from: 'noreply@write-only-relay.example',
+        to: 'recipient@example.net',
+        subject: 'write-only credential test',
+        text: 'hello',
+        smtpRelayId: relayId
+      }
+    });
+    assert.equal(send.status, 202);
+    const authCommand = relayServer.commands.find((command) => command.startsWith('AUTH PLAIN '));
+    assert.ok(authCommand);
+    assert.equal(
+      Buffer.from(authCommand.slice('AUTH PLAIN '.length), 'base64').toString('utf8'),
+      '\0relay-user\0relay-secret'
+    );
+  } finally {
+    fixture.child.kill('SIGTERM');
+    await waitForExit(fixture.child, 1000);
+    await relayServer.close();
+  }
+});
+
+test('authentication next paths preserve safe deep links and reject open redirects', async () => {
+  const fixture = await startTestServer();
+  try {
+    const target = '/domains/42?tab=dns&q=pending';
+    const anonymous = await fetch(`${fixture.baseUrl}${target}`, { redirect: 'manual' });
+    assert.equal(anonymous.status, 302);
+    const loginLocation = new URL(anonymous.headers.get('location'), fixture.baseUrl);
+    assert.equal(loginLocation.pathname, '/login');
+    assert.equal(loginLocation.searchParams.get('next'), target);
+
+    const successfulLogin = await loginResponse(fixture.baseUrl, 'admin', 'password123', target);
+    assert.equal(successfulLogin.status, 200);
+    const loginBody = await successfulLogin.json();
+    assert.equal(loginBody.redirectTo, target);
+    const cookie = sessionCookieFrom(successfulLogin);
+    assert.ok(cookie);
+
+    const authenticatedLogin = await fetch(
+      `${fixture.baseUrl}/login?next=${encodeURIComponent(target)}`,
+      { headers: { Cookie: cookie }, redirect: 'manual' }
+    );
+    assert.equal(authenticatedLogin.status, 302);
+    assert.equal(authenticatedLogin.headers.get('location'), target);
+
+    const queryUrlTarget = '/activity?q=https%3A%2F%2Fexample.com%2Fmessage';
+    const queryUrlLogin = await loginResponse(fixture.baseUrl, 'admin', 'password123', queryUrlTarget);
+    assert.equal(queryUrlLogin.status, 200);
+    assert.equal((await queryUrlLogin.json()).redirectTo, queryUrlTarget);
+
+    for (const unsafe of [
+      '//evil.example/path',
+      'https://evil.example/path',
+      '/\\evil.example/path',
+      '/%2F%2Fevil.example/path',
+      '/%5C%5Cevil.example/path',
+      '/%0Aevil',
+      '/api/events'
+    ]) {
+      const response = await loginResponse(fixture.baseUrl, 'admin', 'password123', unsafe);
+      assert.equal(response.status, 200, unsafe);
+      assert.equal((await response.json()).redirectTo, '/', unsafe);
+    }
+
+    const invalidAuthenticatedLogin = await fetch(
+      `${fixture.baseUrl}/login?next=${encodeURIComponent('//evil.example/path')}`,
+      { headers: { Cookie: cookie }, redirect: 'manual' }
+    );
+    assert.equal(invalidAuthenticatedLogin.headers.get('location'), '/');
+  } finally {
+    fixture.child.kill('SIGTERM');
+    await waitForExit(fixture.child, 1000);
+  }
+});
+
+function seedListingFixtures(dataDir, sessionSecret) {
+  const script = `
+    import {
+      createDomain,
+      createInboundMailbox,
+      createInboundMessage,
+      createUser,
+      initDatabase,
+      logSendEvent,
+      markInboundMessageRead
+    } from './src/db.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    const alice = createUser({ username: 'list-alice', email: 'list-alice@example.com', password: 'password123', status: 'active' });
+    const bob = createUser({ username: 'list-bob', email: 'list-bob@example.com', password: 'password123', status: 'active' });
+    const domain = (userId, name) => createDomain(userId, {
+      domain: name,
+      selector: 'mh',
+      verificationToken: 'token-' + name,
+      dkimPublic: 'public-' + name,
+      dkimPrivate: 'private-' + name,
+      senderHost: 'mail.' + name,
+      sendingIp: '127.0.0.1',
+      spfExtra: '',
+      dmarcPolicy: 'none',
+      dmarcRua: ''
+    });
+    const alicePrimary = domain(alice.id, 'listing-alice.example');
+    const aliceSecondary = domain(alice.id, 'listing-alice-secondary.example');
+    const bobDomain = domain(bob.id, 'listing-bob.example');
+
+    const events = [
+      { domainId: alicePrimary.id, status: 'sent', recipient: 'one@example.net', subject: 'First event', queueId: 'QUEUE-ALICE-1' },
+      { domainId: alicePrimary.id, status: 'failed', recipient: 'two@example.net', subject: 'Failed event', queueId: 'QUEUE-ALICE-FAILED' },
+      { domainId: alicePrimary.id, status: 'bounced', recipient: 'target+filter@example.net', subject: 'Recipient filter event', queueId: 'QUEUE-ALICE-3' },
+      { domainId: aliceSecondary.id, status: 'sent', recipient: 'four@example.net', subject: 'Secondary domain event', queueId: 'QUEUE-ALICE-4' },
+      { domainId: alicePrimary.id, status: 'sent', recipient: 'five@example.net', subject: 'Message identifier event', queueId: 'QUEUE-ALICE-5' }
+    ];
+    let specialEventId = null;
+    for (const event of events) {
+      const eventId = Number(logSendEvent({
+        userId: alice.id,
+        domainId: event.domainId,
+        sender: 'noreply@listing-alice.example',
+        recipients: [event.recipient],
+        subject: event.subject,
+        status: event.status,
+        detail: event.status + ' detail',
+        queueId: event.queueId
+      }));
+      if (event.subject === 'Message identifier event') specialEventId = eventId;
+    }
+    logSendEvent({
+      userId: bob.id,
+      domainId: bobDomain.id,
+      sender: 'noreply@listing-bob.example',
+      recipients: ['bob@example.net'],
+      subject: 'Bob only',
+      status: 'sent',
+      detail: 'bob detail',
+      queueId: 'QUEUE-BOB-ONLY'
+    });
+
+    const aliceMailbox = createInboundMailbox(alice.id, { address: 'inbox@listing-alice.example', password: 'mailbox-pass-123' });
+    const bobMailbox = createInboundMailbox(bob.id, { address: 'inbox@listing-bob.example', password: 'mailbox-pass-123' });
+    const createMessage = (mailbox, values) => createInboundMessage(mailbox, {
+      sender: values.sender || 'sender@example.net',
+      recipients: [mailbox.address],
+      subject: values.subject,
+      messageId: values.messageId,
+      folder: values.folder || 'INBOX',
+      rawMessage: 'Subject: ' + values.subject + '\\r\\n\\r\\n' + values.subject,
+      textBody: values.subject,
+      receivedAt: values.receivedAt
+    });
+    createMessage(aliceMailbox, { subject: 'Inbox unread', messageId: '<inbound-unread@example.net>', receivedAt: '2026-07-14T10:00:00.000Z' });
+    const inboxRead = createMessage(aliceMailbox, { subject: 'Inbox read', messageId: '<inbound-read@example.net>', receivedAt: '2026-07-14T11:00:00.000Z' });
+    markInboundMessageRead(alice.id, inboxRead.id, true);
+    const sent = createMessage(aliceMailbox, { subject: 'Sent message', messageId: '<inbound-sent@example.net>', folder: 'Sent', receivedAt: '2026-07-14T12:00:00.000Z' });
+    markInboundMessageRead(alice.id, sent.id, true);
+    createMessage(aliceMailbox, { subject: 'Project message', messageId: '<inbound-project-special@example.net>', folder: 'Projects', receivedAt: '2026-07-14T13:00:00.000Z' });
+    createMessage(bobMailbox, { subject: 'Bob private', messageId: '<inbound-bob@example.net>', receivedAt: '2026-07-14T14:00:00.000Z' });
+
+    console.log(JSON.stringify({
+      aliceSecondaryDomainId: aliceSecondary.id,
+      aliceMailboxId: aliceMailbox.id,
+      bobMailboxId: bobMailbox.id,
+      specialEventId
+    }));
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: { ...process.env, DATA_DIR: dataDir, SESSION_SECRET: sessionSecret },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  return JSON.parse(result.stdout);
+}
+
+async function startTestServer() {
+  const port = await freePort();
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-listing-api-test-'));
+  const sessionSecret = 'listing-api-session-secret';
+  const child = spawn(process.execPath, ['src/server.js'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      PORT: String(port),
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret,
+      ADMIN_USER: 'admin',
+      ADMIN_EMAIL: 'admin@example.com',
+      ADMIN_PASSWORD: 'password123',
+      DNS_AUTO_CHECK_ENABLED: 'false',
+      DELIVERY_TRACKING_ENABLED: 'false',
+      WEBHOOK_WORKER_ENABLED: 'false',
+      SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false'
+    },
+    stdio: ['ignore', 'pipe', 'pipe']
+  });
+  await waitForOutput(child, 'MailHub listening');
+  return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
+}
+
+async function login(baseUrl, username, password) {
+  const response = await loginResponse(baseUrl, username, password);
+  assert.equal(response.status, 200);
+  const cookie = sessionCookieFrom(response);
+  assert.ok(cookie);
+  return cookie;
+}
+
+function loginResponse(baseUrl, username, password, next = '') {
+  return fetch(`${baseUrl}/api/login`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ username, password, next })
+  });
+}
+
+function sessionCookieFrom(response) {
+  return response.headers.get('set-cookie')?.split(';')[0] || '';
+}
+
+async function getJson(baseUrl, pathname, cookie) {
+  const response = await fetch(`${baseUrl}${pathname}`, { headers: { Cookie: cookie }, redirect: 'manual' });
+  return { status: response.status, body: await response.json() };
+}
+
+async function requestJson(baseUrl, pathname, cookie, { method, body }) {
+  const response = await fetch(`${baseUrl}${pathname}`, {
+    method,
+    headers: { 'Content-Type': 'application/json', Cookie: cookie },
+    body: JSON.stringify(body)
+  });
+  return { status: response.status, body: await response.json() };
+}
+
+function folderSummary(folders, name) {
+  const folder = folders.find((entry) => entry.name === name);
+  assert.ok(folder, `expected folder ${name}`);
+  return folder;
+}
+
+function assertSecretSummary(value) {
+  assert.ok(value);
+  assert.equal(value.passwordSet, true);
+  assert.equal('password' in value, false);
+  assert.equal('passwordHash' in value, false);
+  assert.equal('passwordSecret' in value, false);
+  assert.equal('passwordRecoverable' in value, false);
+}
+
+function freePort() {
+  return new Promise((resolve, reject) => {
+    const server = net.createServer();
+    server.listen(0, '127.0.0.1', () => {
+      const { port } = server.address();
+      server.close((error) => (error ? reject(error) : resolve(port)));
+    });
+    server.on('error', reject);
+  });
+}
+
+function waitForOutput(child, text, timeoutMs = 8000) {
+  return new Promise((resolve, reject) => {
+    let buffer = '';
+    const timer = setTimeout(() => reject(new Error(`Timed out waiting for: ${text}\n${buffer}`)), timeoutMs);
+    const onData = (chunk) => {
+      buffer += String(chunk);
+      if (!buffer.includes(text)) return;
+      clearTimeout(timer);
+      child.stdout?.off('data', onData);
+      child.stderr?.off('data', onData);
+      resolve();
+    };
+    child.stdout?.on('data', onData);
+    child.stderr?.on('data', onData);
+  });
+}
+
+function waitForExit(child, timeoutMs) {
+  return new Promise((resolve) => {
+    if (child.exitCode != null) return resolve(true);
+    const timer = setTimeout(() => resolve(false), timeoutMs);
+    child.once('exit', () => {
+      clearTimeout(timer);
+      resolve(true);
+    });
+  });
+}
+
+function startFakeSmtpServer() {
+  const commands = [];
+  const server = net.createServer((socket) => {
+    socket.setEncoding('utf8');
+    socket.write('220 relay.test ESMTP ready\r\n');
+    let buffer = '';
+    let dataMode = false;
+    socket.on('data', (chunk) => {
+      buffer += chunk;
+      let index;
+      while ((index = buffer.indexOf('\n')) !== -1) {
+        const line = buffer.slice(0, index).replace(/\r$/, '');
+        buffer = buffer.slice(index + 1);
+        if (dataMode) {
+          if (line === '.') {
+            dataMode = false;
+            socket.write('250 2.0.0 queued as WRITEONLY123\r\n');
+          }
+          continue;
+        }
+        commands.push(line);
+        if (line.startsWith('EHLO')) socket.write('250-relay.test\r\n250 AUTH PLAIN\r\n');
+        else if (line.startsWith('AUTH PLAIN')) socket.write('235 2.7.0 authenticated\r\n');
+        else if (line.startsWith('MAIL FROM')) socket.write('250 2.1.0 ok\r\n');
+        else if (line.startsWith('RCPT TO')) socket.write('250 2.1.5 ok\r\n');
+        else if (line === 'DATA') {
+          dataMode = true;
+          socket.write('354 end data\r\n');
+        } else if (line === 'QUIT') {
+          socket.write('221 bye\r\n');
+          socket.end();
+        }
+      }
+    });
+  });
+  return new Promise((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => {
+      server.off('error', reject);
+      resolve({
+        port: server.address().port,
+        commands,
+        close: () => new Promise((closeResolve) => server.close(closeResolve))
+      });
+    });
+  });
+}

+ 66 - 0
test/ui/add-domain-drawer.test.tsx

@@ -0,0 +1,66 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AddDomainDrawer } from '../../src/components/domain/AddDomainDrawer';
+import { I18nProvider } from '../../src/frontend/i18n/react';
+import { mailhubTheme } from '../../src/frontend/theme';
+import type { RuntimeConfig } from '../../src/frontend/types';
+
+describe('AddDomainDrawer', () => {
+  it('progressively collects a domain and submits the three-step review', async () => {
+    const user = userEvent.setup();
+    const onSubmit = vi.fn(async () => undefined);
+    render(
+      <ConfigProvider theme={mailhubTheme}>
+        <AntApp>
+          <I18nProvider>
+            <AddDomainDrawer
+              open
+              config={runtimeConfig}
+              dnsCredentials={[]}
+              smtpRelays={[]}
+              onClose={vi.fn()}
+              onSubmit={onSubmit}
+            />
+          </I18nProvider>
+        </AntApp>
+      </ConfigProvider>
+    );
+
+    await user.type(screen.getByLabelText('域名'), 'Example.COM');
+    await user.click(screen.getByRole('button', { name: '下一步' }));
+    expect(await screen.findByText('手动配置 DNS')).toBeTruthy();
+    await user.click(screen.getByRole('button', { name: '下一步' }));
+    expect(await screen.findByText('检查并创建')).toBeTruthy();
+    await user.click(screen.getByRole('button', { name: /创建并验证/ }));
+
+    await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+    expect(onSubmit.mock.calls[0][0]).toMatchObject({
+      domain: 'example.com',
+      senderHost: 'mail.example.test',
+      sendingIp: '192.0.2.10',
+      immediateCheck: true
+    });
+  });
+});
+
+const runtimeConfig: RuntimeConfig = {
+  appBaseUrl: 'https://mail.example.test',
+  mailHostname: 'mail.example.test',
+  sendingIp: '192.0.2.10',
+  defaultSpfMechanisms: '',
+  dmarcPolicy: 'none',
+  dmarcRua: '',
+  sendRequiresVerified: true,
+  engagementTrackingEnabled: true,
+  listUnsubscribeMailto: '',
+  listUnsubscribeUrl: '',
+  listUnsubscribePostEnabled: false,
+  feedbackIdEnabled: false,
+  reportAbuseTo: '',
+  csaComplaintsTo: '',
+  bounceAddress: '',
+  bounceEnvelopeEnabled: false
+};

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

@@ -0,0 +1,102 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AppContext } from '../../src/frontend/app-context';
+import { I18nProvider } from '../../src/frontend/i18n/react';
+import { mailhubTheme } from '../../src/frontend/theme';
+import { AdminLayout, navigationSelection, visibleNavigation } from '../../src/layouts/AdminLayout';
+import type { AppContextValue } from '../../src/frontend/app-context';
+
+describe('AdminLayout navigation', () => {
+  it('hides all system management destinations from a normal user', () => {
+    const paths = visibleNavigation(false).flatMap((group) => group.items.map((item) => item.path));
+    expect(paths).not.toContain('/admin/users');
+    expect(paths).not.toContain('/settings');
+  });
+
+  it('maps detail routes back to their owning navigation item', () => {
+    expect(navigationSelection('/activity/42')).toBe('/activity');
+    expect(navigationSelection('/domains/7/dns')).toBe('/domains');
+    expect(navigationSelection('/inbox/messages/9')).toBe('/inbox');
+  });
+
+  it('navigates with semantic menu items and exposes a skip link', async () => {
+    const user = userEvent.setup();
+    renderShell('/overview', { role: 'admin' });
+
+    expect(screen.getByRole('link', { name: '跳至主要内容' }).getAttribute('href')).toBe('#main-content');
+    const activityItems = screen.getAllByText('发送活动');
+    await user.click(activityItems[0]);
+    expect(screen.getByTestId('location').textContent).toBe('/activity');
+  });
+
+  it('provides an explicit mobile navigation escape action', async () => {
+    const user = userEvent.setup();
+    renderShell('/overview', { role: 'user' });
+
+    await user.click(screen.getByRole('button', { name: '打开主导航' }));
+    const close = await screen.findByRole('button', { name: '关闭主导航' });
+    expect(close.style.minHeight).toBe('44px');
+    expect(close.style.minWidth).toBe('44px');
+    await user.click(close);
+    await waitFor(() => expect(screen.queryByRole('button', { name: '关闭主导航' })).toBeNull());
+  });
+});
+
+function renderShell(path: string, { role }: { role: 'admin' | 'user' }) {
+  const context: AppContextValue = {
+    user: {
+      id: 1,
+      username: 'operator',
+      email: 'operator@example.test',
+      role,
+      status: 'active'
+    },
+    config: {
+      appBaseUrl: 'https://mail.example.test',
+      mailHostname: 'mail.example.test',
+      sendingIp: '192.0.2.10',
+      defaultSpfMechanisms: '',
+      dmarcPolicy: 'none',
+      dmarcRua: '',
+      sendRequiresVerified: true,
+      engagementTrackingEnabled: true,
+      listUnsubscribeMailto: '',
+      listUnsubscribeUrl: '',
+      listUnsubscribePostEnabled: false,
+      feedbackIdEnabled: false,
+      reportAbuseTo: '',
+      csaComplaintsTo: '',
+      bounceAddress: '',
+      bounceEnvelopeEnabled: false
+    },
+    refreshBootstrap: vi.fn(async () => undefined),
+    logout: vi.fn(async () => undefined)
+  };
+
+  return render(
+    <ConfigProvider theme={mailhubTheme}>
+      <AntApp>
+        <I18nProvider>
+          <AppContext.Provider value={context}>
+            <MemoryRouter initialEntries={[path]}>
+              <Routes>
+                <Route element={<AdminLayout />}>
+                  <Route path="*" element={<LocationProbe />} />
+                </Route>
+              </Routes>
+            </MemoryRouter>
+          </AppContext.Provider>
+        </I18nProvider>
+      </AntApp>
+    </ConfigProvider>
+  );
+}
+
+function LocationProbe() {
+  const location = useLocation();
+  return <div data-testid="location">{location.pathname}</div>;
+}

+ 94 - 0
test/ui/api-token-secret.test.tsx

@@ -0,0 +1,94 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
+import { I18nProvider } from '../../src/frontend/i18n/react';
+import { api } from '../../src/frontend/services/api';
+import { mailhubTheme } from '../../src/frontend/theme';
+import type { ApiToken, RuntimeConfig } from '../../src/frontend/types';
+import ApiTokens from '../../src/pages/ApiTokens';
+
+describe('API token one-time secret', () => {
+  it('removes the full token after the explicit acknowledgement', async () => {
+    const user = userEvent.setup();
+    const summary: ApiToken = {
+      id: 9,
+      name: 'CI sender',
+      tokenPrefix: 'mh_12345678',
+      scopes: ['send'],
+      status: 'active',
+      createdAt: '2026-07-14T00:00:00.000Z'
+    };
+    const fullToken = 'mh_12345678.full-secret-value';
+
+    vi.spyOn(api, 'apiTokens')
+      .mockResolvedValueOnce({ tokens: [] })
+      .mockResolvedValue({ tokens: [summary] });
+    const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({
+      token: { ...summary, token: fullToken }
+    });
+
+    renderPage();
+    await screen.findByText('创建 Token 后可复制完整密钥;历史 Token 只显示前缀,示例中使用占位符。');
+    await user.click(screen.getAllByRole('button', { name: /创建 Token/ })[0]);
+
+    const editor = await screen.findByRole('dialog');
+    await user.type(within(editor).getByLabelText('名称'), 'CI sender');
+    await user.click(within(editor).getByRole('button', { name: /创建 Token/ }));
+
+    await waitFor(() => expect(createToken).toHaveBeenCalledWith({
+      name: 'CI sender',
+      scopes: ['send'],
+      expiresAt: null
+    }));
+    expect(await screen.findByText(fullToken)).not.toBeNull();
+
+    const reveal = screen.getByRole('dialog', { name: 'API Token 已创建' });
+    await user.click(within(reveal).getByRole('button', { name: /确.*认/ }));
+
+    await waitFor(() => expect(screen.queryByRole('dialog', { name: 'API Token 已创建' })).toBeNull());
+    expect(await screen.findByText('mh_12345678...')).not.toBeNull();
+    expect(screen.queryByRole('dialog', { name: 'API Token 已创建' })).toBeNull();
+  });
+});
+
+function renderPage() {
+  const context: AppContextValue = {
+    user: { id: 1, username: 'operator', email: 'operator@example.test', role: 'admin', status: 'active' },
+    config,
+    refreshBootstrap: vi.fn(async () => undefined),
+    logout: vi.fn(async () => undefined)
+  };
+  return render(
+    <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
+      <AntApp>
+        <I18nProvider>
+          <AppContext.Provider value={context}>
+            <ApiTokens />
+          </AppContext.Provider>
+        </I18nProvider>
+      </AntApp>
+    </ConfigProvider>
+  );
+}
+
+const config: RuntimeConfig = {
+  appBaseUrl: 'https://mail.example.test',
+  mailHostname: 'mail.example.test',
+  sendingIp: '192.0.2.10',
+  defaultSpfMechanisms: '',
+  dmarcPolicy: 'none',
+  dmarcRua: '',
+  sendRequiresVerified: true,
+  engagementTrackingEnabled: true,
+  listUnsubscribeMailto: '',
+  listUnsubscribeUrl: '',
+  listUnsubscribePostEnabled: false,
+  feedbackIdEnabled: false,
+  reportAbuseTo: '',
+  csaComplaintsTo: '',
+  bounceAddress: '',
+  bounceEnvelopeEnabled: false
+};

+ 21 - 0
test/ui/auth-next.test.ts

@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+
+import { nextAuthSuccessState, safeInternalPath } from '../../src/frontend/auth/auth-model.js';
+
+describe('authentication next path', () => {
+  it('restores a filtered internal deep link after login', () => {
+    expect(nextAuthSuccessState('/api/login', {}, '/activity?status=failed&page=2').redirectTo)
+      .toBe('/activity?status=failed&page=2');
+  });
+
+  it.each([
+    'https://attacker.example/path',
+    '//attacker.example/path',
+    '/\\attacker.example/path',
+    '/login',
+    '/api/events',
+    'javascript:alert(1)'
+  ])('rejects unsafe destination %s', (value) => {
+    expect(safeInternalPath(value, '/overview')).toBe('/overview');
+  });
+});

+ 125 - 0
test/ui/direct-detail-navigation.test.tsx

@@ -0,0 +1,125 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createMemoryRouter, RouterProvider } from 'react-router-dom';
+import { describe, expect, it, vi } from 'vitest';
+
+import { I18nProvider } from '../../src/frontend/i18n/react';
+import { api } from '../../src/frontend/services/api';
+import { mailhubTheme } from '../../src/frontend/theme';
+import type { SendEvent } from '../../src/frontend/types';
+import SendingLogs from '../../src/pages/SendingLogs';
+
+describe('Activity direct detail navigation', () => {
+  it('restores direct-link tabs with history and closes past them to the filtered list', async () => {
+    const user = userEvent.setup();
+    mockActivityApis();
+    const router = renderPage({
+      initialEntries: ['/overview', '/activity/11?page=2&pageSize=25&status=failed&tab=raw'],
+      initialIndex: 1
+    });
+
+    expect(await screen.findByRole('tab', { name: '原始日志', selected: true })).not.toBeNull();
+    await user.click(screen.getByRole('tab', { name: 'Webhook' }));
+    await waitFor(() => expect(currentTab(router)).toBe('webhooks'));
+    await user.click(screen.getByRole('tab', { name: '互动跟踪' }));
+    await waitFor(() => expect(currentTab(router)).toBe('tracking'));
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(currentTab(router)).toBe('webhooks');
+    expect(screen.getByRole('tab', { name: 'Webhook', selected: true })).not.toBeNull();
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(currentTab(router)).toBe('raw');
+    expect(screen.getByRole('tab', { name: '原始日志', selected: true })).not.toBeNull();
+
+    await act(async () => {
+      await router.navigate(1);
+    });
+    expect(currentTab(router)).toBe('webhooks');
+    await act(async () => {
+      await router.navigate(1);
+    });
+    expect(currentTab(router)).toBe('tracking');
+
+    await user.click(screen.getByRole('button', { name: 'Close' }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
+    const listParams = new URLSearchParams(router.state.location.search);
+    expect(listParams.get('page')).toBe('2');
+    expect(listParams.get('pageSize')).toBe('25');
+    expect(listParams.get('status')).toBe('failed');
+    expect(listParams.has('tab')).toBe(false);
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/overview');
+  });
+
+  it('replaces an untouched direct detail with its list even without prior history', async () => {
+    const user = userEvent.setup();
+    mockActivityApis();
+    const router = renderPage({
+      initialEntries: ['/activity/11?page=1&pageSize=25'],
+      initialIndex: 0
+    });
+
+    await screen.findByRole('dialog', { name: '发送详情' });
+    await user.click(screen.getByRole('button', { name: 'Close' }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/activity');
+  });
+});
+
+function mockActivityApis() {
+  vi.spyOn(api, 'events').mockResolvedValue({ events: [sendEvent], total: 1, page: 2, pageSize: 25 });
+  vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
+  vi.spyOn(api, 'event').mockResolvedValue({ event: sendEvent });
+}
+
+function renderPage({ initialEntries, initialIndex }: { initialEntries: string[]; initialIndex: number }) {
+  const router = createMemoryRouter([
+    { path: '/activity', element: <SendingLogs /> },
+    { path: '/activity/:eventId', element: <SendingLogs /> },
+    { path: '*', element: <div>other</div> }
+  ], { initialEntries, initialIndex });
+
+  render(
+    <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
+      <AntApp>
+        <I18nProvider>
+          <RouterProvider router={router} />
+        </I18nProvider>
+      </AntApp>
+    </ConfigProvider>
+  );
+  return router;
+}
+
+function currentTab(router: ReturnType<typeof createMemoryRouter>) {
+  return new URLSearchParams(router.state.location.search).get('tab');
+}
+
+const sendEvent: SendEvent = {
+  id: 11,
+  userId: 1,
+  domainId: null,
+  smtpRelayId: null,
+  domain: 'example.test',
+  sender: 'sender@example.test',
+  recipients: ['recipient@example.test'],
+  subject: 'Delivery test',
+  status: 'failed',
+  detail: '550 mailbox unavailable',
+  queueId: 'QUEUE-11',
+  messageId: 'mh-11',
+  createdAt: '2026-07-14T00:00:00.000Z'
+};

+ 112 - 0
test/ui/domains-delete.test.tsx

@@ -0,0 +1,112 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { describe, expect, it, vi } from 'vitest';
+
+import { AppContext } from '../../src/frontend/app-context';
+import { I18nProvider } from '../../src/frontend/i18n/react';
+import { api } from '../../src/frontend/services/api';
+import { mailhubTheme } from '../../src/frontend/theme';
+import Domains from '../../src/pages/Domains';
+import type { AppContextValue } from '../../src/frontend/app-context';
+import type { Domain } from '../../src/frontend/types';
+
+describe('Domains dangerous actions', () => {
+  it('requires the exact domain before deletion', async () => {
+    const user = userEvent.setup();
+    const deleteDomain = vi.spyOn(api, 'deleteDomain').mockResolvedValue({ deleted: true });
+    vi.spyOn(api, 'domains').mockResolvedValue({ domains: [domain] });
+    vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials: [] });
+    vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [] });
+    vi.spyOn(api, 'events').mockResolvedValue({ events: [], total: 0, page: 1, pageSize: 100 });
+
+    renderPage();
+    await screen.findAllByText('example.com');
+    await user.click(screen.getByRole('button', { name: '更多操作 example.com' }));
+    await user.click(await screen.findByText('删除域名'));
+
+    const dialog = await screen.findByRole('dialog');
+    const confirmation = within(dialog).getByLabelText('域名删除确认');
+    const destructiveButton = within(dialog).getByRole('button', { name: '删除域名' });
+    expect((destructiveButton as HTMLButtonElement).disabled).toBe(true);
+
+    await user.type(confirmation, 'wrong.example.com');
+    expect((destructiveButton as HTMLButtonElement).disabled).toBe(true);
+    await user.clear(confirmation);
+    await user.type(confirmation, 'example.com');
+    expect((destructiveButton as HTMLButtonElement).disabled).toBe(false);
+    await user.click(destructiveButton);
+
+    await waitFor(() => expect(deleteDomain).toHaveBeenCalledWith(7));
+  });
+});
+
+function renderPage() {
+  const context: AppContextValue = {
+    user: { id: 1, username: 'operator', email: 'operator@example.test', role: 'admin', status: 'active' },
+    config,
+    refreshBootstrap: vi.fn(async () => undefined),
+    logout: vi.fn(async () => undefined)
+  };
+  return render(
+    <ConfigProvider theme={mailhubTheme}>
+      <AntApp>
+        <I18nProvider>
+          <AppContext.Provider value={context}>
+            <MemoryRouter initialEntries={['/domains']}>
+              <Routes><Route path="/domains" element={<Domains />} /></Routes>
+            </MemoryRouter>
+          </AppContext.Provider>
+        </I18nProvider>
+      </AntApp>
+    </ConfigProvider>
+  );
+}
+
+const config = {
+  appBaseUrl: 'https://mail.example.test',
+  mailHostname: 'mail.example.test',
+  sendingIp: '192.0.2.10',
+  defaultSpfMechanisms: '',
+  dmarcPolicy: 'none',
+  dmarcRua: '',
+  sendRequiresVerified: true,
+  engagementTrackingEnabled: true,
+  listUnsubscribeMailto: '',
+  listUnsubscribeUrl: '',
+  listUnsubscribePostEnabled: false,
+  feedbackIdEnabled: false,
+  reportAbuseTo: '',
+  csaComplaintsTo: '',
+  bounceAddress: '',
+  bounceEnvelopeEnabled: false
+};
+
+const domain: Domain = {
+  id: 7,
+  userId: 1,
+  dnsCredentialId: null,
+  smtpRelayId: null,
+  domain: 'example.com',
+  selector: 'mh202607',
+  verificationToken: 'verification',
+  dkimPublic: 'public-key',
+  senderHost: 'mail.example.com',
+  sendingIp: '192.0.2.10',
+  spfExtra: '',
+  dmarcPolicy: 'none',
+  dmarcRua: '',
+  catchAllAddress: '',
+  status: {
+    verified: true,
+    checkedAt: '2026-07-14T00:00:00.000Z',
+    records: [
+      { key: 'spf', label: 'SPF', host: 'example.com', type: 'TXT', status: 'ok' },
+      { key: 'dkim', label: 'DKIM', host: 'mh._domainkey.example.com', type: 'TXT', status: 'ok' },
+      { key: 'dmarc', label: 'DMARC', host: '_dmarc.example.com', type: 'TXT', status: 'ok' }
+    ]
+  },
+  createdAt: '2026-07-14T00:00:00.000Z',
+  updatedAt: '2026-07-14T00:00:00.000Z'
+};

+ 326 - 0
test/ui/domains-workflow.test.tsx

@@ -0,0 +1,326 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { AppContext } from '../../src/frontend/app-context';
+import { I18nProvider } from '../../src/frontend/i18n/react';
+import { api } from '../../src/frontend/services/api';
+import { mailhubTheme } from '../../src/frontend/theme';
+import type { AppContextValue } from '../../src/frontend/app-context';
+import type { AddDomainPayload, Domain } from '../../src/frontend/types';
+import Domains, { createDomainWithSetup, readDomainPagination } from '../../src/pages/Domains';
+import DomainDetail from '../../src/pages/Domains/DomainDetail';
+
+const defaultMatchMedia = window.matchMedia;
+
+afterEach(() => {
+  Object.defineProperty(window, 'matchMedia', { configurable: true, writable: true, value: defaultMatchMedia });
+});
+
+describe('Domains URL state and creation workflow', () => {
+  it('normalizes unsupported pagination values', () => {
+    expect(readDomainPagination(new URLSearchParams('page=-1&pageSize=999'))).toEqual({ page: 1, pageSize: 20 });
+  });
+
+  it('opens from create=1 and removes only the create flag when cancelled', async () => {
+    const user = userEvent.setup();
+    mockListApis(makeDomains(25));
+
+    renderPage('/domains?create=1&q=keep');
+
+    expect(await screen.findByText('添加发信域名')).toBeTruthy();
+    await waitFor(() => {
+      const params = currentSearchParams();
+      expect(params.get('page')).toBe('1');
+      expect(params.get('pageSize')).toBe('20');
+    });
+    const drawer = screen.getByRole('dialog', { name: '添加发信域名' });
+    await user.click(within(drawer).getByRole('button', { name: /取\s*消/ }));
+
+    await waitFor(() => expect(currentSearchParams().has('create')).toBe(false));
+    const params = currentSearchParams();
+    expect(params.get('q')).toBe('keep');
+    expect(params.get('page')).toBe('1');
+    expect(params.get('pageSize')).toBe('20');
+  });
+
+  it('always applies DNS after an automatic-mode creation', async () => {
+    const created = makeDomain(1);
+    const applied = { ...created, status: { ...created.status, verified: true } };
+    const operations = {
+      createDomain: vi.fn(async () => ({ domain: created })),
+      applyDns: vi.fn(async () => ({ domain: applied, apply: { ok: true, results: [] } })),
+      checkDomain: vi.fn(async () => ({ domain: created }))
+    };
+
+    const result = await createDomainWithSetup({ ...createPayload, dnsCredentialId: 9 }, operations);
+
+    expect(operations.applyDns).toHaveBeenCalledWith(created.id);
+    expect(operations.checkDomain).not.toHaveBeenCalled();
+    expect(result).toMatchObject({ domain: applied, setup: 'complete', followUp: 'apply' });
+  });
+
+  it('checks DNS after a manual creation only when immediate checking is enabled', async () => {
+    const created = makeDomain(1);
+    const checked = { ...created, status: { ...created.status, checkedAt: '2026-07-14T01:00:00.000Z' } };
+    const operations = {
+      createDomain: vi.fn(async () => ({ domain: created })),
+      applyDns: vi.fn(async () => ({ domain: created, apply: { ok: true, results: [] } })),
+      checkDomain: vi.fn(async () => ({ domain: checked }))
+    };
+
+    const result = await createDomainWithSetup({ ...createPayload, immediateCheck: true }, operations);
+
+    expect(operations.checkDomain).toHaveBeenCalledWith(created.id);
+    expect(operations.applyDns).not.toHaveBeenCalled();
+    expect(result).toMatchObject({ domain: checked, setup: 'complete', followUp: 'check' });
+  });
+
+  it('returns partial after a follow-up failure without turning it into a second create failure', async () => {
+    const created = makeDomain(1);
+    const operations = {
+      createDomain: vi.fn(async () => ({ domain: created })),
+      applyDns: vi.fn(async () => { throw new Error('provider unavailable'); }),
+      checkDomain: vi.fn(async () => ({ domain: created }))
+    };
+
+    await expect(createDomainWithSetup({ ...createPayload, dnsCredentialId: 9 }, operations)).resolves.toMatchObject({
+      domain: created,
+      setup: 'partial',
+      followUp: 'apply',
+      error: 'provider unavailable'
+    });
+    expect(operations.createDomain).toHaveBeenCalledTimes(1);
+  });
+
+  it('treats apply.ok=false as partial and keeps the persisted per-record result', async () => {
+    const created = makeDomain(1);
+    const applied = {
+      ...created,
+      status: {
+        ...created.status,
+        apply: {
+          ok: false,
+          results: [{ key: 'dmarc', type: 'TXT', host: '_dmarc.example.test', ok: false, error: 'permission denied' }]
+        }
+      }
+    } satisfies Domain;
+    const operations = {
+      createDomain: vi.fn(async () => ({ domain: created })),
+      applyDns: vi.fn(async () => ({ domain: applied, apply: applied.status.apply })),
+      checkDomain: vi.fn(async () => ({ domain: created }))
+    };
+
+    await expect(createDomainWithSetup({ ...createPayload, dnsCredentialId: 9 }, operations)).resolves.toMatchObject({
+      domain: applied,
+      setup: 'partial',
+      followUp: 'apply',
+      error: 'permission denied'
+    });
+  });
+
+  it('still rejects when domain creation itself fails', async () => {
+    const operations = {
+      createDomain: vi.fn(async () => { throw new Error('domain already exists'); }),
+      applyDns: vi.fn(async () => ({ domain: makeDomain(1), apply: { ok: true, results: [] } })),
+      checkDomain: vi.fn(async () => ({ domain: makeDomain(1) }))
+    };
+
+    await expect(createDomainWithSetup({ ...createPayload, dnsCredentialId: 9 }, operations)).rejects.toThrow('domain already exists');
+    expect(operations.applyDns).not.toHaveBeenCalled();
+    expect(operations.checkDomain).not.toHaveBeenCalled();
+  });
+
+  it('keeps page and pageSize in the URL and resets page when a filter changes', async () => {
+    const user = userEvent.setup();
+    mockListApis(makeDomains(25));
+    setViewport(390);
+
+    renderPage('/domains?page=2&pageSize=10');
+
+    expect(await screen.findByText('keep-11.example.test')).toBeTruthy();
+    expect(screen.queryByText('keep-01.example.test')).toBeNull();
+    await user.type(screen.getByLabelText('搜索域名'), '01');
+
+    await waitFor(() => {
+      const params = currentSearchParams();
+      expect(params.get('q')).toBe('01');
+      expect(params.get('page')).toBe('1');
+      expect(params.get('pageSize')).toBe('10');
+    });
+    expect(await screen.findByText('keep-01.example.test')).toBeTruthy();
+  });
+
+  it('uses the same URL page slice on mobile and desktop', async () => {
+    const domains = makeDomains(25);
+    const expected = domains.slice(10, 20).map((domain) => domain.domain);
+    mockListApis(domains);
+    setViewport(390);
+
+    renderPage('/domains?page=2&pageSize=10');
+    await screen.findByText(expected[0]);
+    const mobile = visibleDomainNames(domains);
+
+    cleanup();
+    setViewport(1024);
+    renderPage('/domains?page=2&pageSize=10');
+    await screen.findByText(expected[0]);
+    const desktop = visibleDomainNames(domains);
+
+    expect(mobile).toEqual(expected);
+    expect(desktop).toEqual(expected);
+  });
+
+  it('shows partial setup guidance and each persisted DNS apply result', async () => {
+    const domain = {
+      ...makeDomain(7),
+      dnsCredentialId: 4,
+      status: {
+        verified: false,
+        records: [],
+        apply: {
+          ok: false,
+          results: [
+            { key: 'spf', type: 'TXT', host: 'example.test', ok: true, detail: 'updated' },
+            { key: 'dmarc', type: 'TXT', host: '_dmarc.example.test', ok: false, error: 'permission denied' }
+          ]
+        }
+      }
+    } satisfies Domain;
+    mockListApis([domain], [{ id: 4, userId: 1, name: 'Cloudflare', provider: 'cloudflare', zoneName: 'example.test', defaultTtl: 600, createdAt: domain.createdAt, updatedAt: domain.updatedAt }]);
+
+    renderPage('/domains/7/dns?setup=partial');
+
+    expect(await screen.findByText('域名已创建,但 DNS 后续操作需要处理')).toBeTruthy();
+    const title = await screen.findByText('DNS 写入结果');
+    const card = title.closest('.ant-card');
+    expect(card).not.toBeNull();
+    expect(within(card as HTMLElement).getByText('example.test')).toBeTruthy();
+    expect(within(card as HTMLElement).getByText('_dmarc.example.test')).toBeTruthy();
+    expect(within(card as HTMLElement).getByText('permission denied')).toBeTruthy();
+    expect(within(card as HTMLElement).getByText('成功')).toBeTruthy();
+    expect(within(card as HTMLElement).getByText('失败')).toBeTruthy();
+  });
+});
+
+function renderPage(initialEntry: string) {
+  return render(
+    <ConfigProvider theme={mailhubTheme}>
+      <AntApp>
+        <I18nProvider>
+          <AppContext.Provider value={context}>
+            <MemoryRouter initialEntries={[initialEntry]}>
+              <Routes>
+                <Route path="/domains" element={<><Domains /><LocationProbe /></>} />
+                <Route path="/domains/:id/:section" element={<><DomainDetail /><LocationProbe /></>} />
+              </Routes>
+            </MemoryRouter>
+          </AppContext.Provider>
+        </I18nProvider>
+      </AntApp>
+    </ConfigProvider>
+  );
+}
+
+function LocationProbe() {
+  const location = useLocation();
+  return <output data-testid="location-search">{location.search}</output>;
+}
+
+function currentSearchParams() {
+  return new URLSearchParams(screen.getByTestId('location-search').textContent || '');
+}
+
+function mockListApis(domains: Domain[], credentials: Awaited<ReturnType<typeof api.dnsCredentials>>['credentials'] = []) {
+  vi.spyOn(api, 'domains').mockResolvedValue({ domains });
+  vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials });
+  vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [] });
+  vi.spyOn(api, 'events').mockResolvedValue({ events: [], total: 0, page: 1, pageSize: 100 });
+}
+
+function setViewport(width: number) {
+  Object.defineProperty(window, 'matchMedia', {
+    configurable: true,
+    writable: true,
+    value: (query: string) => {
+      const min = Number(query.match(/min-width:\s*(\d+)px/)?.[1] || 0);
+      const max = Number(query.match(/max-width:\s*(\d+)px/)?.[1] || Number.POSITIVE_INFINITY);
+      return {
+        matches: width >= min && width <= max,
+        media: query,
+        onchange: null,
+        addListener: () => undefined,
+        removeListener: () => undefined,
+        addEventListener: () => undefined,
+        removeEventListener: () => undefined,
+        dispatchEvent: () => false
+      };
+    }
+  });
+}
+
+function visibleDomainNames(domains: Domain[]) {
+  return domains.filter((domain) => screen.queryAllByText(domain.domain).length > 0).map((domain) => domain.domain);
+}
+
+function makeDomains(count: number) {
+  return Array.from({ length: count }, (_, index) => makeDomain(index + 1));
+}
+
+function makeDomain(id: number): Domain {
+  const ordinal = String(id).padStart(2, '0');
+  return {
+    id,
+    userId: 1,
+    dnsCredentialId: null,
+    smtpRelayId: null,
+    domain: `keep-${ordinal}.example.test`,
+    selector: 'mh202607',
+    verificationToken: `verification-${id}`,
+    dkimPublic: 'public-key',
+    senderHost: `mail-${ordinal}.example.test`,
+    sendingIp: '192.0.2.10',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: '',
+    catchAllAddress: '',
+    status: { verified: false, records: [] },
+    createdAt: '2026-07-14T00:00:00.000Z',
+    updatedAt: '2026-07-14T00:00:00.000Z'
+  };
+}
+
+const createPayload: AddDomainPayload = {
+  domain: 'example.test',
+  senderHost: 'mail.example.test',
+  sendingIp: '192.0.2.10',
+  selector: 'mh202607',
+  dmarcPolicy: 'none',
+  immediateCheck: true
+};
+
+const context: AppContextValue = {
+  user: { id: 1, username: 'operator', email: 'operator@example.test', role: 'admin', status: 'active' },
+  config: {
+    appBaseUrl: 'https://mail.example.test',
+    mailHostname: 'mail.example.test',
+    sendingIp: '192.0.2.10',
+    defaultSpfMechanisms: '',
+    dmarcPolicy: 'none',
+    dmarcRua: '',
+    sendRequiresVerified: true,
+    engagementTrackingEnabled: true,
+    listUnsubscribeMailto: '',
+    listUnsubscribeUrl: '',
+    listUnsubscribePostEnabled: false,
+    feedbackIdEnabled: false,
+    reportAbuseTo: '',
+    csaComplaintsTo: '',
+    bounceAddress: '',
+    bounceEnvelopeEnabled: false
+  },
+  refreshBootstrap: vi.fn(async () => undefined),
+  logout: vi.fn(async () => undefined)
+};

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

@@ -0,0 +1,204 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createMemoryRouter, RouterProvider } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
+import { I18nProvider } from '../../src/frontend/i18n/react';
+import { detailHistoryState } from '../../src/frontend/navigation-state';
+import { api } from '../../src/frontend/services/api';
+import { mailhubTheme } from '../../src/frontend/theme';
+import type { InboundMailbox, InboundMessage, RuntimeConfig } from '../../src/frontend/types';
+import Inbox from '../../src/pages/Inbox';
+
+describe('Inbox detail return path', () => {
+  afterEach(() => vi.restoreAllMocks());
+
+  it('keeps the original list history when another message is selected from an open detail', async () => {
+    const user = userEvent.setup();
+    const first = messageFixture(9, 1, 'Sent', 'First message');
+    const second = messageFixture(10, 1, 'Sent', 'Second message');
+    mockInboxApis([mailboxFixture(1)], [first, second]);
+    vi.spyOn(api, 'inboundMessage').mockImplementation(async (id) => ({ message: id === second.id ? second : first }));
+    const listPath = '/inbox?mailboxId=1&folder=Sent&page=2';
+    const router = createInboxRouter(['/overview', listPath], 1);
+    renderRouter(router);
+
+    await user.click(await screen.findByRole('button', { name: 'First message · sender@example.test' }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/inbox/messages/9'));
+    expect(detailHistoryState(router.state.location.state)).toEqual({ listPath, depth: 1, origin: 'list' });
+
+    fireEvent.click(screen.getByRole('button', { name: 'Second message · sender@example.test' }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/inbox/messages/10'));
+    expect(detailHistoryState(router.state.location.state)).toEqual({ listPath, depth: 2, origin: 'list' });
+
+    fireEvent.click(screen.getByRole('button', { name: 'Close' }));
+    await waitFor(() => expect(`${router.state.location.pathname}${router.state.location.search}`).toBe(listPath));
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/overview');
+  });
+
+  it('keeps direct detail tabs navigable and closes them without reopening on Back', async () => {
+    const user = userEvent.setup();
+    const deepLinked = messageFixture(20, 2, 'Archive', 'Archived message');
+    const messages = vi.fn(async () => ({ messages: [deepLinked], total: 1, page: 1, pageSize: 25 }));
+    mockInboxApis([mailboxFixture(1), mailboxFixture(2)], [deepLinked]);
+    vi.spyOn(api, 'inboundMessages').mockImplementation(messages);
+    vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: deepLinked });
+    const router = createInboxRouter(['/overview', '/inbox/messages/20'], 1);
+    renderRouter(router);
+
+    expect(await screen.findByText('Archived message')).toBeTruthy();
+    await waitFor(() => {
+      const params = new URLSearchParams(router.state.location.search);
+      expect(params.get('mailboxId')).toBe('2');
+      expect(params.get('folder')).toBe('Archive');
+    });
+    await waitFor(() => expect(messages).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 2, folder: 'Archive' })));
+
+    await user.click(screen.getByRole('tab', { name: 'HTML 源码' }));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html'));
+    expect(detailHistoryState(router.state.location.state)).toEqual({
+      listPath: '/inbox?mailboxId=2&folder=Archive',
+      depth: 1,
+      origin: 'direct'
+    });
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/inbox/messages/20');
+    expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
+    expect(detailHistoryState(router.state.location.state)).toBeNull();
+
+    await act(async () => {
+      await router.navigate(1);
+    });
+    expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html');
+    expect(detailHistoryState(router.state.location.state)?.origin).toBe('direct');
+
+    fireEvent.click(screen.getByRole('button', { name: 'Close' }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/inbox'));
+    const params = new URLSearchParams(router.state.location.search);
+    expect(params.get('mailboxId')).toBe('2');
+    expect(params.get('folder')).toBe('Archive');
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/overview');
+    expect(router.state.location.pathname).not.toContain('/messages/');
+  });
+});
+
+function createInboxRouter(initialEntries: string[], initialIndex: number) {
+  return createMemoryRouter([
+    { path: '/inbox', element: <Inbox /> },
+    { path: '/inbox/messages/:messageId', element: <Inbox /> },
+    { path: '*', element: <div>other</div> }
+  ], { initialEntries, initialIndex });
+}
+
+function renderRouter(router: ReturnType<typeof createInboxRouter>) {
+  return render(
+    <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
+      <AntApp>
+        <I18nProvider>
+          <AppContext.Provider value={appContext}>
+            <RouterProvider router={router} />
+          </AppContext.Provider>
+        </I18nProvider>
+      </AntApp>
+    </ConfigProvider>
+  );
+}
+
+function mockInboxApis(mailboxes: InboundMailbox[], messages: InboundMessage[]) {
+  vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
+  vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes });
+  vi.spyOn(api, 'inboundFolders').mockImplementation(async (mailboxId) => ({
+    folders: [
+      { name: 'INBOX', specialUse: null, messageCount: 0, unreadCount: 0 },
+      { name: 'Sent', specialUse: '\\Sent', messageCount: mailboxId === 1 ? messages.length : 0, unreadCount: 0 },
+      { name: 'Archive', specialUse: '\\Archive', messageCount: mailboxId === 2 ? messages.length : 0, unreadCount: 0 }
+    ]
+  }));
+  vi.spyOn(api, 'inboundMessages').mockResolvedValue({ messages, total: messages.length, page: 1, pageSize: 25 });
+}
+
+function mailboxFixture(id: number): InboundMailbox {
+  return {
+    id,
+    userId: 1,
+    domainId: id,
+    domain: `example-${id}.test`,
+    address: `inbox-${id}@example-${id}.test`,
+    localPart: `inbox-${id}`,
+    displayName: `Inbox ${id}`,
+    aliases: [],
+    forwardTo: [],
+    keepForwarded: true,
+    quotaMb: 1024,
+    passwordSet: true,
+    passwordRecoverable: false,
+    status: 'active',
+    messageCount: 2,
+    unreadCount: 0,
+    createdAt: '2026-07-14T00:00:00.000Z',
+    updatedAt: '2026-07-14T00:00:00.000Z'
+  };
+}
+
+function messageFixture(id: number, mailboxId: number, folder: string, subject: string): InboundMessage {
+  return {
+    id,
+    mailboxId,
+    userId: 1,
+    domainId: mailboxId,
+    domain: `example-${mailboxId}.test`,
+    mailboxAddress: `inbox-${mailboxId}@example-${mailboxId}.test`,
+    folder,
+    sender: 'sender@example.test',
+    recipients: [`inbox-${mailboxId}@example-${mailboxId}.test`],
+    subject,
+    messageId: `<message-${id}@example.test>`,
+    preview: `${subject} preview`,
+    read: true,
+    receivedAt: '2026-07-14T00:00:00.000Z',
+    createdAt: '2026-07-14T00:00:00.000Z',
+    updatedAt: '2026-07-14T00:00:00.000Z',
+    textBody: `${subject} body`,
+    htmlBody: `<p>${subject}</p>`,
+    rawMessage: `Subject: ${subject}`
+  };
+}
+
+const runtimeConfig: RuntimeConfig = {
+  appBaseUrl: 'https://mail.example.test',
+  mailHostname: 'mail.example.test',
+  sendingIp: '192.0.2.10',
+  defaultSpfMechanisms: '',
+  dmarcPolicy: 'none',
+  dmarcRua: '',
+  sendRequiresVerified: true,
+  engagementTrackingEnabled: true,
+  listUnsubscribeMailto: '',
+  listUnsubscribeUrl: '',
+  listUnsubscribePostEnabled: false,
+  feedbackIdEnabled: false,
+  reportAbuseTo: '',
+  csaComplaintsTo: '',
+  bounceAddress: '',
+  bounceEnvelopeEnabled: false
+};
+
+const appContext: AppContextValue = {
+  user: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin', status: 'active' },
+  config: runtimeConfig,
+  refreshBootstrap: vi.fn(async () => undefined),
+  logout: vi.fn(async () => undefined)
+};

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

@@ -0,0 +1,317 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createMemoryRouter, RouterProvider } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
+import { I18nProvider } from '../../src/frontend/i18n/react';
+import { api } from '../../src/frontend/services/api';
+import { mailhubTheme } from '../../src/frontend/theme';
+import type { AdminUser, AuditLogEntry, InboundMailbox, InboundMessage, RuntimeConfig, SendEvent } from '../../src/frontend/types';
+import AdminPage from '../../src/pages/Admin';
+import Inbox from '../../src/pages/Inbox';
+import SendingLogs from '../../src/pages/SendingLogs';
+
+describe('Operational navigation state', () => {
+  afterEach(() => vi.restoreAllMocks());
+
+  it('keeps inbox body tabs in the URL and does not reopen a closed detail drawer on back', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
+    vi.spyOn(api, 'inboundFolders').mockResolvedValue({
+      folders: [{ name: 'INBOX', specialUse: null, messageCount: 1, unreadCount: 0 }]
+    });
+    vi.spyOn(api, 'inboundMessages').mockResolvedValue({ messages: [inboundMessage], total: 1, page: 1, pageSize: 25 });
+    vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: inboundMessage });
+    const router = createMemoryRouter([
+      { path: '/inbox', element: <Inbox /> },
+      { path: '/inbox/messages/:messageId', element: <Inbox /> },
+      { path: '*', element: <div>other</div> }
+    ], {
+      initialEntries: ['/overview', '/inbox?mailboxId=1&folder=INBOX'],
+      initialIndex: 1
+    });
+    renderRouter(router);
+
+    await user.click(await screen.findByRole('button', { name: 'Quarterly report · sender@example.test' }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/inbox/messages/9'));
+    await user.click(await screen.findByRole('tab', { name: 'HTML 源码' }));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html'));
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/inbox/messages/9');
+    expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
+    await act(async () => {
+      await router.navigate(1);
+    });
+    expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('html');
+
+    await user.click(screen.getByRole('button', { name: 'Close' }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/inbox'));
+    expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/overview');
+    expect(router.state.location.pathname).not.toContain('/messages/');
+  });
+
+  it('does not reopen a closed activity drawer after switching its detail tab', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'events').mockResolvedValue({ events: [sendEvent], total: 1, page: 1, pageSize: 25 });
+    vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
+    vi.spyOn(api, 'event').mockResolvedValue({ event: sendEvent });
+    const router = createMemoryRouter([
+      { path: '/activity', element: <SendingLogs /> },
+      { path: '/activity/:eventId', element: <SendingLogs /> },
+      { path: '*', element: <div>other</div> }
+    ], {
+      initialEntries: ['/overview', '/activity?page=1&pageSize=25'],
+      initialIndex: 1
+    });
+    renderRouter(router);
+
+    await user.click(await screen.findByRole('button', { name: '查看发送详情' }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/activity/11'));
+    await user.click(await screen.findByRole('tab', { name: 'Webhook' }));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('webhooks'));
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/activity/11');
+    expect(new URLSearchParams(router.state.location.search).has('tab')).toBe(false);
+    await act(async () => {
+      await router.navigate(1);
+    });
+    expect(new URLSearchParams(router.state.location.search).get('tab')).toBe('webhooks');
+
+    await user.click(screen.getByRole('button', { name: 'Close' }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/overview');
+  });
+
+  it('keeps other admin rows interactive while a user mutation is pending', async () => {
+    const user = userEvent.setup();
+    const pending = deferred<{ message: string }>();
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
+    vi.spyOn(api, 'resendAdminVerification').mockReturnValue(pending.promise);
+    const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
+      initialEntries: ['/admin/users']
+    });
+    renderRouter(router);
+
+    const firstRow = (await screen.findByText('first@example.test')).closest('tr');
+    const secondRow = screen.getByText('second@example.test').closest('tr');
+    expect(firstRow).not.toBeNull();
+    expect(secondRow).not.toBeNull();
+    await user.click(within(firstRow!).getByRole('button', { name: /重发验证/ }));
+
+    await waitFor(() => expect((within(firstRow!).getByRole('button', { name: /重置邮件/ }) as HTMLButtonElement).disabled).toBe(true));
+    expect((within(secondRow!).getByRole('button', { name: /重置邮件/ }) as HTMLButtonElement).disabled).toBe(false);
+
+    pending.resolve({ message: 'ok' });
+    await waitFor(() => expect((within(firstRow!).getByRole('button', { name: /重置邮件/ }) as HTMLButtonElement).disabled).toBe(false));
+  });
+
+  it('restores audit filters from URL history', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
+    const auditLogs = vi.spyOn(api, 'adminAuditLogs').mockResolvedValue({ logs: [] });
+    const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
+      initialEntries: ['/admin/audit-logs?action=admin.old&actorUserId=1']
+    });
+    renderRouter(router);
+
+    const actionInput = await screen.findByLabelText('动作');
+    await waitFor(() => expect((actionInput as HTMLInputElement).value).toBe('admin.old'));
+    expect(auditLogs).toHaveBeenCalledWith('action=admin.old&actorUserId=1');
+    await user.clear(actionInput);
+    await user.type(actionInput, 'admin.new');
+    await user.click(screen.getByRole('button', { name: /查.*询/ }));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('action')).toBe('admin.new'));
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    await waitFor(() => expect((screen.getByLabelText('动作') as HTMLInputElement).value).toBe('admin.old'));
+    await waitFor(() => expect(auditLogs).toHaveBeenLastCalledWith('action=admin.old&actorUserId=1'));
+  });
+
+  it('ignores a late audit response after browser back restores an earlier query', async () => {
+    const user = userEvent.setup();
+    const delayedNewQuery = deferred<{ logs: AuditLogEntry[] }>();
+    const oldLog = auditLog(1, 'admin.old.result');
+    const lateLog = auditLog(2, 'admin.new.late-result');
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
+    const auditLogs = vi.spyOn(api, 'adminAuditLogs').mockImplementation((query) => (
+      query === 'action=admin.new' ? delayedNewQuery.promise : Promise.resolve({ logs: [oldLog] })
+    ));
+    const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
+      initialEntries: ['/admin/audit-logs?action=admin.old']
+    });
+    renderRouter(router);
+
+    const actionInput = await screen.findByLabelText('动作');
+    expect(await screen.findByText(oldLog.action)).toBeTruthy();
+    await user.clear(actionInput);
+    await user.type(actionInput, 'admin.new');
+    await user.click(screen.getByRole('button', { name: /查.*询/ }));
+    await waitFor(() => expect(auditLogs).toHaveBeenCalledWith('action=admin.new'));
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    await waitFor(() => expect((screen.getByLabelText('动作') as HTMLInputElement).value).toBe('admin.old'));
+    await waitFor(() => expect(auditLogs).toHaveBeenLastCalledWith('action=admin.old'));
+    expect(screen.getByText(oldLog.action)).toBeTruthy();
+
+    await act(async () => {
+      delayedNewQuery.resolve({ logs: [lateLog] });
+      await delayedNewQuery.promise;
+    });
+    await waitFor(() => expect(screen.queryByText(lateLog.action)).toBeNull());
+    expect(screen.getByText(oldLog.action)).toBeTruthy();
+  });
+});
+
+function renderRouter(router: ReturnType<typeof createMemoryRouter>) {
+  return render(
+    <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
+      <AntApp>
+        <I18nProvider>
+          <AppContext.Provider value={appContext}>
+            <RouterProvider router={router} />
+          </AppContext.Provider>
+        </I18nProvider>
+      </AntApp>
+    </ConfigProvider>
+  );
+}
+
+function deferred<T>() {
+  let resolve!: (value: T) => void;
+  const promise = new Promise<T>((done) => {
+    resolve = done;
+  });
+  return { promise, resolve };
+}
+
+function auditLog(id: number, action: string): AuditLogEntry {
+  return {
+    id,
+    actorUserId: 1,
+    action,
+    targetType: 'user',
+    targetId: '2',
+    targetUserId: 2,
+    summary: {},
+    createdAt: '2026-07-14T00:00:00.000Z'
+  };
+}
+
+const runtimeConfig: RuntimeConfig = {
+  appBaseUrl: 'https://mail.example.test',
+  mailHostname: 'mail.example.test',
+  sendingIp: '192.0.2.10',
+  defaultSpfMechanisms: '',
+  dmarcPolicy: 'none',
+  dmarcRua: '',
+  sendRequiresVerified: true,
+  engagementTrackingEnabled: true,
+  listUnsubscribeMailto: '',
+  listUnsubscribeUrl: '',
+  listUnsubscribePostEnabled: false,
+  feedbackIdEnabled: false,
+  reportAbuseTo: '',
+  csaComplaintsTo: '',
+  bounceAddress: '',
+  bounceEnvelopeEnabled: false
+};
+
+const appContext: AppContextValue = {
+  user: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin', status: 'active' },
+  config: runtimeConfig,
+  refreshBootstrap: vi.fn(async () => undefined),
+  logout: vi.fn(async () => undefined)
+};
+
+const mailbox: InboundMailbox = {
+  id: 1,
+  userId: 1,
+  domainId: 1,
+  domain: 'example.test',
+  address: 'inbox@example.test',
+  localPart: 'inbox',
+  displayName: 'Inbox',
+  aliases: [],
+  forwardTo: [],
+  keepForwarded: true,
+  quotaMb: 1024,
+  passwordSet: true,
+  passwordRecoverable: false,
+  status: 'active',
+  messageCount: 1,
+  unreadCount: 0,
+  createdAt: '2026-07-14T00:00:00.000Z',
+  updatedAt: '2026-07-14T00:00:00.000Z'
+};
+
+const inboundMessage: InboundMessage = {
+  id: 9,
+  mailboxId: 1,
+  userId: 1,
+  domainId: 1,
+  domain: 'example.test',
+  mailboxAddress: 'inbox@example.test',
+  folder: 'INBOX',
+  sender: 'sender@example.test',
+  recipients: ['inbox@example.test'],
+  subject: 'Quarterly report',
+  messageId: '<message-9@example.test>',
+  preview: 'Report preview',
+  read: true,
+  receivedAt: '2026-07-14T00:00:00.000Z',
+  createdAt: '2026-07-14T00:00:00.000Z',
+  updatedAt: '2026-07-14T00:00:00.000Z',
+  textBody: 'Plain text',
+  htmlBody: '<p>HTML</p>',
+  rawMessage: 'Raw MIME'
+};
+
+const sendEvent: SendEvent = {
+  id: 11,
+  userId: 1,
+  domainId: null,
+  smtpRelayId: null,
+  domain: 'example.test',
+  sender: 'sender@example.test',
+  recipients: ['recipient@example.test'],
+  subject: 'Delivery test',
+  status: 'delivered',
+  detail: '250 accepted',
+  queueId: 'QUEUE-11',
+  messageId: 'mh-11',
+  createdAt: '2026-07-14T00:00:00.000Z'
+};
+
+const resourceCounts = {
+  domains: 0,
+  dnsCredentials: 0,
+  apiTokens: 0,
+  inboundMailboxes: 0,
+  inboundMessages: 0,
+  sendEvents: 0,
+  smtpCredential: 0
+};
+
+const adminUsers: AdminUser[] = [
+  { id: 1, username: 'first', email: 'first@example.test', role: 'user', status: 'pending_email', resourceCounts },
+  { id: 2, username: 'second', email: 'second@example.test', role: 'user', status: 'active', resourceCounts }
+];

+ 153 - 0
test/ui/settings-navigation-guard.test.tsx

@@ -0,0 +1,153 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
+import { I18nProvider } from '../../src/frontend/i18n/react';
+import { api } from '../../src/frontend/services/api';
+import { mailhubTheme } from '../../src/frontend/theme';
+import type { RuntimeConfig } from '../../src/frontend/types';
+import { AdminLayout } from '../../src/layouts/AdminLayout';
+import Settings from '../../src/pages/Settings';
+
+describe('Settings navigation guard', () => {
+  afterEach(() => vi.restoreAllMocks());
+
+  it('guards sidebar and history navigation, beforeunload, and stops guarding after a successful save', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'adminSettings').mockResolvedValue({ settings: runtimeConfig });
+    const saveSettings = vi.spyOn(api, 'saveAdminSettings').mockImplementation(async (values) => ({
+      settings: { ...runtimeConfig, ...values }
+    }));
+    const router = renderSettingsRouter();
+
+    let baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
+    const cleanUnload = new Event('beforeunload', { cancelable: true });
+    window.dispatchEvent(cleanUnload);
+    expect(cleanUnload.defaultPrevented).toBe(false);
+
+    await user.clear(baseUrlInput);
+    await user.type(baseUrlInput, 'https://changed.example.test');
+    const dirtyUnload = new Event('beforeunload', { cancelable: true });
+    window.dispatchEvent(dirtyUnload);
+    expect(dirtyUnload.defaultPrevented).toBe(true);
+
+    await user.click(screen.getAllByText('发送活动')[0]);
+    let dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
+    expect(router.state.location.pathname).toBe('/settings');
+    await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
+    await waitFor(() => expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull());
+    expect(router.state.location.pathname).toBe('/settings');
+
+    await user.click(screen.getAllByText('发送活动')[0]);
+    dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
+    await user.click(within(dialog).getByRole('button', { name: '离开页面' }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
+    await user.clear(baseUrlInput);
+    await user.type(baseUrlInput, 'https://history.example.test');
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
+    expect(router.state.location.pathname).toBe('/settings');
+    await user.click(within(dialog).getByRole('button', { name: '继续编辑' }));
+    await waitFor(() => expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull());
+
+    await act(async () => {
+      await router.navigate(1);
+    });
+    dialog = await screen.findByRole('dialog', { name: '放弃未保存的更改?' });
+    await user.click(within(dialog).getByRole('button', { name: '离开页面' }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/activity'));
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    baseUrlInput = await screen.findByLabelText('APP_BASE_URL');
+    await user.clear(baseUrlInput);
+    await user.type(baseUrlInput, 'https://saved.example.test');
+    const saveButton = screen.getByRole('button', { name: /保存设置/ });
+    await user.click(saveButton);
+    await waitFor(() => expect(saveSettings).toHaveBeenCalledTimes(1));
+    await waitFor(() => expect((saveButton as HTMLButtonElement).disabled).toBe(true));
+
+    const savedUnload = new Event('beforeunload', { cancelable: true });
+    window.dispatchEvent(savedUnload);
+    expect(savedUnload.defaultPrevented).toBe(false);
+    await user.click(screen.getAllByText('概览')[0]);
+    await waitFor(() => expect(router.state.location.pathname).toBe('/overview'));
+    expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull();
+  });
+});
+
+function renderSettingsRouter() {
+  const context: AppContextValue = {
+    user: {
+      id: 1,
+      username: 'operator',
+      email: 'operator@example.test',
+      role: 'admin',
+      status: 'active'
+    },
+    config: runtimeConfig,
+    refreshBootstrap: vi.fn(async () => undefined),
+    logout: vi.fn(async () => undefined)
+  };
+  const router = createMemoryRouter([
+    {
+      element: <AdminLayout />,
+      children: [
+        { path: '/settings', element: <Settings /> },
+        { path: '*', element: <LocationProbe /> }
+      ]
+    }
+  ], {
+    initialEntries: ['/overview', '/settings', '/activity'],
+    initialIndex: 1
+  });
+
+  render(
+    <ConfigProvider theme={{ ...mailhubTheme, token: { ...mailhubTheme.token, motion: false } }}>
+      <AntApp>
+        <I18nProvider>
+          <AppContext.Provider value={context}>
+            <RouterProvider router={router} />
+          </AppContext.Provider>
+        </I18nProvider>
+      </AntApp>
+    </ConfigProvider>
+  );
+  return router;
+}
+
+function LocationProbe() {
+  const location = useLocation();
+  return <div data-testid="location">{location.pathname}</div>;
+}
+
+const runtimeConfig: RuntimeConfig = {
+  appBaseUrl: 'https://mail.example.test',
+  mailHostname: 'mail.example.test',
+  sendingIp: '192.0.2.10',
+  defaultSpfMechanisms: '',
+  dmarcPolicy: 'none',
+  dmarcRua: '',
+  sendRequiresVerified: true,
+  engagementTrackingEnabled: true,
+  listUnsubscribeMailto: '',
+  listUnsubscribeUrl: '',
+  listUnsubscribePostEnabled: false,
+  feedbackIdEnabled: false,
+  reportAbuseTo: '',
+  csaComplaintsTo: '',
+  bounceAddress: '',
+  bounceEnvelopeEnabled: false
+};

+ 50 - 0
test/ui/setup.ts

@@ -0,0 +1,50 @@
+import { afterEach } from 'vitest';
+import { cleanup } from '@testing-library/react';
+
+afterEach(() => cleanup());
+
+function createStorage(): Storage {
+  const values = new Map<string, string>();
+  return {
+    get length() { return values.size; },
+    clear: () => values.clear(),
+    getItem: (key) => values.get(key) ?? null,
+    key: (index) => Array.from(values.keys())[index] ?? null,
+    removeItem: (key) => values.delete(key),
+    setItem: (key, value) => values.set(key, String(value))
+  };
+}
+
+Object.defineProperty(window, 'localStorage', { configurable: true, value: createStorage() });
+Object.defineProperty(window, 'sessionStorage', { configurable: true, value: createStorage() });
+
+Object.defineProperty(window, 'matchMedia', {
+  writable: true,
+  value: (query: string) => ({
+    matches: false,
+    media: query,
+    onchange: null,
+    addListener: () => undefined,
+    removeListener: () => undefined,
+    addEventListener: () => undefined,
+    removeEventListener: () => undefined,
+    dispatchEvent: () => false
+  })
+});
+
+class ResizeObserverStub {
+  observe() {}
+  unobserve() {}
+  disconnect() {}
+}
+
+Object.defineProperty(window, 'ResizeObserver', { writable: true, value: ResizeObserverStub });
+Object.defineProperty(globalThis, 'ResizeObserver', { writable: true, value: ResizeObserverStub });
+
+const getComputedStyle = window.getComputedStyle.bind(window);
+window.getComputedStyle = (element: Element) => getComputedStyle(element);
+
+if (!window.requestAnimationFrame) {
+  window.requestAnimationFrame = (callback) => window.setTimeout(callback, 0);
+  window.cancelAnimationFrame = (id) => window.clearTimeout(id);
+}

Some files were not shown because too many files changed in this diff