Explorar o código

feat: improve operations console usability

AI-Co-Authored-By: Codex
chendeben hai 1 mes
pai
achega
0b4a6d1532
Modificáronse 78 ficheiros con 2922 adicións e 583 borrados
  1. 0 0
      public/assets/Admin-CAnGE_j-.js
  2. 0 0
      public/assets/Admin-f5ZMQ_Pd.js
  3. 0 0
      public/assets/ApiTokens-CgzCcT7A.js
  4. 0 0
      public/assets/ApiTokens-TOP1JCZP.js
  5. 1 1
      public/assets/CodeBlock-Bv1z-p9E.js
  6. 1 1
      public/assets/CopyOutlined-DgLEpGTa.js
  7. 0 1
      public/assets/Dashboard-DG8Jzdjp.js
  8. 1 0
      public/assets/Dashboard-UH-WrLjE.js
  9. 2 2
      public/assets/DashboardCharts-REjKAK94.js
  10. 0 1
      public/assets/DeleteOutlined-B1WEjJYP.js
  11. 0 0
      public/assets/DnsApi-Bqc2786Y.js
  12. 0 0
      public/assets/DnsApi-U4yCaDJr.js
  13. 0 0
      public/assets/DomainDetail-C0KPEXKc.js
  14. 0 0
      public/assets/Domains-6Wy_5xjO.js
  15. 0 0
      public/assets/Domains-CuwsXj6O.js
  16. 1 0
      public/assets/EditOutlined-B4WrWHV3.js
  17. 0 1
      public/assets/EditOutlined-_SvZHznu.js
  18. 1 1
      public/assets/EllipsisOutlined-oNNgezW9.js
  19. 0 0
      public/assets/Inbox-BGre424Y.js
  20. 0 0
      public/assets/Inbox-BaUBfWPm.js
  21. 1 1
      public/assets/PlusOutlined-DNU7Mzq3.js
  22. 1 1
      public/assets/ReloadOutlined-C_t3CP1C.js
  23. 1 1
      public/assets/SearchOutlined-BDeH_Qai.js
  24. 0 0
      public/assets/SendingLogs-CLm9qApO.js
  25. 0 0
      public/assets/SendingLogs-Csu2aI-n.js
  26. 0 0
      public/assets/Settings-BJeG4UlS.js
  27. 0 0
      public/assets/Settings-DOAF7_S3.js
  28. 0 0
      public/assets/SmtpCredentials-CAxT6CE7.js
  29. 0 0
      public/assets/SmtpCredentials-DTJSeM-w.js
  30. 0 0
      public/assets/StatusPill-CAqweeP1.js
  31. 1 1
      public/assets/StatusTag-C9tkl7IN.js
  32. 1 1
      public/assets/ThunderboltOutlined-zxxPM5e8.js
  33. 0 0
      public/assets/Webhooks-DNpLohfY.js
  34. 0 0
      public/assets/Webhooks-rktzQtQD.js
  35. 1 1
      public/assets/form-Dc_x_ybP.js
  36. 1 1
      public/assets/grid-CvLJ37H1.js
  37. 0 1
      public/assets/index-CrChtXhS.js
  38. 0 0
      public/assets/landing-BUb9icyI.js
  39. 0 0
      public/assets/list-BZfhgW9n.js
  40. 0 0
      public/assets/login-BlHoEHHV.js
  41. 1 1
      public/assets/modal-hO5zE2jK.js
  42. 0 0
      public/assets/popconfirm-B3WWCjXM.js
  43. 0 1
      public/assets/row-BogeM7vF.js
  44. 1 0
      public/assets/row-cwQH2RQY.js
  45. 0 0
      public/assets/table-CJKsuOaW.js
  46. 0 0
      public/assets/theme-C5uSFXHJ.css
  47. 0 0
      public/assets/theme-CT_1QDVH.css
  48. 0 0
      public/assets/theme-Dy2IKOlV.js
  49. 3 3
      public/index.html
  50. 1 1
      public/landing.html
  51. 5 5
      public/login.html
  52. 70 62
      src/frontend/i18n/index.js
  53. 10 10
      src/frontend/landing/i18n.js
  54. 198 6
      src/frontend/styles.css
  55. 4 2
      src/layouts/AdminLayout.tsx
  56. 1 1
      src/pages/Admin/admin-model.js
  57. 241 92
      src/pages/Admin/index.tsx
  58. 173 41
      src/pages/ApiTokens.tsx
  59. 166 53
      src/pages/Dashboard.tsx
  60. 71 30
      src/pages/DnsApi.tsx
  61. 56 24
      src/pages/Domains/index.tsx
  62. 361 70
      src/pages/Inbox.tsx
  63. 164 48
      src/pages/SendingLogs.tsx
  64. 61 15
      src/pages/Settings.tsx
  65. 133 37
      src/pages/SmtpCredentials.tsx
  66. 60 27
      src/pages/Webhooks.tsx
  67. 19 0
      test/admin-responsive-style.test.js
  68. 1 1
      test/frontend-admin-model.test.js
  69. 22 10
      test/frontend-i18n.test.js
  70. 1 0
      test/ui/admin-layout.test.tsx
  71. 12 9
      test/ui/api-token-secret.test.tsx
  72. 263 0
      test/ui/dashboard.test.tsx
  73. 64 1
      test/ui/direct-detail-navigation.test.tsx
  74. 42 0
      test/ui/domains-workflow.test.tsx
  75. 159 7
      test/ui/inbox-navigation.test.tsx
  76. 488 0
      test/ui/integration-resource-resilience.test.tsx
  77. 32 8
      test/ui/operations-navigation.test.tsx
  78. 25 2
      test/ui/settings-navigation-guard.test.tsx

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Admin-CAnGE_j-.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Admin-f5ZMQ_Pd.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/ApiTokens-CgzCcT7A.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/ApiTokens-TOP1JCZP.js


+ 1 - 1
public/assets/CodeBlock--Yutv0IZ.js → public/assets/CodeBlock-Bv1z-p9E.js

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

+ 1 - 1
public/assets/CopyOutlined-DMc5GYOD.js → public/assets/CopyOutlined-DgLEpGTa.js

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
public/assets/Dashboard-DG8Jzdjp.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 0
public/assets/Dashboard-UH-WrLjE.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 2 - 2
public/assets/DashboardCharts-REjKAK94.js


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

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/DnsApi-Bqc2786Y.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/DnsApi-U4yCaDJr.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/DomainDetail-C0KPEXKc.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Domains-6Wy_5xjO.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Domains-CuwsXj6O.js


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

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

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

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Inbox-BGre424Y.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Inbox-BaUBfWPm.js


+ 1 - 1
public/assets/PlusOutlined-DWXMp6Ds.js → public/assets/PlusOutlined-DNU7Mzq3.js

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

+ 1 - 1
public/assets/ReloadOutlined-C3S2BDne.js → public/assets/ReloadOutlined-C_t3CP1C.js

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

+ 1 - 1
public/assets/SearchOutlined-C_VWNehW.js → public/assets/SearchOutlined-BDeH_Qai.js

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/SendingLogs-CLm9qApO.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/SendingLogs-Csu2aI-n.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Settings-BJeG4UlS.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Settings-DOAF7_S3.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/SmtpCredentials-CAxT6CE7.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/SmtpCredentials-DTJSeM-w.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/StatusPill-CAqweeP1.js


+ 1 - 1
public/assets/StatusTag-BsDXxFW1.js → public/assets/StatusTag-C9tkl7IN.js

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

+ 1 - 1
public/assets/ThunderboltOutlined-CbXdMhP9.js → public/assets/ThunderboltOutlined-zxxPM5e8.js

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Webhooks-DNpLohfY.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/Webhooks-rktzQtQD.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 1
public/assets/form-Dc_x_ybP.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1 - 1
public/assets/grid-CvLJ37H1.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
public/assets/index-CrChtXhS.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/landing-BUb9icyI.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/list-BZfhgW9n.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/login-BlHoEHHV.js


+ 1 - 1
public/assets/modal-B8KcNGBa.js → public/assets/modal-hO5zE2jK.js

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/popconfirm-B3WWCjXM.js


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

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

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

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

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/table-CJKsuOaW.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/theme-C5uSFXHJ.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/theme-CT_1QDVH.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
public/assets/theme-Dy2IKOlV.js


+ 3 - 3
public/index.html

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

+ 1 - 1
public/landing.html

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

+ 5 - 5
public/login.html

@@ -4,17 +4,17 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub Auth</title>
-    <script type="module" crossorigin src="/assets/login-BWBmrFVZ.js"></script>
+    <script type="module" crossorigin src="/assets/login-BlHoEHHV.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/react-dom-stZKkA6x.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-dm82oUI8.js">
-    <link rel="modulepreload" crossorigin href="/assets/theme-DjPzgo6g.js">
-    <link rel="modulepreload" crossorigin href="/assets/grid-BmaFEMOO.js">
+    <link rel="modulepreload" crossorigin href="/assets/theme-Dy2IKOlV.js">
+    <link rel="modulepreload" crossorigin href="/assets/grid-CvLJ37H1.js">
     <link rel="modulepreload" crossorigin href="/assets/segmented-DUpU-ING.js">
     <link rel="modulepreload" crossorigin href="/assets/useForm-sdDvuRHy.js">
-    <link rel="modulepreload" crossorigin href="/assets/form-CvEcrSb3.js">
+    <link rel="modulepreload" crossorigin href="/assets/form-Dc_x_ybP.js">
     <link rel="modulepreload" crossorigin href="/assets/input-0_Xo22Fl.js">
-    <link rel="stylesheet" crossorigin href="/assets/theme-CT_1QDVH.css">
+    <link rel="stylesheet" crossorigin href="/assets/theme-C5uSFXHJ.css">
   </head>
   <body>
     <div id="auth-root"></div>

+ 70 - 62
src/frontend/i18n/index.js

@@ -70,7 +70,7 @@ const messages = {
     'auth.valueDns': 'DNS 自动化',
     'auth.valueIsolation': '多用户隔离',
     'auth.valueObservability': '发送可观测',
-    'auth.subtitle': '发信域名、DNS、SMTP 与 API Token 的统一控制台',
+    'auth.subtitle': '发信域名、DNS、SMTP 与 API 密钥的统一控制台',
     'auth.requestFailed': '请求失败。',
     'dashboard.title': '统计概览',
     'dashboard.verifiedDomains': '已验证域名',
@@ -342,7 +342,7 @@ const messages = {
     'logs.statusBounced': '已退信',
     'logs.statusFailed': '失败',
     'smtp.connectionTitle': 'SMTP 连接信息',
-    'smtp.loginCredentialsTitle': '发信登录凭据',
+    'smtp.loginCredentialsTitle': 'SMTP 凭据',
     'smtp.username': '用户名',
     'smtp.password': '密码',
     'smtp.usernameRequired': '请输入 SMTP Username',
@@ -351,16 +351,16 @@ const messages = {
     'smtp.passwordEmpty': '未设置',
     'smtp.passwordUnavailable': '请重新设置后复制',
     'smtp.create': '新增凭据',
-    'smtp.createTitle': '新增发信登录凭据',
-    'smtp.editTitle': '编辑发信登录凭据',
+    'smtp.createTitle': '新增 SMTP 凭据',
+    'smtp.editTitle': '编辑 SMTP 凭据',
     'smtp.deleteConfirm': '确认删除该 SMTP 登录凭据?',
     'smtp.passwordExtra': '留空保存时会保留原密码;出于安全原因,已保存密码不会再次显示。',
     'smtp.regenerate': '重新生成密码',
     'smtp.resetToCopy': '请重新设置后复制',
-    'smtpRelay.title': '高级:外部 SMTP 出口',
+    'smtpRelay.title': '发送中继',
     'smtpRelay.create': '新增出口',
-    'smtpRelay.createTitle': '新增发信 SMTP 出口',
-    'smtpRelay.editTitle': '编辑发信 SMTP 出口',
+    'smtpRelay.createTitle': '新增发送中继',
+    'smtpRelay.editTitle': '编辑发送中继',
     'smtpRelay.name': '名称',
     'smtpRelay.nameRequired': '请输入出口名称',
     'smtpRelay.server': '服务器',
@@ -378,8 +378,8 @@ const messages = {
     'smtpRelay.domainDefaultExtra': '未选择时按请求指定、用户默认出口、系统全局 SMTP 的顺序选择。',
     'smtpRelay.useResolutionOrder': '按默认选择顺序',
     'smtpRelay.deleteConfirm': '确认删除该 SMTP 出口?绑定到该出口的域名会改为按默认顺序选择。',
-    'dnsApi.title': 'DNS API 凭据',
-    'dnsApi.createTitle': '新增 DNS API',
+    'dnsApi.title': 'DNS 集成凭据',
+    'dnsApi.createTitle': '新增 DNS 集成凭据',
     'dnsApi.editTitle': '编辑',
     'dnsApi.zone': 'Zone / 根域名',
     'dnsApi.zoneRequired': '请输入 Zone 或根域名',
@@ -390,12 +390,13 @@ const messages = {
     'dnsApi.save': '保存修改',
     'dnsApi.create': '新增凭据',
     'dnsApi.secretHint': '密钥只在服务端加密保存,不会在列表中回显。',
+    'dnsApi.deleteConfirm': '确认删除该 DNS 集成凭据?删除后将无法再通过此 Provider 管理对应 Zone。',
     'settings.checkItem': '检查项',
     'settings.deliveryChecks': '发信环境检查',
     'settings.noPermission': '当前账号没有系统设置权限。',
     'settings.save': '保存设置',
     'settings.engagementTracking': '默认启用打开与点击追踪',
-    'admin.title': '管理员面板',
+    'admin.title': '管理中心',
     'admin.users': '用户',
     'admin.resources': '资源',
     'admin.migration': '合并迁移',
@@ -409,15 +410,15 @@ const messages = {
     'status.pending': '等待生效',
     'status.error': '配置错误',
     'status.idle': '未配置',
-    'tokens.createTitle': '创建 API Token',
+    'tokens.createTitle': '创建 API 密钥',
     'tokens.subtitle': '按最小权限原则创建、更新或撤销用于发信和邮箱自动化的访问令牌。',
     'tokens.name': '名称',
     'tokens.namePlaceholder': 'Production API',
-    'tokens.nameRequired': '请输入 Token 名称',
-    'tokens.create': '创建 Token',
-    'tokens.edit': '编辑 Token',
-    'tokens.editTitle': '编辑 API Token',
-    'tokens.listTitle': 'API Tokens',
+    'tokens.nameRequired': '请输入密钥名称',
+    'tokens.create': '创建密钥',
+    'tokens.edit': '编辑密钥',
+    'tokens.editTitle': '编辑 API 密钥',
+    'tokens.listTitle': 'API 密钥',
     'tokens.prefix': 'Token 前缀',
     'tokens.scopes': '权限范围',
     'tokens.scopesRequired': '请至少选择一项权限',
@@ -439,31 +440,31 @@ const messages = {
     'tokens.fullToken': '完整 Token',
     'tokens.copyFull': '复制完整 Token',
     'tokens.copyPrefix': '复制前缀',
-    'tokens.createdTitle': 'API Token 已创建',
+    'tokens.createdTitle': 'API 密钥已创建',
     'tokens.createdWarning': '完整 Token 已加密保存,之后仍可在列表或详情中复制。请仅存放在可信的服务端环境。',
-    'tokens.createdSuccess': 'Token 已创建',
-    'tokens.rotatedTitle': 'API Token 已重新生成',
-    'tokens.rotatedSuccess': 'Token 已重新生成,旧值已立即失效',
+    'tokens.createdSuccess': 'API 密钥已创建',
+    'tokens.rotatedTitle': 'API 密钥已重新生成',
+    'tokens.rotatedSuccess': 'API 密钥已重新生成,旧值已立即失效',
     'tokens.rotatedWarning': '旧 Token 已立即失效。请尽快更新所有调用方,新的完整 Token 之后仍可在列表或详情中复制。',
-    'tokens.deletedSuccess': 'Token 已删除',
-    'tokens.updatedSuccess': 'Token 已更新',
-    'tokens.revokedSuccess': 'Token 已撤销',
+    'tokens.deletedSuccess': 'API 密钥已删除',
+    'tokens.updatedSuccess': 'API 密钥已更新',
+    'tokens.revokedSuccess': 'API 密钥已撤销',
     'tokens.secretAvailable': '完整 Token 已加密保存,可随时复制。',
     'tokens.secretUnavailable': '该历史 Token 不可恢复,只能通过前缀识别。重新生成后旧值会立即失效。',
     'tokens.recoverable': '可复制',
     'tokens.unrecoverable': '不可恢复',
     'tokens.rotate': '重新生成',
-    'tokens.rotateConfirm': '重新生成 API Token?',
+    'tokens.rotateConfirm': '重新生成 API 密钥?',
     'tokens.rotateConfirmDescription': '旧 Token 会立即失效,所有仍使用旧值的调用都会失败。此操作无法撤销。',
     'tokens.lastUsed': '最近使用',
     'tokens.neverUsed': '未使用',
     'tokens.createdAt': '创建时间',
     'tokens.actions': '操作',
-    'tokens.deleteConfirm': '确认删除该 Token?',
-    'tokens.revoke': '撤销 Token',
-    'tokens.revokeConfirm': '撤销后该 Token 将立即失效,确认继续?',
+    'tokens.deleteConfirm': '确认删除该 API 密钥?',
+    'tokens.revoke': '撤销密钥',
+    'tokens.revokeConfirm': '撤销后该 API 密钥将立即失效,确认继续?',
     'tokens.copyCreated': '复制完整 Token',
-    'tokens.prefixOnlyHelp': '新 Token 会加密保存并支持完整复制;旧版历史 Token 可能仅保留前缀,需要重新生成后才能恢复完整复制能力。',
+    'tokens.prefixOnlyHelp': '新 API 密钥会加密保存并支持完整复制;旧版历史 Token 可能仅保留前缀,需要重新生成后才能恢复完整复制能力。',
     'tokens.docsTitle': 'API 使用文档',
     'tokens.endpoint': 'API Endpoint',
     'tokens.mailboxEndpoint': '邮箱 API Endpoint',
@@ -498,7 +499,7 @@ const messages = {
     'tokens.securityTipRotate': '不同环境使用不同 Token,泄露后立即重新生成并更新所有调用方。',
     'tokens.securityTipScopes': '只授予实际需要的权限;给自动化邮箱创建任务时无需授予 send。',
     'tokens.securityTipDomain': 'From 域名必须属于当前账号,建议先完成 DNS 验证再接入生产发送。',
-    'tokens.noTokenHint': '创建 Token 后可在列表和详情中复制完整密钥,并按最小权限限制邮件读取范围。',
+    'tokens.noTokenHint': '创建 API 密钥后可在列表和详情中复制完整 Token,并按最小权限限制邮件读取范围。',
     'webhooks.title': 'Webhooks',
     'webhooks.subtitle': '在投递状态变为已送达、退信或失败时,向你的 HTTPS 端点推送通知。',
     'webhooks.create': '新建 Webhook',
@@ -542,6 +543,9 @@ const messages = {
     'webhooks.deleteConfirm': '确认删除该 Webhook?相关投递记录也会一并删除。',
     'webhooks.empty': '暂无 Webhook。创建后即可接收投递状态回调。',
     'webhooks.listTitle': '端点列表',
+    'webhooks.loadFailed': 'Webhook 端点加载失败',
+    'webhooks.recentDeliveriesUnavailable': '最近投递状态暂不可用',
+    'webhooks.resourcesUnavailable': '域名或邮箱选项暂不可用',
     'webhooks.actions': '操作',
     'webhooks.deliveriesTitle': '投递记录',
     'webhooks.deliveriesEmpty': '暂无投递记录',
@@ -600,17 +604,17 @@ const messages = {
     'nav.overview': '概览',
     'nav.activity': '发送活动',
     'nav.domains': '发信域名',
-    'nav.dnsApi': 'DNS API',
-    'nav.smtp': 'SMTP 凭据',
+    'nav.dnsApi': 'DNS 集成',
+    'nav.smtp': 'SMTP 与中继',
     'nav.inbox': '收件箱',
-    'nav.tokens': 'API Token',
+    'nav.tokens': 'API 密钥',
     'nav.logs': '发送记录',
     'nav.webhooks': 'Webhooks',
     'nav.smtpIntegration': 'SMTP 与中继',
     'nav.apiKeys': 'API 密钥',
     'nav.dnsIntegration': 'DNS 集成',
     'nav.adminCenter': '管理中心',
-    'nav.admin': '管理',
+    'nav.admin': '管理中心',
     'nav.settings': '系统设置',
     'nav.group.overview': '概览',
     'nav.group.delivery': '投递',
@@ -686,7 +690,7 @@ const messages = {
     'auth.valueDns': 'DNS automation',
     'auth.valueIsolation': 'User isolation',
     'auth.valueObservability': 'Delivery visibility',
-    'auth.subtitle': 'One console for sending domains, DNS, SMTP, and API tokens',
+    'auth.subtitle': 'One console for sending domains, DNS, SMTP, and API keys',
     'auth.requestFailed': 'Request failed.',
     'dashboard.title': 'Analytics',
     'dashboard.verifiedDomains': 'Verified domains',
@@ -958,7 +962,7 @@ const messages = {
     'inbox.messageNotFound': 'Message not found or deleted.',
     'inbox.detailLoadFailed': 'Failed to load message detail.',
     'smtp.connectionTitle': 'SMTP connection',
-    'smtp.loginCredentialsTitle': 'Sending login credentials',
+    'smtp.loginCredentialsTitle': 'SMTP credentials',
     'smtp.username': 'Username',
     'smtp.password': 'Password',
     'smtp.usernameRequired': 'Enter SMTP username',
@@ -967,13 +971,13 @@ const messages = {
     'smtp.passwordEmpty': 'Empty',
     'smtp.passwordUnavailable': 'Reset before copying',
     'smtp.create': 'New credential',
-    'smtp.createTitle': 'New sending login credential',
-    'smtp.editTitle': 'Edit sending login credential',
+    'smtp.createTitle': 'New SMTP credential',
+    'smtp.editTitle': 'Edit SMTP credential',
     'smtp.deleteConfirm': 'Delete this SMTP login credential?',
     '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',
+    'smtpRelay.title': 'Sending relays',
     'smtpRelay.create': 'New relay',
     'smtpRelay.createTitle': 'New outbound SMTP relay',
     'smtpRelay.editTitle': 'Edit outbound SMTP relay',
@@ -994,8 +998,8 @@ const messages = {
     'smtpRelay.domainDefaultExtra': 'When empty, MailHub uses request relay, user default relay, then system SMTP.',
     'smtpRelay.useResolutionOrder': 'Use default selection order',
     'smtpRelay.deleteConfirm': 'Delete this SMTP relay? Bound domains will fall back to the default selection order.',
-    'dnsApi.title': 'DNS API credentials',
-    'dnsApi.createTitle': 'New DNS API',
+    'dnsApi.title': 'DNS integration credentials',
+    'dnsApi.createTitle': 'New DNS integration credential',
     'dnsApi.editTitle': 'Edit',
     'dnsApi.zone': 'Zone / root domain',
     'dnsApi.zoneRequired': 'Enter the Zone or root domain',
@@ -1006,12 +1010,13 @@ const messages = {
     'dnsApi.save': 'Save changes',
     'dnsApi.create': 'Create credential',
     'dnsApi.secretHint': 'Secrets are encrypted server-side and never shown in the list.',
+    'dnsApi.deleteConfirm': 'Delete this DNS integration credential? This provider will no longer be available for managing the associated zone.',
     'settings.checkItem': 'Check',
     'settings.deliveryChecks': 'Sending Environment Checks',
     'settings.noPermission': 'This account cannot access system settings.',
     'settings.save': 'Save settings',
     'settings.engagementTracking': 'Enable open and click tracking by default',
-    'admin.title': 'Admin Panel',
+    'admin.title': 'Admin center',
     'admin.users': 'Users',
     'admin.resources': 'Resources',
     'admin.migration': 'Merge',
@@ -1025,15 +1030,15 @@ const messages = {
     'status.pending': 'Pending',
     'status.error': 'Misconfigured',
     'status.idle': 'Not configured',
-    'tokens.createTitle': 'Create API Token',
+    'tokens.createTitle': 'Create API key',
     'tokens.subtitle': 'Create, update, and revoke scoped tokens for sending and mailbox automation.',
     'tokens.name': 'Name',
     'tokens.namePlaceholder': 'Production API',
-    'tokens.nameRequired': 'Enter a token name',
-    'tokens.create': 'Create Token',
-    'tokens.edit': 'Edit token',
-    'tokens.editTitle': 'Edit API Token',
-    'tokens.listTitle': 'API Tokens',
+    'tokens.nameRequired': 'Enter a key name',
+    'tokens.create': 'Create key',
+    'tokens.edit': 'Edit key',
+    'tokens.editTitle': 'Edit API key',
+    'tokens.listTitle': 'API keys',
     'tokens.prefix': 'Token Prefix',
     'tokens.scopes': 'Scopes',
     'tokens.scopesRequired': 'Select at least one scope',
@@ -1055,29 +1060,29 @@ const messages = {
     'tokens.fullToken': 'Full Token',
     'tokens.copyFull': 'Copy full token',
     'tokens.copyPrefix': 'Copy prefix',
-    'tokens.createdTitle': 'API Token created',
+    'tokens.createdTitle': 'API key created',
     'tokens.createdWarning': 'The full token is stored encrypted and remains copyable from the list or detail view. Keep it only in a trusted server environment.',
-    'tokens.createdSuccess': 'Token created',
-    'tokens.rotatedTitle': 'API Token regenerated',
-    'tokens.rotatedSuccess': 'Token regenerated. The previous value stopped working immediately.',
+    'tokens.createdSuccess': 'API key created',
+    'tokens.rotatedTitle': 'API key regenerated',
+    'tokens.rotatedSuccess': 'API key regenerated. The previous value stopped working immediately.',
     'tokens.rotatedWarning': 'The previous token stopped working immediately. Update every caller soon; the new full token remains copyable from the list or detail view.',
-    'tokens.deletedSuccess': 'Token deleted',
-    'tokens.updatedSuccess': 'Token updated',
-    'tokens.revokedSuccess': 'Token revoked',
+    'tokens.deletedSuccess': 'API key deleted',
+    'tokens.updatedSuccess': 'API key updated',
+    'tokens.revokedSuccess': 'API key revoked',
     'tokens.secretAvailable': 'The full token is stored encrypted and can be copied at any time.',
     'tokens.secretUnavailable': 'This legacy token cannot be recovered and is identifiable only by its prefix. Regenerating it immediately invalidates the old value.',
     'tokens.recoverable': 'Copyable',
     'tokens.unrecoverable': 'Not recoverable',
     'tokens.rotate': 'Regenerate',
-    'tokens.rotateConfirm': 'Regenerate API Token?',
+    'tokens.rotateConfirm': 'Regenerate API key?',
     'tokens.rotateConfirmDescription': 'The previous token will stop working immediately. Every caller still using it will fail. This cannot be undone.',
     'tokens.lastUsed': 'Last used',
     'tokens.neverUsed': 'Never used',
     'tokens.createdAt': 'Created at',
     'tokens.actions': 'Actions',
-    'tokens.deleteConfirm': 'Delete this token?',
-    'tokens.revoke': 'Revoke token',
-    'tokens.revokeConfirm': 'This token will stop working immediately. Continue?',
+    'tokens.deleteConfirm': 'Delete this API key?',
+    'tokens.revoke': 'Revoke key',
+    'tokens.revokeConfirm': 'This API key will stop working immediately. Continue?',
     'tokens.copyCreated': 'Copy full token',
     'tokens.prefixOnlyHelp': 'New tokens are stored encrypted and remain fully copyable. Legacy tokens may retain only a prefix and must be regenerated to become recoverable.',
     'tokens.docsTitle': 'API guide',
@@ -1158,6 +1163,9 @@ const messages = {
     'webhooks.deleteConfirm': 'Delete this webhook? Related delivery records will also be removed.',
     'webhooks.empty': 'No webhooks yet. Create one to receive delivery status callbacks.',
     'webhooks.listTitle': 'Endpoints',
+    'webhooks.loadFailed': 'Unable to load webhook endpoints',
+    'webhooks.recentDeliveriesUnavailable': 'Recent delivery status is temporarily unavailable',
+    'webhooks.resourcesUnavailable': 'Domain or mailbox options are temporarily unavailable',
     'webhooks.actions': 'Actions',
     'webhooks.deliveriesTitle': 'Deliveries',
     'webhooks.deliveriesEmpty': 'No deliveries yet',
@@ -1216,17 +1224,17 @@ const messages = {
     'nav.overview': 'Overview',
     'nav.activity': 'Sending activity',
     'nav.domains': 'Domains',
-    'nav.dnsApi': 'DNS API',
-    'nav.smtp': 'SMTP Credentials',
+    'nav.dnsApi': 'DNS integrations',
+    'nav.smtp': 'SMTP & relays',
     'nav.inbox': 'Inbox',
-    'nav.tokens': 'API Tokens',
+    'nav.tokens': 'API keys',
     '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.admin': 'Admin center',
     'nav.settings': 'Settings',
     'nav.group.overview': 'Overview',
     'nav.group.delivery': 'Delivery',

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

@@ -49,11 +49,11 @@ const messages = {
     'features.hooks.title': 'Webhooks',
     'features.hooks.body': '对已送达 / 退信 / 失败终态事件进行签名回调。',
     'features.multi.title': '多用户隔离',
-    'features.multi.body': '域名、Token 与日志按账号隔离。',
+    'features.multi.body': '域名、API 密钥与日志按账号隔离。',
     'integrate.title': '三种接入方式',
     'integrate.sub': '选择适合你技术栈的路径 —— 注册登录后完成配置。',
     'integrate.api.title': 'API',
-    'integrate.api.body': '创建 API Token,使用 Bearer 认证 POST JSON 到 /api/send。',
+    'integrate.api.body': '创建 API 密钥,使用 Bearer Token 认证 POST JSON 到 /api/send。',
     'integrate.api.link': '查看 API 示例 →',
     'integrate.smtp.title': 'SMTP',
     'integrate.smtp.body': '将应用指向 MailHub Submission 端口,使用独立登录凭据。',
@@ -62,11 +62,11 @@ const messages = {
     'integrate.hooks.body': '为终态投递事件订阅端点,校验 HMAC 签名。',
     'integrate.hooks.link': '查看 Webhook 事件 →',
     'quickstart.title': '四步开始使用',
-    'quickstart.sub': '在同一个控制台完成域名、Token、邮箱和投递配置。',
+    'quickstart.sub': '在同一个控制台完成域名、API 密钥、邮箱和投递配置。',
     'quickstart.domain.title': '添加域名',
     'quickstart.domain.body': '添加发信域名,并发布 MailHub 展示的 DNS 记录。',
     'quickstart.domain.link': '查看域名配置 →',
-    'quickstart.token.title': '创建最小权限 Token',
+    'quickstart.token.title': '创建最小权限 API 密钥',
     'quickstart.token.body': '只为每个接入授予所需的发信或邮箱权限。',
     'quickstart.token.link': '查看 API 权限 →',
     'quickstart.mailbox.title': '创建邮箱',
@@ -77,8 +77,8 @@ const messages = {
     'quickstart.client.link': '查看协议配置 →',
     'api.title': 'API 认证与发信',
     'api.sub': '所有公开 API 均使用 Bearer Token。下列主机名为占位符,请替换为你的 MailHub 地址。',
-    'api.auth.title': '创建并保护 Token',
-    'api.auth.body': '在控制台的 API Token 页面创建 Token。所属账号可查看并复制完整 Token,请严格控制控制台访问权限,切勿写入浏览器代码或公开仓库。',
+    'api.auth.title': '创建并保护 API 密钥',
+    'api.auth.body': '在控制台的 API 密钥页面创建访问 Token。所属账号可查看并复制完整 Token,请严格控制控制台访问权限,切勿写入浏览器代码或公开仓库。',
     'api.auth.send': '使用 POST /api/send 发信',
     'api.auth.read': '使用 GET /api/mailboxes 查询邮箱',
     'api.auth.write': '使用 POST /api/mailboxes 创建邮箱',
@@ -103,7 +103,7 @@ const messages = {
     'api.field.text': '纯文本正文',
     'api.field.html': 'HTML 正文;启用打开或点击跟踪时需要提供',
     'api.field.tracking': 'true/false,或 { opens, clicks };仅改写 HTML',
-    'api.field.relay': '属于当前账号的外部 SMTP 出口 ID',
+    'api.field.relay': '属于当前账号的发送中继 ID',
     'api.responseLabel': '202 响应',
     'api.response': `{
   "eventId": 42,
@@ -219,7 +219,7 @@ const messages = {
     'errors.401.meaning': 'Token 缺失、无效、已撤销或已过期',
     'errors.401.action': '创建或轮换一个有效 Token。',
     'errors.403.meaning': 'Token 缺少所需权限',
-    'errors.403.action': '在控制台 API Token 页面授予最小所需权限。',
+    'errors.403.action': '在控制台 API 密钥页面授予最小所需权限。',
     'errors.409.meaning': '邮箱地址已存在',
     'errors.409.action': '换用其他地址,或使用现有邮箱。',
     'webhooks.title': '投递 Webhooks',
@@ -307,7 +307,7 @@ const messages = {
     'api.title': 'API authentication and sending',
     'api.sub': 'All public API calls use a Bearer token. The hostname below is a placeholder; replace it with your MailHub host.',
     'api.auth.title': 'Create and protect a token',
-    'api.auth.body': 'Create a token in Console > API Tokens. Full tokens remain visible to the owning account, so restrict console access and never place tokens in browser code or public repositories.',
+    'api.auth.body': 'Create a token in Console > API keys. Full tokens remain visible to the owning account, so restrict console access and never place tokens in browser code or public repositories.',
     'api.auth.send': 'send messages with POST /api/send',
     'api.auth.read': 'list mailboxes with GET /api/mailboxes',
     'api.auth.write': 'create mailboxes with POST /api/mailboxes',
@@ -448,7 +448,7 @@ const messages = {
     'errors.401.meaning': 'Missing, invalid, revoked, or expired token',
     'errors.401.action': 'Create or rotate a valid token.',
     'errors.403.meaning': 'Token does not have the required scope',
-    'errors.403.action': 'Grant the minimum required scope in Console > API Tokens.',
+    'errors.403.action': 'Grant the minimum required scope in Console > API keys.',
     'errors.409.meaning': 'Mailbox address already exists',
     'errors.409.action': 'Choose another address or use the existing mailbox.',
     'webhooks.title': 'Delivery webhooks',

+ 198 - 6
src/frontend/styles.css

@@ -22,6 +22,10 @@ body,
   min-height: 100%;
 }
 
+#root {
+  width: 100%;
+}
+
 html {
   scrollbar-gutter: stable;
 }
@@ -31,7 +35,6 @@ body {
   background: var(--mh-canvas);
   color: var(--mh-ink);
   font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-  overflow-x: hidden;
 }
 
 button,
@@ -71,6 +74,8 @@ textarea,
 }
 
 .admin-layout {
+  max-width: 100%;
+  min-width: 0;
   min-height: 100vh;
 }
 
@@ -266,25 +271,53 @@ textarea,
 }
 
 .header-actions {
+  max-width: 100%;
+  min-width: 0;
   flex-shrink: 0;
 }
 
+.header-status {
+  flex: 1;
+  min-width: 0;
+}
+
 .runtime-line {
   font-size: 13px;
 }
 
 .user-button {
+  max-width: 240px;
+  min-width: 44px;
   padding-inline: 10px;
 }
 
+.user-button .ant-space,
+.user-button .ant-space-item {
+  min-width: 0;
+}
+
+.user-button__name {
+  display: block;
+  max-width: 176px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .admin-content {
   background: var(--mh-canvas);
+  max-width: 100%;
   min-width: 0;
-  outline: none;
   padding: 28px 28px 40px;
 }
 
+.admin-content:focus {
+  outline: 3px solid rgba(79, 70, 229, 0.45);
+  outline-offset: -3px;
+}
+
 .admin-main-layout {
+  max-width: 100%;
   min-width: 0;
 }
 
@@ -304,9 +337,15 @@ textarea,
 .account-menu-summary {
   display: grid;
   gap: 2px;
+  max-width: min(320px, calc(100vw - 32px));
   min-width: 190px;
 }
 
+.account-menu-summary .ant-typography {
+  min-width: 0;
+  overflow-wrap: anywhere;
+}
+
 .bootstrap-skeleton {
   display: grid;
   grid-template-columns: 252px minmax(0, 1fr);
@@ -356,6 +395,13 @@ textarea,
   min-width: 0;
 }
 
+.admin-content > *,
+.ant-space.full-width,
+.ant-space.full-width > .ant-space-item {
+  max-width: 100%;
+  min-width: 0;
+}
+
 .ant-table-content {
   overscroll-behavior-inline: contain;
 }
@@ -765,6 +811,107 @@ code {
   min-width: 120px;
 }
 
+.admin-page,
+.admin-center-card,
+.admin-center-card .ant-card-body,
+.admin-center-tabs,
+.admin-center-tabs .ant-tabs-content-holder,
+.admin-center-tabs .ant-tabs-tabpane {
+  max-width: 100%;
+  min-width: 0;
+}
+
+.admin-user-column {
+  min-width: 240px;
+}
+
+.admin-user-identity {
+  display: grid;
+  gap: 2px;
+  max-width: 100%;
+  min-width: 0;
+}
+
+.admin-user-identity__line.ant-typography {
+  display: block;
+  max-width: 100%;
+  min-width: 0;
+}
+
+.admin-user-select {
+  max-width: 100%;
+  width: 100%;
+}
+
+.admin-user-actions-trigger {
+  min-width: 44px;
+}
+
+.admin-users-table,
+.admin-users-table .ant-table-content,
+.admin-users-list,
+.admin-user-card {
+  max-width: 100%;
+  min-width: 0;
+}
+
+.admin-users-list .ant-list-item {
+  border-block-end: 0;
+  padding: 0 0 12px;
+}
+
+.admin-user-card {
+  width: 100%;
+}
+
+.admin-user-card__header {
+  align-items: start;
+  display: grid;
+  gap: 12px;
+  grid-template-columns: minmax(0, 1fr) auto;
+}
+
+.admin-user-card__meta.ant-typography {
+  display: block;
+  margin-top: 6px;
+  overflow-wrap: anywhere;
+}
+
+.admin-user-card__fields {
+  display: grid;
+  gap: 12px;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  margin-top: 16px;
+}
+
+.admin-user-card__field,
+.admin-user-card__resources {
+  display: grid;
+  gap: 6px;
+  min-width: 0;
+}
+
+.admin-user-card__resources {
+  margin-top: 16px;
+}
+
+.admin-resource-owner {
+  align-items: center;
+  display: grid;
+  gap: 6px;
+  grid-template-columns: minmax(0, 1fr) auto;
+  min-width: 0;
+}
+
+.admin-resource-owner .ant-typography {
+  min-width: 0;
+}
+
+.admin-breakable-text {
+  overflow-wrap: anywhere;
+  word-break: break-word;
+}
+
 .auth-page {
   background:
     radial-gradient(circle at 12% 10%, rgba(79, 70, 229, 0.14), transparent 30%),
@@ -1020,7 +1167,6 @@ code {
 }
 
 @media (max-width: 900px) {
-  .admin-header,
   .page-toolbar,
   .domain-title-row,
   .health-progress > div {
@@ -1028,9 +1174,27 @@ code {
     flex-direction: column;
   }
 
-  .admin-header {
-    gap: 12px;
-    position: static;
+  .environment-status__label {
+    display: none;
+  }
+
+  .environment-status {
+    justify-content: center;
+    min-width: 40px;
+    padding-inline: 8px;
+  }
+
+  .language-select {
+    min-width: 104px;
+    width: 104px;
+  }
+
+  .user-button {
+    max-width: 184px;
+  }
+
+  .user-button__name {
+    max-width: 120px;
   }
 
   .admin-sider {
@@ -1125,9 +1289,11 @@ code {
     align-items: center;
     flex-direction: row;
     gap: 12px;
+    max-width: 100%;
     min-height: 64px;
     padding: 10px 20px;
     position: sticky;
+    width: 100%;
   }
 
   .admin-sider {
@@ -1209,6 +1375,32 @@ code {
   .route-skeleton {
     padding: 20px 16px;
   }
+
+  .admin-inline-form.ant-form-inline {
+    display: grid;
+    gap: 12px;
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  .admin-inline-form.ant-form-inline .ant-form-item,
+  .admin-inline-form.ant-form-inline .ant-form-item-control,
+  .admin-inline-form.ant-form-inline .ant-form-item-control-input,
+  .admin-inline-form.ant-form-inline .ant-form-item-control-input-content {
+    margin-inline-end: 0;
+    max-width: 100%;
+    min-width: 0;
+    width: 100%;
+  }
+
+  .admin-inline-form .ant-select,
+  .admin-inline-form .ant-input,
+  .admin-inline-form .ant-btn {
+    width: 100%;
+  }
+
+  .admin-user-card__fields {
+    grid-template-columns: minmax(0, 1fr);
+  }
 }
 
 @media (max-width: 374px) {

+ 4 - 2
src/layouts/AdminLayout.tsx

@@ -178,7 +178,7 @@ export function AdminLayout() {
 
       <Layout className="admin-main-layout">
         <Header className="admin-header">
-          <Space size={12}>
+          <Space size={12} className="header-status">
             <Button
               className="mobile-nav-trigger"
               type="text"
@@ -231,7 +231,9 @@ export function AdminLayout() {
               <Button className="user-button" aria-label={t('shell.accountMenu')}>
                 <Space size={8}>
                   <Avatar size={24} icon={<UserOutlined />} />
-                  <span className="user-button__name">{user?.username || t('common.user')}</span>
+                  <span className="user-button__name" title={user?.username || t('common.user')}>
+                    {user?.username || t('common.user')}
+                  </span>
                 </Space>
               </Button>
             </Dropdown>

+ 1 - 1
src/pages/Admin/admin-model.js

@@ -8,7 +8,7 @@ const USER_STATUS_META = {
 const MERGE_SUMMARY_ITEMS = [
   ['domains', '域名'],
   ['dnsCredentials', 'DNS 凭据'],
-  ['apiTokens', 'API Token'],
+  ['apiTokens', 'API 密钥'],
   ['inboundMailboxes', '收信邮箱'],
   ['inboundMessages', '入站邮件'],
   ['sendEvents', '发送记录'],

+ 241 - 92
src/pages/Admin/index.tsx

@@ -1,5 +1,6 @@
 import {
   CheckCircleOutlined,
+  EllipsisOutlined,
   MailOutlined,
   ReloadOutlined,
   SendOutlined,
@@ -9,11 +10,14 @@ import {
   Alert,
   App as AntApp,
   Button,
+  Card,
   Checkbox,
   Descriptions,
+  Dropdown,
   Form,
   Input,
   InputNumber,
+  List,
   Modal,
   Select,
   Space,
@@ -21,8 +25,10 @@ import {
   Table,
   Tabs,
   Tag,
+  Tooltip,
   Typography
 } from 'antd';
+import type { MenuProps } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import { useEffect, useRef, useState } from 'react';
 import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
@@ -32,6 +38,7 @@ 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 { useMediaQuery } from '../../frontend/use-media-query';
 import {
   adminUserStatusMeta,
   buildMergeConfirmationText,
@@ -60,7 +67,7 @@ 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'],
+  ['transferApiTokens', '迁移 API 密钥', 'Transfer API keys'],
   ['transferSendEvents', '迁移发送记录', 'Transfer sending activity'],
   ['transferSmtpCredential', '迁移 SMTP 凭据', 'Transfer SMTP credential'],
   ['disableSource', '禁用源用户', 'Disable source user']
@@ -101,8 +108,8 @@ export default function AdminPage() {
 
   if (me?.role !== 'admin') {
     return (
-      <Space direction="vertical" size={20} className="full-width">
-        <PageHeader title={t('admin.title')} />
+      <Space direction="vertical" size={20} className="full-width admin-page">
+        <PageHeader title={t('nav.adminCenter')} />
         <SectionCard>
           <Typography.Text type="secondary">{t('settings.noPermission')}</Typography.Text>
         </SectionCard>
@@ -221,7 +228,7 @@ export default function AdminPage() {
             runAction(`transfer-dns:${credentialId}`, () => api.transferAdminDnsCredential(credentialId, values), tr(locale, 'DNS 凭据已迁移', 'DNS credential transferred'))
           }
           onTransferApiTokens={(values) =>
-            runAction('transfer-token', () => api.transferAdminApiTokens(values), tr(locale, 'API Token 已迁移', 'API tokens transferred'))
+            runAction('transfer-token', () => api.transferAdminApiTokens(values), tr(locale, 'API 密钥已迁移', 'API keys transferred'))
           }
         />
       )
@@ -268,17 +275,18 @@ export default function AdminPage() {
   ];
 
   return (
-    <Space direction="vertical" size={20} className="full-width">
+    <Space direction="vertical" size={20} className="full-width admin-page">
       <PageHeader
-        title={t('admin.title')}
+        title={t('nav.adminCenter')}
         extra={
           <Button icon={<ReloadOutlined />} loading={loading} onClick={() => void loadSection(activeSection, true)}>
             {t('common.refresh')}
           </Button>
         }
       />
-      <SectionCard>
+      <SectionCard className="admin-center-card">
         <Tabs
+          className="admin-center-tabs"
           activeKey={activeSection}
           items={tabItems}
           destroyOnHidden
@@ -309,58 +317,33 @@ function AdminUsers({
   onUpdateUser: (user: AdminUser, patch: { role?: UserRole; status?: UserStatus }) => void;
 }) {
   const { locale } = useI18n();
+  const isTableView = useMediaQuery('(min-width: 768px)');
   const [tempUser, setTempUser] = useState<AdminUser | null>(null);
   const [form] = Form.useForm<{ password: string }>();
 
   const columns: ColumnsType<AdminUser> = [
-    { title: 'ID', dataIndex: 'id', width: 80 },
+    { title: 'ID', dataIndex: 'id', width: 72 },
     {
       title: tr(locale, '用户', 'User'),
-      render: (_, user) => (
-        <Space direction="vertical" size={0}>
-          <Typography.Text strong>{user.username}</Typography.Text>
-          <Typography.Text type="secondary">{user.email}</Typography.Text>
-        </Space>
-      )
+      width: 260,
+      className: 'admin-user-column',
+      render: (_, user) => <AdminUserIdentity user={user} />
     },
     {
       title: tr(locale, '状态', 'Status'),
       dataIndex: 'status',
-      width: 180,
-      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"
-          />
-        );
-      }
+      width: 200,
+      render: (_, user) => <AdminUserStatusSelect user={user} actionKeys={actionKeys} onUpdateUser={onUpdateUser} />
     },
     {
       title: tr(locale, '角色', 'Role'),
       dataIndex: 'role',
-      width: 140,
-      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"
-          />
-        );
-      }
+      width: 152,
+      render: (_, user) => <AdminUserRoleSelect user={user} actionKeys={actionKeys} onUpdateUser={onUpdateUser} />
     },
     {
       title: tr(locale, '资源', 'Resources'),
+      width: 340,
       render: (_, user) => <ResourceCountTags counts={user.resourceCounts} />
     },
     {
@@ -371,36 +354,19 @@ function AdminUsers({
     },
     {
       title: tr(locale, '操作', 'Actions'),
-      width: 380,
-      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>
-        );
-      }
+      width: 72,
+      fixed: 'right',
+      align: 'center',
+      render: (_, user) => (
+        <AdminUserActions
+          user={user}
+          actionKeys={actionKeys}
+          onApprove={onApprove}
+          onResendVerification={onResendVerification}
+          onPasswordReset={onPasswordReset}
+          onTemporaryPassword={() => setTempUser(user)}
+        />
+      )
     }
   ];
 
@@ -414,13 +380,57 @@ function AdminUsers({
 
   return (
     <>
-      <Table
-        rowKey="id"
-        columns={columns}
-        dataSource={users}
-        loading={loading}
-        scroll={{ x: 1100 }}
-      />
+      {isTableView ? (
+        <Table
+          className="admin-users-table"
+          rowKey="id"
+          columns={columns}
+          dataSource={users}
+          loading={loading}
+          tableLayout="fixed"
+          scroll={{ x: 1280 }}
+        />
+      ) : (
+        <List
+          className="admin-users-list"
+          dataSource={users}
+          loading={loading}
+          renderItem={(user) => (
+            <List.Item>
+              <Card className="admin-user-card" size="small">
+                <div className="admin-user-card__header">
+                  <AdminUserIdentity user={user} />
+                  <AdminUserActions
+                    user={user}
+                    actionKeys={actionKeys}
+                    onApprove={onApprove}
+                    onResendVerification={onResendVerification}
+                    onPasswordReset={onPasswordReset}
+                    onTemporaryPassword={() => setTempUser(user)}
+                  />
+                </div>
+                <Typography.Text type="secondary" className="admin-user-card__meta">
+                  ID #{user.id} · {formatDate(user.createdAt)}
+                </Typography.Text>
+                <div className="admin-user-card__fields">
+                  <div className="admin-user-card__field">
+                    <Typography.Text type="secondary">{tr(locale, '状态', 'Status')}</Typography.Text>
+                    <AdminUserStatusSelect user={user} actionKeys={actionKeys} onUpdateUser={onUpdateUser} />
+                  </div>
+                  <div className="admin-user-card__field">
+                    <Typography.Text type="secondary">{tr(locale, '角色', 'Role')}</Typography.Text>
+                    <AdminUserRoleSelect user={user} actionKeys={actionKeys} onUpdateUser={onUpdateUser} />
+                  </div>
+                </div>
+                <div className="admin-user-card__resources">
+                  <Typography.Text type="secondary">{tr(locale, '资源', 'Resources')}</Typography.Text>
+                  <ResourceCountTags counts={user.resourceCounts} />
+                </div>
+              </Card>
+            </List.Item>
+          )}
+        />
+      )}
       <Modal
         title={tempUser ? `${tr(locale, '设置临时密码', 'Set temporary password')} · ${tempUser.username}` : tr(locale, '设置临时密码', 'Set temporary password')}
         open={Boolean(tempUser)}
@@ -442,6 +452,140 @@ function AdminUsers({
   );
 }
 
+function AdminUserIdentity({ user }: { user: AdminUser }) {
+  return (
+    <div className="admin-user-identity">
+      <Typography.Text strong className="admin-user-identity__line" ellipsis={{ tooltip: user.username }}>
+        {user.username}
+      </Typography.Text>
+      <Typography.Text type="secondary" className="admin-user-identity__line" ellipsis={{ tooltip: user.email }}>
+        {user.email}
+      </Typography.Text>
+    </div>
+  );
+}
+
+function AdminUserStatusSelect({
+  user,
+  actionKeys,
+  onUpdateUser
+}: {
+  user: AdminUser;
+  actionKeys: ReadonlySet<string>;
+  onUpdateUser: (user: AdminUser, patch: { role?: UserRole; status?: UserStatus }) => void;
+}) {
+  const { locale } = useI18n();
+  return (
+    <Select
+      aria-label={tr(locale, `用户状态 · ${user.username}`, `Status · ${user.username}`)}
+      value={user.status}
+      loading={actionKeys.has(`update:${user.id}`)}
+      disabled={userMutationPending(actionKeys, user.id)}
+      options={statusValues.map((value) => ({ value, label: userStatusLabel(value, locale) }))}
+      onChange={(status) => onUpdateUser(user, { status })}
+      className="table-select admin-user-select"
+    />
+  );
+}
+
+function AdminUserRoleSelect({
+  user,
+  actionKeys,
+  onUpdateUser
+}: {
+  user: AdminUser;
+  actionKeys: ReadonlySet<string>;
+  onUpdateUser: (user: AdminUser, patch: { role?: UserRole; status?: UserStatus }) => void;
+}) {
+  const { locale } = useI18n();
+  return (
+    <Select
+      aria-label={tr(locale, `用户角色 · ${user.username}`, `Role · ${user.username}`)}
+      value={user.role}
+      loading={actionKeys.has(`update:${user.id}`)}
+      disabled={userMutationPending(actionKeys, user.id)}
+      options={roleValues.map((value) => ({ value, label: value }))}
+      onChange={(role) => onUpdateUser(user, { role })}
+      className="table-select admin-user-select"
+    />
+  );
+}
+
+function AdminUserActions({
+  user,
+  actionKeys,
+  onApprove,
+  onResendVerification,
+  onPasswordReset,
+  onTemporaryPassword
+}: {
+  user: AdminUser;
+  actionKeys: ReadonlySet<string>;
+  onApprove: (user: AdminUser) => void;
+  onResendVerification: (user: AdminUser) => void;
+  onPasswordReset: (user: AdminUser) => void;
+  onTemporaryPassword: () => void;
+}) {
+  const { locale } = useI18n();
+  const pending = userMutationPending(actionKeys, user.id);
+  const actionLabel = tr(locale, `用户操作 · ${user.username}`, `User actions · ${user.username}`);
+  const items: MenuProps['items'] = [
+    {
+      key: 'approve',
+      icon: <CheckCircleOutlined />,
+      label: tr(locale, '审批', 'Approve'),
+      disabled: pending || user.status !== 'pending_review'
+    },
+    {
+      key: 'resend',
+      icon: <MailOutlined />,
+      label: tr(locale, '重发验证', 'Resend verification'),
+      disabled: pending || user.status !== 'pending_email'
+    },
+    { type: 'divider' },
+    {
+      key: 'reset',
+      icon: <SendOutlined />,
+      label: tr(locale, '重置邮件', 'Reset email'),
+      danger: true,
+      disabled: pending
+    },
+    {
+      key: 'temporary-password',
+      icon: <UserSwitchOutlined />,
+      label: tr(locale, '临时密码', 'Temporary password'),
+      danger: true,
+      disabled: pending
+    }
+  ];
+
+  function runSelectedAction(key: string) {
+    if (key === 'approve') onApprove(user);
+    else if (key === 'resend') onResendVerification(user);
+    else if (key === 'reset') onPasswordReset(user);
+    else if (key === 'temporary-password') onTemporaryPassword();
+  }
+
+  return (
+    <Tooltip title={actionLabel}>
+      <Dropdown
+        trigger={['click']}
+        placement="bottomRight"
+        menu={{ items, onClick: ({ key }) => runSelectedAction(key) }}
+        disabled={pending}
+      >
+        <Button
+          aria-label={actionLabel}
+          icon={<EllipsisOutlined />}
+          loading={pending}
+          disabled={pending}
+          className="admin-user-actions-trigger"
+        />
+      </Dropdown>
+    </Tooltip>
+  );
+}
+
 function AdminResources({
   users,
   inventory,
@@ -476,14 +620,15 @@ function AdminResources({
   const groupColumns: ColumnsType<AdminResourceInventory['users'][number]> = [
     {
       title: tr(locale, '用户', 'User'),
+      width: 260,
       render: (_, group) => (
-        <Space>
-          <Typography.Text strong>{group.user.username}</Typography.Text>
+        <div className="admin-resource-owner">
+          <Typography.Text strong ellipsis={{ tooltip: group.user.username }}>{group.user.username}</Typography.Text>
           <UserStatusTag status={group.user.status} />
-        </Space>
+        </div>
       )
     },
-    { title: tr(locale, '资源', 'Resources'), render: (_, group) => <ResourceCountTags counts={group.user.resourceCounts} /> },
+    { title: tr(locale, '资源', 'Resources'), width: 360, 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 },
     {
@@ -522,7 +667,7 @@ function AdminResources({
 
   async function submitTokenTransfer(values: { tokenIds: number[]; targetUserId: number }) {
     Modal.confirm({
-      title: tr(locale, `确认迁移 ${values.tokenIds.length} 个 API Token?`, `Transfer ${values.tokenIds.length} API tokens?`),
+      title: tr(locale, `确认迁移 ${values.tokenIds.length} 个 API 密钥?`, `Transfer ${values.tokenIds.length} API keys?`),
       onOk: async () => {
         await onTransferApiTokens({ tokenIds: values.tokenIds, targetUserId: values.targetUserId });
         tokenForm.resetFields();
@@ -585,9 +730,9 @@ function AdminResources({
             <Button htmlType="submit" loading={actionKeys.has(`transfer-dns:${selectedCredentialId}`)}>{tr(locale, '执行迁移', 'Transfer')}</Button>
           </Form>
         </SectionCard>
-        <SectionCard title={tr(locale, '迁移 API Token', 'Transfer API tokens')}>
+        <SectionCard title={tr(locale, '迁移 API 密钥', 'Transfer API keys')}>
           <Form form={tokenForm} layout="vertical" onFinish={submitTokenTransfer} disabled={loading || actionKeys.has('transfer-token')}>
-            <Form.Item name="tokenIds" label="API Token" rules={[{ required: true }]}>
+            <Form.Item name="tokenIds" label={tr(locale, 'API 密钥', 'API key')} rules={[{ required: true }]}>
               <Select
                 mode="multiple"
                 optionFilterProp="label"
@@ -611,6 +756,7 @@ function AdminResources({
           dataSource={groups}
           loading={loading}
           expandable={{ expandedRowRender: (group) => <ResourceDetails group={group} /> }}
+          scroll={{ x: 980 }}
         />
       </SectionCard>
     </Space>
@@ -621,8 +767,8 @@ function ResourceDetails({ group }: { group: AdminResourceInventory['users'][num
   const { locale } = useI18n();
   return (
     <Space direction="vertical" size={16} className="full-width">
-      <Descriptions size="small" column={2}>
-        <Descriptions.Item label={tr(locale, '邮箱', 'Email')}>{group.user.email}</Descriptions.Item>
+      <Descriptions size="small" column={2} className="admin-resource-descriptions">
+        <Descriptions.Item label={tr(locale, '邮箱', 'Email')}><span className="admin-breakable-text">{group.user.email}</span></Descriptions.Item>
         <Descriptions.Item label={tr(locale, '角色', 'Role')}>{group.user.role}</Descriptions.Item>
       </Descriptions>
       <Table
@@ -635,6 +781,7 @@ function ResourceDetails({ group }: { group: AdminResourceInventory['users'][num
           { title: tr(locale, '发信主机', 'Sending host'), dataIndex: 'senderHost' },
           { title: tr(locale, 'DNS 凭据 ID', 'DNS credential ID'), dataIndex: 'dnsCredentialId' }
         ]}
+        scroll={{ x: 640 }}
       />
       <Table
         size="small"
@@ -646,6 +793,7 @@ function ResourceDetails({ group }: { group: AdminResourceInventory['users'][num
           { title: 'Provider', dataIndex: 'provider' },
           { title: 'Zone', dataIndex: 'zoneName' }
         ]}
+        scroll={{ x: 520 }}
       />
       <Table
         size="small"
@@ -653,10 +801,11 @@ function ResourceDetails({ group }: { group: AdminResourceInventory['users'][num
         pagination={false}
         dataSource={group.apiTokens}
         columns={[
-          { title: 'API Token', dataIndex: 'name' },
+          { title: tr(locale, 'API 密钥', 'API key'), dataIndex: 'name' },
           { title: tr(locale, '前缀', 'Prefix'), dataIndex: 'tokenPrefix' },
           { title: tr(locale, '创建时间', 'Created at'), dataIndex: 'createdAt', render: formatDate }
         ]}
+        scroll={{ x: 580 }}
       />
     </Space>
   );
@@ -723,7 +872,7 @@ function AdminMigration({
   return (
     <Space direction="vertical" size={16} className="full-width">
       <SectionCard title={tr(locale, '合并预览', 'Merge preview')}>
-        <Form form={form} layout="inline" onFinish={submitPreview} disabled={loading}>
+        <Form form={form} layout="inline" onFinish={submitPreview} disabled={loading} className="admin-inline-form">
           <Form.Item name="sourceUserId" label={tr(locale, '源用户', 'Source user')} rules={[{ required: true }]}>
             <Select options={userOptions} className="toolbar-select" />
           </Form.Item>
@@ -894,7 +1043,7 @@ function AdminAuditLogs({
   return (
     <Space direction="vertical" size={16} className="full-width">
       <SectionCard className="admin-audit-toolbar-card">
-        <Form form={form} layout="inline" onFinish={submit} disabled={loading}>
+        <Form form={form} layout="inline" onFinish={submit} disabled={loading} className="admin-inline-form">
           <Form.Item name="actorUserId" label={tr(locale, '操作者', 'Actor')}>
             <Select allowClear options={[{ value: 'system', label: 'system' }, ...userOptions]} className="toolbar-select" />
           </Form.Item>
@@ -1000,7 +1149,7 @@ function resourceLabel(key: string, locale: string) {
   const labels: Record<string, [string, string]> = {
     domains: ['域名', 'Domains'],
     dnsCredentials: ['DNS 凭据', 'DNS credentials'],
-    apiTokens: ['API Token', 'API tokens'],
+    apiTokens: ['API 密钥', 'API keys'],
     inboundMailboxes: ['收信邮箱', 'Mailboxes'],
     inboundMessages: ['入站邮件', 'Inbound mail'],
     sendEvents: ['发送记录', 'Sending activity'],

+ 173 - 41
src/pages/ApiTokens.tsx

@@ -28,7 +28,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';
@@ -45,6 +46,7 @@ import {
 } from '../frontend/api-token-model.js';
 import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
+import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
 import { api } from '../frontend/services/api';
 import type {
   ApiToken,
@@ -63,6 +65,7 @@ interface TokenFormValues {
 }
 
 type SecretModalMode = 'created' | 'rotated';
+type GuideSection = 'send' | 'mailboxes' | 'messages';
 
 const scopeOptions = [
   { label: 'send', value: 'send' },
@@ -75,6 +78,9 @@ export default function ApiTokens() {
   const { message } = AntApp.useApp();
   const { t } = useI18n();
   const { config, user } = useAppContext();
+  const location = useLocation();
+  const navigate = useNavigate();
+  const [searchParams, setSearchParams] = useSearchParams();
   const [form] = Form.useForm<TokenFormValues>();
   const [tokens, setTokens] = useState<ApiToken[]>([]);
   const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
@@ -84,14 +90,21 @@ export default function ApiTokens() {
   const [actionKey, setActionKey] = useState('');
   const [editorOpen, setEditorOpen] = useState(false);
   const [editingToken, setEditingToken] = useState<ApiToken | null>(null);
-  const [selectedToken, setSelectedToken] = useState<ApiToken | null>(null);
   const [revealedToken, setRevealedToken] = useState<ApiToken | null>(null);
   const [secretModalMode, setSecretModalMode] = useState<SecretModalMode>('created');
-  const [guideOpen, setGuideOpen] = useState(false);
+  const pendingDirectGuideClose = useRef<string | null>(null);
   const selectedScopes = Form.useWatch('scopes', form) || [];
   const selectedMailboxAccess = Form.useWatch('mailboxAccess', form) || 'owner';
   const messagesReadEnabled = selectedScopes.includes('messages:read');
   const isAdmin = user?.role === 'admin';
+  const requestedTokenId = positiveInteger(searchParams.get('tokenId'));
+  const guideParam = searchParams.get('guide');
+  const guideSection = normalizeGuideSection(guideParam);
+  const guideOpen = guideParam != null;
+  const selectedToken = useMemo(
+    () => requestedTokenId ? tokens.find((token) => token.id === requestedTokenId) || null : null,
+    [requestedTokenId, tokens]
+  );
 
   const loadData = useCallback(async () => {
     setLoading(true);
@@ -105,9 +118,6 @@ export default function ApiTokens() {
     if (tokenResult.status === 'fulfilled') {
       const nextTokens = tokenResult.value.tokens || [];
       setTokens(nextTokens);
-      setSelectedToken((current) => current
-        ? nextTokens.find((token) => token.id === current.id) || null
-        : null);
     } else {
       setLoadError(tokenResult.reason instanceof Error ? tokenResult.reason.message : t('common.error'));
     }
@@ -124,6 +134,13 @@ export default function ApiTokens() {
     void loadData();
   }, [loadData]);
 
+  useEffect(() => {
+    const target = pendingDirectGuideClose.current;
+    if (!target || detailHistoryState(location.state)?.origin === 'direct') return;
+    pendingDirectGuideClose.current = null;
+    navigate(target, { replace: true });
+  }, [location.key, location.state, navigate]);
+
   async function copyValue(value: string) {
     if (!value) return;
     await navigator.clipboard.writeText(value);
@@ -186,7 +203,6 @@ export default function ApiTokens() {
       message.success(t('tokens.rotatedSuccess'));
       setSecretModalMode('rotated');
       setRevealedToken(result.token);
-      setSelectedToken(result.token);
       await loadData();
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
@@ -200,7 +216,7 @@ export default function ApiTokens() {
     try {
       await api.deleteApiToken(token.id);
       message.success(t('tokens.revokedSuccess'));
-      if (selectedToken?.id === token.id) setSelectedToken(null);
+      if (requestedTokenId === token.id) closeTokenDetail();
       await loadData();
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
@@ -209,6 +225,81 @@ export default function ApiTokens() {
     }
   }
 
+  function openTokenDetail(token: ApiToken) {
+    const next = new URLSearchParams(searchParams);
+    next.set('tokenId', String(token.id));
+    setSearchParams(next, {
+      state: detailHistoryLocation(`${location.pathname}${location.search}`, 1)
+    });
+  }
+
+  function closeTokenDetail() {
+    const historyState = detailHistoryState(location.state);
+    if (historyState) {
+      navigate(-historyState.depth);
+      return;
+    }
+    const next = new URLSearchParams(searchParams);
+    next.delete('tokenId');
+    setSearchParams(next, { replace: true, state: null });
+  }
+
+  function openGuide(section: GuideSection = 'send') {
+    if (guideOpen && section === guideSection) return;
+    const next = new URLSearchParams(searchParams);
+    next.set('guide', section);
+    if (!guideOpen) {
+      setSearchParams(next, {
+        state: detailHistoryLocation(`${location.pathname}${location.search}`, 1)
+      });
+      return;
+    }
+    const historyState = detailHistoryState(location.state);
+    const search = next.toString();
+    if (historyState) {
+      navigate(
+        { pathname: location.pathname, search: search ? `?${search}` : '' },
+        {
+          state: detailHistoryLocation(
+            historyState.listPath,
+            historyState.depth + 1,
+            historyState.origin
+          )
+        }
+      );
+      return;
+    }
+    const listParams = new URLSearchParams(searchParams);
+    listParams.delete('guide');
+    const listSearch = listParams.toString();
+    navigate(
+      { pathname: location.pathname, search: search ? `?${search}` : '' },
+      {
+        state: detailHistoryLocation(
+          `${location.pathname}${listSearch ? `?${listSearch}` : ''}`,
+          1,
+          'direct'
+        )
+      }
+    );
+  }
+
+  function closeGuide() {
+    const historyState = detailHistoryState(location.state);
+    if (historyState?.origin === 'list') {
+      navigate(-historyState.depth);
+      return;
+    }
+    if (historyState?.origin === 'direct') {
+      pendingDirectGuideClose.current = historyState.listPath;
+      navigate(-historyState.depth);
+      return;
+    }
+    const next = new URLSearchParams(searchParams);
+    next.delete('guide');
+    setSearchParams(next, { replace: true, state: null });
+  }
+
   const columns: ColumnsType<ApiToken> = [
     {
       title: t('tokens.name'),
@@ -219,19 +310,21 @@ export default function ApiTokens() {
     {
       title: t('tokens.fullToken'),
       dataIndex: 'token',
-      width: 380,
+      width: 320,
       render: (_value, token) => <TokenValue token={token} onCopy={copyValue} t={t} />
     },
     {
       title: t('tokens.scopes'),
       dataIndex: 'scopes',
       width: 250,
+      responsive: ['xl'],
       render: (scopes: string[]) => <Space size={[4, 4]} wrap>{(scopes || []).map((scope) => <Tag key={scope}>{scope}</Tag>)}</Space>
     },
     {
       title: t('tokens.mailboxAccess'),
       dataIndex: 'mailboxAccess',
       width: 210,
+      responsive: ['xl'],
       render: (_value, token) => mailboxAccessSummary(token, t)
     },
     {
@@ -244,7 +337,6 @@ export default function ApiTokens() {
       title: t('tokens.lastUsed'),
       dataIndex: 'lastUsedAt',
       width: 190,
-      responsive: ['xl'],
       render: (value?: string) => value ? formatOptionalTime(value) : t('tokens.neverUsed')
     },
     {
@@ -282,35 +374,37 @@ export default function ApiTokens() {
         subtitle={t('tokens.subtitle')}
         extra={
           <Space wrap>
-            <Button icon={<ReadOutlined />} onClick={() => setGuideOpen(true)} style={{ minHeight: 44 }}>{t('tokens.docsTitle')}</Button>
+            <Button icon={<ReadOutlined />} onClick={() => openGuide()} 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: 1510 }}
-            onRow={(token) => ({
-              onClick: () => setSelectedToken(token),
-              style: { cursor: 'pointer' },
-              tabIndex: 0,
-              onKeyDown: (event) => {
-                if (event.target !== event.currentTarget) return;
-                if (event.key === 'Enter' || event.key === ' ') {
-                  event.preventDefault();
-                  setSelectedToken(token);
+      {loadError && !tokens.length ? 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: 1030 }}
+              onRow={(token) => ({
+                onClick: () => openTokenDetail(token),
+                style: { cursor: 'pointer' },
+                tabIndex: 0,
+                onKeyDown: (event) => {
+                  if (event.target !== event.currentTarget) return;
+                  if (event.key === 'Enter' || event.key === ' ') {
+                    event.preventDefault();
+                    openTokenDetail(token);
+                  }
                 }
-              }
-            })}
-          />
-        ) : <EmptyState description={t('tokens.noTokenHint')} action={<Button icon={<PlusOutlined />} onClick={openCreate}>{t('tokens.create')}</Button>} />}
-      </SectionCard>
+              })}
+            />
+          ) : <EmptyState description={t('tokens.noTokenHint')} action={<Button icon={<PlusOutlined />} onClick={openCreate}>{t('tokens.create')}</Button>} />}
+        </SectionCard>
+      )}
 
       <Drawer
         title={editingToken ? t('tokens.editTitle') : t('tokens.createTitle')}
@@ -359,8 +453,8 @@ export default function ApiTokens() {
         </Form>
       </Drawer>
 
-      <Drawer title={selectedToken?.name} width={600} open={Boolean(selectedToken)} onClose={() => setSelectedToken(null)}>
-        {selectedToken ? (
+      <Drawer title={selectedToken?.name || t('nav.tokens')} width={600} open={Boolean(requestedTokenId)} onClose={closeTokenDetail}>
+        {loading && !selectedToken ? <Skeleton active paragraph={{ rows: 6 }} /> : selectedToken ? (
           <Space direction="vertical" size={20} className="full-width">
             <Descriptions bordered column={1} size="small">
               <Descriptions.Item label={t('tokens.fullToken')}><TokenValue token={selectedToken} onCopy={copyValue} t={t} /></Descriptions.Item>
@@ -383,12 +477,14 @@ export default function ApiTokens() {
               message={canCopyFullApiToken(selectedToken) ? t('tokens.secretAvailable') : t('tokens.secretUnavailable')}
               action={!canCopyFullApiToken(selectedToken) && selectedToken.status !== 'revoked' ? <RotateTokenConfirm token={selectedToken} loading={actionKey === `rotate:${selectedToken.id}`} onRotate={rotateToken} t={t} text /> : undefined}
             />
-            <Button icon={<ReadOutlined />} onClick={() => setGuideOpen(true)}>{t('tokens.docsTitle')}</Button>
+            <Button icon={<ReadOutlined />} onClick={() => openGuide()}>{t('tokens.docsTitle')}</Button>
           </Space>
-        ) : null}
+        ) : loadError && requestedTokenId ? (
+          <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>} />
+        ) : requestedTokenId ? <EmptyState description={t('common.notFound')} /> : null}
       </Drawer>
 
-      <ApiGuideDrawer open={guideOpen} config={config} onClose={() => setGuideOpen(false)} onCopy={copyValue} />
+      <ApiGuideDrawer open={guideOpen} section={guideSection} config={config} onClose={closeGuide} onSectionChange={openGuide} onCopy={copyValue} />
 
       <Modal
         title={secretModalMode === 'rotated' ? t('tokens.rotatedTitle') : t('tokens.createdTitle')}
@@ -455,9 +551,9 @@ function RotateTokenConfirm({ token, loading, onRotate, t, text = false }: { tok
   );
 }
 
-function ApiGuideDrawer({ open, config, onClose, onCopy }: { open: boolean; config: RuntimeConfig | null; onClose: () => void; onCopy: (value: string) => void }) {
+function ApiGuideDrawer({ open, section, config, onClose, onSectionChange, onCopy }: { open: boolean; section: GuideSection; config: RuntimeConfig | null; onClose: () => void; onSectionChange: (section: GuideSection) => void; onCopy: (value: string) => void }) {
   const { t } = useI18n();
-  const baseUrl = config?.appBaseUrl || window.location.origin;
+  const baseUrl = secureApiBaseUrl(config?.appBaseUrl, window.location.origin);
   const endpoint = `${baseUrl}/api/send`;
   const mailboxEndpoint = `${baseUrl}/api/mailboxes`;
   const inboundMessagesEndpoint = `${baseUrl}/api/inbound-messages`;
@@ -475,7 +571,9 @@ function ApiGuideDrawer({ open, config, onClose, onCopy }: { open: boolean; conf
           <Descriptions.Item label={t('tokens.authHeader')}><Typography.Text code>{t('tokens.authHeaderValue')}</Typography.Text></Descriptions.Item>
         </Descriptions>
         <Collapse
-          defaultActiveKey={['send']}
+          accordion
+          activeKey={section}
+          onChange={(key) => onSectionChange(normalizeGuideSection(Array.isArray(key) ? key[0] : key))}
           items={[
             {
               key: 'send',
@@ -558,3 +656,37 @@ function tokenStatusText(status: string | undefined, t: (key: string) => string)
 function formatOptionalTime(value?: string | null) {
   return value ? new Date(value).toLocaleString() : '—';
 }
+
+function positiveInteger(value: string | null) {
+  const parsed = Number(value);
+  return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
+}
+
+function normalizeGuideSection(value: string | null | undefined): GuideSection {
+  return value === 'mailboxes' || value === 'messages' ? value : 'send';
+}
+
+function secureApiBaseUrl(configuredValue: string | null | undefined, currentOrigin: string) {
+  const current = parseUrl(currentOrigin);
+  if (current?.protocol === 'https:') return current.origin;
+  const configured = parseUrl(configuredValue);
+  if (configured?.protocol === 'https:') return configured.origin;
+  if (configured && !isLocalHostname(configured.hostname)) {
+    configured.protocol = 'https:';
+    return configured.origin;
+  }
+  return configured?.origin || current?.origin || '';
+}
+
+function parseUrl(value: string | null | undefined) {
+  if (!value) return null;
+  try {
+    return new URL(value);
+  } catch {
+    return null;
+  }
+}
+
+function isLocalHostname(hostname: string) {
+  return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
+}

+ 166 - 53
src/pages/Dashboard.tsx

@@ -15,7 +15,7 @@ import {
   Space,
   Typography
 } from 'antd';
-import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 
 import { EmptyState } from '../components/common/EmptyState';
@@ -28,18 +28,27 @@ import { buildDomainHealth } from '../frontend/domain-model.js';
 import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
 import { api } from '../frontend/services/api';
-import type { Analytics, Domain, SmtpCredential, WebhookDelivery } from '../frontend/types';
+import type { Analytics, ApiToken, Domain, SmtpCredential, WebhookDelivery } from '../frontend/types';
 
 const DashboardCharts = lazy(() => import('./DashboardCharts'));
 
 type RangeDays = 1 | 7 | 30;
 type AnalysisSection = 'delivery' | 'engagement';
+type DashboardDependency = 'smtp' | 'apiTokens' | 'webhooks';
+type CredentialReadiness = 'ready' | 'missing' | 'unknown';
 
 interface DashboardData {
+  range: RangeDays;
   analytics: Analytics;
   domains: Domain[];
-  smtpCredential: SmtpCredential | null;
-  failedWebhooks: WebhookDelivery[];
+  smtpCredential?: SmtpCredential | null;
+  apiTokens?: ApiToken[];
+  failedWebhooks?: WebhookDelivery[];
+}
+
+interface ActionLink {
+  label: string;
+  path: string;
 }
 
 interface ActionItem {
@@ -47,8 +56,14 @@ interface ActionItem {
   title: string;
   description: string;
   tone: StatusTone;
-  label: string;
-  path: string;
+  actions: ActionLink[];
+}
+
+interface ReadinessItem {
+  title: string;
+  done: boolean;
+  unknown: boolean;
+  actions: Array<{ label?: string; path: string }>;
 }
 
 export default function Dashboard() {
@@ -60,39 +75,76 @@ export default function Dashboard() {
   const analysisSection = normalizeAnalysisSection(searchParams.get('analysis'));
   const [data, setData] = useState<DashboardData | null>(null);
   const [loading, setLoading] = useState(true);
-  const [error, setError] = useState('');
+  const [coreError, setCoreError] = useState<{ range: RangeDays; message: string } | null>(null);
+  const [dependencyErrors, setDependencyErrors] = useState<Partial<Record<DashboardDependency, string>>>({});
+  const requestId = useRef(0);
   const copy = locale.startsWith('en') ? enCopy : zhCopy;
 
   const load = useCallback(async () => {
+    const currentRequest = ++requestId.current;
     setLoading(true);
-    setError('');
+    setCoreError(null);
     try {
-      const [analyticsResult, domainsResult] = await Promise.all([
+      const [analyticsResult, domainsResult, credentialResult, tokensResult, webhooksResult] = await Promise.allSettled([
         api.analytics(range),
-        api.domains()
-      ]);
-      const [credentialResult, webhooksResult] = await Promise.allSettled([
+        api.domains(),
         api.smtpCredential(),
+        api.apiTokens(),
         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 : []
+      if (currentRequest !== requestId.current) return;
+
+      const coreErrors = [
+        analyticsResult.status === 'rejected' ? copy.dependencyError(copy.analyticsDependency, analyticsResult.reason) : '',
+        domainsResult.status === 'rejected' ? copy.dependencyError(copy.domainsDependency, domainsResult.reason) : ''
+      ].filter(Boolean);
+      setCoreError(coreErrors.length ? { range, message: coreErrors.join('\n') } : null);
+
+      const nextDependencyErrors: Partial<Record<DashboardDependency, string>> = {};
+      if (credentialResult.status === 'rejected') nextDependencyErrors.smtp = errorMessage(credentialResult.reason, copy.loadFailed);
+      if (tokensResult.status === 'rejected') nextDependencyErrors.apiTokens = errorMessage(tokensResult.reason, copy.loadFailed);
+      if (webhooksResult.status === 'rejected') nextDependencyErrors.webhooks = errorMessage(webhooksResult.reason, copy.loadFailed);
+      setDependencyErrors(nextDependencyErrors);
+
+      setData((current) => {
+        const currentRangeData = current?.range === range ? current : null;
+        const analytics = analyticsResult.status === 'fulfilled' ? analyticsResult.value.analytics : currentRangeData?.analytics;
+        const domains = domainsResult.status === 'fulfilled' ? domainsResult.value.domains : current?.domains;
+        if (!analytics || !domains) return current;
+        return {
+          range,
+          analytics,
+          domains,
+          smtpCredential: credentialResult.status === 'fulfilled' ? credentialResult.value.credential : current?.smtpCredential,
+          apiTokens: tokensResult.status === 'fulfilled' ? tokensResult.value.tokens : current?.apiTokens,
+          failedWebhooks: webhooksResult.status === 'fulfilled' ? webhooksResult.value.deliveries : current?.failedWebhooks
+        };
       });
     } catch (reason) {
-      setError(reason instanceof Error ? reason.message : copy.loadFailed);
+      if (currentRequest === requestId.current) {
+        setCoreError({ range, message: reason instanceof Error ? reason.message : copy.loadFailed });
+      }
     } finally {
-      setLoading(false);
+      if (currentRequest === requestId.current) setLoading(false);
     }
-  }, [copy.loadFailed, range]);
+  }, [copy, range]);
 
   useEffect(() => {
     void load();
+    return () => { requestId.current += 1; };
   }, [load]);
 
-  const actions = useMemo(() => data ? buildActionItems(data, copy) : [], [copy, data]);
+  const currentData = data?.range === range ? data : null;
+  const error = coreError?.range === range ? coreError.message : '';
+  const operationalData = useMemo(() => currentData ? {
+    ...currentData,
+    smtpCredential: dependencyErrors.smtp ? undefined : currentData.smtpCredential,
+    apiTokens: dependencyErrors.apiTokens ? undefined : currentData.apiTokens,
+    failedWebhooks: dependencyErrors.webhooks ? undefined : currentData.failedWebhooks
+  } : null, [currentData, dependencyErrors.apiTokens, dependencyErrors.smtp, dependencyErrors.webhooks]);
+  const actions = useMemo(() => operationalData ? buildActionItems(operationalData, copy) : [], [copy, operationalData]);
+  const dependencyErrorEntries = Object.entries(dependencyErrors) as Array<[DashboardDependency, string]>;
+  const actionStateUnknown = dependencyErrorEntries.length > 0;
 
   function changeRange(value: string | number) {
     const next = new URLSearchParams(searchParams);
@@ -107,7 +159,7 @@ export default function Dashboard() {
     setSearchParams(next);
   }
 
-  if (loading && !data) {
+  if (!currentData && !error) {
     return (
       <Space direction="vertical" size={20} className="full-width" aria-label={copy.loading}>
         <Skeleton active title={{ width: '28%' }} paragraph={{ rows: 2 }} />
@@ -117,7 +169,7 @@ export default function Dashboard() {
     );
   }
 
-  if (error && !data) {
+  if (error && !currentData) {
     return (
       <Result
         status="error"
@@ -128,9 +180,9 @@ export default function Dashboard() {
     );
   }
 
-  if (!data) return null;
+  if (!currentData) return null;
 
-  const { analytics, domains } = data;
+  const { analytics, domains } = currentData;
   const summary = analytics.summary;
   const engagement = buildEngagementSummary(analytics);
   const failuresAndDelays = Number(summary.terminalFailed || summary.failed || 0)
@@ -140,11 +192,27 @@ export default function Dashboard() {
   return (
     <Space direction="vertical" size={20} className="full-width">
       {config?.usingDefaultAdminPassword ? (
-        <Alert type="warning" showIcon message={copy.defaultPassword} />
+        <Alert type="warning" showIcon message={copy.defaultPassword} description={copy.defaultPasswordDescription} />
       ) : null}
 
       {error ? <Alert type="error" showIcon message={copy.partialFailure} description={error} action={<Button size="small" onClick={() => void load()}>{copy.retry}</Button>} /> : null}
 
+      {dependencyErrorEntries.length ? (
+        <Alert
+          type="warning"
+          showIcon
+          message={copy.dependencyFailureTitle}
+          description={
+            <Space direction="vertical" size={2}>
+              {dependencyErrorEntries.map(([dependency, detail]) => (
+                <Typography.Text key={dependency}>{copy.dependencyLabel(dependency)}: {detail}</Typography.Text>
+              ))}
+            </Space>
+          }
+          action={<Button size="small" loading={loading} onClick={() => void load()}>{copy.retry}</Button>}
+        />
+      ) : null}
+
       <PageHeader
         title={copy.title}
         subtitle={copy.subtitle}
@@ -188,18 +256,22 @@ export default function Dashboard() {
 
       <SectionCard
         title={copy.actionNeeded}
-        extra={actions.length ? <StatusPill tone="warning">{actions.length} {copy.items}</StatusPill> : <StatusPill tone="success">{copy.allClear}</StatusPill>}
+        extra={actions.length
+          ? <StatusPill tone="warning">{actions.length} {copy.items}</StatusPill>
+          : actionStateUnknown
+            ? <StatusPill tone="warning">{copy.statusUnknown}</StatusPill>
+            : <StatusPill tone="success">{copy.allClear}</StatusPill>}
       >
         {actions.length ? (
           <List
             dataSource={actions}
             renderItem={(item) => (
               <List.Item
-                actions={[
-                  <Button key="resolve" type="link" onClick={() => navigate(item.path)}>
-                    {item.label} <ArrowRightOutlined />
+                actions={item.actions.map((action) => (
+                  <Button key={`${item.key}:${action.path}`} type="link" onClick={() => navigate(action.path)}>
+                    {action.label} <ArrowRightOutlined />
                   </Button>
-                ]}
+                ))}
               >
                 <List.Item.Meta
                   avatar={<ExclamationCircleOutlined aria-hidden style={{ color: item.tone === 'error' ? '#dc2626' : '#d97706', fontSize: 20 }} />}
@@ -209,12 +281,14 @@ export default function Dashboard() {
               </List.Item>
             )}
           />
+        ) : actionStateUnknown ? (
+          <EmptyState description={copy.actionStatusUnknown} icon={<ExclamationCircleOutlined style={{ color: '#d97706' }} />} />
         ) : (
           <EmptyState description={copy.noActionNeeded} icon={<CheckCircleOutlined style={{ color: '#16a34a' }} />} />
         )}
       </SectionCard>
 
-      {!summary.total ? <ReadinessChecklist domains={domains} credential={data.smtpCredential} onNavigate={navigate} copy={copy} /> : null}
+      {!summary.total ? <ReadinessChecklist domains={domains} credential={operationalData?.smtpCredential} apiTokens={operationalData?.apiTokens} onNavigate={navigate} copy={copy} /> : null}
 
       <Suspense fallback={<SectionCard><Skeleton active paragraph={{ rows: 8 }} /></SectionCard>}>
         <DashboardCharts
@@ -231,31 +305,45 @@ export default function Dashboard() {
 function ReadinessChecklist({
   domains,
   credential,
+  apiTokens,
   onNavigate,
   copy
 }: {
   domains: Domain[];
-  credential: SmtpCredential | null;
+  credential?: SmtpCredential | null;
+  apiTokens?: ApiToken[];
   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' }
+  const credentialStatus = getCredentialReadiness(credential, apiTokens);
+  const items: ReadinessItem[] = [
+    { title: copy.addDomain, done: hasDomain, unknown: false, actions: [{ path: '/domains' }] },
+    { title: copy.configureDns, done: dnsReady, unknown: false, actions: [{ path: hasDomain ? `/domains/${domains[0].id}/dns` : '/domains' }] },
+    {
+      title: copy.createCredential,
+      done: credentialStatus === 'ready',
+      unknown: credentialStatus === 'unknown',
+      actions: [
+        { label: copy.smtpEntry, path: '/integrations/smtp' },
+        { label: copy.apiKeyEntry, path: '/integrations/api-keys' }
+      ]
+    },
+    { title: copy.sendTest, done: false, unknown: false, actions: [{ 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 actions={item.actions.map((action) => <Button key={action.path} type={index === 0 && !item.done ? 'primary' : 'default'} onClick={() => onNavigate(action.path)}>{action.label || (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>}
+              avatar={item.done
+                ? <CheckCircleOutlined style={{ color: '#16a34a', fontSize: 20 }} />
+                : item.unknown
+                  ? <StatusPill tone="warning">{copy.statusUnknown}</StatusPill>
+                  : <StatusPill tone="neutral">{index + 1}</StatusPill>}
               title={<Typography.Text delete={item.done}>{item.title}</Typography.Text>}
             />
           </List.Item>
@@ -270,20 +358,45 @@ function buildActionItems(data: DashboardData, copy: typeof zhCopy): ActionItem[
   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' });
+    items.push({ key: 'dns', title: copy.dnsNotReady(unhealthy.length), description: copy.dnsNotReadyHint, tone: 'warning', actions: [{ 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' });
+    items.push({ key: 'failures', title: copy.recentFailures(recentFailures.length), description: recentFailures[0]?.detail || copy.recentFailuresHint, tone: 'error', actions: [{ 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.failedWebhooks?.length) {
+    const firstFailedDelivery = data.failedWebhooks[0];
+    const webhookPath = firstFailedDelivery?.webhookId
+      ? `/integrations/webhooks?webhookId=${firstFailedDelivery.webhookId}&deliveryStatus=dead`
+      : '/integrations/webhooks?deliveryStatus=dead';
+    items.push({ key: 'webhooks', title: copy.webhookFailures(data.failedWebhooks.length), description: copy.webhookFailuresHint, tone: 'error', actions: [{ label: copy.reviewWebhooks, path: webhookPath }] });
   }
-  if (!data.smtpCredential?.passwordSet) {
-    items.push({ key: 'credential', title: copy.missingCredential, description: copy.missingCredentialHint, tone: 'warning', label: copy.configure, path: '/integrations/smtp' });
+  if (getCredentialReadiness(data.smtpCredential, data.apiTokens) === 'missing') {
+    items.push({
+      key: 'credential',
+      title: copy.missingCredential,
+      description: copy.missingCredentialHint,
+      tone: 'warning',
+      actions: [
+        { label: copy.configureSmtp, path: '/integrations/smtp' },
+        { label: copy.createApiKey, path: '/integrations/api-keys' }
+      ]
+    });
   }
   return items;
 }
 
+function getCredentialReadiness(smtpCredential?: SmtpCredential | null, apiTokens?: ApiToken[]): CredentialReadiness {
+  const smtpReady = Boolean(smtpCredential?.passwordSet);
+  const apiReady = Boolean(apiTokens?.some((token) => token.status === 'active' && token.scopes.includes('send')));
+  if (smtpReady || apiReady) return 'ready';
+  if (smtpCredential === undefined || apiTokens === undefined) return 'unknown';
+  return 'missing';
+}
+
+function errorMessage(reason: unknown, fallback: string) {
+  return reason instanceof Error ? reason.message : fallback;
+}
+
 function normalizeRange(value: string | null): RangeDays {
   if (value === '1') return 1;
   if (value === '30') return 30;
@@ -297,17 +410,17 @@ function normalizeAnalysisSection(value: string | null): AnalysisSection {
 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: '开始',
+  actionNeeded: '需要处理', items: '项', allClear: '状态正常', statusUnknown: '状态未知', needsAction: '需要处理', noActionNeeded: '当前没有检测到需要处理的投递问题。', actionStatusUnknown: '部分运维状态暂时无法获取,请重试后再确认。', partialFailure: '部分数据刷新失败', dependencyFailureTitle: '部分运维状态获取失败', dependencyLabel: (dependency: DashboardDependency) => ({ smtp: 'SMTP 凭据', apiTokens: 'API 密钥', webhooks: 'Webhook 失败投递' })[dependency], dependencyError: (label: string, reason: unknown) => `${label}:${errorMessage(reason, '加载失败')}`, analyticsDependency: '投递统计', domainsDependency: '域名状态', defaultPassword: '当前仍在使用默认管理员密码', defaultPasswordDescription: '请修改部署环境中的 ADMIN_PASSWORD,并重启 MailHub 服务使新密码生效。',
+  getReady: '发送就绪清单', getReadyHint: '完成以下步骤后即可进行首次测试发送', addDomain: '添加发信域名', configureDns: '配置并验证 DNS', createCredential: '创建 SMTP 或 API 凭据', smtpEntry: 'SMTP', apiKeyEntry: '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: '去配置'
+  dnsNotReady: (count: number) => `${count} 个域名的 DNS 尚未就绪`, dnsNotReadyHint: '认证记录不完整可能导致投递失败或进入垃圾箱。', reviewDomains: '处理域名', recentFailures: (count: number) => `最近发现 ${count} 条发送失败`, recentFailuresHint: '请检查投递响应和队列日志。', inspectActivity: '查看活动', webhookFailures: (count: number) => `${count} 条 Webhook 投递已停止重试`, webhookFailuresHint: '请检查回调地址、签名校验和服务可用性。', reviewWebhooks: '检查 Webhooks', missingCredential: '尚未创建可用的发送凭据', missingCredentialHint: '配置 SMTP 凭据或创建具备 send 权限的 API 密钥后,应用即可发送邮件。', configureSmtp: '配置 SMTP', createApiKey: '创建 API 密钥'
 };
 
 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',
+  actionNeeded: 'Action needed', items: 'items', allClear: 'All clear', statusUnknown: 'Status unknown', needsAction: 'Needs action', noActionNeeded: 'No delivery issues currently require attention.', actionStatusUnknown: 'Some operational status is unavailable. Retry before treating the environment as healthy.', partialFailure: 'Some data could not be refreshed', dependencyFailureTitle: 'Some operational status could not be loaded', dependencyLabel: (dependency: DashboardDependency) => ({ smtp: 'SMTP credentials', apiTokens: 'API keys', webhooks: 'Failed webhook deliveries' })[dependency], dependencyError: (label: string, reason: unknown) => `${label}: ${errorMessage(reason, 'Unable to load')}`, analyticsDependency: 'Delivery analytics', domainsDependency: 'Domain status', defaultPassword: 'The default administrator password is still in use', defaultPasswordDescription: 'Update ADMIN_PASSWORD in the deployment environment, then restart MailHub for the new password to take effect.',
+  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', smtpEntry: 'SMTP', apiKeyEntry: 'API key', 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'
+  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 sending credential', missingCredentialHint: 'Configure SMTP or create an API key with the send scope before applications send email.', configureSmtp: 'Configure SMTP', createApiKey: 'Create API key'
 };

+ 71 - 30
src/pages/DnsApi.tsx

@@ -22,13 +22,15 @@ import {
   Typography
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useLocation, 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 { useI18n } from '../frontend/i18n/react';
+import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
 import { api } from '../frontend/services/api';
 import type { DnsCredential } from '../frontend/types';
 
@@ -48,6 +50,9 @@ interface CredentialFormValues {
 export default function DnsApi() {
   const { message } = AntApp.useApp();
   const { t } = useI18n();
+  const location = useLocation();
+  const navigate = useNavigate();
+  const [searchParams, setSearchParams] = useSearchParams();
   const [form] = Form.useForm<CredentialFormValues>();
   const [credentials, setCredentials] = useState<DnsCredential[]>([]);
   const [loading, setLoading] = useState(true);
@@ -55,8 +60,12 @@ export default function DnsApi() {
   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';
+  const requestedCredentialId = positiveInteger(searchParams.get('credentialId'));
+  const selected = useMemo(
+    () => requestedCredentialId ? credentials.find((credential) => credential.id === requestedCredentialId) || null : null,
+    [credentials, requestedCredentialId]
+  );
 
   const loadCredentials = useCallback(async () => {
     setLoading(true);
@@ -130,7 +139,7 @@ export default function DnsApi() {
     try {
       await api.deleteDnsCredential(credential.id);
       message.success(t('actions.dnsApiDeleted'));
-      if (selected?.id === credential.id) setSelected(null);
+      if (requestedCredentialId === credential.id) closeDetail();
       await loadCredentials();
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
@@ -139,6 +148,25 @@ export default function DnsApi() {
     }
   }
 
+  function openDetail(credential: DnsCredential) {
+    const next = new URLSearchParams(searchParams);
+    next.set('credentialId', String(credential.id));
+    setSearchParams(next, {
+      state: detailHistoryLocation(`${location.pathname}${location.search}`, 1)
+    });
+  }
+
+  function closeDetail() {
+    const historyState = detailHistoryState(location.state);
+    if (historyState) {
+      navigate(-historyState.depth);
+      return;
+    }
+    const next = new URLSearchParams(searchParams);
+    next.delete('credentialId');
+    setSearchParams(next, { replace: true, state: null });
+  }
+
   const columns: ColumnsType<DnsCredential> = [
     {
       title: t('tokens.name'),
@@ -186,7 +214,7 @@ export default function DnsApi() {
             icon={<EditOutlined />}
             onClick={() => openEdit(credential)}
           />
-          <Popconfirm title={t('tokens.deleteConfirm')} onConfirm={() => void deleteCredential(credential)}>
+          <Popconfirm title={t('dnsApi.deleteConfirm')} onConfirm={() => void deleteCredential(credential)}>
             <Button
               aria-label={`${t('common.delete')} ${credential.name}`}
               danger
@@ -220,29 +248,35 @@ export default function DnsApi() {
         />
       ) : 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>
+      {loadError && !credentials.length ? 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: () => openDetail(credential),
+                style: { cursor: 'pointer' },
+                tabIndex: 0,
+                onKeyDown: (event) => {
+                  if (event.target !== event.currentTarget) return;
+                  if (event.key === 'Enter' || event.key === ' ') {
+                    event.preventDefault();
+                    openDetail(credential);
+                  }
+                }
+              })}
+            />
+          ) : (
+            <EmptyState
+              description={t('dnsApi.secretHint')}
+              action={<Button icon={<PlusOutlined />} onClick={openCreate}>{t('dnsApi.create')}</Button>}
+            />
+          )}
+        </SectionCard>
+      )}
 
       <Drawer
         title={editing ? `${t('dnsApi.editTitle')} ${editing.name}` : t('dnsApi.createTitle')}
@@ -301,8 +335,8 @@ export default function DnsApi() {
         </Form>
       </Drawer>
 
-      <Drawer title={selected?.name} width={480} open={Boolean(selected)} onClose={() => setSelected(null)}>
-        {selected ? (
+      <Drawer title={selected?.name || t('dnsApi.title')} width={480} open={Boolean(requestedCredentialId)} onClose={closeDetail}>
+        {loading && !selected ? <Skeleton active paragraph={{ rows: 5 }} /> : 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>
@@ -314,7 +348,9 @@ export default function DnsApi() {
             </Descriptions.Item>
             <Descriptions.Item label={t('tokens.createdAt')}>{new Date(selected.createdAt).toLocaleString()}</Descriptions.Item>
           </Descriptions>
-        ) : null}
+        ) : loadError && requestedCredentialId ? (
+          <Alert type="error" showIcon message={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadCredentials()}>{t('common.refresh')}</Button>} />
+        ) : requestedCredentialId ? <EmptyState description={t('common.notFound')} /> : null}
       </Drawer>
     </Space>
   );
@@ -333,3 +369,8 @@ function providerLabel(provider: string) {
     dnspod: <StatusPill tone="neutral">Tencent DNSPod</StatusPill>
   }[provider] || <StatusPill tone="neutral">{provider}</StatusPill>;
 }
+
+function positiveInteger(value: string | null) {
+  const parsed = Number(value);
+  return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
+}

+ 56 - 24
src/pages/Domains/index.tsx

@@ -11,6 +11,7 @@ import {
   ThunderboltOutlined
 } from '@ant-design/icons';
 import {
+  Alert,
   App,
   Button,
   Card,
@@ -131,6 +132,7 @@ export default function DomainsPage() {
   const [data, setData] = useState<DomainsData | null>(null);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState('');
+  const [auxiliaryErrors, setAuxiliaryErrors] = useState({ dns: '', relays: '', activity: '' });
   const [deleteTarget, setDeleteTarget] = useState<Domain | null>(null);
   const [deleteConfirmation, setDeleteConfirmation] = useState('');
   const [testTarget, setTestTarget] = useState<Domain | null>(null);
@@ -145,24 +147,29 @@ export default function DomainsPage() {
   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);
+    setAuxiliaryErrors({ dns: '', relays: '', activity: '' });
+    const [domainsResult, credentialsResult, relaysResult, eventsResult] = await Promise.allSettled([
+      api.domains(),
+      api.dnsCredentials(),
+      api.smtpRelays(),
+      api.events({ page: 1, pageSize: 100 })
+    ]);
+    if (domainsResult.status === 'rejected') {
+      setError(domainsResult.reason instanceof Error ? domainsResult.reason.message : copy.loadFailed);
+    } else {
+      setData((current) => ({
+        domains: domainsResult.value.domains,
+        dnsCredentials: credentialsResult.status === 'fulfilled' ? credentialsResult.value.credentials : current?.dnsCredentials || [],
+        smtpRelays: relaysResult.status === 'fulfilled' ? relaysResult.value.relays : current?.smtpRelays || [],
+        events: eventsResult.status === 'fulfilled' ? eventsResult.value.events : current?.events || []
+      }));
     }
+    setAuxiliaryErrors({
+      dns: credentialsResult.status === 'rejected' ? errorText(credentialsResult.reason, copy.auxUnavailable) : '',
+      relays: relaysResult.status === 'rejected' ? errorText(relaysResult.reason, copy.auxUnavailable) : '',
+      activity: eventsResult.status === 'rejected' ? errorText(eventsResult.reason, copy.auxUnavailable) : ''
+    });
+    setLoading(false);
   }, [copy.loadFailed]);
 
   useEffect(() => {
@@ -361,13 +368,14 @@ export default function DomainsPage() {
       title: copy.dnsIntegration,
       width: 180,
       render: (_, domain) => domain.dnsCredentialId
-        ? <StatusPill tone={credentialNames.has(domain.dnsCredentialId) ? 'info' : 'warning'}>{credentialNames.get(domain.dnsCredentialId) || copy.credentialUnavailable}</StatusPill>
+        ? <StatusPill tone={credentialNames.has(domain.dnsCredentialId) ? 'info' : 'warning'}>{auxiliaryErrors.dns ? copy.auxUnavailable : credentialNames.get(domain.dnsCredentialId) || copy.credentialUnavailable}</StatusPill>
         : <StatusPill tone="neutral">{copy.manualDns}</StatusPill>
     },
     {
       title: copy.lastActivity,
       width: 190,
       render: (_, domain) => {
+        if (auxiliaryErrors.activity) return <Typography.Text type="secondary">{copy.auxUnavailable}</Typography.Text>;
         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>;
       }
@@ -402,6 +410,16 @@ export default function DomainsPage() {
     <Space direction="vertical" size={20} className="full-width">
       <PageHeader title={copy.title} subtitle={copy.subtitle} extra={<Button type="primary" style={{ minHeight: 44 }} onClick={() => setCreateOpen(true)}>{copy.addDomain}</Button>} />
 
+      {error ? (
+        <Alert
+          type="error"
+          showIcon
+          message={copy.loadFailed}
+          description={error}
+          action={<Button icon={<ReloadOutlined aria-hidden />} loading={loading} onClick={() => void load()}>{copy.retry}</Button>}
+        />
+      ) : null}
+
       <SectionCard className="domains-toolbar-card">
         <div className="page-toolbar">
           <Space wrap>
@@ -434,6 +452,16 @@ export default function DomainsPage() {
         </div>
       </SectionCard>
 
+      {auxiliaryErrors.dns || auxiliaryErrors.relays || auxiliaryErrors.activity ? (
+        <Alert
+          type="warning"
+          showIcon
+          message={copy.partialLoad}
+          description={[auxiliaryErrors.dns && `${copy.dnsIntegration}: ${auxiliaryErrors.dns}`, auxiliaryErrors.relays && `SMTP: ${auxiliaryErrors.relays}`, auxiliaryErrors.activity && `${copy.lastActivity}: ${auxiliaryErrors.activity}`].filter(Boolean).join(';')}
+          action={<Button icon={<ReloadOutlined aria-hidden />} onClick={() => void load()}>{copy.retry}</Button>}
+        />
+      ) : null}
+
       {filtered.length ? screens.md ? (
         <SectionCard>
           <Table rowKey="id" columns={columns} dataSource={pagedDomains} pagination={pagination} scroll={{ x: 1120 }} />
@@ -444,7 +472,7 @@ export default function DomainsPage() {
             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} />
+                <DomainCard domain={domain} credentialName={domain.dnsCredentialId ? credentialNames.get(domain.dnsCredentialId) : undefined} event={lastActivity.get(domain.domain)} auxiliaryErrors={auxiliaryErrors} menuItems={menuItems(domain)} onView={() => navigate(`/domains/${domain.id}/overview`)} copy={copy} />
               </List.Item>
             )}
           />
@@ -523,7 +551,7 @@ function CapabilitySummary({ domain, copy }: { domain: Domain; copy: typeof zhCo
   );
 }
 
-function DomainCard({ domain, credentialName, event, menuItems, onView, copy }: { domain: Domain; credentialName?: string; event?: SendEvent; menuItems: MenuProps['items']; onView: () => void; copy: typeof zhCopy }) {
+function DomainCard({ domain, credentialName, event, auxiliaryErrors, menuItems, onView, copy }: { domain: Domain; credentialName?: string; event?: SendEvent; auxiliaryErrors: { dns: string; relays: string; activity: string }; menuItems: MenuProps['items']; onView: () => void; copy: typeof zhCopy }) {
   return (
     <Card
       style={{ width: '100%' }}
@@ -534,8 +562,8 @@ function DomainCard({ domain, credentialName, event, menuItems, onView, copy }:
       <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 wrap><Typography.Text type="secondary">{copy.dnsIntegration}</Typography.Text><StatusPill tone={credentialName ? 'info' : domain.dnsCredentialId && auxiliaryErrors.dns ? 'warning' : 'neutral'}>{domain.dnsCredentialId && auxiliaryErrors.dns ? copy.auxUnavailable : credentialName || copy.manualDns}</StatusPill></Space>
+        <Typography.Text type="secondary">{copy.lastActivity}: {auxiliaryErrors.activity ? copy.auxUnavailable : event ? new Date(event.createdAt).toLocaleString() : copy.noActivity}</Typography.Text>
       </Space>
     </Card>
   );
@@ -545,6 +573,10 @@ function firstDnsApplyError(apply: NonNullable<Domain['status']['apply']>) {
   return apply.results?.find((result) => !result.ok && result.error)?.error;
 }
 
+function errorText(reason: unknown, fallback: string) {
+  return reason instanceof Error ? reason.message : fallback;
+}
+
 function positiveInteger(value: string | null, fallback: number) {
   const parsed = Number(value);
   return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
@@ -559,7 +591,7 @@ function recordState(record: DnsRecord | undefined, copy: typeof zhCopy) {
 }
 
 const zhCopy = {
-  title: '发信域名', subtitle: '统一管理域名认证、发送能力和最近投递状态。', loading: '正在加载域名', loadFailed: '域名列表加载失败', retry: '重试', domain: '域名', capabilities: '发送 / 接收能力', authentication: '认证健康度', dnsIntegration: 'DNS 集成', lastActivity: '最近活动', actions: '操作', view: '查看', moreActions: '更多操作',
+  title: '发信域名', subtitle: '统一管理域名认证、发送能力和最近投递状态。', loading: '正在加载域名', loadFailed: '域名列表加载失败', retry: '重试', partialLoad: '部分辅助信息暂不可用', auxUnavailable: '暂不可用', 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} 个域名`,
@@ -567,7 +599,7 @@ const zhCopy = {
 };
 
 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',
+  title: 'Sending domains', subtitle: 'Manage authentication, sending capability, and recent delivery health.', loading: 'Loading domains', loadFailed: 'Unable to load domains', retry: 'Retry', partialLoad: 'Some supporting data is temporarily unavailable', auxUnavailable: 'Unavailable', 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`,

+ 361 - 70
src/pages/Inbox.tsx

@@ -2,6 +2,7 @@ import {
   ContainerOutlined,
   CopyOutlined,
   DeleteOutlined,
+  EditOutlined,
   FileTextOutlined,
   FolderOutlined,
   InboxOutlined,
@@ -64,15 +65,19 @@ interface MailboxFormValues {
   aliases?: string;
   forwardTo?: string;
   keepForwarded?: boolean;
+  status?: string;
 }
 
 const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
 type MessageTab = 'text' | 'html' | 'raw';
+type MailFolder = InboundFolder & { countsAvailable: boolean };
+type MailboxSort = 'activity' | 'address' | 'unread' | 'messages';
+type RouteSort = 'domain' | 'configured';
 
 export default function Inbox() {
   const { message } = AntApp.useApp();
   const screens = Grid.useBreakpoint();
-  const isDesktop = useMediaQuery('(min-width: 1024px)');
+  const isDesktop = useMediaQuery('(min-width: 1280px)');
   const { locale, t } = useI18n();
   const { config } = useAppContext();
   const location = useLocation();
@@ -83,22 +88,36 @@ export default function Inbox() {
   const [catchAllForm] = Form.useForm<{ catchAllAddress?: string }>();
   const [domains, setDomains] = useState<Domain[]>([]);
   const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
-  const [folders, setFolders] = useState<InboundFolder[]>(fallbackFolders());
+  const [folders, setFolders] = useState<MailFolder[]>(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 [foldersLoading, setFoldersLoading] = useState(false);
   const [loadError, setLoadError] = useState('');
+  const [domainsError, setDomainsError] = useState('');
   const [messagesError, setMessagesError] = useState('');
   const [detailError, setDetailError] = useState('');
+  const [foldersError, setFoldersError] = useState('');
+  const [readMutationError, setReadMutationError] = useState('');
   const [actionKey, setActionKey] = useState('');
   const [searchDraft, setSearchDraft] = useState(searchParams.get('q') || '');
   const [mailboxOpen, setMailboxOpen] = useState(false);
+  const [editingMailbox, setEditingMailbox] = useState<InboundMailbox | null>(null);
+  const [mailboxListQuery, setMailboxListQuery] = useState('');
+  const [mailboxSort, setMailboxSort] = useState<MailboxSort>('activity');
+  const [routeListQuery, setRouteListQuery] = useState('');
+  const [routeSort, setRouteSort] = useState<RouteSort>('domain');
   const [clientConfig, setClientConfig] = useState<MailboxClientConfig | null>(null);
   const [catchAllDomain, setCatchAllDomain] = useState<Domain | null>(null);
   const pendingDirectClose = useRef<string | null>(null);
+  const baseRequestId = useRef(0);
+  const foldersRequestId = useRef(0);
+  const messagesRequestId = useRef(0);
+  const detailRequestId = useRef(0);
+  const readMutationRequestId = useRef(0);
 
   const workspace = searchParams.get('workspace') === 'routing' ? 'routing' : 'messages';
   const selectedMailboxId = Number(searchParams.get('mailboxId') || 0) || null;
@@ -109,60 +128,97 @@ export default function Inbox() {
   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;
+
+  useEffect(() => {
+    setSearchDraft(query);
+  }, [query]);
+
+  const mailboxOptions = useMemo(() => mailboxes.map((item) => ({
+    value: item.id,
+    label: locale.startsWith('en')
+      ? `${item.address} · ${item.unreadCount} unread / ${item.messageCount} total`
+      : `${item.address} · ${item.unreadCount} 未读 / ${item.messageCount} 封`
+  })), [locale, mailboxes]);
+  const visibleMailboxes = useMemo(
+    () => sortMailboxes(filterMailboxes(mailboxes, mailboxListQuery), mailboxSort),
+    [mailboxListQuery, mailboxSort, mailboxes]
+  );
+  const visibleRoutes = useMemo(
+    () => sortRoutes(filterRoutes(domains, routeListQuery), routeSort),
+    [domains, routeListQuery, routeSort]
+  );
 
   const loadBase = useCallback(async () => {
+    const requestId = ++baseRequestId.current;
     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);
-    }
+    setDomainsError('');
+    const [domainResult, mailboxResult] = await Promise.allSettled([
+      api.domains(), api.inboundMailboxes()
+    ]);
+    if (requestId !== baseRequestId.current) return;
+    if (domainResult.status === 'fulfilled') setDomains(domainResult.value.domains || []);
+    else setDomainsError(domainResult.reason instanceof Error ? domainResult.reason.message : t('common.error'));
+    if (mailboxResult.status === 'fulfilled') setMailboxes(mailboxResult.value.mailboxes || []);
+    else setLoadError(mailboxResult.reason instanceof Error ? mailboxResult.reason.message : t('common.error'));
+    setLoading(false);
   }, [t]);
 
   useEffect(() => {
     void loadBase();
+    return () => { baseRequestId.current += 1; };
   }, [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 preferredId = preferredMailboxId(mailboxes, selectedMailboxId);
+    if (!preferredId) return;
     const next = new URLSearchParams(searchParams);
-    next.set('mailboxId', String(mailboxes[0].id));
+    next.set('mailboxId', String(preferredId));
     next.set('folder', 'INBOX');
     next.set('page', '1');
     setSearchParams(next, { replace: true });
   }, [mailboxes, routeMessageId, searchParams, selectedMailboxId, setSearchParams]);
 
   const loadFolders = useCallback(async () => {
+    const requestId = ++foldersRequestId.current;
     if (!selectedMailboxId) {
       setFolders(fallbackFolders());
+      setFoldersError('');
+      setFoldersLoading(false);
       return;
     }
+    setFoldersLoading(true);
+    setFoldersError('');
+    setFolders(fallbackFolders());
     try {
       const result = await api.inboundFolders(selectedMailboxId);
-      setFolders(result.folders?.length ? result.folders : fallbackFolders(selectedMailbox || undefined));
-    } catch {
-      setFolders(fallbackFolders(selectedMailbox || undefined));
+      if (requestId !== foldersRequestId.current) return;
+      if (!result.folders?.length) throw new Error(locale.startsWith('en') ? 'Folder counts are unavailable.' : '文件夹计数暂不可用。');
+      setFolders(result.folders.map((item) => ({ ...item, countsAvailable: true })));
+    } catch (error) {
+      if (requestId !== foldersRequestId.current) return;
+      setFolders(fallbackFolders());
+      setFoldersError(error instanceof Error ? error.message : (locale.startsWith('en') ? 'Unable to load folder counts.' : '文件夹计数加载失败。'));
+    } finally {
+      if (requestId === foldersRequestId.current) setFoldersLoading(false);
     }
-  }, [selectedMailbox, selectedMailboxId]);
+  }, [locale, selectedMailboxId]);
 
   useEffect(() => {
     void loadFolders();
+    return () => { foldersRequestId.current += 1; };
   }, [loadFolders]);
 
   const loadMessages = useCallback(async () => {
+    const requestId = ++messagesRequestId.current;
     if (!selectedMailboxId || workspace !== 'messages') {
       setMessages([]);
       setTotal(0);
+      setMessagesError('');
+      setMessagesLoading(false);
       return;
     }
     setMessagesLoading(true);
@@ -176,31 +232,38 @@ export default function Inbox() {
         q: query || undefined,
         read: readFilter === 'read' ? true : readFilter === 'unread' ? false : undefined
       });
+      if (requestId !== messagesRequestId.current) return;
       setMessages(result.messages || []);
       setTotal(result.total ?? result.messages?.length ?? 0);
     } catch (error) {
-      setMessagesError(error instanceof Error ? error.message : t('common.error'));
+      if (requestId === messagesRequestId.current) {
+        setMessagesError(error instanceof Error ? error.message : t('common.error'));
+      }
     } finally {
-      setMessagesLoading(false);
+      if (requestId === messagesRequestId.current) setMessagesLoading(false);
     }
   }, [folder, page, query, readFilter, selectedMailboxId, t, workspace]);
 
   useEffect(() => {
     void loadMessages();
+    return () => { messagesRequestId.current += 1; };
   }, [loadMessages]);
 
   useEffect(() => {
+    const requestId = ++detailRequestId.current;
+    readMutationRequestId.current += 1;
+    setReadMutationError('');
     if (!routeMessageId) {
       setSelectedMessage(null);
       setDetailError('');
+      setDetailLoading(false);
       return;
     }
-    let active = true;
     setDetailLoading(true);
     setDetailError('');
     void api.inboundMessage(routeMessageId)
-      .then(async (result) => {
-        if (!active) return;
+      .then((result) => {
+        if (requestId !== detailRequestId.current) return;
         const detail = result.message as MailMessage | null;
         if (!detail) {
           setDetailError(t('inbox.messageNotFound'));
@@ -208,21 +271,34 @@ export default function Inbox() {
         }
         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();
+          const mutationId = ++readMutationRequestId.current;
+          void api.markInboundMessageRead(detail.id, true)
+            .then(() => {
+              if (requestId !== detailRequestId.current || mutationId !== readMutationRequestId.current) return;
+              setSelectedMessage((current) => current?.id === detail.id ? { ...current, read: true } : current);
+              setMessages((items) => items.map((item) => item.id === detail.id ? { ...item, read: true } : item));
+              setMailboxes((items) => items.map((item) => item.id === detail.mailboxId
+                ? { ...item, unreadCount: Math.max(0, item.unreadCount - 1) }
+                : item));
+              void loadFolders();
+            })
+            .catch((error) => {
+              if (requestId !== detailRequestId.current || mutationId !== readMutationRequestId.current) return;
+              setReadMutationError(error instanceof Error ? error.message : (locale.startsWith('en') ? 'Unable to mark this message as read.' : '邮件标记已读失败。'));
+            });
         }
       })
       .catch((error) => {
-        if (active) setDetailError(error instanceof Error ? error.message : t('inbox.detailLoadFailed'));
+        if (requestId === detailRequestId.current) setDetailError(error instanceof Error ? error.message : t('inbox.detailLoadFailed'));
       })
       .finally(() => {
-        if (active) setDetailLoading(false);
+        if (requestId === detailRequestId.current) setDetailLoading(false);
       });
-    return () => { active = false; };
-  }, [loadFolders, routeMessageId, t]);
+    return () => {
+      if (detailRequestId.current === requestId) detailRequestId.current += 1;
+      readMutationRequestId.current += 1;
+    };
+  }, [loadFolders, locale, routeMessageId, t]);
 
   useEffect(() => {
     if (!routeMessageId || selectedMessage?.id !== routeMessageId) return;
@@ -336,28 +412,69 @@ export default function Inbox() {
   }
 
   function openCreateMailbox() {
+    setEditingMailbox(null);
     mailboxForm.resetFields();
-    mailboxForm.setFieldsValue({ domain: domains[0]?.domain, password: generateMailboxPassword(), quotaMb: 1024, keepForwarded: true });
+    mailboxForm.setFieldsValue({ domain: domains[0]?.domain, password: generateMailboxPassword(), quotaMb: 1024, keepForwarded: true, status: 'active' });
     setMailboxOpen(true);
   }
 
-  async function createMailbox() {
+  function openEditMailbox(mailbox: InboundMailbox) {
+    setEditingMailbox(mailbox);
+    mailboxForm.resetFields();
+    mailboxForm.setFieldsValue({
+      localPart: mailbox.localPart,
+      domain: mailbox.domain,
+      password: '',
+      displayName: mailbox.displayName,
+      quotaMb: mailbox.quotaMb,
+      aliases: mailbox.aliases.join('\n'),
+      forwardTo: mailbox.forwardTo.join('\n'),
+      keepForwarded: mailbox.keepForwarded,
+      status: mailbox.status
+    });
+    setMailboxOpen(true);
+  }
+
+  function closeMailboxDrawer() {
+    setMailboxOpen(false);
+    setEditingMailbox(null);
+    mailboxForm.resetFields();
+  }
+
+  async function saveMailbox() {
     const values = await mailboxForm.validateFields();
-    setActionKey('mailbox:create');
+    const key = editingMailbox ? `mailbox:update:${editingMailbox.id}` : 'mailbox:create';
+    setActionKey(key);
     try {
-      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,
-        quotaMb: values.quotaMb
-      });
-      message.success(t('actions.inboundMailboxCreated'));
-      setMailboxOpen(false);
-      mailboxForm.resetFields();
-      setClientConfig(result.clientConfig || buildMailboxClientConfig(result.mailbox, config, values.password));
+      if (editingMailbox) {
+        const password = String(values.password || '').trim();
+        const result = await api.updateInboundMailbox(editingMailbox.id, {
+          displayName: values.displayName || '',
+          aliases: values.aliases || '',
+          forwardTo: values.forwardTo || '',
+          keepForwarded: Boolean(values.keepForwarded),
+          quotaMb: values.quotaMb ?? null,
+          status: values.status || editingMailbox.status,
+          ...(password ? { password } : {})
+        });
+        message.success(locale.startsWith('en') ? 'Mailbox updated' : '收信邮箱已更新');
+        if (password) {
+          setClientConfig(result.clientConfig || buildMailboxClientConfig(result.mailbox, config, password));
+        }
+      } else {
+        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,
+          quotaMb: values.quotaMb
+        });
+        message.success(t('actions.inboundMailboxCreated'));
+        setClientConfig(result.clientConfig || buildMailboxClientConfig(result.mailbox, config, values.password));
+      }
+      closeMailboxDrawer();
       await loadBase();
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
@@ -396,7 +513,7 @@ export default function Inbox() {
     { 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>
+      title: t('common.actions'), width: 330, render: (_, item) => <Space wrap><Button icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button><Button icon={<SettingOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button icon={<MailOutlined />} onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space>
     }
   ];
 
@@ -416,6 +533,7 @@ export default function Inbox() {
         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}
+      {domainsError ? <Alert type="warning" showIcon message={locale.startsWith('en') ? 'Domain and routing options are temporarily unavailable.' : '域名与路由选项暂不可用。'} description={domainsError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadBase()}>{t('common.refresh')}</Button>} /> : null}
       {config?.submission?.inboundEnabled === false ? <Alert type="warning" showIcon message={t('inbox.inboundDisabled')} /> : null}
       <Tabs
         activeKey={workspace}
@@ -426,15 +544,31 @@ export default function Inbox() {
         ]}
       />
       {workspace === 'messages' ? (
-        mailboxes.length ? (
+        loadError && !mailboxes.length ? null : mailboxes.length ? (
           <div style={{ display: 'grid', gridTemplateColumns: isDesktop ? '220px minmax(320px, 380px) minmax(0, 1fr)' : screens.md ? '220px minmax(0, 1fr)' : 'minmax(0, 1fr)', gap: 16, minWidth: 0 }}>
-            {screens.md ? <FolderPane mailboxes={mailboxes} selectedMailboxId={selectedMailboxId} folders={folders} activeFolder={folder} onMailbox={selectMailbox} onFolder={selectFolder} locale={locale} /> : null}
+            {screens.md ? (
+              <FolderPane
+                mailboxOptions={mailboxOptions}
+                selectedMailboxId={selectedMailboxId}
+                folders={folders}
+                foldersLoading={foldersLoading}
+                foldersError={foldersError}
+                activeFolder={folder}
+                onMailbox={selectMailbox}
+                onFolder={selectFolder}
+                onRetryFolders={loadFolders}
+                locale={locale}
+                mailboxLabel={t('inbox.mailboxFilter')}
+                refreshLabel={t('common.refresh')}
+              />
+            ) : 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' : '文件夹'} />
+                    <Select value={selectedMailboxId || undefined} onChange={selectMailbox} options={mailboxOptions} showSearch optionFilterProp="label" className="full-width" aria-label={t('inbox.mailboxFilter')} />
+                    {foldersError ? <Alert type="warning" showIcon message={locale.startsWith('en') ? 'Folder counts are unavailable.' : '文件夹计数暂不可用。'} action={<Button size="small" loading={foldersLoading} onClick={() => void loadFolders()}>{t('common.refresh')}</Button>} /> : null}
+                    <Select value={folder} onChange={selectFolder} loading={foldersLoading} options={folders.map((item) => ({ value: item.name, label: folderOptionLabel(item, locale) }))} className="full-width" aria-label={locale.startsWith('en') ? 'Folder' : '文件夹'} />
                   </Space>
                 ) : null}
                 <Space.Compact block>
@@ -453,36 +587,107 @@ export default function Inbox() {
               <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>
-            {isDesktop ? <Card styles={{ body: { padding: 20, minWidth: 0 } }}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Card> : null}
+            {isDesktop ? <Card styles={{ body: { padding: 20, minWidth: 0 } }}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} mutationError={readMutationError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Card> : null}
           </div>
         ) : <EmptyState description={locale.startsWith('en') ? 'No receiving mailbox has been created yet.' : '尚未创建收信邮箱。'} action={<Button icon={<PlusOutlined />} disabled={!domains.length} onClick={() => { switchWorkspace('routing'); openCreateMailbox(); }}>{t('inbox.createMailbox')}</Button>} />
       ) : (
         <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>} />}
+            {mailboxes.length ? (
+              <Space direction="vertical" size={12} className="full-width">
+                <Space wrap className="full-width">
+                  <Input
+                    allowClear
+                    aria-label={locale.startsWith('en') ? 'Search mailboxes' : '搜索收信邮箱'}
+                    prefix={<SearchOutlined />}
+                    placeholder={locale.startsWith('en') ? 'Search address, alias, or forwarding target' : '搜索邮箱、别名或转发地址'}
+                    value={mailboxListQuery}
+                    onChange={(event) => setMailboxListQuery(event.target.value)}
+                    style={{ flex: '1 1 280px' }}
+                  />
+                  <Select
+                    aria-label={locale.startsWith('en') ? 'Sort mailboxes' : '收信邮箱排序'}
+                    value={mailboxSort}
+                    onChange={setMailboxSort}
+                    style={{ minWidth: 180 }}
+                    options={mailboxSortOptions(locale)}
+                  />
+                </Space>
+                {screens.md ? (
+                  <Table rowKey="id" columns={mailboxColumns} dataSource={visibleMailboxes} scroll={{ x: 1040 }} />
+                ) : (
+                  <List dataSource={visibleMailboxes} renderItem={(item) => <List.Item><Card size="small" className="full-width" title={item.address}><Space direction="vertical" className="full-width"><Typography.Text type="secondary">{item.messageCount} {t('inbox.messageCount')} · {item.unreadCount} {t('inbox.unread')}</Typography.Text><Space wrap><Button icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button><Button onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space></Space></Card></List.Item>} />
+                )}
+              </Space>
+            ) : loadError ? null : <EmptyState description={t('inbox.noDomain')} action={<Button icon={<PlusOutlined />} disabled={!domains.length} onClick={openCreateMailbox}>{t('inbox.createMailbox')}</Button>} />}
           </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>} />}
+            {domains.length ? (
+              <Space direction="vertical" size={12} className="full-width">
+                <Space wrap className="full-width">
+                  <Input
+                    allowClear
+                    aria-label={locale.startsWith('en') ? 'Search receiving routes' : '搜索域名收信路由'}
+                    prefix={<SearchOutlined />}
+                    placeholder={locale.startsWith('en') ? 'Search domain or catch-all address' : '搜索域名或未知地址收件邮箱'}
+                    value={routeListQuery}
+                    onChange={(event) => setRouteListQuery(event.target.value)}
+                    style={{ flex: '1 1 280px' }}
+                  />
+                  <Select
+                    aria-label={locale.startsWith('en') ? 'Sort receiving routes' : '域名收信路由排序'}
+                    value={routeSort}
+                    onChange={setRouteSort}
+                    style={{ minWidth: 180 }}
+                    options={routeSortOptions(locale)}
+                  />
+                </Space>
+                {screens.md ? <Table rowKey="id" columns={routeColumns} dataSource={visibleRoutes} /> : <List dataSource={visibleRoutes} 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>} />}
+              </Space>
+            ) : domainsError ? null : <EmptyState description={t('inbox.noDomain')} action={<Button onClick={() => navigate('/domains?create=1')}>{t('common.addDomain')}</Button>} />}
           </SectionCard>
         </Space>
       )}
 
-      {!isDesktop ? <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}
+      {!isDesktop ? <Drawer title={t('inbox.messageDetail')} width={screens.md ? 680 : '100%'} open={Boolean(routeMessageId)} onClose={closeMessage}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} mutationError={readMutationError} 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>}>
+      <Drawer
+        title={editingMailbox ? (locale.startsWith('en') ? 'Edit mailbox' : '编辑收信邮箱') : t('inbox.createMailbox')}
+        width={560}
+        open={mailboxOpen}
+        onClose={closeMailboxDrawer}
+        destroyOnHidden
+        footer={<Space style={{ display: 'flex', justifyContent: 'flex-end' }}><Button onClick={closeMailboxDrawer}>{t('common.cancel')}</Button><Button type="primary" loading={actionKey === 'mailbox:create' || actionKey === `mailbox:update:${editingMailbox?.id}`} onClick={() => void saveMailbox()}>{editingMailbox ? t('common.save') : t('inbox.createMailbox')}</Button></Space>}
+      >
         {!domains.length ? <Alert type="warning" showIcon message={t('inbox.noDomain')} /> : (
           <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>
+            {editingMailbox ? (
+              <Descriptions bordered column={1} size="small" style={{ marginBottom: 20 }}>
+                <Descriptions.Item label={t('inbox.mailboxAddress')}><Typography.Text code>{editingMailbox.address}</Typography.Text></Descriptions.Item>
+              </Descriptions>
+            ) : (
+              <div style={{ display: 'grid', gridTemplateColumns: screens.sm ? 'minmax(0, 1fr) minmax(220px, 0.8fr)' : 'minmax(0, 1fr)', columnGap: screens.sm ? 0 : 8 }}>
+                <Form.Item name="localPart" label={t('inbox.localPart')} rules={[{ required: true, message: t('inbox.localPartRequired') }, { pattern: /^[^@\s]+$/, message: t('inbox.localPartInvalid') }]}><Input autoComplete="off" /></Form.Item>
+                <Form.Item name="domain" label={t('domains.domain')} rules={[{ required: true, message: t('inbox.domainRequired') }]}><Select options={domains.map((domain) => ({ value: domain.domain, label: `@${domain.domain}` }))} /></Form.Item>
+              </div>
+            )}
             <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>
+            <Form.Item
+              name="password"
+              label={editingMailbox ? (locale.startsWith('en') ? 'New password' : '新密码') : t('inbox.password')}
+              extra={editingMailbox ? (locale.startsWith('en') ? 'Leave blank to keep the current password.' : '留空表示保留当前密码。') : undefined}
+              rules={[{ required: !editingMailbox, 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>
+            {editingMailbox ? (
+              <Form.Item name="status" label={t('common.status')} rules={[{ required: true }]}>
+                <Select options={[{ value: 'active', label: locale.startsWith('en') ? 'Active' : '启用' }, { value: 'disabled', label: locale.startsWith('en') ? 'Disabled' : '停用' }]} />
+              </Form.Item>
+            ) : null}
           </Form>
         )}
       </Drawer>
@@ -499,15 +704,40 @@ export default function Inbox() {
   );
 }
 
-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 }) {
+function FolderPane({ mailboxOptions, selectedMailboxId, folders, foldersLoading, foldersError, activeFolder, onMailbox, onFolder, onRetryFolders, locale, mailboxLabel, refreshLabel }: {
+  mailboxOptions: Array<{ value: number; label: string }>;
+  selectedMailboxId: number | null;
+  folders: MailFolder[];
+  foldersLoading: boolean;
+  foldersError: string;
+  activeFolder: string;
+  onMailbox: (id: number) => void;
+  onFolder: (name: string) => void;
+  onRetryFolders: () => Promise<void>;
+  locale: string;
+  mailboxLabel: string;
+  refreshLabel: 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 }} />
+      <Select aria-label={mailboxLabel} value={selectedMailboxId || undefined} onChange={onMailbox} options={mailboxOptions} showSearch optionFilterProp="label" className="full-width" style={{ marginBottom: 12 }} />
+      {foldersError ? (
+        <Alert
+          type="warning"
+          showIcon
+          message={locale.startsWith('en') ? 'Folder counts are unavailable.' : '文件夹计数暂不可用。'}
+          description={foldersError}
+          action={<Button size="small" loading={foldersLoading} onClick={() => void onRetryFolders()}>{refreshLabel}</Button>}
+          style={{ marginBottom: 8 }}
+        />
+      ) : null}
       <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>}
+            {item.countsAvailable
+              ? (item.unreadCount ? <Badge count={item.unreadCount} size="small" /> : <Typography.Text type="secondary">{item.messageCount}</Typography.Text>)
+              : <Typography.Text type="secondary" aria-label={locale.startsWith('en') ? 'Count unavailable' : '计数不可用'}>—</Typography.Text>}
           </Button>
         ))}
       </Space>
@@ -541,12 +771,13 @@ function MessageList({ items, loading, activeId, onOpen, locale, t }: { items: M
   );
 }
 
-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 }) {
+function MessageDetail({ message, loading, error, mutationError, activeTab, onTabChange, onCopy, t }: { message: MailMessage | null; loading: boolean; error: string; mutationError: 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">
+      {mutationError ? <Alert type="warning" showIcon message={mutationError} /> : null}
       <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>
@@ -596,8 +827,68 @@ 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 preferredMailboxId(mailboxes: InboundMailbox[], requestedId: number | null) {
+  if (requestedId && mailboxes.some((item) => item.id === requestedId)) return requestedId;
+  const lastActive = [...mailboxes]
+    .filter((item) => item.lastMessageAt)
+    .sort((left, right) => Date.parse(right.lastMessageAt || '') - Date.parse(left.lastMessageAt || ''))[0];
+  return lastActive?.id || mailboxes.find((item) => item.messageCount > 0)?.id || mailboxes[0]?.id || null;
+}
+
+function filterMailboxes(mailboxes: InboundMailbox[], query: string) {
+  const normalized = query.trim().toLocaleLowerCase();
+  if (!normalized) return mailboxes;
+  return mailboxes.filter((item) => [item.address, item.displayName, ...item.aliases, ...item.forwardTo]
+    .some((value) => String(value || '').toLocaleLowerCase().includes(normalized)));
+}
+
+function sortMailboxes(mailboxes: InboundMailbox[], sort: MailboxSort) {
+  return [...mailboxes].sort((left, right) => {
+    if (sort === 'address') return left.address.localeCompare(right.address);
+    if (sort === 'unread') return right.unreadCount - left.unreadCount || left.address.localeCompare(right.address);
+    if (sort === 'messages') return right.messageCount - left.messageCount || left.address.localeCompare(right.address);
+    const leftActivity = Date.parse(left.lastMessageAt || '') || 0;
+    const rightActivity = Date.parse(right.lastMessageAt || '') || 0;
+    return rightActivity - leftActivity || left.address.localeCompare(right.address);
+  });
+}
+
+function mailboxSortOptions(locale: string) {
+  return locale.startsWith('en')
+    ? [{ value: 'activity', label: 'Recent activity' }, { value: 'address', label: 'Address' }, { value: 'unread', label: 'Unread count' }, { value: 'messages', label: 'Message count' }]
+    : [{ value: 'activity', label: '最近收信' }, { value: 'address', label: '邮箱地址' }, { value: 'unread', label: '未读数' }, { value: 'messages', label: '邮件数' }];
+}
+
+function filterRoutes(domains: Domain[], query: string) {
+  const normalized = query.trim().toLocaleLowerCase();
+  if (!normalized) return domains;
+  return domains.filter((item) => [item.domain, item.catchAllAddress]
+    .some((value) => String(value || '').toLocaleLowerCase().includes(normalized)));
+}
+
+function sortRoutes(domains: Domain[], sort: RouteSort) {
+  return [...domains].sort((left, right) => {
+    if (sort === 'configured') {
+      const configured = Number(Boolean(right.catchAllAddress)) - Number(Boolean(left.catchAllAddress));
+      if (configured) return configured;
+    }
+    return left.domain.localeCompare(right.domain);
+  });
+}
+
+function routeSortOptions(locale: string) {
+  return locale.startsWith('en')
+    ? [{ value: 'domain', label: 'Domain' }, { value: 'configured', label: 'Configured first' }]
+    : [{ value: 'domain', label: '域名' }, { value: 'configured', label: '已配置优先' }];
+}
+
+function fallbackFolders(): MailFolder[] {
+  return standardFolders.map((name) => ({ name, specialUse: folderSpecialUse(name), messageCount: 0, unreadCount: 0, countsAvailable: false }));
+}
+
+function folderOptionLabel(folder: MailFolder, locale: string) {
+  const count = folder.countsAvailable ? folder.unreadCount : '—';
+  return `${folderLabel(folder.name, locale)} (${count})`;
 }
 
 function folderSpecialUse(name: string) {

+ 164 - 48
src/pages/SendingLogs.tsx

@@ -95,15 +95,19 @@ export default function SendingLogs() {
   const [detailLoading, setDetailLoading] = useState(false);
   const [detailError, setDetailError] = useState('');
   const pendingDirectClose = useRef<string | null>(null);
+  const listRequestId = useRef(0);
+  const detailRequestId = useRef(0);
   const detailTab = normalizeDetailTab(searchParams.get('tab'));
 
   const filters = useMemo(() => filtersFromParams(searchParams), [queryKey]);
 
   const loadEvents = useCallback(async () => {
+    const requestId = ++listRequestId.current;
     setLoading(true);
     setError('');
     try {
       const result = await api.events(filters);
+      if (requestId !== listRequestId.current) return;
       setPageData({
         events: result.events,
         total: result.total ?? result.events.length,
@@ -111,14 +115,16 @@ export default function SendingLogs() {
         pageSize: result.pageSize ?? filters.pageSize
       });
     } catch (reason) {
+      if (requestId !== listRequestId.current) return;
       setError(reason instanceof Error ? reason.message : copy.loadFailed);
     } finally {
-      setLoading(false);
+      if (requestId === listRequestId.current) setLoading(false);
     }
   }, [copy.loadFailed, filters]);
 
   useEffect(() => {
     void loadEvents();
+    return () => { listRequestId.current += 1; };
   }, [loadEvents]);
 
   useEffect(() => {
@@ -130,15 +136,18 @@ export default function SendingLogs() {
   }, [queryKey]);
 
   useEffect(() => {
+    const requestId = ++detailRequestId.current;
     if (!eventId) {
       setSelected(null);
       setDetailError('');
+      setDetailLoading(false);
       return;
     }
     const id = Number(eventId);
     if (!Number.isInteger(id) || id <= 0) {
       setSelected(null);
       setDetailError(copy.detailNotFound);
+      setDetailLoading(false);
       return;
     }
     const preview = pageData?.events.find((event) => event.id === id) || null;
@@ -147,11 +156,21 @@ export default function SendingLogs() {
     setDetailError('');
     api.event(id)
       .then((result) => {
+        if (requestId !== detailRequestId.current) return;
         setSelected(result.event);
         if (!result.event) setDetailError(copy.detailNotFound);
       })
-      .catch((reason) => setDetailError(reason instanceof Error ? reason.message : copy.detailLoadFailed))
-      .finally(() => setDetailLoading(false));
+      .catch((reason) => {
+        if (requestId === detailRequestId.current) {
+          setDetailError(reason instanceof Error ? reason.message : copy.detailLoadFailed);
+        }
+      })
+      .finally(() => {
+        if (requestId === detailRequestId.current) setDetailLoading(false);
+      });
+    return () => {
+      if (detailRequestId.current === requestId) detailRequestId.current += 1;
+    };
   }, [copy.detailLoadFailed, copy.detailNotFound, eventId, pageData?.events]);
 
   useEffect(() => {
@@ -228,16 +247,20 @@ export default function SendingLogs() {
   async function retryDetail() {
     const id = Number(eventId);
     if (!Number.isInteger(id) || id <= 0) return;
+    const requestId = ++detailRequestId.current;
     setDetailLoading(true);
     setDetailError('');
     try {
       const result = await api.event(id);
+      if (requestId !== detailRequestId.current) return;
       setSelected(result.event);
       if (!result.event) setDetailError(copy.detailNotFound);
     } catch (reason) {
-      setDetailError(reason instanceof Error ? reason.message : copy.detailLoadFailed);
+      if (requestId === detailRequestId.current) {
+        setDetailError(reason instanceof Error ? reason.message : copy.detailLoadFailed);
+      }
     } finally {
-      setDetailLoading(false);
+      if (requestId === detailRequestId.current) setDetailLoading(false);
     }
   }
 
@@ -251,25 +274,13 @@ export default function SendingLogs() {
   }
 
   const columns: ColumnsType<SendEvent> = [
-    { title: copy.time, dataIndex: 'createdAt', width: 190, render: (value) => new Date(value).toLocaleString() },
+    { title: copy.time, dataIndex: 'createdAt', width: 180, render: (value) => formatDateTime(value, locale) },
     { 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.subject, dataIndex: 'subject', ellipsis: true, render: (value) => displaySubject(value) },
     { 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: 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>
-      )
-    }
+    { title: 'Queue ID', dataIndex: 'queueId', width: 150, ellipsis: true, render: (value) => value ? <Typography.Text code>{value}</Typography.Text> : '-' }
   ];
 
   return (
@@ -307,13 +318,25 @@ export default function SendingLogs() {
                 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)}` })}
+                scroll={{ x: 1220 }}
+                onRow={(event) => ({
+                  onClick: () => openDetail(event),
+                  style: { cursor: 'pointer' },
+                  'aria-label': `${copy.viewDetail} ${messageIdentifier(event)}`,
+                  tabIndex: 0,
+                  onKeyDown: (keyboardEvent) => {
+                    if (keyboardEvent.target !== keyboardEvent.currentTarget) return;
+                    if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') {
+                      keyboardEvent.preventDefault();
+                      openDetail(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>} />
+                <List dataSource={pageData.events} renderItem={(event) => <List.Item><EventCard event={event} copy={copy} locale={locale} 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} />}
@@ -327,6 +350,7 @@ export default function SendingLogs() {
         loading={detailLoading}
         error={detailError}
         copy={copy}
+        locale={locale}
         activeTab={detailTab}
         onClose={closeDetail}
         onTabChange={changeDetailTab}
@@ -341,23 +365,28 @@ function FilterField({ label, children }: { label: string; children: React.React
   return <Space direction="vertical" size={4}><Typography.Text type="secondary">{label}</Typography.Text>{children}</Space>;
 }
 
-function EventCard({ event, copy, onView, onCopy }: { event: SendEvent; copy: typeof zhCopy; onView: () => void; onCopy: () => void }) {
+function EventCard({ event, copy, locale, onView, onCopy }: { event: SendEvent; copy: typeof zhCopy; locale: string; 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 style={{ width: '100%' }} title={<Space wrap><EventStatus status={event.status} copy={copy} /><Typography.Text ellipsis>{displaySubject(event.subject)}</Typography.Text></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}>{formatDateTime(event.createdAt, locale)}</Descriptions.Item>
+        <Descriptions.Item label={copy.recipient}><Typography.Text ellipsis>{event.recipients.join(', ')}</Typography.Text></Descriptions.Item>
+        <Descriptions.Item label={copy.domain}>{event.domain || '-'}</Descriptions.Item>
+      </Descriptions>
     </Card>
   );
 }
 
-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> }) {
+function DeliveryDrawer({ open, event, loading, error, copy, locale, activeTab, onClose, onTabChange, onRetry, onCopy }: { open: boolean; event: SendEvent | null; loading: boolean; error: string; copy: typeof zhCopy; locale: string; activeTab: DetailTab; onClose: () => void; onTabChange: (tab: DetailTab) => void; onRetry: () => void; onCopy: (event: SendEvent) => Promise<void> }) {
+  const screens = Grid.useBreakpoint();
   const timeline = buildEventTimeline(event);
   return (
     <Drawer
-      title={event ? `${copy.detailTitle} · ${messageIdentifier(event)}` : copy.detailTitle}
+      title={event ? <Typography.Text ellipsis style={{ maxWidth: screens.sm ? 480 : 190 }}>{copy.detailTitle} · {messageIdentifier(event)}</Typography.Text> : copy.detailTitle}
       open={open}
       width="min(840px, 100vw)"
       onClose={onClose}
-      extra={event ? <Button icon={<CopyOutlined />} onClick={() => void onCopy(event)}>{copy.copyDiagnostics}</Button> : null}
+      extra={event ? <Button aria-label={copy.copyDiagnostics} icon={<CopyOutlined />} onClick={() => void onCopy(event)}>{screens.sm ? copy.copyDiagnostics : null}</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">
@@ -365,18 +394,18 @@ function DeliveryDrawer({ open, event, loading, error, copy, activeTab, onClose,
           <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.Item label={copy.time}>{formatDateTime(event.createdAt, locale)}</Descriptions.Item>
+              <Descriptions.Item label={copy.sender}><BreakableText>{event.sender}</BreakableText></Descriptions.Item>
+              <Descriptions.Item label={copy.recipient}><BreakableText>{event.recipients.join(', ')}</BreakableText></Descriptions.Item>
+              <Descriptions.Item label={copy.subject}><BreakableText>{displaySubject(event.subject)}</BreakableText></Descriptions.Item>
+              <Descriptions.Item label="Message ID"><BreakableCode>{messageIdentifier(event)}</BreakableCode></Descriptions.Item>
+              <Descriptions.Item label="Queue ID"><BreakableCode>{event.queueId || '-'}</BreakableCode></Descriptions.Item>
+              <Descriptions.Item label={copy.finalResponse}><Space align="start" style={{ maxWidth: '100%' }}><BreakableCode>{event.detail || '-'}</BreakableCode>{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} />}
+            {timeline.length ? <Timeline items={timeline.map((item, index) => ({ key: `${item.stage}-${item.at}-${index}`, color: timelineColor(item.tone), children: <TimelineEntry item={item} copy={copy} locale={locale} /> }))} /> : <EmptyState description={copy.noTimeline} />}
           </SectionCard>
 
           <SectionCard>
@@ -384,8 +413,8 @@ function DeliveryDrawer({ open, event, loading, error, copy, activeTab, onClose,
               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: 'attempts', label: copy.deliveryAttempts, children: <DeliveryAttempts attempts={event.deliveryAttempts || []} copy={copy} locale={locale} /> },
+                { key: 'tracking', label: copy.engagement, children: <TrackingDetail event={event} copy={copy} locale={locale} /> },
                 { key: 'webhooks', label: 'Webhook', children: <WebhookDetail deliveries={event.webhookDeliveries || []} copy={copy} /> },
                 { key: 'raw', label: copy.rawLogs, children: <RawLogs entries={event.deliveryLog || []} copy={copy} /> }
               ]}
@@ -397,23 +426,24 @@ function DeliveryDrawer({ open, event, loading, error, copy, activeTab, onClose,
   );
 }
 
-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 TimelineEntry({ item, copy, locale }: { item: SendEventTimelineEntry; copy: typeof zhCopy; locale: string }) {
+  return <Space direction="vertical" size={2} style={{ maxWidth: '100%' }}><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">{formatDateTime(item.at, locale)}</Typography.Text></Space>{item.recipient ? <BreakableText>To: {item.recipient}</BreakableText> : null}{item.relay ? <BreakableCode>{item.relay}</BreakableCode> : null}{item.response ? <BreakableCode>{item.response}</BreakableCode> : null}</Space>;
 }
 
-function DeliveryAttempts({ attempts, copy }: { attempts: DeliveryAttempt[]; copy: typeof zhCopy }) {
+function DeliveryAttempts({ attempts, copy, locale }: { attempts: DeliveryAttempt[]; copy: typeof zhCopy; locale: string }) {
   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> }))} />;
+  return <Timeline items={attempts.map((attempt, index) => ({ key: `${attempt.at}-${index}`, color: timelineColor(eventTone(attempt.status)), children: <Space direction="vertical" size={2} style={{ maxWidth: '100%' }}><Space wrap><EventStatus status={attempt.status} copy={copy} /><Typography.Text type="secondary">{formatDateTime(attempt.at, locale)}</Typography.Text>{attempt.dsn ? <Typography.Text code>DSN {attempt.dsn}</Typography.Text> : null}</Space>{attempt.recipient ? <BreakableText>To: {attempt.recipient}</BreakableText> : null}{attempt.relay ? <BreakableCode>{attempt.relay}</BreakableCode> : null}{attempt.response ? <BreakableCode>{attempt.response}</BreakableCode> : null}</Space> }))} />;
 }
 
-function TrackingDetail({ event, copy }: { event: SendEvent; copy: typeof zhCopy }) {
+function TrackingDetail({ event, copy, locale }: { event: SendEvent; copy: typeof zhCopy; locale: string }) {
+  const screens = Grid.useBreakpoint();
   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>;
+  return <Space direction="vertical" size={16} className="full-width"><Descriptions bordered size="small" column={screens.sm ? 2 : 1}><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}>{formatDateTime(summary?.firstOpenedAt, locale)}</Descriptions.Item><Descriptions.Item label={copy.lastClicked}>{formatDateTime(summary?.lastClickedAt, locale)}</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} locale={locale} /> : <EmptyState description={copy.noLinks} />}</Space>;
 }
 
-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 }];
+function TrackingLinks({ links, copy, locale }: { links: TrackingLink[]; copy: typeof zhCopy; locale: string }) {
+  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: (value) => formatDateTime(value, locale) }];
   return <Table rowKey="id" size="small" columns={columns} dataSource={links} pagination={false} scroll={{ x: 620 }} />;
 }
 
@@ -508,7 +538,93 @@ function timelineColor(tone: ReturnType<typeof eventTone> | SendEventTimelineEnt
 }
 
 function formatTime(value?: string | null) {
-  return value ? new Date(value).toLocaleString() : '-';
+  return formatDateTime(value, undefined);
+}
+
+function formatDateTime(value?: string | null, locale?: string) {
+  if (!value) return '-';
+  const date = new Date(value);
+  if (Number.isNaN(date.getTime())) return '-';
+  return new Intl.DateTimeFormat(locale, {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit',
+    hour12: false
+  }).format(date);
+}
+
+function displaySubject(value?: string | null) {
+  return decodeMimeHeader(value || '') || '-';
+}
+
+function decodeMimeHeader(value: string) {
+  const expression = /=\?([^?]+)\?([bq])\?([^?]+)\?=/gi;
+  let output = '';
+  let cursor = 0;
+  let previousWasEncoded = false;
+  for (const match of value.matchAll(expression)) {
+    const between = value.slice(cursor, match.index);
+    const decoded = decodeEncodedWord(match[1], match[2], match[3]);
+    if (decoded == null) {
+      output += between + match[0];
+      previousWasEncoded = false;
+    } else {
+      output += previousWasEncoded && /^[\t\r\n ]+$/.test(between) ? '' : between;
+      output += decoded;
+      previousWasEncoded = true;
+    }
+    cursor = (match.index || 0) + match[0].length;
+  }
+  return output + value.slice(cursor);
+}
+
+function decodeEncodedWord(charset: string, encoding: string, encoded: string) {
+  try {
+    const bytes = encoding.toLowerCase() === 'b'
+      ? decodeBase64(encoded)
+      : decodeQuotedPrintable(encoded);
+    if (!bytes) return null;
+    const normalized = charset.trim().toLowerCase() === 'utf8' ? 'utf-8' : charset.trim().toLowerCase();
+    return new TextDecoder(normalized, { fatal: true }).decode(bytes);
+  } catch {
+    return null;
+  }
+}
+
+function decodeBase64(value: string) {
+  if (!/^[a-z0-9+/]+={0,2}$/i.test(value) || value.length % 4 === 1) return null;
+  const unpadded = value.replace(/=+$/, '');
+  const padded = unpadded.padEnd(Math.ceil(unpadded.length / 4) * 4, '=');
+  try {
+    return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
+  } catch {
+    return null;
+  }
+}
+
+function decodeQuotedPrintable(value: string) {
+  const source = value.replace(/_/g, ' ');
+  const bytes: number[] = [];
+  for (let index = 0; index < source.length; index += 1) {
+    if (source[index] === '=' && /^[0-9a-f]{2}$/i.test(source.slice(index + 1, index + 3))) {
+      bytes.push(Number.parseInt(source.slice(index + 1, index + 3), 16));
+      index += 2;
+    } else {
+      bytes.push(source.charCodeAt(index));
+    }
+  }
+  return Uint8Array.from(bytes);
+}
+
+function BreakableCode({ children }: { children: React.ReactNode }) {
+  return <Typography.Text code style={{ overflowWrap: 'anywhere', wordBreak: 'break-word', maxWidth: '100%' }}>{children}</Typography.Text>;
+}
+
+function BreakableText({ children }: { children: React.ReactNode }) {
+  return <Typography.Text style={{ overflowWrap: 'anywhere', wordBreak: 'break-word', maxWidth: '100%' }}>{children}</Typography.Text>;
 }
 
 function formatDiagnostics(event: SendEvent) {

+ 61 - 15
src/pages/Settings.tsx

@@ -30,6 +30,7 @@ export default function Settings() {
   const { locale, t } = useI18n();
   const { user: me } = useAppContext();
   const [form] = Form.useForm<Partial<RuntimeConfig>>();
+  const appBaseUrl = Form.useWatch('appBaseUrl', form);
   const [settings, setSettings] = useState<RuntimeConfig | null>(null);
   const [loading, setLoading] = useState(true);
   const [saving, setSaving] = useState(false);
@@ -49,6 +50,8 @@ export default function Settings() {
         confirm: '离开页面',
         cancel: '继续编辑'
       }, [locale]);
+  const copy = locale.startsWith('en') ? settingsCopyEn : settingsCopyZh;
+  const appUrlError = publicUrlSecurityError(appBaseUrl, locale);
 
   const shouldBlock = useCallback<BlockerFunction>(({ currentLocation, nextLocation }) => (
     dirty && locationIdentity(currentLocation) !== locationIdentity(nextLocation)
@@ -165,28 +168,36 @@ export default function Settings() {
         <Form form={form} layout="vertical" onFinish={save} onValuesChange={() => setDirty(true)}>
           <Space direction="vertical" size={20} className="full-width">
             <SectionCard title={locale.startsWith('en') ? 'Instance' : '实例配置'}>
+              {appUrlError ? <Alert type="error" showIcon message={copy.publicUrlInvalid} description={appUrlError} style={{ marginBottom: 20 }} /> : null}
               <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>
+                <Form.Item
+                  name="appBaseUrl"
+                  label={copy.publicUrl}
+                  extra={envExtra('APP_BASE_URL')}
+                  rules={[{ required: true, message: copy.publicUrlRequired }, { validator: (_, value) => publicUrlSecurityError(value, locale) ? Promise.reject(new Error(publicUrlSecurityError(value, locale))) : Promise.resolve() }]}
+                >
+                  <Input aria-label="APP_BASE_URL" inputMode="url" placeholder="https://mail.example.com" />
+                </Form.Item>
+                <Form.Item name="mailHostname" label={copy.mailHostname} extra={envExtra('MAIL_HOSTNAME')}><Input aria-label="MAIL_HOSTNAME" /></Form.Item>
+                <Form.Item name="sendingIp" label={copy.sendingIp} extra={envExtra('SENDING_IP')}><Input aria-label="SENDING_IP" inputMode="decimal" /></Form.Item>
+                <Form.Item name="defaultSpfMechanisms" label={copy.spfMechanisms} extra={envExtra('DEFAULT_SPF_MECHANISMS')}><Input aria-label="DEFAULT_SPF_MECHANISMS" /></Form.Item>
+                <Form.Item name="dmarcPolicy" label={copy.dmarcPolicy} extra={envExtra('DMARC_POLICY')}><Select aria-label="DMARC_POLICY" options={['none', 'quarantine', 'reject'].map((value) => ({ value, label: value }))} /></Form.Item>
+                <Form.Item name="dmarcRua" label={copy.dmarcReports} extra={envExtra('DMARC_RUA')}><Input aria-label="DMARC_RUA" 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>
+                <Form.Item name="listUnsubscribeMailto" label={copy.unsubscribeEmail} extra={envExtra('LIST_UNSUBSCRIBE_MAILTO')}><Input aria-label="LIST_UNSUBSCRIBE_MAILTO" placeholder="unsubscribe@example.com" /></Form.Item>
+                <Form.Item name="listUnsubscribeUrl" label={copy.unsubscribeUrl} extra={envExtra('LIST_UNSUBSCRIBE_URL')}><Input aria-label="LIST_UNSUBSCRIBE_URL" placeholder="https://example.com/unsubscribe/{eventId}" /></Form.Item>
+                <Form.Item name="reportAbuseTo" label={copy.abuseMailbox} extra={envExtra('REPORT_ABUSE_TO')}><Input aria-label="REPORT_ABUSE_TO" placeholder="abuse@example.com" /></Form.Item>
+                <Form.Item name="csaComplaintsTo" label={copy.csaMailbox} extra={envExtra('CSA_COMPLAINTS_TO')}><Input aria-label="CSA_COMPLAINTS_TO" placeholder="csa-complaints@example.com" /></Form.Item>
+                <Form.Item name="bounceAddress" label={copy.bounceAddress} extra={envExtra('BOUNCE_ADDRESS')}><Input aria-label="BOUNCE_ADDRESS" 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>
+                <Form.Item name="listUnsubscribePostEnabled" label={copy.oneClickUnsubscribe} extra={envExtra('LIST_UNSUBSCRIBE_POST_ENABLED')} valuePropName="checked" style={{ marginBottom: 0 }}><Switch aria-label="LIST_UNSUBSCRIBE_POST_ENABLED" /></Form.Item>
+                <Form.Item name="feedbackIdEnabled" label={copy.feedbackId} extra={envExtra('FEEDBACK_ID_ENABLED')} valuePropName="checked" style={{ marginBottom: 0 }}><Switch aria-label="FEEDBACK_ID_ENABLED" /></Form.Item>
               </Space>
             </SectionCard>
 
@@ -206,8 +217,8 @@ export default function Settings() {
                 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>
+                <Form.Item name="sendRequiresVerified" label={copy.requireVerifiedDomain} extra={envExtra('SEND_REQUIRES_VERIFIED')} valuePropName="checked" style={{ marginBottom: 0 }}><Switch aria-label="SEND_REQUIRES_VERIFIED" /></Form.Item>
+                <Form.Item name="bounceEnvelopeEnabled" label={copy.bounceEnvelope} extra={envExtra('BOUNCE_ENVELOPE_ENABLED')} valuePropName="checked" style={{ marginBottom: 0 }}><Switch aria-label="BOUNCE_ENVELOPE_ENABLED" /></Form.Item>
               </Space>
             </SectionCard>
           </Space>
@@ -220,3 +231,38 @@ export default function Settings() {
 function locationIdentity(location: { pathname: string; search: string; hash: string }) {
   return `${location.pathname}${location.search}${location.hash}`;
 }
+
+function envExtra(name: string) {
+  return <Typography.Text type="secondary" code>{name}</Typography.Text>;
+}
+
+function publicUrlSecurityError(value: unknown, locale: string) {
+  if (!value) return '';
+  let url: URL;
+  try {
+    url = new URL(String(value));
+  } catch {
+    return locale.startsWith('en') ? 'Enter a valid absolute URL.' : '请输入有效的完整访问地址。';
+  }
+  const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
+  if (url.protocol !== 'https:' && !local) {
+    return locale.startsWith('en')
+      ? 'Public instances must use HTTPS so API credentials are never sent over plaintext HTTP.'
+      : '公网实例必须使用 HTTPS,避免 API 密钥通过明文 HTTP 传输。';
+  }
+  return '';
+}
+
+const settingsCopyZh = {
+  publicUrl: '公开访问地址', publicUrlInvalid: '公开访问地址不安全', publicUrlRequired: '请输入公开访问地址',
+  mailHostname: '邮件服务器主机名', sendingIp: '发信公网 IP', spfMechanisms: '默认 SPF 扩展', dmarcPolicy: '默认 DMARC 策略', dmarcReports: 'DMARC 报告邮箱',
+  unsubscribeEmail: '退订邮箱', unsubscribeUrl: '退订链接', abuseMailbox: '滥用举报邮箱', csaMailbox: 'CSA 投诉邮箱', bounceAddress: '退信地址',
+  oneClickUnsubscribe: '启用一键退订', feedbackId: '启用 Feedback-ID', requireVerifiedDomain: '仅允许已验证域名发信', bounceEnvelope: '启用退信信封地址'
+};
+
+const settingsCopyEn: typeof settingsCopyZh = {
+  publicUrl: 'Public URL', publicUrlInvalid: 'Public URL is insecure', publicUrlRequired: 'Enter the public URL',
+  mailHostname: 'Mail server hostname', sendingIp: 'Public sending IP', spfMechanisms: 'Default SPF mechanisms', dmarcPolicy: 'Default DMARC policy', dmarcReports: 'DMARC report mailbox',
+  unsubscribeEmail: 'Unsubscribe mailbox', unsubscribeUrl: 'Unsubscribe URL', abuseMailbox: 'Abuse mailbox', csaMailbox: 'CSA complaints mailbox', bounceAddress: 'Bounce address',
+  oneClickUnsubscribe: 'Enable one-click unsubscribe', feedbackId: 'Enable Feedback-ID', requireVerifiedDomain: 'Require a verified sending domain', bounceEnvelope: 'Enable bounce envelope address'
+};

+ 133 - 37
src/pages/SmtpCredentials.tsx

@@ -23,8 +23,8 @@ import {
   Typography
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { useCallback, useEffect, useState } from 'react';
-import { useSearchParams } from 'react-router-dom';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
 
 import { EmptyState } from '../components/common/EmptyState';
 import { PageHeader } from '../components/common/PageHeader';
@@ -32,6 +32,7 @@ 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 { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
 import { api } from '../frontend/services/api';
 import type { SmtpCredential, SmtpRelay, SmtpRelayPayload } from '../frontend/types';
 
@@ -46,6 +47,8 @@ export default function SmtpCredentials() {
   const { message } = AntApp.useApp();
   const { locale, t } = useI18n();
   const { config } = useAppContext();
+  const location = useLocation();
+  const navigate = useNavigate();
   const [searchParams, setSearchParams] = useSearchParams();
   const [credentialForm] = Form.useForm<CredentialFormValues>();
   const [relayForm] = Form.useForm<SmtpRelayPayload>();
@@ -53,33 +56,47 @@ export default function SmtpCredentials() {
   const [credentials, setCredentials] = useState<SmtpCredential[]>([]);
   const [relays, setRelays] = useState<SmtpRelay[]>([]);
   const [loading, setLoading] = useState(true);
-  const [loadError, setLoadError] = useState('');
+  const [credentialError, setCredentialError] = useState('');
+  const [relayError, setRelayError] = useState('');
+  const [connectionError, setConnectionError] = useState('');
   const [actionKey, setActionKey] = useState('');
   const [credentialOpen, setCredentialOpen] = useState(false);
   const [editingCredential, setEditingCredential] = useState<SmtpCredential | null>(null);
   const [relayOpen, setRelayOpen] = 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';
+  const requestedCredentialId = positiveInteger(searchParams.get('credentialId'));
+  const requestedRelayId = positiveInteger(searchParams.get('relayId'));
+  const detail = useMemo(() => {
+    if (requestedCredentialId) {
+      const value = credentials.find((credential) => credential.id === requestedCredentialId);
+      return value ? { type: 'credential' as const, value } : null;
+    }
+    if (requestedRelayId) {
+      const value = relays.find((relay) => relay.id === requestedRelayId);
+      return value ? { type: 'relay' as const, value } : null;
+    }
+    return null;
+  }, [credentials, relays, requestedCredentialId, requestedRelayId]);
 
   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);
-    }
+    setCredentialError('');
+    setRelayError('');
+    setConnectionError('');
+    const [primaryResult, credentialResult, relayResult] = await Promise.allSettled([
+      api.smtpCredential(),
+      api.smtpCredentials(),
+      api.smtpRelays()
+    ]);
+    if (primaryResult.status === 'fulfilled') setPrimaryCredential(primaryResult.value.credential || null);
+    else setConnectionError(errorMessage(primaryResult.reason, t));
+    if (credentialResult.status === 'fulfilled') setCredentials(credentialResult.value.credentials || []);
+    else setCredentialError(errorMessage(credentialResult.reason, t));
+    if (relayResult.status === 'fulfilled') setRelays(relayResult.value.relays || []);
+    else setRelayError(errorMessage(relayResult.reason, t));
+    setLoading(false);
   }, [t]);
 
   useEffect(() => {
@@ -89,7 +106,42 @@ export default function SmtpCredentials() {
   function changeTab(key: string) {
     const next = new URLSearchParams(searchParams);
     next.set('tab', key);
-    setSearchParams(next);
+    next.delete('credentialId');
+    next.delete('relayId');
+    setSearchParams(next, requestedCredentialId || requestedRelayId ? { replace: true, state: null } : undefined);
+  }
+
+  function openCredentialDetail(item: SmtpCredential) {
+    if (!item.id) return;
+    const next = new URLSearchParams(searchParams);
+    next.set('tab', 'credentials');
+    next.set('credentialId', String(item.id));
+    next.delete('relayId');
+    setSearchParams(next, {
+      state: detailHistoryLocation(`${location.pathname}${location.search}`, 1)
+    });
+  }
+
+  function openRelayDetail(relay: SmtpRelay) {
+    const next = new URLSearchParams(searchParams);
+    next.set('tab', 'relays');
+    next.set('relayId', String(relay.id));
+    next.delete('credentialId');
+    setSearchParams(next, {
+      state: detailHistoryLocation(`${location.pathname}${location.search}`, 1)
+    });
+  }
+
+  function closeDetail() {
+    const historyState = detailHistoryState(location.state);
+    if (historyState) {
+      navigate(-historyState.depth);
+      return;
+    }
+    const next = new URLSearchParams(searchParams);
+    next.delete('credentialId');
+    next.delete('relayId');
+    setSearchParams(next, { replace: true, state: null });
   }
 
   async function copyValue(value: string) {
@@ -139,7 +191,7 @@ export default function SmtpCredentials() {
     try {
       await api.deleteSmtpCredential(item.id);
       message.success(t('actions.smtpDeleted'));
-      setDetail(null);
+      if (requestedCredentialId === item.id) closeDetail();
       await loadData();
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
@@ -198,7 +250,7 @@ export default function SmtpCredentials() {
     try {
       await api.deleteSmtpRelay(relay.id);
       message.success(t('actions.smtpRelayDeleted'));
-      setDetail(null);
+      if (requestedRelayId === relay.id) closeDetail();
       await loadData();
     } catch (error) {
       message.error(error instanceof Error ? error.message : t('common.error'));
@@ -221,7 +273,7 @@ export default function SmtpCredentials() {
         <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: (value) => formatDate(value, locale) },
     {
       title: t('common.actions'),
       width: 140,
@@ -278,9 +330,6 @@ export default function SmtpCredentials() {
   return (
     <Space direction="vertical" size={20} className="full-width">
       <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}
@@ -289,33 +338,61 @@ export default function SmtpCredentials() {
             {
               key: 'credentials',
               label: t('smtp.loginCredentialsTitle'),
-              children: loading ? <Skeleton active paragraph={{ rows: 5 }} /> : credentials.length ? (
+              children: loading && !credentials.length ? <Skeleton active paragraph={{ rows: 5 }} /> : credentialError ? (
+                <Alert type="error" showIcon message={credentialError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>} />
+              ) : 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' } })}
+                  onRow={(item) => ({
+                    onClick: () => openCredentialDetail(item),
+                    style: { cursor: item.id ? 'pointer' : 'default' },
+                    tabIndex: item.id ? 0 : -1,
+                    onKeyDown: (event) => {
+                      if (event.target !== event.currentTarget) return;
+                      if (event.key === 'Enter' || event.key === ' ') {
+                        event.preventDefault();
+                        openCredentialDetail(item);
+                      }
+                    }
+                  })}
                 />
               ) : <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 ? (
+              children: loading && !relays.length ? <Skeleton active paragraph={{ rows: 5 }} /> : relayError ? (
+                <Alert type="error" showIcon message={relayError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>} />
+              ) : relays.length ? (
                 <Table
                   rowKey="id"
                   columns={relayColumns}
                   dataSource={relays}
                   scroll={{ x: 860 }}
-                  onRow={(relay) => ({ onClick: () => setDetail({ type: 'relay', value: relay }), style: { cursor: 'pointer' } })}
+                  onRow={(relay) => ({
+                    onClick: () => openRelayDetail(relay),
+                    style: { cursor: 'pointer' },
+                    tabIndex: 0,
+                    onKeyDown: (event) => {
+                      if (event.target !== event.currentTarget) return;
+                      if (event.key === 'Enter' || event.key === ' ') {
+                        event.preventDefault();
+                        openRelayDetail(relay);
+                      }
+                    }
+                  })}
                 />
               ) : <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 /> : (
+              children: loading && !primaryCredential ? <Skeleton active /> : connectionError ? (
+                <Alert type="error" showIcon message={connectionError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>} />
+              ) : (
                 <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">
@@ -373,12 +450,12 @@ export default function SmtpCredentials() {
         </Form>
       </Drawer>
 
-      <Drawer title={detail?.type === 'credential' ? detail.value.username : detail?.value.name} width={480} open={Boolean(detail)} onClose={() => setDetail(null)}>
-        {detail?.type === 'credential' ? (
+      <Drawer title={detail?.type === 'credential' ? detail.value.username : detail?.type === 'relay' ? detail.value.name : t('nav.smtp')} width={480} open={Boolean(requestedCredentialId || requestedRelayId)} onClose={closeDetail}>
+        {loading && !detail ? <Skeleton active paragraph={{ rows: 5 }} /> : 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.Item label={t('tokens.createdAt')}>{formatDate(detail.value.createdAt, locale)}</Descriptions.Item>
           </Descriptions>
         ) : detail?.type === 'relay' ? (
           <Descriptions bordered column={1} size="small">
@@ -388,7 +465,11 @@ export default function SmtpCredentials() {
             <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}
+        ) : requestedCredentialId && credentialError ? (
+          <Alert type="error" showIcon message={credentialError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>} />
+        ) : requestedRelayId && relayError ? (
+          <Alert type="error" showIcon message={relayError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>} />
+        ) : requestedCredentialId || requestedRelayId ? <EmptyState description={t('common.notFound')} /> : null}
       </Drawer>
     </Space>
   );
@@ -408,6 +489,21 @@ 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() : '—';
+function formatDate(value: string | undefined, locale: string) {
+  if (!value) return '—';
+  const date = new Date(value);
+  if (Number.isNaN(date.getTime())) return '—';
+  return new Intl.DateTimeFormat(locale, {
+    year: 'numeric', month: '2-digit', day: '2-digit',
+    hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
+  }).format(date);
+}
+
+function positiveInteger(value: string | null) {
+  const parsed = Number(value);
+  return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
+}
+
+function errorMessage(error: unknown, t: (key: string) => string) {
+  return error instanceof Error ? error.message : t('common.error');
 }

+ 60 - 27
src/pages/Webhooks.tsx

@@ -98,11 +98,15 @@ export default function Webhooks({ domainId: domainIdProp, mailboxId: mailboxIdP
   const [availableDomains, setAvailableDomains] = useState<Domain[]>(domains);
   const [availableMailboxes, setAvailableMailboxes] = useState<InboundMailbox[]>(mailboxes);
   const [loading, setLoading] = useState(true);
+  const [loadError, setLoadError] = useState('');
+  const [resourceError, setResourceError] = useState('');
+  const [recentDeliveriesError, setRecentDeliveriesError] = useState('');
   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 loadRequestId = useRef(0);
   const detailRequestId = useRef(0);
 
   const mailboxScoped = mailboxId != null;
@@ -116,33 +120,52 @@ export default function Webhooks({ domainId: domainIdProp, mailboxId: mailboxIdP
   );
 
   const loadData = useCallback(async () => {
+    const requestId = ++loadRequestId.current;
     setLoading(true);
-    try {
-      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 }),
-        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);
+    setLoadError('');
+    setResourceError('');
+    setRecentDeliveriesError('');
+    const shouldLoadResources = !domains.length || !mailboxes.length;
+    const [webhooksResult, deliveriesResult, domainsResult, mailboxesResult] = await Promise.allSettled([
+      api.webhooks(mailboxScoped ? undefined : (domainId != null ? domainId : undefined), mailboxId),
+      api.webhookDeliveries({ limit: 100 }),
+      shouldLoadResources ? api.domains() : Promise.resolve(null),
+      shouldLoadResources ? api.inboundMailboxes() : Promise.resolve(null)
+    ]);
+    if (requestId !== loadRequestId.current) return;
+
+    let nextWebhooks: Webhook[] = [];
+    if (webhooksResult.status === 'fulfilled') {
+      nextWebhooks = (webhooksResult.value.webhooks || []).filter((webhook) => scoped || webhook.mailboxId == null);
       setWebhooks(nextWebhooks);
+    } else {
+      setLoadError(webhooksResult.reason instanceof Error ? webhooksResult.reason.message : t('common.error'));
+    }
+
+    if (deliveriesResult.status === 'fulfilled') {
       const webhookIds = new Set(nextWebhooks.map((w) => w.id));
-      const nextDeliveries = (deliveriesResult.deliveries || []).filter((d) =>
+      const nextDeliveries = (deliveriesResult.value.deliveries || []).filter((d) =>
         scoped ? webhookIds.has(d.webhookId) : true
       );
       setRecentDeliveries(nextDeliveries);
-    } catch (error) {
-      message.error(error instanceof Error ? error.message : t('common.error'));
-    } finally {
-      setLoading(false);
+    } else {
+      setRecentDeliveriesError(deliveriesResult.reason instanceof Error ? deliveriesResult.reason.message : t('common.error'));
     }
-  }, [domainId, domains.length, mailboxId, mailboxScoped, mailboxes.length, message, scoped, t]);
+
+    const resourceFailures: string[] = [];
+    if (domainsResult.status === 'fulfilled') {
+      if (domainsResult.value) setAvailableDomains(domainsResult.value.domains || []);
+    } else {
+      resourceFailures.push(domainsResult.reason instanceof Error ? domainsResult.reason.message : t('common.error'));
+    }
+    if (mailboxesResult.status === 'fulfilled') {
+      if (mailboxesResult.value) setAvailableMailboxes(mailboxesResult.value.mailboxes || []);
+    } else {
+      resourceFailures.push(mailboxesResult.reason instanceof Error ? mailboxesResult.reason.message : t('common.error'));
+    }
+    setResourceError(resourceFailures.join(';'));
+    setLoading(false);
+  }, [domainId, domains.length, mailboxId, mailboxScoped, mailboxes.length, scoped, t]);
 
   useEffect(() => {
     if (domains.length) setAvailableDomains(domains);
@@ -154,6 +177,7 @@ export default function Webhooks({ domainId: domainIdProp, mailboxId: mailboxIdP
 
   useEffect(() => {
     void loadData();
+    return () => { loadRequestId.current += 1; };
   }, [loadData]);
 
   const lastDeliveryByWebhook = useMemo(() => {
@@ -618,7 +642,19 @@ export default function Webhooks({ domainId: domainIdProp, mailboxId: mailboxIdP
         title={t('webhooks.listTitle')}
         extra={<StatusPill tone="neutral">{webhooks.length}</StatusPill>}
       >
-        {webhooks.length ? (
+        {!loadError && resourceError ? <Alert type="warning" showIcon message={t('webhooks.resourcesUnavailable')} description={resourceError} style={{ marginBottom: 12 }} /> : null}
+        {!loadError && recentDeliveriesError && webhooks.length ? <Alert type="warning" showIcon message={t('webhooks.recentDeliveriesUnavailable')} description={recentDeliveriesError} style={{ marginBottom: 12 }} /> : null}
+        {loadError ? (
+          <Alert
+            type="error"
+            showIcon
+            message={t('webhooks.loadFailed')}
+            description={loadError}
+            action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>}
+          />
+        ) : loading && !webhooks.length ? (
+          <Skeleton active paragraph={{ rows: 6 }} />
+        ) : webhooks.length ? (
           <Table
             rowKey="id"
             columns={endpointColumns}
@@ -630,11 +666,6 @@ export default function Webhooks({ domainId: domainIdProp, mailboxId: mailboxIdP
         ) : (
           <EmptyState
             description={t('webhooks.empty')}
-            action={
-              <Button icon={<PlusOutlined />} onClick={openCreate}>
-                {t('webhooks.create')}
-              </Button>
-            }
           />
         )}
       </SectionCard>
@@ -715,7 +746,7 @@ export default function Webhooks({ domainId: domainIdProp, mailboxId: mailboxIdP
           <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>
+                <Typography.Text code copyable={{ onCopy: () => void copyValue(selectedWebhook.url) }} style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{selectedWebhook.url}</Typography.Text>
               </Descriptions.Item>
               <Descriptions.Item label={t('webhooks.scope')}>{scopeLabel(selectedWebhook)}</Descriptions.Item>
               <Descriptions.Item label={t('webhooks.events')}>
@@ -759,6 +790,8 @@ export default function Webhooks({ domainId: domainIdProp, mailboxId: mailboxIdP
             <Alert type="info" showIcon message={t('webhooks.docsSignature')} description={t('webhooks.docsEvents')} />
             <CodeBlock value={buildSignatureSample('whsec_your_secret')} onCopy={copyValue} />
           </Space>
+        ) : loadError && requestedWebhookId ? (
+          <Alert type="error" showIcon message={t('webhooks.loadFailed')} description={loadError} action={<Button icon={<ReloadOutlined />} onClick={() => void loadData()}>{t('common.refresh')}</Button>} />
         ) : requestedWebhookId ? <EmptyState description={t('common.notFound')} /> : null}
       </Drawer>
 

+ 19 - 0
test/admin-responsive-style.test.js

@@ -0,0 +1,19 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+
+const styles = readFileSync(new URL('../src/frontend/styles.css', import.meta.url), 'utf8');
+
+test('admin layout uses component constraints instead of hiding page overflow', () => {
+  const bodyRule = styles.match(/(?:^|\n)body\s*\{([^}]*)\}/)?.[1] || '';
+  assert.doesNotMatch(bodyRule, /overflow-x\s*:\s*(?:hidden|clip)/);
+  assert.match(styles, /\.admin-main-layout\s*\{[^}]*max-width:\s*100%[^}]*min-width:\s*0/s);
+  assert.match(styles, /\.admin-center-tabs[^{}]*\{[^}]*max-width:\s*100%[^}]*min-width:\s*0/s);
+});
+
+test('admin route focus and tablet header states remain visible and bounded', () => {
+  assert.match(styles, /\.admin-content:focus\s*\{[^}]*outline:\s*3px\s+solid/s);
+  assert.match(styles, /@media\s*\(max-width:\s*900px\)[\s\S]*?\.environment-status__label\s*\{\s*display:\s*none;/);
+  assert.match(styles, /\.user-button__name\s*\{[^}]*text-overflow:\s*ellipsis[^}]*white-space:\s*nowrap/s);
+});
+

+ 1 - 1
test/frontend-admin-model.test.js

@@ -35,7 +35,7 @@ test('summarizes merge preview counts and confirmation text', () => {
   assert.deepEqual(mergePreviewSummary(preview), [
     { key: 'domains', label: '域名', count: 3 },
     { key: 'dnsCredentials', label: 'DNS 凭据', count: 2 },
-    { key: 'apiTokens', label: 'API Token', count: 1 },
+    { key: 'apiTokens', label: 'API 密钥', count: 1 },
     { key: 'inboundMailboxes', label: '收信邮箱', count: 4 },
     { key: 'inboundMessages', label: '入站邮件', count: 5 },
     { key: 'sendEvents', label: '发送记录', count: 9 },

+ 22 - 10
test/frontend-i18n.test.js

@@ -35,12 +35,12 @@ test('uses localized Chinese labels for the main navigation', () => {
 
   assert.equal(zh('nav.dashboard'), '仪表盘');
   assert.equal(zh('nav.domains'), '发信域名');
-  assert.equal(zh('nav.dnsApi'), 'DNS API');
-  assert.equal(zh('nav.smtp'), 'SMTP 凭据');
-  assert.equal(zh('nav.tokens'), 'API Token');
+  assert.equal(zh('nav.dnsApi'), 'DNS 集成');
+  assert.equal(zh('nav.smtp'), 'SMTP 与中继');
+  assert.equal(zh('nav.tokens'), 'API 密钥');
   assert.equal(zh('nav.logs'), '发送记录');
   assert.equal(zh('nav.webhooks'), 'Webhooks');
-  assert.equal(zh('nav.admin'), '管理');
+  assert.equal(zh('nav.admin'), '管理中心');
   assert.equal(zh('nav.settings'), '系统设置');
 });
 
@@ -63,12 +63,12 @@ test('translates admin panel labels', () => {
   const zh = createTranslator('zh-CN');
   const en = createTranslator('en-US');
 
-  assert.equal(zh('admin.title'), '管理员面板');
+  assert.equal(zh('admin.title'), '管理中心');
   assert.equal(zh('admin.users'), '用户');
   assert.equal(zh('admin.resources'), '资源');
   assert.equal(zh('admin.auditLogs'), '审计日志');
   assert.equal(zh('settings.deliveryChecks'), '发信环境检查');
-  assert.equal(en('admin.title'), 'Admin Panel');
+  assert.equal(en('admin.title'), 'Admin center');
   assert.equal(en('settings.deliveryChecks'), 'Sending Environment Checks');
 });
 
@@ -76,10 +76,22 @@ test('translates smtp login credential labels separately from outbound relays',
   const zh = createTranslator('zh-CN');
   const en = createTranslator('en-US');
 
-  assert.equal(zh('smtp.loginCredentialsTitle'), '发信登录凭据');
-  assert.equal(zh('smtpRelay.title'), '高级:外部 SMTP 出口');
-  assert.equal(en('smtp.loginCredentialsTitle'), 'Sending login credentials');
-  assert.equal(en('smtpRelay.title'), 'Advanced: external SMTP relays');
+  assert.equal(zh('smtp.loginCredentialsTitle'), 'SMTP 凭据');
+  assert.equal(zh('smtpRelay.title'), '发送中继');
+  assert.equal(en('smtp.loginCredentialsTitle'), 'SMTP credentials');
+  assert.equal(en('smtpRelay.title'), 'Sending relays');
+});
+
+test('uses DNS integration credential terminology consistently', () => {
+  const zh = createTranslator('zh-CN');
+  const en = createTranslator('en-US');
+
+  assert.equal(zh('dnsApi.title'), 'DNS 集成凭据');
+  assert.equal(zh('dnsApi.createTitle'), '新增 DNS 集成凭据');
+  assert.equal(zh('dnsApi.deleteConfirm'), '确认删除该 DNS 集成凭据?删除后将无法再通过此 Provider 管理对应 Zone。');
+  assert.equal(en('dnsApi.title'), 'DNS integration credentials');
+  assert.equal(en('dnsApi.createTitle'), 'New DNS integration credential');
+  assert.equal(en('dnsApi.deleteConfirm'), 'Delete this DNS integration credential? This provider will no longer be available for managing the associated zone.');
 });
 
 test('translates webhook page chrome strings', () => {

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

@@ -31,6 +31,7 @@ describe('AdminLayout navigation', () => {
     const activityItems = screen.getAllByText('发送活动');
     await user.click(activityItems[0]);
     expect(screen.getByTestId('location').textContent).toBe('/activity');
+    await waitFor(() => expect(document.activeElement?.id).toBe('main-content'));
   });
 
   it('provides an explicit mobile navigation escape action', async () => {

+ 12 - 9
test/ui/api-token-secret.test.tsx

@@ -1,6 +1,7 @@
 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 } from 'react-router-dom';
 import { describe, expect, it, vi } from 'vitest';
 
 import { AppContext, type AppContextValue } from '../../src/frontend/app-context';
@@ -23,12 +24,12 @@ describe('API token secrets and message access', () => {
     const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({ token: summary });
 
     renderPage();
-    await screen.findByText(/新 Token 会加密保存并支持完整复制/);
-    await user.click(screen.getAllByRole('button', { name: /创建 Token/ })[0]);
+    await screen.findByText(/新 API 密钥会加密保存并支持完整复制/);
+    await user.click(screen.getAllByRole('button', { name: /创建密钥/ })[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 user.click(within(editor).getByRole('button', { name: /创建密钥/ }));
 
     await waitFor(() => expect(createToken).toHaveBeenCalledWith({
       name: 'CI sender',
@@ -39,10 +40,10 @@ describe('API token secrets and message access', () => {
     }));
     expect((await screen.findAllByText(fullToken)).length).toBeGreaterThan(0);
 
-    const reveal = screen.getByRole('dialog', { name: 'API Token 已创建' });
+    const reveal = screen.getByRole('dialog', { name: 'API 密钥已创建' });
     await user.click(within(reveal).getByRole('button', { name: /确.*认/ }));
 
-    await waitFor(() => expect(screen.queryByRole('dialog', { name: 'API Token 已创建' })).toBeNull());
+    await waitFor(() => expect(screen.queryByRole('dialog', { name: 'API 密钥已创建' })).toBeNull());
     await waitFor(() => expect(screen.getAllByText(fullToken)).toHaveLength(1));
     const copyButtons = screen.getAllByRole('button', { name: '复制完整 Token CI sender' });
     expect(copyButtons).toHaveLength(1);
@@ -66,7 +67,7 @@ describe('API token secrets and message access', () => {
 
     renderPage('admin');
     await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith(true));
-    await user.click(screen.getAllByRole('button', { name: /创建 Token/ })[0]);
+    await user.click(screen.getAllByRole('button', { name: /创建密钥/ })[0]);
     const editor = await screen.findByRole('dialog');
     expect(within(editor).queryByText('邮件读取范围')).toBeNull();
 
@@ -76,7 +77,7 @@ describe('API token secrets and message access', () => {
     await user.click(within(editor).getByRole('radio', { name: '指定邮箱' }));
     await user.click(within(editor).getByLabelText('授权邮箱'));
     await user.click(await screen.findByText(new RegExp(mailbox.address)));
-    await user.click(within(editor).getByRole('button', { name: /创建 Token/ }));
+    await user.click(within(editor).getByRole('button', { name: /创建密钥/ }));
 
     await waitFor(() => expect(createToken).toHaveBeenCalledWith({
       name: 'Message reader',
@@ -102,7 +103,7 @@ describe('API token secrets and message access', () => {
     await user.click(confirmations[confirmations.length - 1]);
 
     await waitFor(() => expect(rotate).toHaveBeenCalledWith(9));
-    expect(await screen.findByText('API Token 已重新生成')).not.toBeNull();
+    expect(await screen.findByText('API 密钥已重新生成')).not.toBeNull();
     expect((await screen.findAllByText('mh_rotated.new-secret')).length).toBeGreaterThan(0);
   });
 });
@@ -119,7 +120,9 @@ function renderPage(role: UserRole = 'admin') {
       <AntApp>
         <I18nProvider>
           <AppContext.Provider value={context}>
-            <ApiTokens />
+            <MemoryRouter initialEntries={['/integrations/api-keys']}>
+              <ApiTokens />
+            </MemoryRouter>
           </AppContext.Provider>
         </I18nProvider>
       </AntApp>

+ 263 - 0
test/ui/dashboard.test.tsx

@@ -0,0 +1,263 @@
+import { App as AntApp, ConfigProvider } from 'antd';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createMemoryRouter, RouterProvider } from 'react-router-dom';
+import { afterEach, beforeEach, 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 { Analytics, ApiToken, RuntimeConfig, WebhookDelivery } from '../../src/frontend/types';
+import Dashboard from '../../src/pages/Dashboard';
+
+vi.mock('../../src/pages/DashboardCharts', () => ({
+  default: () => <div data-testid="dashboard-charts" />
+}));
+
+describe('Dashboard operational states', () => {
+  beforeEach(() => {
+    window.localStorage.removeItem('mailhub.locale');
+  });
+
+  afterEach(() => vi.restoreAllMocks());
+
+  it('keeps dependency failures visible without reporting a missing credential or all-clear state', async () => {
+    const user = userEvent.setup();
+    mockCoreApis();
+    vi.spyOn(api, 'smtpCredential')
+      .mockRejectedValueOnce(new Error('SMTP timeout'))
+      .mockResolvedValue({ credential: null });
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [sendToken] });
+    const deliveries = vi.spyOn(api, 'webhookDeliveries')
+      .mockRejectedValueOnce(new Error('Webhook timeout'))
+      .mockResolvedValue({ deliveries: [] });
+
+    renderDashboard();
+
+    expect(await screen.findByText('部分运维状态获取失败')).toBeTruthy();
+    expect(screen.getByText(/SMTP 凭据.*SMTP timeout/)).toBeTruthy();
+    expect(screen.getByText(/Webhook 失败投递.*Webhook timeout/)).toBeTruthy();
+    expect(screen.queryByText('尚未创建可用的发送凭据')).toBeNull();
+    expect(screen.queryByText('状态正常')).toBeNull();
+    expect(screen.getByText('状态未知')).toBeTruthy();
+
+    await user.click(screen.getByRole('button', { name: /重\s*试/ }));
+
+    await waitFor(() => expect(screen.queryByText('部分运维状态获取失败')).toBeNull());
+    expect(await screen.findByText('状态正常')).toBeTruthy();
+    expect(deliveries).toHaveBeenCalledTimes(2);
+  });
+
+  it('accepts SMTP or a send-scoped API key and exposes both setup entrances', async () => {
+    const user = userEvent.setup();
+    mockCoreApis();
+    vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: null });
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
+    vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
+
+    const router = renderDashboard();
+
+    expect(await screen.findByText('尚未创建可用的发送凭据')).toBeTruthy();
+    expect(screen.getByRole('button', { name: /配置 SMTP/ })).toBeTruthy();
+    expect(screen.getByRole('button', { name: /创建 API 密钥/ })).toBeTruthy();
+    expect(screen.getByRole('button', { name: 'SMTP' })).toBeTruthy();
+    expect(screen.getByRole('button', { name: 'API 密钥' })).toBeTruthy();
+
+    await user.click(screen.getByRole('button', { name: /创建 API 密钥/ }));
+    await waitFor(() => expect(router.state.location.pathname).toBe('/integrations/api-keys'));
+  });
+
+  it('does not flag credentials as missing when an active send-scoped API key exists', async () => {
+    mockCoreApis();
+    vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: null });
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [sendToken] });
+    vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
+
+    renderDashboard();
+
+    expect(await screen.findByText('状态正常')).toBeTruthy();
+    expect(screen.queryByText('尚未创建可用的发送凭据')).toBeNull();
+  });
+
+  it('explains the deployment-level ADMIN_PASSWORD change without linking to system settings', async () => {
+    mockHealthyDependencies();
+
+    renderDashboard({ usingDefaultAdminPassword: true });
+
+    const warning = (await screen.findByText('当前仍在使用默认管理员密码')).closest('.ant-alert');
+    expect(warning).not.toBeNull();
+    expect(within(warning as HTMLElement).getByText(/ADMIN_PASSWORD/)).toBeTruthy();
+    expect(within(warning as HTMLElement).getByText(/重启 MailHub/)).toBeTruthy();
+    expect(within(warning as HTMLElement).queryByRole('link')).toBeNull();
+  });
+
+  it('links a failed webhook warning to that webhook dead-delivery filter', async () => {
+    const user = userEvent.setup();
+    mockCoreApis();
+    vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: { username: 'smtp-user', passwordSet: true } });
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
+    const deliveries = vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [deadDelivery] });
+
+    const router = renderDashboard();
+
+    await user.click(await screen.findByRole('button', { name: /检查 Webhooks/ }));
+
+    await waitFor(() => expect(router.state.location.pathname).toBe('/integrations/webhooks'));
+    const params = new URLSearchParams(router.state.location.search);
+    expect(params.get('webhookId')).toBe('42');
+    expect(params.get('deliveryStatus')).toBe('dead');
+    expect(deliveries).toHaveBeenCalledWith({ status: 'dead', limit: 5 });
+  });
+
+  it('never labels analytics from the previous range as the newly selected range after a failed refresh', async () => {
+    const user = userEvent.setup();
+    const previousRangeAnalytics: Analytics = {
+      ...analytics,
+      summary: { ...analytics.summary, total: 712345 }
+    };
+    const analyticsRequest = vi.spyOn(api, 'analytics')
+      .mockResolvedValueOnce({ analytics: previousRangeAnalytics })
+      .mockRejectedValueOnce(new Error('Analytics service unavailable'));
+    vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
+    vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: { username: 'smtp-user', passwordSet: true } });
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
+    vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
+
+    renderDashboard();
+
+    expect(await screen.findByText('712345')).toBeTruthy();
+    await user.click(screen.getByText('30 天'));
+
+    expect(await screen.findByText('概览加载失败')).toBeTruthy();
+    expect(screen.getByText(/投递统计.*Analytics service unavailable/)).toBeTruthy();
+    expect(screen.queryByText('712345')).toBeNull();
+    expect(analyticsRequest).toHaveBeenLastCalledWith(30);
+  });
+});
+
+function renderDashboard(configPatch: Partial<RuntimeConfig> = {}) {
+  const router = createMemoryRouter([
+    { path: '/overview', element: <Dashboard /> },
+    { path: '*', element: <div>Destination</div> }
+  ], { initialEntries: ['/overview'] });
+  const context: AppContextValue = {
+    user: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin', status: 'active' },
+    config: { ...runtimeConfig, ...configPatch },
+    refreshBootstrap: vi.fn(async () => undefined),
+    logout: vi.fn(async () => undefined)
+  };
+  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 mockCoreApis() {
+  vi.spyOn(api, 'analytics').mockResolvedValue({ analytics });
+  vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
+}
+
+function mockHealthyDependencies() {
+  mockCoreApis();
+  vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: { username: 'smtp-user', passwordSet: true } });
+  vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
+  vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
+}
+
+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 analytics: Analytics = {
+  windowDays: 7,
+  summary: {
+    total: 0,
+    submitted: 0,
+    queued: 0,
+    failed: 0,
+    accepted: 0,
+    delivered: 0,
+    pending: 0,
+    deferred: 0,
+    bounced: 0,
+    terminalFailed: 0,
+    recipients: 0,
+    today: 0,
+    last7Days: 0,
+    successRate: 0,
+    acceptanceRate: 0,
+    deliveryRate: 0,
+    failureRate: 0,
+    domains: 0,
+    verifiedDomains: 0
+  },
+  deliveryFunnel: [],
+  engagement: {
+    trackedDelivered: 0,
+    totalOpens: 0,
+    uniqueOpens: 0,
+    proxyOpens: 0,
+    totalClicks: 0,
+    uniqueClicks: 0,
+    scannerEvents: 0,
+    openRate: 0,
+    clickRate: 0,
+    clickToOpenRate: 0
+  },
+  engagementByDay: [],
+  topLinks: [],
+  byDay: [],
+  byDomain: [],
+  byStatus: [],
+  hourly: [],
+  failureReasons: [],
+  recentFailures: []
+};
+
+const sendToken: ApiToken = {
+  id: 7,
+  name: 'sender',
+  tokenPrefix: 'mh_sender',
+  tokenRecoverable: false,
+  scopes: ['send'],
+  mailboxAccess: 'owner',
+  mailboxIds: [],
+  status: 'active',
+  createdAt: '2026-07-15T00:00:00.000Z'
+};
+
+const deadDelivery: WebhookDelivery = {
+  id: 10,
+  webhookId: 42,
+  userId: 1,
+  sendEventId: 99,
+  eventType: 'failed',
+  status: 'dead',
+  attemptCount: 8,
+  error: 'connection refused',
+  createdAt: '2026-07-15T00:00:00.000Z'
+};

+ 64 - 1
test/ui/direct-detail-navigation.test.tsx

@@ -1,5 +1,5 @@
 import { App as AntApp, ConfigProvider } from 'antd';
-import { act, render, screen, waitFor } from '@testing-library/react';
+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 { describe, expect, it, vi } from 'vitest';
@@ -11,6 +11,69 @@ import type { SendEvent } from '../../src/frontend/types';
 import SendingLogs from '../../src/pages/SendingLogs';
 
 describe('Activity direct detail navigation', () => {
+  it('decodes MIME subjects and keeps verbose SMTP responses inside the detail drawer on mobile', async () => {
+    const user = userEvent.setup();
+    const encodedEvent = {
+      ...sendEvent,
+      subject: '=?UTF-8?B?5L2g5aW977yM5LiW55WM?=',
+      detail: '550 a deliberately verbose SMTP response that belongs in detail'
+    };
+    vi.spyOn(api, 'events').mockResolvedValue({ events: [encodedEvent], total: 1, page: 1, pageSize: 20 });
+    vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
+    vi.spyOn(api, 'event').mockResolvedValue({ event: encodedEvent });
+    renderPage({ initialEntries: ['/activity'], initialIndex: 0 });
+
+    expect(await screen.findByText('你好,世界')).not.toBeNull();
+    expect(screen.queryByText(encodedEvent.subject)).toBeNull();
+    expect(screen.queryByText(encodedEvent.detail)).toBeNull();
+
+    await user.click(screen.getByRole('button', { name: '查看发送详情' }));
+    expect(await screen.findByText(encodedEvent.detail)).not.toBeNull();
+    expect(screen.getAllByText('你好,世界').length).toBeGreaterThan(0);
+  });
+
+  it('ignores a stale detail response after navigating to another event', async () => {
+    let resolveFirst: ((value: { event: SendEvent }) => void) | undefined;
+    const secondEvent: SendEvent = { ...sendEvent, id: 12, messageId: 'mh-12', subject: 'Current event' };
+    vi.spyOn(api, 'events').mockResolvedValue({ events: [sendEvent, secondEvent], total: 2, page: 1, pageSize: 20 });
+    vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
+    vi.spyOn(api, 'event').mockImplementation((id) => {
+      if (id === 11) return new Promise((resolve) => { resolveFirst = resolve; });
+      return Promise.resolve({ event: secondEvent });
+    });
+    const router = renderPage({ initialEntries: ['/activity/11'], initialIndex: 0 });
+    await waitFor(() => expect(api.event).toHaveBeenCalledWith(11));
+
+    await act(async () => {
+      await router.navigate('/activity/12');
+    });
+    const drawer = await screen.findByRole('dialog', { name: /mh-12/ });
+    expect(await within(drawer).findByText('Current event')).not.toBeNull();
+
+    await act(async () => {
+      resolveFirst?.({ event: sendEvent });
+      await Promise.resolve();
+    });
+    expect(within(drawer).queryByText('Delivery test')).toBeNull();
+    expect(within(drawer).getByText('Current event')).not.toBeNull();
+  });
+
+  it('leaves the loading state when navigation changes to an invalid event id', async () => {
+    vi.spyOn(api, 'events').mockResolvedValue({ events: [sendEvent], total: 1, page: 1, pageSize: 20 });
+    vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
+    vi.spyOn(api, 'event').mockImplementation(() => new Promise(() => undefined));
+    const router = renderPage({ initialEntries: ['/activity/11'], initialIndex: 0 });
+
+    await waitFor(() => expect(api.event).toHaveBeenCalledWith(11));
+    await act(async () => {
+      await router.navigate('/activity/not-a-number');
+    });
+
+    const drawer = await screen.findByRole('dialog', { name: '发送详情' });
+    expect(await within(drawer).findByText('未找到该发送记录,或当前账户没有访问权限。')).not.toBeNull();
+    expect(drawer.querySelector('.ant-skeleton')).toBeNull();
+  });
+
   it('restores direct-link tabs with history and closes past them to the filtered list', async () => {
     const user = userEvent.setup();
     mockActivityApis();

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

@@ -153,6 +153,48 @@ describe('Domains URL state and creation workflow', () => {
     expect(await screen.findByText('keep-01.example.test')).toBeTruthy();
   });
 
+  it('keeps the domain list usable when supporting integrations fail', async () => {
+    const domain = { ...makeDomain(1), dnsCredentialId: 8 };
+    vi.spyOn(api, 'domains').mockResolvedValue({ domains: [domain] });
+    vi.spyOn(api, 'dnsCredentials').mockRejectedValue(new Error('DNS provider unavailable'));
+    vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [] });
+    vi.spyOn(api, 'events').mockRejectedValue(new Error('Activity service unavailable'));
+
+    renderPage('/domains');
+
+    expect(await screen.findByText(domain.domain)).toBeTruthy();
+    expect(screen.getByText('部分辅助信息暂不可用')).toBeTruthy();
+    expect(screen.getByText(/DNS provider unavailable/)).toBeTruthy();
+    expect(screen.getAllByText('暂不可用').length).toBeGreaterThan(0);
+    expect(screen.queryByText('域名列表加载失败')).toBeNull();
+  });
+
+  it('keeps stale domains visible but reports a primary-list refresh failure', async () => {
+    const user = userEvent.setup();
+    const domain = makeDomain(1);
+    vi.spyOn(api, 'domains')
+      .mockResolvedValueOnce({ domains: [domain] })
+      .mockRejectedValueOnce(new Error('Domain service unavailable'));
+    vi.spyOn(api, 'dnsCredentials')
+      .mockRejectedValueOnce(new Error('DNS provider unavailable'))
+      .mockResolvedValue({ credentials: [] });
+    vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [] });
+    vi.spyOn(api, 'events').mockResolvedValue({ events: [], total: 0, page: 1, pageSize: 100 });
+
+    renderPage('/domains');
+
+    expect(await screen.findByText(domain.domain)).toBeTruthy();
+    const auxiliaryWarning = screen.getByText('部分辅助信息暂不可用').closest('.ant-alert');
+    expect(auxiliaryWarning).not.toBeNull();
+    await user.click(within(auxiliaryWarning as HTMLElement).getByRole('button', { name: '重试' }));
+
+    const primaryError = await screen.findByText('域名列表加载失败');
+    const primaryAlert = primaryError.closest('.ant-alert');
+    expect(primaryAlert).not.toBeNull();
+    expect(within(primaryAlert as HTMLElement).getByText('Domain service unavailable')).toBeTruthy();
+    expect(screen.getByText(domain.domain)).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);

+ 159 - 7
test/ui/inbox-navigation.test.tsx

@@ -1,5 +1,5 @@
 import { App as AntApp, ConfigProvider } from 'antd';
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { createMemoryRouter, RouterProvider } from 'react-router-dom';
 import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -9,7 +9,7 @@ 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 type { Domain, InboundMailbox, InboundMessage, RuntimeConfig } from '../../src/frontend/types';
 import Inbox from '../../src/pages/Inbox';
 
 describe('Inbox detail return path', () => {
@@ -94,7 +94,7 @@ describe('Inbox detail return path', () => {
     expect(router.state.location.pathname).not.toContain('/messages/');
   });
 
-  it('uses a detail drawer below the 1024px product breakpoint', async () => {
+  it('uses a detail drawer below the 1280px product breakpoint', async () => {
     vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQueryList(query === '(min-width: 992px)', query));
     const deepLinked = messageFixture(30, 1, 'INBOX', 'Compact detail');
     mockInboxApis([mailboxFixture(1)], [deepLinked]);
@@ -106,6 +106,135 @@ describe('Inbox detail return path', () => {
   });
 });
 
+describe('Inbox request and mailbox behavior', () => {
+  afterEach(() => vi.restoreAllMocks());
+
+  it('keeps the search draft synchronized with browser history', async () => {
+    mockInboxApis([mailboxFixture(1)], []);
+    const router = createInboxRouter([
+      '/inbox?mailboxId=1&folder=INBOX&q=first%20query',
+      '/inbox?mailboxId=1&folder=INBOX&q=second%20query'
+    ], 1);
+    renderRouter(router);
+
+    const searchInput = await screen.findByPlaceholderText('搜索发件人、主题或正文预览') as HTMLInputElement;
+    expect(searchInput.value).toBe('second query');
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    await waitFor(() => expect(searchInput.value).toBe('first query'));
+
+    await act(async () => {
+      await router.navigate(1);
+    });
+    await waitFor(() => expect(searchInput.value).toBe('second query'));
+  });
+
+  it('does not let an older message-list response replace the current mailbox', async () => {
+    const first = messageFixture(41, 1, 'INBOX', 'Stale mailbox message');
+    const second = messageFixture(42, 2, 'INBOX', 'Current mailbox message');
+    let resolveFirst: ((value: { messages: InboundMessage[]; total: number; page: number; pageSize: number }) => void) | undefined;
+    const firstResponse = new Promise<{ messages: InboundMessage[]; total: number; page: number; pageSize: number }>((resolve) => {
+      resolveFirst = resolve;
+    });
+    mockInboxApis([mailboxFixture(1), mailboxFixture(2)], []);
+    const list = vi.spyOn(api, 'inboundMessages').mockImplementation(async (filters) => {
+      const mailboxId = typeof filters === 'number' ? filters : filters?.mailboxId;
+      if (mailboxId === 1) return firstResponse;
+      return { messages: [second], total: 1, page: 1, pageSize: 25 };
+    });
+    const router = createInboxRouter(['/inbox?mailboxId=1&folder=INBOX'], 0);
+    renderRouter(router);
+
+    await waitFor(() => expect(list).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 1 })));
+    await act(async () => {
+      await router.navigate('/inbox?mailboxId=2&folder=INBOX');
+    });
+    expect(await screen.findByText('Current mailbox message')).toBeTruthy();
+
+    await act(async () => {
+      resolveFirst?.({ messages: [first], total: 1, page: 1, pageSize: 25 });
+      await Promise.resolve();
+    });
+    expect(screen.queryByText('Stale mailbox message')).toBeNull();
+    expect(screen.getByText('Current mailbox message')).toBeTruthy();
+  });
+
+  it('keeps message content visible when marking it as read fails', async () => {
+    const unread = { ...messageFixture(50, 1, 'INBOX', 'Unread detail'), read: false };
+    mockInboxApis([mailboxFixture(1, { unreadCount: 1 })], [unread]);
+    vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: unread });
+    vi.spyOn(api, 'markInboundMessageRead').mockRejectedValue(new Error('mark read failed'));
+    const router = createInboxRouter(['/inbox/messages/50?mailboxId=1&folder=INBOX'], 0);
+    renderRouter(router);
+
+    expect(await screen.findByText('Unread detail body')).toBeTruthy();
+    expect(await screen.findByText('mark read failed')).toBeTruthy();
+  });
+
+  it('shows unavailable folder counts and retries instead of displaying fake zeroes', async () => {
+    vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQueryList(query === '(min-width: 992px)' || query === '(min-width: 768px)', query));
+    mockInboxApis([mailboxFixture(1)], []);
+    const folders = vi.spyOn(api, 'inboundFolders')
+      .mockRejectedValueOnce(new Error('folder request failed'))
+      .mockResolvedValue({ folders: [{ name: 'INBOX', specialUse: null, messageCount: 3, unreadCount: 2 }] });
+    const router = createInboxRouter(['/inbox?mailboxId=1&folder=INBOX'], 0);
+    renderRouter(router);
+
+    const warning = await screen.findByText('文件夹计数暂不可用。');
+    expect(warning).toBeTruthy();
+    expect(screen.getAllByLabelText('计数不可用').length).toBeGreaterThan(0);
+    const alert = warning.closest('.ant-alert');
+    expect(alert).toBeTruthy();
+    await userEvent.click(within(alert as HTMLElement).getByRole('button', { name: /刷\s*新/ }));
+    await waitFor(() => expect(folders).toHaveBeenCalledTimes(2));
+    await waitFor(() => expect(screen.queryByText('文件夹计数暂不可用。')).toBeNull());
+  });
+
+  it('selects the mailbox with the most recent activity when the URL has no mailbox', async () => {
+    const older = mailboxFixture(1, { lastMessageAt: '2026-07-14T00:00:00.000Z' });
+    const recent = mailboxFixture(2, { lastMessageAt: '2026-07-15T00:00:00.000Z' });
+    mockInboxApis([older, recent], []);
+    const list = vi.spyOn(api, 'inboundMessages');
+    const router = createInboxRouter(['/inbox'], 0);
+    renderRouter(router);
+
+    await waitFor(() => expect(list).toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 2 })));
+    expect(new URLSearchParams(router.state.location.search).get('mailboxId')).toBe('2');
+  });
+
+  it('updates mailbox settings from the routing workspace edit drawer', async () => {
+    const user = userEvent.setup();
+    const mailbox = mailboxFixture(1);
+    mockInboxApis([mailbox], [], [domainFixture(1)]);
+    const update = vi.spyOn(api, 'updateInboundMailbox').mockResolvedValue({
+      mailbox: { ...mailbox, displayName: 'Updated inbox', aliases: ['sales'] }
+    });
+    const router = createInboxRouter(['/inbox?workspace=routing'], 0);
+    renderRouter(router);
+
+    const mailboxTitle = await screen.findByText(mailbox.address);
+    const card = mailboxTitle.closest('.ant-card');
+    expect(card).toBeTruthy();
+    await user.click(within(card as HTMLElement).getByRole('button', { name: /修改/ }));
+    const displayName = await screen.findByLabelText('显示名称');
+    await user.clear(displayName);
+    await user.type(displayName, 'Updated inbox');
+    await user.type(screen.getByLabelText('新密码'), 'new-password-123');
+    await user.clear(screen.getByLabelText('别名'));
+    await user.type(screen.getByLabelText('别名'), 'sales');
+    await user.click(screen.getByRole('button', { name: /保\s*存/ }));
+
+    await waitFor(() => expect(update).toHaveBeenCalledWith(1, expect.objectContaining({
+      displayName: 'Updated inbox',
+      password: 'new-password-123',
+      aliases: 'sales',
+      status: 'active'
+    })));
+  });
+});
+
 function mediaQueryList(matches: boolean, media: string): MediaQueryList {
   return {
     matches,
@@ -141,8 +270,8 @@ function renderRouter(router: ReturnType<typeof createInboxRouter>) {
   );
 }
 
-function mockInboxApis(mailboxes: InboundMailbox[], messages: InboundMessage[]) {
-  vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
+function mockInboxApis(mailboxes: InboundMailbox[], messages: InboundMessage[], domains: Domain[] = []) {
+  vi.spyOn(api, 'domains').mockResolvedValue({ domains });
   vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes });
   vi.spyOn(api, 'inboundFolders').mockImplementation(async (mailboxId) => ({
     folders: [
@@ -154,7 +283,29 @@ function mockInboxApis(mailboxes: InboundMailbox[], messages: InboundMessage[])
   vi.spyOn(api, 'inboundMessages').mockResolvedValue({ messages, total: messages.length, page: 1, pageSize: 25 });
 }
 
-function mailboxFixture(id: number): InboundMailbox {
+function domainFixture(id: number): Domain {
+  return {
+    id,
+    userId: 1,
+    dnsCredentialId: null,
+    smtpRelayId: null,
+    domain: `example-${id}.test`,
+    selector: 'mail',
+    verificationToken: 'verification',
+    dkimPublic: 'public-key',
+    senderHost: `mail.example-${id}.test`,
+    sendingIp: '192.0.2.10',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: '',
+    catchAllAddress: '',
+    status: {},
+    createdAt: '2026-07-14T00:00:00.000Z',
+    updatedAt: '2026-07-14T00:00:00.000Z'
+  };
+}
+
+function mailboxFixture(id: number, overrides: Partial<InboundMailbox> = {}): InboundMailbox {
   return {
     id,
     userId: 1,
@@ -173,7 +324,8 @@ function mailboxFixture(id: number): InboundMailbox {
     messageCount: 2,
     unreadCount: 0,
     createdAt: '2026-07-14T00:00:00.000Z',
-    updatedAt: '2026-07-14T00:00:00.000Z'
+    updatedAt: '2026-07-14T00:00:00.000Z',
+    ...overrides
   };
 }
 

+ 488 - 0
test/ui/integration-resource-resilience.test.tsx

@@ -0,0 +1,488 @@
+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 type { ReactElement } from 'react';
+import { createMemoryRouter, RouterProvider } from 'react-router-dom';
+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,
+  DnsCredential,
+  RuntimeConfig,
+  SmtpCredential,
+  SmtpRelay,
+  Webhook
+} from '../../src/frontend/types';
+import ApiTokens from '../../src/pages/ApiTokens';
+import DnsApi from '../../src/pages/DnsApi';
+import SmtpCredentials from '../../src/pages/SmtpCredentials';
+import Webhooks from '../../src/pages/Webhooks';
+
+describe('Integration resource resilience and deep links', () => {
+  it('keeps an initial Webhook endpoint failure visible and never presents it as an empty list', async () => {
+    const user = userEvent.setup();
+    const endpoints = vi.spyOn(api, 'webhooks')
+      .mockRejectedValueOnce(new Error('Webhook endpoint service unavailable'))
+      .mockResolvedValue({ webhooks: [webhook] });
+    vi.spyOn(api, 'webhookDeliveries').mockResolvedValue({ deliveries: [] });
+    vi.spyOn(api, 'domains').mockResolvedValue({ domains: [] });
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
+
+    renderPage(<Webhooks />, '/integrations/webhooks', '/integrations/webhooks');
+
+    const errorTitle = await screen.findByText('Webhook 端点加载失败');
+    const alert = errorTitle.closest('.ant-alert');
+    expect(alert).not.toBeNull();
+    expect(within(alert as HTMLElement).getByText('Webhook endpoint service unavailable')).toBeTruthy();
+    expect(screen.queryByText('暂无 Webhook。创建后即可接收投递状态回调。')).toBeNull();
+
+    await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
+    expect(await screen.findByText(webhook.name)).toBeTruthy();
+    expect(endpoints).toHaveBeenCalledTimes(2);
+  });
+
+  it('keeps an initial API token failure visible instead of presenting a business empty state', async () => {
+    const user = userEvent.setup();
+    const tokens = vi.spyOn(api, 'apiTokens')
+      .mockRejectedValueOnce(new Error('API token service unavailable'))
+      .mockResolvedValue({ tokens: [apiToken] });
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
+    renderPage(<ApiTokens />, '/integrations/api-keys', '/integrations/api-keys');
+
+    const error = await screen.findByText('API token service unavailable');
+    const alert = error.closest('.ant-alert');
+    expect(alert).not.toBeNull();
+    expect(document.querySelector('.empty-state')).toBeNull();
+    expect(screen.queryByText('创建 API 密钥后可在列表和详情中复制完整 Token,并按最小权限限制邮件读取范围。')).toBeNull();
+
+    await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
+    expect(await screen.findByText(apiToken.name)).toBeTruthy();
+    expect(tokens).toHaveBeenCalledTimes(2);
+  });
+
+  it('keeps an initial DNS credential failure visible instead of presenting a business empty state', async () => {
+    const user = userEvent.setup();
+    const credentials = vi.spyOn(api, 'dnsCredentials')
+      .mockRejectedValueOnce(new Error('DNS credential service unavailable'))
+      .mockResolvedValue({ credentials: [dnsCredential] });
+    renderPage(<DnsApi />, '/integrations/dns', '/integrations/dns');
+
+    const error = await screen.findByText('DNS credential service unavailable');
+    const alert = error.closest('.ant-alert');
+    expect(alert).not.toBeNull();
+    expect(document.querySelector('.empty-state')).toBeNull();
+
+    await user.click(within(alert as HTMLElement).getByRole('button', { name: /刷新/ }));
+    expect(await screen.findByText(dnsCredential.name)).toBeTruthy();
+    expect(credentials).toHaveBeenCalledTimes(2);
+
+    await user.click(screen.getByRole('button', { name: /新增凭据/ }));
+    expect(await findDrawer('新增 DNS 集成凭据')).toBeTruthy();
+  });
+
+  it('restores a DNS credential detail from the URL and uses the DNS-specific deletion warning', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials: [dnsCredential] });
+    const router = renderPage(
+      <DnsApi />,
+      '/integrations/dns',
+      `/integrations/dns?credentialId=${dnsCredential.id}`
+    );
+
+    expect(await findDrawer(dnsCredential.name)).toBeTruthy();
+    expect(new URLSearchParams(router.state.location.search).get('credentialId')).toBe(String(dnsCredential.id));
+
+    await user.click(screen.getByRole('button', { name: `删除 ${dnsCredential.name}` }));
+    expect(await screen.findByText('确认删除该 DNS 集成凭据?删除后将无法再通过此 Provider 管理对应 Zone。')).toBeTruthy();
+    expect(screen.queryByText('确认删除该 API 密钥?')).toBeNull();
+  });
+
+  it('closes a list-opened DNS detail without leaving a duplicate list history entry', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials: [dnsCredential] });
+    const router = renderPage(
+      <DnsApi />,
+      '/integrations/dns',
+      '/integrations/dns',
+      appContext(runtimeConfig),
+      { initialEntries: ['/overview', '/integrations/dns'], initialIndex: 1 }
+    );
+
+    await user.click(await screen.findByRole('row', { name: new RegExp(dnsCredential.name) }));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('credentialId')).toBe(String(dnsCredential.id)));
+    const detail = await findDrawer(dnsCredential.name);
+    await user.click(within(detail).getByRole('button', { name: 'Close' }));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).has('credentialId')).toBe(false));
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/overview');
+  });
+
+  it('replaces an untouched DNS direct link when closing it', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'dnsCredentials').mockResolvedValue({ credentials: [dnsCredential] });
+    const router = renderPage(
+      <DnsApi />,
+      '/integrations/dns',
+      `/integrations/dns?credentialId=${dnsCredential.id}`,
+      appContext(runtimeConfig),
+      { initialEntries: ['/overview', `/integrations/dns?credentialId=${dnsCredential.id}`], initialIndex: 1 }
+    );
+
+    const detail = await findDrawer(dnsCredential.name);
+    await user.click(within(detail).getByRole('button', { name: 'Close' }));
+    await waitFor(() => expect(router.state.location.search).toBe(''));
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/overview');
+  });
+
+  it('keeps relay navigation usable when the SMTP credential list fails', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: smtpCredential });
+    vi.spyOn(api, 'smtpCredentials').mockRejectedValue(new Error('SMTP credential service unavailable'));
+    vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [smtpRelay] });
+    const router = renderPage(
+      <SmtpCredentials />,
+      '/integrations/smtp',
+      `/integrations/smtp?tab=relays&relayId=${smtpRelay.id}`
+    );
+
+    const relayDetail = await findDrawer(smtpRelay.name);
+    expect(within(relayDetail).getByText(`${smtpRelay.host}:${smtpRelay.port}`)).toBeTruthy();
+    expect(new URLSearchParams(router.state.location.search).get('relayId')).toBe(String(smtpRelay.id));
+
+    expect(screen.getAllByText(smtpRelay.name).length).toBeGreaterThan(0);
+    await user.click(screen.getByRole('tab', { name: 'SMTP 凭据' }));
+    expect(await screen.findByText('SMTP credential service unavailable')).toBeTruthy();
+  });
+
+  it('keeps credential navigation usable when the SMTP relay list fails', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: smtpCredential });
+    vi.spyOn(api, 'smtpCredentials').mockResolvedValue({ credentials: [smtpCredential] });
+    vi.spyOn(api, 'smtpRelays').mockRejectedValue(new Error('SMTP relay service unavailable'));
+    const router = renderPage(
+      <SmtpCredentials />,
+      '/integrations/smtp',
+      `/integrations/smtp?tab=credentials&credentialId=${smtpCredential.id}`
+    );
+
+    const credentialDetail = await findDrawer(smtpCredential.username);
+    expect(within(credentialDetail).getAllByText(smtpCredential.username).length).toBeGreaterThan(0);
+    expect(new URLSearchParams(router.state.location.search).get('credentialId')).toBe(String(smtpCredential.id));
+
+    await user.click(screen.getByRole('tab', { name: '发送中继' }));
+    expect(await screen.findByText('SMTP relay service unavailable')).toBeTruthy();
+  });
+
+  it('closes a list-opened SMTP relay without leaving the detail behind the list', async () => {
+    const user = userEvent.setup();
+    mockSmtpApis();
+    const listPath = '/integrations/smtp?tab=relays';
+    const router = renderPage(
+      <SmtpCredentials />,
+      '/integrations/smtp',
+      listPath,
+      appContext(runtimeConfig),
+      { initialEntries: ['/overview', listPath], initialIndex: 1 }
+    );
+
+    await user.click(await screen.findByRole('row', { name: new RegExp(smtpRelay.name) }));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('relayId')).toBe(String(smtpRelay.id)));
+    const detail = await findDrawer(smtpRelay.name);
+    await user.click(within(detail).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('replaces an untouched SMTP credential direct link while preserving its tab', async () => {
+    const user = userEvent.setup();
+    mockSmtpApis();
+    const detailPath = `/integrations/smtp?tab=credentials&credentialId=${smtpCredential.id}`;
+    const router = renderPage(
+      <SmtpCredentials />,
+      '/integrations/smtp',
+      detailPath,
+      appContext(runtimeConfig),
+      { initialEntries: ['/overview', detailPath], initialIndex: 1 }
+    );
+
+    const detail = await findDrawer(smtpCredential.username);
+    await user.click(within(detail).getByRole('button', { name: 'Close' }));
+    await waitFor(() => {
+      const params = new URLSearchParams(router.state.location.search);
+      expect(params.get('tab')).toBe('credentials');
+      expect(params.has('credentialId')).toBe(false);
+    });
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/overview');
+  });
+
+  it('restores API token and messages guide deep links while upgrading a public HTTP base URL to HTTPS', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [apiToken] });
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
+    const router = renderPage(
+      <ApiTokens />,
+      '/integrations/api-keys',
+      `/integrations/api-keys?guide=messages&tokenId=${apiToken.id}`,
+      appContext({ ...runtimeConfig, appBaseUrl: 'http://mail-send.ss5.xyz' })
+    );
+
+    expect(await findDrawer(apiToken.name)).toBeTruthy();
+    const guide = await findDrawer('API 使用文档');
+    expect(within(guide).getByText('需要 messages:read 权限,并受 Token 的邮件读取范围限制。列表支持分页与邮箱、文件夹、已读状态、关键词筛选。')).toBeTruthy();
+    expect(within(guide).getAllByText('https://mail-send.ss5.xyz/api/inbound-messages').length).toBeGreaterThan(0);
+    expect(guide.textContent).not.toContain('http://mail-send.ss5.xyz');
+
+    const params = new URLSearchParams(router.state.location.search);
+    expect(params.get('guide')).toBe('messages');
+    expect(params.get('tokenId')).toBe(String(apiToken.id));
+
+    await user.click(within(guide).getByRole('button', { name: 'Close' }));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).has('guide')).toBe(false));
+    expect(new URLSearchParams(router.state.location.search).get('tokenId')).toBe(String(apiToken.id));
+  });
+
+  it('closes nested token and guide history without reopening either drawer on Back', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [apiToken] });
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
+    const listPath = '/integrations/api-keys';
+    const router = renderPage(
+      <ApiTokens />,
+      '/integrations/api-keys',
+      listPath,
+      appContext(runtimeConfig),
+      { initialEntries: ['/overview', listPath], initialIndex: 1 }
+    );
+
+    await user.click(await screen.findByRole('row', { name: new RegExp(apiToken.name) }));
+    const tokenDetail = await findDrawer(apiToken.name);
+    await user.click(within(tokenDetail).getByRole('button', { name: /API 使用文档/ }));
+    const guide = await findDrawer('API 使用文档');
+    expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('send');
+
+    await user.click(within(guide).getByText('收信 API', { selector: '.ant-collapse-header-text' }));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages'));
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('send');
+    await act(async () => {
+      await router.navigate(1);
+    });
+    expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages');
+
+    await user.click(within(guide).getByRole('button', { name: 'Close' }));
+    await waitFor(() => {
+      const params = new URLSearchParams(router.state.location.search);
+      expect(params.has('guide')).toBe(false);
+      expect(params.get('tokenId')).toBe(String(apiToken.id));
+    });
+    await user.click(within(tokenDetail).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('closes a changed direct-link API guide before replacing its direct token detail', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [apiToken] });
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
+    const directPath = `/integrations/api-keys?guide=send&tokenId=${apiToken.id}`;
+    const router = renderPage(
+      <ApiTokens />,
+      '/integrations/api-keys',
+      directPath,
+      appContext(runtimeConfig),
+      { initialEntries: ['/overview', directPath], initialIndex: 1 }
+    );
+
+    const tokenDetail = await findDrawer(apiToken.name);
+    const guide = await findDrawer('API 使用文档');
+    await user.click(within(guide).getByText('收信 API', { selector: '.ant-collapse-header-text' }));
+    await waitFor(() => expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages'));
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('send');
+    await act(async () => {
+      await router.navigate(1);
+    });
+    expect(new URLSearchParams(router.state.location.search).get('guide')).toBe('messages');
+
+    await user.click(within(guide).getByRole('button', { name: 'Close' }));
+    await waitFor(() => {
+      const params = new URLSearchParams(router.state.location.search);
+      expect(params.has('guide')).toBe(false);
+      expect(params.get('tokenId')).toBe(String(apiToken.id));
+    });
+    await user.click(within(tokenDetail).getByRole('button', { name: 'Close' }));
+    await waitFor(() => expect(router.state.location.search).toBe(''));
+
+    await act(async () => {
+      await router.navigate(-1);
+    });
+    expect(router.state.location.pathname).toBe('/overview');
+  });
+});
+
+function renderPage(
+  element: ReactElement,
+  routePath: string,
+  initialEntry: string,
+  context: AppContextValue = appContext(runtimeConfig),
+  history?: { initialEntries: string[]; initialIndex: number }
+) {
+  const router = createMemoryRouter([
+    { path: routePath, element },
+    { path: '*', element: <div>other</div> }
+  ], history || { initialEntries: [initialEntry], initialIndex: 0 });
+  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;
+}
+
+async function findDrawer(title: string) {
+  const titleElement = await screen.findByText(title, { selector: '.ant-drawer-title' });
+  const drawer = titleElement.closest('[role="dialog"]');
+  expect(drawer).not.toBeNull();
+  return drawer as HTMLElement;
+}
+
+function appContext(config: RuntimeConfig): AppContextValue {
+  return {
+    user: { id: 1, username: 'operator', email: 'operator@example.test', role: 'admin', status: 'active' },
+    config,
+    refreshBootstrap: vi.fn(async () => undefined),
+    logout: vi.fn(async () => undefined)
+  };
+}
+
+function mockSmtpApis() {
+  vi.spyOn(api, 'smtpCredential').mockResolvedValue({ credential: smtpCredential });
+  vi.spyOn(api, 'smtpCredentials').mockResolvedValue({ credentials: [smtpCredential] });
+  vi.spyOn(api, 'smtpRelays').mockResolvedValue({ relays: [smtpRelay] });
+}
+
+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,
+  submission: {
+    enabled: true,
+    host: 'mail.example.test',
+    ports: [{ port: 587, protocol: 'STARTTLS' }],
+    username: 'primary-smtp',
+    passwordSet: true,
+    inboundEnabled: true,
+    tls: true,
+    requireTlsForAuth: true
+  }
+};
+
+const webhook: Webhook = {
+  id: 7,
+  userId: 1,
+  domainId: null,
+  mailboxId: null,
+  name: 'Production events',
+  url: 'https://hooks.example.test/mailhub',
+  secretPrefix: 'whsec_prod',
+  events: ['sent', 'failed'],
+  enabled: true,
+  createdAt: '2026-07-14T00:00:00.000Z',
+  updatedAt: '2026-07-14T00:00:00.000Z'
+};
+
+const dnsCredential: DnsCredential = {
+  id: 3,
+  userId: 1,
+  name: 'Primary Cloudflare',
+  provider: 'cloudflare',
+  zoneName: 'example.test',
+  defaultTtl: 600,
+  credentialSet: true,
+  createdAt: '2026-07-14T00:00:00.000Z',
+  updatedAt: '2026-07-14T00:00:00.000Z'
+};
+
+const smtpCredential: SmtpCredential = {
+  id: 5,
+  userId: 1,
+  username: 'application-smtp',
+  passwordSet: true,
+  createdAt: '2026-07-14T00:00:00.000Z',
+  updatedAt: '2026-07-14T00:00:00.000Z'
+};
+
+const smtpRelay: SmtpRelay = {
+  id: 8,
+  userId: 1,
+  name: 'SES outbound',
+  host: 'email-smtp.us-east-1.amazonaws.com',
+  port: 587,
+  secure: false,
+  username: 'relay-user',
+  passwordSet: true,
+  helo: 'mail.example.test',
+  isDefault: true,
+  createdAt: '2026-07-14T00:00:00.000Z',
+  updatedAt: '2026-07-14T00:00:00.000Z'
+};
+
+const apiToken: ApiToken = {
+  id: 9,
+  userId: 1,
+  name: 'Message reader',
+  tokenPrefix: 'mh_message',
+  tokenRecoverable: false,
+  scopes: ['messages:read'],
+  mailboxAccess: 'owner',
+  mailboxIds: [],
+  status: 'active',
+  createdAt: '2026-07-14T00:00:00.000Z'
+};

+ 32 - 8
test/ui/operations-navigation.test.tsx

@@ -107,17 +107,41 @@ describe('Operational navigation state', () => {
     });
     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: /重发验证/ }));
+    const firstCard = (await screen.findByText('first@example.test')).closest('.admin-user-card');
+    const secondCard = screen.getByText('second@example.test').closest('.admin-user-card');
+    expect(firstCard).not.toBeNull();
+    expect(secondCard).not.toBeNull();
+    const firstActions = within(firstCard!).getByRole('button', { name: /用户操作.*first/ });
+    const secondActions = within(secondCard!).getByRole('button', { name: /用户操作.*second/ });
+    await user.click(firstActions);
+    await user.click(await screen.findByRole('menuitem', { 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);
+    await waitFor(() => expect((firstActions as HTMLButtonElement).disabled).toBe(true));
+    expect((secondActions as HTMLButtonElement).disabled).toBe(false);
 
     pending.resolve({ message: 'ok' });
-    await waitFor(() => expect((within(firstRow!).getByRole('button', { name: /重置邮件/ }) as HTMLButtonElement).disabled).toBe(false));
+    await waitFor(() => expect((firstActions as HTMLButtonElement).disabled).toBe(false));
+  });
+
+  it('labels admin user controls and keeps risky actions in a confirmed overflow menu', async () => {
+    const user = userEvent.setup();
+    vi.spyOn(api, 'adminUsers').mockResolvedValue({ users: adminUsers });
+    const router = createMemoryRouter([{ path: '/admin/:section', element: <AdminPage /> }], {
+      initialEntries: ['/admin/users']
+    });
+    renderRouter(router);
+
+    expect(await screen.findByRole('heading', { name: '管理中心' })).toBeTruthy();
+    const firstCard = screen.getByText('first@example.test').closest('.admin-user-card');
+    expect(firstCard).not.toBeNull();
+    expect(within(firstCard!).getByRole('combobox', { name: /用户状态.*first/ })).toBeTruthy();
+    expect(within(firstCard!).getByRole('combobox', { name: /用户角色.*first/ })).toBeTruthy();
+    expect(within(firstCard!).queryByRole('button', { name: '重置邮件' })).toBeNull();
+
+    await user.click(within(firstCard!).getByRole('button', { name: /用户操作.*first/ }));
+    await user.click(await screen.findByRole('menuitem', { name: /重置邮件/ }));
+    const confirmation = await screen.findByRole('dialog');
+    expect(confirmation.textContent).toContain('确认给 first@example.test 发送密码重置邮件?');
   });
 
   it('restores audit filters from URL history', async () => {

+ 25 - 2
test/ui/settings-navigation-guard.test.tsx

@@ -85,10 +85,33 @@ describe('Settings navigation guard', () => {
     await user.click(screen.getAllByText('概览')[0]);
     await waitFor(() => expect(router.state.location.pathname).toBe('/overview'));
     expect(screen.queryByRole('dialog', { name: '放弃未保存的更改?' })).toBeNull();
+  }, 20_000);
+
+  it('blocks a public HTTP base URL until it is changed to HTTPS', async () => {
+    const user = userEvent.setup();
+    const insecureConfig = { ...runtimeConfig, appBaseUrl: 'http://mail.example.test' };
+    vi.spyOn(api, 'adminSettings').mockResolvedValue({ settings: insecureConfig });
+    const saveSettings = vi.spyOn(api, 'saveAdminSettings').mockImplementation(async (values) => ({
+      settings: { ...insecureConfig, ...values }
+    }));
+    renderSettingsRouter(insecureConfig);
+
+    expect(await screen.findByText('公开访问地址不安全')).not.toBeNull();
+    const input = screen.getByLabelText('APP_BASE_URL');
+    await user.clear(input);
+    await user.type(input, 'http://public.example.test');
+    await user.click(screen.getByRole('button', { name: /保存设置/ }));
+    expect((await screen.findAllByText('公网实例必须使用 HTTPS,避免 API 密钥通过明文 HTTP 传输。')).length).toBeGreaterThan(0);
+    expect(saveSettings).not.toHaveBeenCalled();
+
+    await user.clear(input);
+    await user.type(input, 'https://public.example.test');
+    await user.click(screen.getByRole('button', { name: /保存设置/ }));
+    await waitFor(() => expect(saveSettings).toHaveBeenCalledTimes(1));
   });
 });
 
-function renderSettingsRouter() {
+function renderSettingsRouter(config = runtimeConfig) {
   const context: AppContextValue = {
     user: {
       id: 1,
@@ -97,7 +120,7 @@ function renderSettingsRouter() {
       role: 'admin',
       status: 'active'
     },
-    config: runtimeConfig,
+    config,
     refreshBootstrap: vi.fn(async () => undefined),
     logout: vi.fn(async () => undefined)
   };

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio