Browse Source

feat: add scoped inbound API token access

AI-Co-Authored-By: Codex
chendeben 1 month ago
parent
commit
5e86fb0a22
84 changed files with 1740 additions and 184 deletions
  1. 26 2
      README.md
  2. 14 2
      landing.html
  3. 0 0
      public/assets/Admin-f5ZMQ_Pd.js
  4. 0 0
      public/assets/ApiTokens-BCcpUvp-.js
  5. 0 0
      public/assets/ApiTokens-TOP1JCZP.js
  6. 1 1
      public/assets/CodeBlock--Yutv0IZ.js
  7. 1 1
      public/assets/CopyOutlined-DMc5GYOD.js
  8. 0 1
      public/assets/Dashboard-DG8Jzdjp.js
  9. 0 2
      public/assets/DashboardCharts-okY0rwB6.js
  10. 1 1
      public/assets/DeleteOutlined-B1WEjJYP.js
  11. 0 0
      public/assets/DnsApi-U4yCaDJr.js
  12. 0 0
      public/assets/DomainDetail-DZ9jDnQA.js
  13. 0 0
      public/assets/DomainDetail-HdQk4-E6.js
  14. 0 0
      public/assets/Domains-4xZfGUFp.js
  15. 0 0
      public/assets/Domains-CuwsXj6O.js
  16. 1 1
      public/assets/EditOutlined-_SvZHznu.js
  17. 1 1
      public/assets/EllipsisOutlined-DHTs_FOL.js
  18. 0 1
      public/assets/EmptyState-CeUjt5Wb.js
  19. 1 0
      public/assets/EmptyState-rI31ioMm.js
  20. 0 0
      public/assets/Inbox-BaUBfWPm.js
  21. 0 0
      public/assets/Inbox-DqlZOpVQ.js
  22. 1 1
      public/assets/PlusOutlined-DWXMp6Ds.js
  23. 1 1
      public/assets/ReloadOutlined-C3S2BDne.js
  24. 1 1
      public/assets/SearchOutlined-C_VWNehW.js
  25. 0 0
      public/assets/SendingLogs-CLm9qApO.js
  26. 0 0
      public/assets/SendingLogs-CcPGMgQp.js
  27. 0 0
      public/assets/Settings-BJeG4UlS.js
  28. 0 0
      public/assets/SmtpCredentials-CAxT6CE7.js
  29. 0 0
      public/assets/SmtpCredentials-Irlwliwa.js
  30. 0 0
      public/assets/StatusPill-BYj3zI1_.js
  31. 0 0
      public/assets/StatusPill-IFsKXy8H.js
  32. 1 1
      public/assets/StatusTag-BsDXxFW1.js
  33. 1 1
      public/assets/ThunderboltOutlined-CbXdMhP9.js
  34. 0 0
      public/assets/Webhooks-DA-y9GWA.js
  35. 0 0
      public/assets/Webhooks-DNpLohfY.js
  36. 0 0
      public/assets/badge-CT2dDAGA.js
  37. 0 2
      public/assets/card-DMbCO2Fu.js
  38. 0 0
      public/assets/descriptions-vs1os_w7.js
  39. 3 0
      public/assets/form-CvEcrSb3.js
  40. 0 3
      public/assets/form-y5mvm6Rb.js
  41. 2 0
      public/assets/grid-BmaFEMOO.js
  42. 1 0
      public/assets/index-BCIgegZI.js
  43. 0 1
      public/assets/index-Dy4xV4t8.js
  44. 0 0
      public/assets/input-0_Xo22Fl.js
  45. 0 0
      public/assets/input-7g-iBRng.js
  46. 0 0
      public/assets/input-number-5vwSxUlw.js
  47. 0 0
      public/assets/input-number-Cb6-nOBv.js
  48. 0 0
      public/assets/jsx-runtime-dm82oUI8.js
  49. 0 0
      public/assets/landing-DTmgToGP.js
  50. 0 0
      public/assets/list-6VGU-0Wr.js
  51. 0 0
      public/assets/list-HagL8Wla.js
  52. 0 0
      public/assets/login-BWBmrFVZ.js
  53. 1 1
      public/assets/modal-B8KcNGBa.js
  54. 0 0
      public/assets/popconfirm-BStiKU4v.js
  55. 1 0
      public/assets/row-BogeM7vF.js
  56. 0 1
      public/assets/row-Zz2thRPV.js
  57. 0 0
      public/assets/segmented-DUpU-ING.js
  58. 0 0
      public/assets/switch-CKxlbFCo.js
  59. 0 0
      public/assets/switch-CsMGEdLR.js
  60. 0 0
      public/assets/table-CSW7F8Qw.js
  61. 0 0
      public/assets/table-DDUhevyo.js
  62. 0 0
      public/assets/theme-CVfSdO4m.js
  63. 0 0
      public/assets/theme-DjPzgo6g.js
  64. 0 0
      public/assets/useForm-sdDvuRHy.js
  65. 3 3
      public/index.html
  66. 15 3
      public/landing.html
  67. 8 8
      public/login.html
  68. 179 32
      src/db.js
  69. 58 1
      src/frontend/api-token-model.js
  70. 60 12
      src/frontend/i18n/index.js
  71. 16 4
      src/frontend/landing/i18n.js
  72. 6 3
      src/frontend/services/api.ts
  73. 15 1
      src/frontend/types.ts
  74. 15 0
      src/frontend/use-media-query.ts
  75. 7 3
      src/layouts/AdminLayout.tsx
  76. 259 43
      src/pages/ApiTokens.tsx
  77. 5 3
      src/pages/Inbox.tsx
  78. 106 21
      src/server.js
  79. 216 0
      test/api-token-db.test.js
  80. 22 0
      test/frontend-api-token-model.test.js
  81. 532 0
      test/server-api-token-inbound-auth.test.js
  82. 23 0
      test/ui/admin-layout.test.tsx
  83. 112 21
      test/ui/api-token-secret.test.tsx
  84. 24 0
      test/ui/inbox-navigation.test.tsx

+ 26 - 2
README.md

@@ -69,9 +69,18 @@ Password: 用户在网页“SMTP 凭据”中配置
 
 SMTP 密码会同时保存哈希和服务端加密密文:哈希用于认证,加密密文用于用户本人在网页复制。旧数据如果只有哈希,无法反解,需要用户重新设置一次密码后才能复制。
 
-## 发 API
+## API Token 与收发 API
 
-每个用户可以在面板生成自己的发送 API Token。Token 只在创建时完整显示,之后列表只显示前缀。
+每个用户可以在面板生成自己的 API Token。新 Token 会以哈希用于认证,并以服务端加密密文供所属账号在面板完整查看和复制;升级前创建、仅保存哈希的历史 Token 无法反解,可在面板确认后重新生成。
+
+Token 支持以下权限:
+
+- `send`:发送邮件。
+- `mailboxes:read`:查询可访问邮箱的元数据。
+- `mailboxes:write`:创建长期或临时邮箱。
+- `messages:read`:查询收到的邮件列表、文件夹和邮件正文。
+
+收信读取还受 Token 邮箱范围约束:默认仅 Token 所有者名下邮箱,也可限定到指定邮箱;管理员可显式授予全系统邮箱读取权限。该范围同样约束带 `mailboxes:read` 权限的邮箱元数据列表。
 
 ```bash
 curl -X POST https://mailhub.example.com/api/send \
@@ -87,6 +96,21 @@ curl -X POST https://mailhub.example.com/api/send \
 
 发送时会校验 `From` 所属域名必须归当前 Token 所属用户所有。`.env` 中的 `API_TOKEN` 仅作为旧版 admin 兼容发送 token,新用户应使用网页生成的用户级 Token。
 
+查询收信列表和邮件详情:
+
+```bash
+curl -H "Authorization: Bearer <INBOUND_API_TOKEN>" \
+  "https://mailhub.example.com/api/inbound-messages?page=1&pageSize=20&folder=INBOX&read=false"
+
+curl -H "Authorization: Bearer <INBOUND_API_TOKEN>" \
+  "https://mailhub.example.com/api/inbound-messages/123"
+
+curl -H "Authorization: Bearer <INBOUND_API_TOKEN>" \
+  "https://mailhub.example.com/api/inbound-mailboxes/42/folders"
+```
+
+列表接口支持 `page`、`pageSize`、`mailboxId`、`folder`、`read` 和 `q`;详情接口返回 `rawMessage`、`textBody` 与 `htmlBody`;文件夹接口返回标准用途、邮件数和未读数。未授权邮箱不会出现在列表中,直接读取详情或文件夹时返回 `404`。
+
 ## DNS API 一键配置
 
 用户可在网页“DNS API”中保存以下服务商凭据:

+ 14 - 2
landing.html

@@ -128,11 +128,12 @@
           <div class="doc-grid">
             <article class="card">
               <h3 data-i18n="api.auth.title">Create and protect a token</h3>
-              <p data-i18n="api.auth.body">Create a token in Console &gt; API Tokens. The complete secret is shown only once, so store it outside browser code and public repositories.</p>
+              <p data-i18n="api.auth.body">Create a token in Console &gt; API Tokens. Full tokens can be viewed and copied by the owning account, so keep console access restricted and never place tokens in browser code or public repositories.</p>
               <ul class="field-list compact-list">
                 <li><code>send</code> — <span data-i18n="api.auth.send">send messages with POST /api/send</span></li>
                 <li><code>mailboxes:read</code> — <span data-i18n="api.auth.read">list mailboxes with GET /api/mailboxes</span></li>
                 <li><code>mailboxes:write</code> — <span data-i18n="api.auth.write">create mailboxes with POST /api/mailboxes</span></li>
+                <li><code>messages:read</code> — <span data-i18n="api.auth.messages">read inbound messages with GET /api/inbound-messages</span></li>
               </ul>
             </article>
             <article class="card">
@@ -229,8 +230,19 @@
         <section id="receiving" class="section">
           <div class="section-head">
             <h2 data-i18n="receiving.title">Receiving mail</h2>
-            <p data-i18n="receiving.sub">Mail arrives through the domain MX record. Read it in MailHub Inbox or through standard IMAP/POP3 clients; there is no separate inbound Bearer API.</p>
+            <p data-i18n="receiving.sub">Mail arrives through the domain MX record. Read it in MailHub Inbox, through IMAP/POP3, or with a scoped Bearer token.</p>
           </div>
+          <div class="reference-grid">
+            <div class="code-card doc-code-card">
+              <div class="code-card-bar"><span data-i18n="receiving.list.label">GET /api/inbound-messages - list</span><button type="button" class="copy-btn" data-copy-target="inbound-list-curl" data-i18n="common.copy">Copy</button></div>
+              <pre id="inbound-list-curl"><code data-i18n-html="receiving.list.curl"></code></pre>
+            </div>
+            <div class="code-card doc-code-card">
+              <div class="code-card-bar"><span data-i18n="receiving.detail.label">GET /api/inbound-messages/:id - detail</span><button type="button" class="copy-btn" data-copy-target="inbound-detail-curl" data-i18n="common.copy">Copy</button></div>
+              <pre id="inbound-detail-curl"><code data-i18n-html="receiving.detail.curl"></code></pre>
+            </div>
+          </div>
+          <p class="note" data-i18n="receiving.api.note">Inbound APIs require messages:read and are limited by the token mailbox access setting. Only administrator tokens explicitly set to all mailboxes can read across accounts.</p>
           <div class="doc-grid">
             <article class="card">
               <h3 data-i18n="receiving.delivery.title">Inbound delivery</h3>

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


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


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


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

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

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

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

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


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


+ 1 - 1
public/assets/DeleteOutlined-J0odQPKG.js → public/assets/DeleteOutlined-B1WEjJYP.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-CVfSdO4m.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+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};

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


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


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


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


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


+ 1 - 1
public/assets/EditOutlined-CO1ElKoL.js → public/assets/EditOutlined-_SvZHznu.js

@@ -1 +1 @@
-import{a as e,n as t,r as n}from"./react-dom-stZKkA6x.js";import{u as r}from"./theme-CVfSdO4m.js";var i=n((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.default={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M257.7 752c2 0 4-.2 6-.5L431.9 722c2-.4 3.9-1.3 5.3-2.8l423.9-423.9a9.96 9.96 0 000-14.1L694.9 114.9c-1.9-1.9-4.4-2.9-7.1-2.9s-5.2 1-7.1 2.9L256.8 538.8c-1.5 1.5-2.4 3.3-2.8 5.3l-29.5 168.2a33.5 33.5 0 009.4 29.8c6.6 6.4 14.9 9.9 23.8 9.9zm67.4-174.4L687.8 215l73.3 73.3-362.7 362.6-88.9 15.7 15.6-89zM880 836H144c-17.7 0-32 14.3-32 32v36c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-36c0-17.7-14.3-32-32-32z`}}]},name:`edit`,theme:`outlined`}})),a=e(t()),o=e(i());function s(){return s=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var n=arguments[t];for(var r in n)Object.prototype.hasOwnProperty.call(n,r)&&(e[r]=n[r])}return e},s.apply(this,arguments)}var c=a.forwardRef((e,t)=>a.createElement(r,s({},e,{ref:t,icon:o.default})));export{c as t};
+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-BF-4cC5U.js → public/assets/EllipsisOutlined-DHTs_FOL.js

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

+ 0 - 1
public/assets/EmptyState-CeUjt5Wb.js

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

+ 1 - 0
public/assets/EmptyState-rI31ioMm.js

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

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


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


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

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

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

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

+ 1 - 1
public/assets/SearchOutlined-8ZUFC3UK.js → public/assets/SearchOutlined-C_VWNehW.js

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

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


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


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


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


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


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


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


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

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

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

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

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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

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


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

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

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

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

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


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


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


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


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


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


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


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


+ 3 - 3
public/index.html

@@ -4,11 +4,11 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-Dy4xV4t8.js"></script>
+    <script type="module" crossorigin src="/assets/index-BCIgegZI.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-C5RSiriy.js">
-    <link rel="modulepreload" crossorigin href="/assets/theme-CVfSdO4m.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="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>

+ 15 - 3
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-BeR3r5vM.js"></script>
+    <script type="module" crossorigin src="/assets/landing-DTmgToGP.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="stylesheet" crossorigin href="/assets/landing-BkPrzdt8.css">
   </head>
@@ -131,11 +131,12 @@
           <div class="doc-grid">
             <article class="card">
               <h3 data-i18n="api.auth.title">Create and protect a token</h3>
-              <p data-i18n="api.auth.body">Create a token in Console &gt; API Tokens. The complete secret is shown only once, so store it outside browser code and public repositories.</p>
+              <p data-i18n="api.auth.body">Create a token in Console &gt; API Tokens. Full tokens can be viewed and copied by the owning account, so keep console access restricted and never place tokens in browser code or public repositories.</p>
               <ul class="field-list compact-list">
                 <li><code>send</code> — <span data-i18n="api.auth.send">send messages with POST /api/send</span></li>
                 <li><code>mailboxes:read</code> — <span data-i18n="api.auth.read">list mailboxes with GET /api/mailboxes</span></li>
                 <li><code>mailboxes:write</code> — <span data-i18n="api.auth.write">create mailboxes with POST /api/mailboxes</span></li>
+                <li><code>messages:read</code> — <span data-i18n="api.auth.messages">read inbound messages with GET /api/inbound-messages</span></li>
               </ul>
             </article>
             <article class="card">
@@ -232,8 +233,19 @@
         <section id="receiving" class="section">
           <div class="section-head">
             <h2 data-i18n="receiving.title">Receiving mail</h2>
-            <p data-i18n="receiving.sub">Mail arrives through the domain MX record. Read it in MailHub Inbox or through standard IMAP/POP3 clients; there is no separate inbound Bearer API.</p>
+            <p data-i18n="receiving.sub">Mail arrives through the domain MX record. Read it in MailHub Inbox, through IMAP/POP3, or with a scoped Bearer token.</p>
           </div>
+          <div class="reference-grid">
+            <div class="code-card doc-code-card">
+              <div class="code-card-bar"><span data-i18n="receiving.list.label">GET /api/inbound-messages - list</span><button type="button" class="copy-btn" data-copy-target="inbound-list-curl" data-i18n="common.copy">Copy</button></div>
+              <pre id="inbound-list-curl"><code data-i18n-html="receiving.list.curl"></code></pre>
+            </div>
+            <div class="code-card doc-code-card">
+              <div class="code-card-bar"><span data-i18n="receiving.detail.label">GET /api/inbound-messages/:id - detail</span><button type="button" class="copy-btn" data-copy-target="inbound-detail-curl" data-i18n="common.copy">Copy</button></div>
+              <pre id="inbound-detail-curl"><code data-i18n-html="receiving.detail.curl"></code></pre>
+            </div>
+          </div>
+          <p class="note" data-i18n="receiving.api.note">Inbound APIs require messages:read and are limited by the token mailbox access setting. Only administrator tokens explicitly set to all mailboxes can read across accounts.</p>
           <div class="doc-grid">
             <article class="card">
               <h3 data-i18n="receiving.delivery.title">Inbound delivery</h3>

+ 8 - 8
public/login.html

@@ -4,16 +4,16 @@
     <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-B2OuF-HT.js"></script>
+    <script type="module" crossorigin src="/assets/login-BWBmrFVZ.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-C5RSiriy.js">
-    <link rel="modulepreload" crossorigin href="/assets/theme-CVfSdO4m.js">
-    <link rel="modulepreload" crossorigin href="/assets/card-DMbCO2Fu.js">
-    <link rel="modulepreload" crossorigin href="/assets/segmented-C01cwBXf.js">
-    <link rel="modulepreload" crossorigin href="/assets/useForm-C8Hf5VoE.js">
-    <link rel="modulepreload" crossorigin href="/assets/form-y5mvm6Rb.js">
-    <link rel="modulepreload" crossorigin href="/assets/input-7g-iBRng.js">
+    <link rel="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/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/input-0_Xo22Fl.js">
     <link rel="stylesheet" crossorigin href="/assets/theme-CT_1QDVH.css">
   </head>
   <body>

+ 179 - 32
src/db.js

@@ -20,7 +20,7 @@ let db;
 let secretKey = '';
 export const USER_STATUSES = new Set(['pending_email', 'pending_review', 'active', 'disabled']);
 export const STANDARD_INBOUND_FOLDERS = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
-export const API_TOKEN_SCOPES = new Set(['send', 'mailboxes:read', 'mailboxes:write']);
+export const API_TOKEN_SCOPES = new Set(['send', 'mailboxes:read', 'mailboxes:write', 'messages:read']);
 const auditSecretKeyPattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
 const auditDescriptorKeyPattern = /^(field|name|path|key|header)$/i;
 const auditDescriptorValuePattern = /password|secret|token|key|credential|dkim[_-]?private|authorization/i;
@@ -220,7 +220,9 @@ export function initDatabase(dataDir, secret = '') {
       name TEXT NOT NULL,
       token_hash TEXT NOT NULL UNIQUE,
       token_prefix TEXT NOT NULL,
+      token_secret TEXT NOT NULL DEFAULT '',
       scopes_json TEXT NOT NULL DEFAULT '["send"]',
+      mailbox_access_json TEXT NOT NULL DEFAULT '{"mode":"owner","mailboxIds":[]}',
       expires_at TEXT,
       revoked_at TEXT,
       revoked_reason TEXT NOT NULL DEFAULT '',
@@ -343,6 +345,8 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('webhooks', 'mailbox_id', 'INTEGER');
   migrateWebhookDeliveriesForInbound();
   ensureColumn('api_tokens', 'scopes_json', "TEXT NOT NULL DEFAULT '[\"send\"]'");
+  ensureColumn('api_tokens', 'token_secret', "TEXT NOT NULL DEFAULT ''");
+  ensureColumn('api_tokens', 'mailbox_access_json', "TEXT NOT NULL DEFAULT '{\"mode\":\"owner\",\"mailboxIds\":[]}'");
   ensureColumn('api_tokens', 'expires_at', 'TEXT');
   ensureColumn('api_tokens', 'revoked_at', 'TEXT');
   ensureColumn('api_tokens', 'revoked_reason', "TEXT NOT NULL DEFAULT ''");
@@ -792,9 +796,17 @@ export function updateUser(id, patch) {
   if (!current) return null;
   const passwordChanged = String(patch.password || '').length > 0;
   if (passwordChanged && String(patch.password).length < 8) throw new Error('密码至少需要 8 位。');
+  const role = normalizeUserRolePatch(patch.role, current.role);
+  const status = patch.status === undefined ? current.status : normalizeUserStatus(patch.status);
+  if (current.role === 'admin' && current.status === 'active' && (role !== 'admin' || status !== 'active')) {
+    const otherActiveAdmin = requireDb()
+      .prepare("SELECT id FROM users WHERE id != ? AND role = 'admin' AND status = 'active' LIMIT 1")
+      .get(Number(id));
+    if (!otherActiveAdmin) throw new Error('至少保留一个启用的管理员账号。');
+  }
   const next = {
-    role: patch.role === 'admin' ? 'admin' : current.role,
-    status: patch.status === undefined ? current.status : normalizeUserStatus(patch.status),
+    role,
+    status,
     passwordHash: passwordChanged ? hashPassword(patch.password) : current.passwordHash,
     updatedAt: now()
   };
@@ -974,7 +986,8 @@ export function updateInboundMailbox(userId, id, patch = {}) {
   return getInboundMailbox(id, userId);
 }
 
-export function listInboundMailboxes(userId) {
+export function listInboundMailboxes(userId, access = {}) {
+  const accessFilter = inboundAccessFilter('m', userId, access);
   return requireDb()
     .prepare(`
       SELECT
@@ -986,15 +999,21 @@ export function listInboundMailboxes(userId) {
       FROM inbound_mailboxes m
       JOIN domains d ON d.id = m.domain_id
       LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
-      WHERE m.user_id = ? AND m.deleted_at IS NULL
+      WHERE ${accessFilter.clause} AND m.deleted_at IS NULL
       GROUP BY m.id
       ORDER BY COALESCE(last_message_at, m.created_at) DESC, m.id DESC
     `)
-    .all(userId)
+    .all(...accessFilter.params)
     .map(publicInboundMailbox);
 }
 
-export function getInboundMailbox(id, userId, { includeHash = false, includeSecret = false } = {}) {
+export function getInboundMailbox(id, userId, {
+  includeHash = false,
+  includeSecret = false,
+  includeAllUsers = false,
+  mailboxIds = null
+} = {}) {
+  const accessFilter = inboundAccessFilter('m', userId, { includeAllUsers, mailboxIds });
   const row = requireDb()
     .prepare(`
       SELECT
@@ -1006,10 +1025,10 @@ export function getInboundMailbox(id, userId, { includeHash = false, includeSecr
       FROM inbound_mailboxes m
       JOIN domains d ON d.id = m.domain_id
       LEFT JOIN inbound_messages msg ON msg.mailbox_id = m.id AND msg.deleted_at IS NULL
-      WHERE m.id = ? AND m.user_id = ? AND m.deleted_at IS NULL
+      WHERE m.id = ? AND ${accessFilter.clause} AND m.deleted_at IS NULL
       GROUP BY m.id
     `)
-    .get(Number(id), userId);
+    .get(Number(id), ...accessFilter.params);
   return publicInboundMailbox(row, { includeHash, includeSecret });
 }
 
@@ -1176,11 +1195,12 @@ export function listInboundMessages(userId, { mailboxId = null, folder = 'INBOX'
     .map((row) => publicInboundMessage(row));
 }
 
-export function searchInboundMessages(userId, filters = {}) {
+export function searchInboundMessages(userId, filters = {}, access = {}) {
   const page = normalizePageNumber(filters.page);
   const pageSize = normalizePageSize(filters.pageSize);
-  const where = ['msg.user_id = ?', 'msg.deleted_at IS NULL'];
-  const params = [userId];
+  const accessFilter = inboundAccessFilter('msg', userId, access);
+  const where = [accessFilter.clause, 'msg.deleted_at IS NULL'];
+  const params = [...accessFilter.params];
   if (filters.mailboxId) {
     where.push('msg.mailbox_id = ?');
     params.push(Number(filters.mailboxId));
@@ -1230,16 +1250,17 @@ export function searchInboundMessages(userId, filters = {}) {
   return { messages: rows, total, page, pageSize };
 }
 
-export function getInboundMessage(userId, id) {
+export function getInboundMessage(userId, id, access = {}) {
+  const accessFilter = inboundAccessFilter('msg', userId, access);
   const row = requireDb()
     .prepare(`
       SELECT msg.*, m.address AS mailbox_address, d.domain
       FROM inbound_messages msg
       JOIN inbound_mailboxes m ON m.id = msg.mailbox_id
       JOIN domains d ON d.id = msg.domain_id
-      WHERE msg.id = ? AND msg.user_id = ? AND msg.deleted_at IS NULL
+      WHERE msg.id = ? AND ${accessFilter.clause} AND msg.deleted_at IS NULL
     `)
-    .get(Number(id), userId);
+    .get(Number(id), ...accessFilter.params);
   return publicInboundMessage(row, { includeBody: true });
 }
 
@@ -1258,8 +1279,8 @@ export function listInboundFolders(mailbox) {
   return [...STANDARD_INBOUND_FOLDERS, ...custom];
 }
 
-export function listInboundMailboxFolders(userId, mailboxId) {
-  const mailbox = getInboundMailbox(mailboxId, userId);
+export function listInboundMailboxFolders(userId, mailboxId, access = {}) {
+  const mailbox = getInboundMailbox(mailboxId, userId, access);
   if (!mailbox) return null;
   const counts = requireDb()
     .prepare(`
@@ -1271,7 +1292,7 @@ export function listInboundMailboxFolders(userId, mailboxId) {
       WHERE mailbox_id = ? AND user_id = ? AND deleted_at IS NULL
       GROUP BY folder
     `)
-    .all(Number(mailboxId), userId);
+    .all(Number(mailboxId), mailbox.userId);
   const countByFolder = new Map(counts.map((row) => [normalizeInboundFolder(row.folder) || 'INBOX', row]));
   const names = listInboundFolders(mailbox);
   for (const name of countByFolder.keys()) {
@@ -3085,43 +3106,45 @@ export function verifySmtpCredential(username, password) {
   } : null;
 }
 
-export function createApiToken(userId, name, { scopes, expiresAt } = {}) {
+export function createApiToken(userId, name, { scopes, expiresAt, mailboxAccess, mailboxIds } = {}) {
   const token = `mh_${crypto.randomBytes(32).toString('base64url')}`;
   const createdAt = now();
   const cleanScopes = normalizeApiTokenScopes(scopes);
   const cleanExpiresAt = normalizeApiTokenExpiresAt(expiresAt);
+  const cleanMailboxAccess = normalizeApiTokenMailboxAccess(userId, { mailboxAccess, mailboxIds });
   const result = requireDb()
     .prepare(`
-      INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes_json, expires_at, created_at)
-      VALUES (?, ?, ?, ?, ?, ?, ?)
+      INSERT INTO api_tokens (
+        user_id, name, token_hash, token_prefix, token_secret, scopes_json,
+        mailbox_access_json, expires_at, created_at
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
     `)
     .run(
       userId,
       normalizeApiTokenName(name),
       tokenHash(token),
       token.slice(0, 12),
+      encryptSecret(token),
       JSON.stringify(cleanScopes),
+      JSON.stringify(cleanMailboxAccess),
       cleanExpiresAt,
       createdAt
     );
-  return {
-    ...getApiToken(result.lastInsertRowid, userId),
-    token
-  };
+  return getApiToken(result.lastInsertRowid, userId, { includeToken: true });
 }
 
-export function listApiTokens(userId) {
+export function listApiTokens(userId, { includeToken = false } = {}) {
   return requireDb()
     .prepare('SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC')
     .all(userId)
-    .map(publicApiToken);
+    .map((row) => publicApiToken(row, { includeToken }));
 }
 
-export function getApiToken(id, userId) {
+export function getApiToken(id, userId, { includeToken = false } = {}) {
   const row = requireDb()
     .prepare('SELECT * FROM api_tokens WHERE id = ? AND user_id = ?')
     .get(id, userId);
-  return publicApiToken(row);
+  return publicApiToken(row, { includeToken });
 }
 
 export function updateApiToken(id, userId, patch = {}) {
@@ -3137,12 +3160,43 @@ export function updateApiToken(id, userId, patch = {}) {
   const expiresAt = Object.hasOwn(patch, 'expiresAt')
     ? normalizeApiTokenExpiresAt(patch.expiresAt)
     : current.expires_at || null;
+  const mailboxAccess = Object.hasOwn(patch, 'mailboxAccess') || Object.hasOwn(patch, 'mailboxIds')
+    ? normalizeApiTokenMailboxAccess(userId, {
+        mailboxAccess: Object.hasOwn(patch, 'mailboxAccess')
+          ? patch.mailboxAccess
+          : storedApiTokenMailboxAccess(current.mailbox_access_json).mode,
+        mailboxIds: Object.hasOwn(patch, 'mailboxIds')
+          ? patch.mailboxIds
+          : storedApiTokenMailboxAccess(current.mailbox_access_json).mailboxIds
+      })
+    : storedApiTokenMailboxAccess(current.mailbox_access_json);
   requireDb()
-    .prepare('UPDATE api_tokens SET name = ?, scopes_json = ?, expires_at = ? WHERE id = ? AND user_id = ?')
-    .run(name, JSON.stringify(scopes), expiresAt, Number(id), userId);
+    .prepare(`
+      UPDATE api_tokens
+      SET name = ?, scopes_json = ?, mailbox_access_json = ?, expires_at = ?
+      WHERE id = ? AND user_id = ?
+    `)
+    .run(name, JSON.stringify(scopes), JSON.stringify(mailboxAccess), expiresAt, Number(id), userId);
   return getApiToken(id, userId);
 }
 
+export function rotateApiToken(id, userId) {
+  const current = requireDb()
+    .prepare('SELECT * FROM api_tokens WHERE id = ? AND user_id = ?')
+    .get(Number(id), userId);
+  if (!current) return null;
+  if (current.revoked_at) throw new Error('已撤销的 API Token 不能重新生成。');
+  const token = `mh_${crypto.randomBytes(32).toString('base64url')}`;
+  requireDb()
+    .prepare(`
+      UPDATE api_tokens
+      SET token_hash = ?, token_prefix = ?, token_secret = ?, last_used_at = NULL
+      WHERE id = ? AND user_id = ?
+    `)
+    .run(tokenHash(token), token.slice(0, 12), encryptSecret(token), Number(id), userId);
+  return getApiToken(id, userId, { includeToken: true });
+}
+
 export function revokeApiToken(id, userId, reason = '') {
   const result = requireDb()
     .prepare(`
@@ -3811,14 +3865,25 @@ function publicSmtpRelay(row, { includePassword = false, includeSecret = false }
   };
 }
 
-function publicApiToken(row) {
+function publicApiToken(row, { includeToken = false } = {}) {
   if (!row) return null;
+  const mailboxAccess = storedApiTokenMailboxAccess(row.mailbox_access_json);
+  const recoveredToken = decryptSecret(row.token_secret);
+  const tokenRecoverable = Boolean(
+    recoveredToken
+    && row.token_hash
+    && safeEqual(tokenHash(recoveredToken), row.token_hash)
+  );
   return {
     id: row.id,
     userId: row.user_id,
     name: row.name,
     tokenPrefix: row.token_prefix,
+    tokenRecoverable,
+    ...(includeToken && tokenRecoverable ? { token: recoveredToken } : {}),
     scopes: storedApiTokenScopes(row.scopes_json),
+    mailboxAccess: mailboxAccess.mode,
+    mailboxIds: mailboxAccess.mailboxIds,
     expiresAt: row.expires_at || null,
     revokedAt: row.revoked_at || null,
     revokedReason: row.revoked_reason || '',
@@ -4295,6 +4360,37 @@ function normalizeApiTokenName(value) {
   return name;
 }
 
+function normalizeUserRolePatch(value, fallback) {
+  if (value === undefined) return fallback;
+  if (value === 'admin' || value === 'user') return value;
+  throw new Error('用户角色不正确。');
+}
+
+function inboundAccessFilter(alias, userId, { includeAllUsers = false, mailboxIds = null } = {}) {
+  const clauses = [];
+  const params = [];
+  const mailboxColumn = alias === 'm' ? 'id' : 'mailbox_id';
+  if (!includeAllUsers) {
+    clauses.push(`${alias}.user_id = ?`);
+    params.push(Number(userId));
+  }
+  if (Array.isArray(mailboxIds)) {
+    const ids = [...new Set(mailboxIds
+      .map((id) => Number(id))
+      .filter((id) => Number.isSafeInteger(id) && id > 0))];
+    if (!ids.length) {
+      clauses.push('1 = 0');
+    } else {
+      clauses.push(`${alias}.${mailboxColumn} IN (${ids.map(() => '?').join(', ')})`);
+      params.push(...ids);
+    }
+  }
+  return {
+    clause: clauses.length ? clauses.join(' AND ') : '1 = 1',
+    params
+  };
+}
+
 function normalizeApiTokenScopes(value) {
   const candidates = value === undefined ? defaultApiTokenScopes : (Array.isArray(value) ? value : [value]);
   const scopes = [...new Set(candidates.map((item) => String(item || '').trim()).filter(Boolean))];
@@ -4304,6 +4400,57 @@ function normalizeApiTokenScopes(value) {
   return scopes;
 }
 
+function normalizeApiTokenMailboxAccess(userId, { mailboxAccess = 'owner', mailboxIds = [] } = {}) {
+  const mode = String(mailboxAccess || '').trim().toLowerCase();
+  if (!['owner', 'selected', 'all'].includes(mode)) {
+    throw new Error('API Token 邮箱访问范围不正确。');
+  }
+
+  const user = getUser(userId);
+  if (!user) throw new Error('API Token 所属账号不存在。');
+  if (mode === 'all') {
+    if (user.role !== 'admin') throw new Error('只有管理员 Token 可以访问全部邮箱。');
+    return { mode, mailboxIds: [] };
+  }
+  if (mode === 'owner') return { mode, mailboxIds: [] };
+
+  const candidates = Array.isArray(mailboxIds) ? mailboxIds : [mailboxIds];
+  const ids = [...new Set(candidates.map((id) => Number(id)))];
+  if (!ids.length || ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
+    throw new Error('请选择至少一个可访问的邮箱。');
+  }
+
+  const placeholders = ids.map(() => '?').join(', ');
+  const params = [...ids];
+  const where = [`id IN (${placeholders})`, 'deleted_at IS NULL'];
+  if (user.role !== 'admin') {
+    where.push('user_id = ?');
+    params.push(Number(userId));
+  }
+  const rows = requireDb()
+    .prepare(`SELECT id FROM inbound_mailboxes WHERE ${where.join(' AND ')}`)
+    .all(...params);
+  if (rows.length !== ids.length) throw new Error('所选邮箱不存在或无权访问。');
+  return { mode, mailboxIds: ids };
+}
+
+function storedApiTokenMailboxAccess(value) {
+  try {
+    const parsed = JSON.parse(value || '');
+    const mode = String(parsed?.mode || '').trim().toLowerCase();
+    if (!['owner', 'selected', 'all'].includes(mode)) throw new Error('invalid mode');
+    if (mode !== 'selected') return { mode, mailboxIds: [] };
+    if (!Array.isArray(parsed.mailboxIds)) throw new Error('invalid mailbox ids');
+    const mailboxIds = [...new Set(parsed.mailboxIds.map((id) => Number(id)))];
+    if (mailboxIds.some((id) => !Number.isSafeInteger(id) || id <= 0)) {
+      throw new Error('invalid mailbox ids');
+    }
+    return { mode, mailboxIds };
+  } catch {
+    return { mode: 'selected', mailboxIds: [] };
+  }
+}
+
 function storedApiTokenScopes(value) {
   try {
     const parsed = JSON.parse(value || '');

+ 58 - 1
src/frontend/api-token-model.js

@@ -1,5 +1,5 @@
 export function canCopyFullApiToken(token = {}) {
-  return Boolean(token.token);
+  return Boolean(token.token && token.tokenRecoverable !== false);
 }
 
 export function getCreatedApiTokenSecret(token = {}) {
@@ -94,6 +94,58 @@ export function buildMailboxApiUsageExamples({
   };
 }
 
+export function buildInboundMessageApiUsageExamples({
+  endpoint = '/api/inbound-messages',
+  mailboxEndpoint = '/api/inbound-mailboxes',
+  token = '<USER_API_TOKEN>',
+  mailboxId = 42,
+  messageId = 108
+} = {}) {
+  const filters = new URLSearchParams({
+    page: '1',
+    pageSize: '20',
+    mailboxId: String(mailboxId),
+    folder: 'INBOX',
+    read: 'false',
+    q: 'invoice'
+  });
+  const listEndpoint = `${endpoint}?${filters}`;
+  const detailEndpoint = `${endpoint}/${messageId}`;
+  const foldersEndpoint = `${mailboxEndpoint}/${mailboxId}/folders`;
+  const message = {
+    id: messageId,
+    mailboxId,
+    folder: 'INBOX',
+    sender: 'billing@example.net',
+    recipients: ['accounts@example.com'],
+    subject: 'Invoice 2026-07',
+    messageId: '<invoice-2026-07@example.net>',
+    read: false,
+    receivedAt: '2026-07-14T12:00:00.000Z'
+  };
+
+  return {
+    listCurl: buildBearerGetCurl(listEndpoint, token),
+    detailCurl: buildBearerGetCurl(detailEndpoint, token),
+    foldersCurl: buildBearerGetCurl(foldersEndpoint, token),
+    listResponse: JSON.stringify({ messages: [message], total: 1, page: 1, pageSize: 20 }, null, 2),
+    detailResponse: JSON.stringify({
+      message: {
+        ...message,
+        textBody: 'Invoice details',
+        htmlBody: '<p>Invoice details</p>',
+        rawMessage: 'From: billing@example.net\\r\\nSubject: Invoice 2026-07'
+      }
+    }, null, 2),
+    foldersResponse: JSON.stringify({
+      folders: [
+        { name: 'INBOX', specialUse: null, messageCount: 12, unreadCount: 3 },
+        { name: 'Sent', specialUse: '\\Sent', messageCount: 8, unreadCount: 0 }
+      ]
+    }, null, 2)
+  };
+}
+
 function buildBearerCurl(endpoint, token, body) {
   return `curl -X POST ${endpoint} \\
   -H 'Authorization: Bearer ${token}' \\
@@ -101,6 +153,11 @@ function buildBearerCurl(endpoint, token, body) {
   -d '${JSON.stringify(body, null, 2)}'`;
 }
 
+function buildBearerGetCurl(endpoint, token) {
+  return `curl '${endpoint}' \\
+  -H 'Authorization: Bearer ${token}'`;
+}
+
 function domainFromAddress(value) {
   return String(value || '').split('@')[1] || '';
 }

+ 60 - 12
src/frontend/i18n/index.js

@@ -421,7 +421,15 @@ const messages = {
     'tokens.prefix': 'Token 前缀',
     'tokens.scopes': '权限范围',
     'tokens.scopesRequired': '请至少选择一项权限',
-    'tokens.scopesExtra': '发信使用 send;邮箱 API 读取和创建分别需要 mailboxes:read 与 mailboxes:write。',
+    'tokens.scopesExtra': '发信使用 send;邮箱资源读取/创建使用 mailboxes:read、mailboxes:write;读取邮件内容使用 messages:read。',
+    'tokens.mailboxAccess': '邮件读取范围',
+    'tokens.mailboxAccessOwner': '当前账号的全部邮箱',
+    'tokens.mailboxAccessSelected': '指定邮箱',
+    'tokens.mailboxAccessAll': '全系统邮箱(管理员)',
+    'tokens.mailboxAccessExtra': '启用 messages:read 后配置;同时约束该 Token 的邮箱列表。指定邮箱按资源 ID 授权;全系统范围只对管理员开放。',
+    'tokens.mailboxIds': '授权邮箱',
+    'tokens.mailboxIdsRequired': '请至少选择一个邮箱',
+    'tokens.mailboxIdsPlaceholder': '选择可读取邮件的邮箱',
     'tokens.status': '状态',
     'tokens.statusActive': '生效中',
     'tokens.statusExpired': '已过期',
@@ -432,12 +440,21 @@ const messages = {
     'tokens.copyFull': '复制完整 Token',
     'tokens.copyPrefix': '复制前缀',
     'tokens.createdTitle': 'API Token 已创建',
-    'tokens.createdWarning': '完整 Token 只会显示这一次。关闭后无法再次查看,请立即复制并保存到安全位置。',
+    'tokens.createdWarning': '完整 Token 已加密保存,之后仍可在列表或详情中复制。请仅存放在可信的服务端环境。',
     'tokens.createdSuccess': 'Token 已创建',
+    'tokens.rotatedTitle': 'API Token 已重新生成',
+    'tokens.rotatedSuccess': 'Token 已重新生成,旧值已立即失效',
+    'tokens.rotatedWarning': '旧 Token 已立即失效。请尽快更新所有调用方,新的完整 Token 之后仍可在列表或详情中复制。',
     'tokens.deletedSuccess': 'Token 已删除',
     'tokens.updatedSuccess': 'Token 已更新',
     'tokens.revokedSuccess': 'Token 已撤销',
-    'tokens.secretUnavailable': '完整 Token 仅在创建时显示',
+    'tokens.secretAvailable': '完整 Token 已加密保存,可随时复制。',
+    'tokens.secretUnavailable': '该历史 Token 不可恢复,只能通过前缀识别。重新生成后旧值会立即失效。',
+    'tokens.recoverable': '可复制',
+    'tokens.unrecoverable': '不可恢复',
+    'tokens.rotate': '重新生成',
+    'tokens.rotateConfirm': '重新生成 API Token?',
+    'tokens.rotateConfirmDescription': '旧 Token 会立即失效,所有仍使用旧值的调用都会失败。此操作无法撤销。',
     'tokens.lastUsed': '最近使用',
     'tokens.neverUsed': '未使用',
     'tokens.createdAt': '创建时间',
@@ -446,7 +463,7 @@ const messages = {
     'tokens.revoke': '撤销 Token',
     'tokens.revokeConfirm': '撤销后该 Token 将立即失效,确认继续?',
     'tokens.copyCreated': '复制完整 Token',
-    'tokens.prefixOnlyHelp': '历史 Token 不保存明文,只能复制前缀用于识别。',
+    'tokens.prefixOnlyHelp': '新 Token 会加密保存并支持完整复制;旧版历史 Token 可能仅保留前缀,需要重新生成后才能恢复完整复制能力。',
     'tokens.docsTitle': 'API 使用文档',
     'tokens.endpoint': 'API Endpoint',
     'tokens.mailboxEndpoint': '邮箱 API Endpoint',
@@ -469,12 +486,19 @@ const messages = {
     'tokens.permanentMailbox': '创建长期邮箱',
     'tokens.temporaryMailbox': '创建临时邮箱',
     'tokens.mailboxResponse': '创建结果与客户端配置',
+    'tokens.messagesApi': '收信 API',
+    'tokens.messagesApiHint': '需要 messages:read 权限,并受 Token 的邮件读取范围限制。列表支持分页与邮箱、文件夹、已读状态、关键词筛选。',
+    'tokens.messagesList': '查询邮件列表',
+    'tokens.messageDetail': '读取邮件详情',
+    'tokens.mailboxFolders': '查询邮箱文件夹',
+    'tokens.messagesFilters': '筛选参数:page/pageSize 控制分页;mailboxId 限定邮箱;folder 限定文件夹;read 使用 true/false;q 搜索发件人、收件人、主题、Message ID 与摘要。',
+    'tokens.messagesAccess': '范围说明:当前账号覆盖 Token 所有者的邮箱;指定邮箱仅覆盖所选 ID;全系统邮箱只允许管理员显式选择。',
     'tokens.securityTips': '安全建议',
     'tokens.securityTipStore': '只在服务端环境保存 Token,不要放进浏览器前端代码或公开仓库。',
-    'tokens.securityTipRotate': '不同环境使用不同 Token,泄露后立即删除并重新创建。',
+    'tokens.securityTipRotate': '不同环境使用不同 Token,泄露后立即重新生成并更新所有调用方。',
     'tokens.securityTipScopes': '只授予实际需要的权限;给自动化邮箱创建任务时无需授予 send。',
     'tokens.securityTipDomain': 'From 域名必须属于当前账号,建议先完成 DNS 验证再接入生产发送。',
-    'tokens.noTokenHint': '创建 Token 后可复制完整密钥;历史 Token 只显示前缀,示例中使用占位符。',
+    'tokens.noTokenHint': '创建 Token 后可在列表和详情中复制完整密钥,并按最小权限限制邮件读取范围。',
     'webhooks.title': 'Webhooks',
     'webhooks.subtitle': '在投递状态变为已送达、退信或失败时,向你的 HTTPS 端点推送通知。',
     'webhooks.create': '新建 Webhook',
@@ -1013,7 +1037,15 @@ const messages = {
     'tokens.prefix': 'Token Prefix',
     'tokens.scopes': 'Scopes',
     'tokens.scopesRequired': 'Select at least one scope',
-    'tokens.scopesExtra': 'Use send for delivery. Mailbox reads and creates require mailboxes:read and mailboxes:write.',
+    'tokens.scopesExtra': 'Use send for delivery, mailboxes:read/mailboxes:write for mailbox resources, and messages:read for inbound message content.',
+    'tokens.mailboxAccess': 'Message access',
+    'tokens.mailboxAccessOwner': 'All mailboxes owned by this account',
+    'tokens.mailboxAccessSelected': 'Selected mailboxes',
+    'tokens.mailboxAccessAll': 'All system mailboxes (admin)',
+    'tokens.mailboxAccessExtra': 'Configured with messages:read and also applied to this token\'s mailbox list. Selected access is bound to mailbox IDs. All-system access is available to administrators only.',
+    'tokens.mailboxIds': 'Authorized mailboxes',
+    'tokens.mailboxIdsRequired': 'Select at least one mailbox',
+    'tokens.mailboxIdsPlaceholder': 'Select mailboxes this token can read',
     'tokens.status': 'Status',
     'tokens.statusActive': 'Active',
     'tokens.statusExpired': 'Expired',
@@ -1024,12 +1056,21 @@ const messages = {
     'tokens.copyFull': 'Copy full token',
     'tokens.copyPrefix': 'Copy prefix',
     'tokens.createdTitle': 'API Token created',
-    'tokens.createdWarning': 'The full token is shown only once. Copy it now and store it somewhere secure.',
+    '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.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.secretUnavailable': 'Full token is only shown at creation time',
+    '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.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',
@@ -1038,7 +1079,7 @@ const messages = {
     'tokens.revoke': 'Revoke token',
     'tokens.revokeConfirm': 'This token will stop working immediately. Continue?',
     'tokens.copyCreated': 'Copy full token',
-    'tokens.prefixOnlyHelp': 'Historical tokens do not store plaintext. The prefix is only for identification.',
+    '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',
     'tokens.endpoint': 'API Endpoint',
     'tokens.mailboxEndpoint': 'Mailbox API Endpoint',
@@ -1061,12 +1102,19 @@ const messages = {
     'tokens.permanentMailbox': 'Create a persistent mailbox',
     'tokens.temporaryMailbox': 'Create a temporary mailbox',
     'tokens.mailboxResponse': 'Creation result and client configuration',
+    'tokens.messagesApi': 'Inbound message API',
+    'tokens.messagesApiHint': 'Requires messages:read and is limited by the token message-access setting. Lists support pagination plus mailbox, folder, read-state, and keyword filters.',
+    'tokens.messagesList': 'List inbound messages',
+    'tokens.messageDetail': 'Read message detail',
+    'tokens.mailboxFolders': 'List mailbox folders',
+    'tokens.messagesFilters': 'Filters: page/pageSize control pagination; mailboxId limits one mailbox; folder limits one folder; read accepts true/false; q searches sender, recipients, subject, Message ID, and preview.',
+    'tokens.messagesAccess': 'Access: account scope covers mailboxes owned by the token owner; selected scope covers only chosen IDs; all-system scope must be explicitly selected by an administrator.',
     'tokens.securityTips': 'Security tips',
     'tokens.securityTipStore': 'Store tokens only on the server side. Do not put them in browser code or public repositories.',
-    'tokens.securityTipRotate': 'Use separate tokens per environment. Delete and recreate immediately after a leak.',
+    'tokens.securityTipRotate': 'Use separate tokens per environment. Regenerate immediately after a leak and update every caller.',
     'tokens.securityTipScopes': 'Grant only the scopes your integration needs. Mailbox automation does not need send.',
     'tokens.securityTipDomain': 'The From domain must belong to this account. Verify DNS before production sending.',
-    'tokens.noTokenHint': 'Copy the full secret right after creation. Historical tokens show prefixes only, so examples use a placeholder.',
+    'tokens.noTokenHint': 'Create a token, copy its full secret from the list or detail view, and limit inbound message access to the minimum required scope.',
     'webhooks.title': 'Webhooks',
     'webhooks.subtitle': 'Push delivery updates to your HTTPS endpoint when mail is sent, bounced, or failed.',
     'webhooks.create': 'Create webhook',

+ 16 - 4
src/frontend/landing/i18n.js

@@ -78,10 +78,11 @@ const messages = {
     'api.title': 'API 认证与发信',
     'api.sub': '所有公开 API 均使用 Bearer Token。下列主机名为占位符,请替换为你的 MailHub 地址。',
     'api.auth.title': '创建并保护 Token',
-    'api.auth.body': '在控制台的 API Token 页面创建 Token。完整密钥仅显示一次,请存储在服务端环境,切勿写入浏览器代码或公开仓库。',
+    'api.auth.body': '在控制台的 API Token 页面创建 Token。所属账号可查看并复制完整 Token,请严格控制控制台访问权限,切勿写入浏览器代码或公开仓库。',
     'api.auth.send': '使用 POST /api/send 发信',
     'api.auth.read': '使用 GET /api/mailboxes 查询邮箱',
     'api.auth.write': '使用 POST /api/mailboxes 创建邮箱',
+    'api.auth.messages': '使用 GET /api/inbound-messages 读取收到的邮件',
     'api.send.title': '发送一封邮件',
     'api.send.body': 'From 域名必须属于该 Token 所在账号。若启用了已验证域名发信,请先完成控制台中的 DNS 校验。',
     'api.curl': `curl -X POST https://mail.example.com/api/send \\
@@ -163,7 +164,12 @@ const messages = {
     'mailboxes.lifecycle.temporary': '临时邮箱在指定时间后到期,随后停止收信和认证。',
     'mailboxes.lifecycle.list': '使用 mailboxes:read 查询邮箱元数据;列表接口永不返回密码。',
     'receiving.title': '收信',
-    'receiving.sub': '邮件通过域名 MX 记录投递。在 MailHub 收件箱或标准 IMAP/POP3 客户端中读取;当前没有单独的 Bearer 收信读取 API。',
+    'receiving.sub': '邮件通过域名 MX 记录投递,可在 MailHub 收件箱、标准 IMAP/POP3 客户端或受限 Bearer API 中读取。',
+    'receiving.list.label': 'GET /api/inbound-messages - 邮件列表',
+    'receiving.list.curl': "curl -H 'Authorization: Bearer &lt;INBOUND_API_TOKEN&gt;' 'https://mail.example.com/api/inbound-messages?page=1&amp;pageSize=20&amp;folder=INBOX&amp;read=false'",
+    'receiving.detail.label': 'GET /api/inbound-messages/:id - 邮件详情',
+    'receiving.detail.curl': "curl -H 'Authorization: Bearer &lt;INBOUND_API_TOKEN&gt;' https://mail.example.com/api/inbound-messages/123",
+    'receiving.api.note': '收信 API 需要 messages:read,并受 Token 邮箱范围限制。只有明确设为“全系统邮箱”的管理员 Token 才能跨账号读取。',
     'receiving.delivery.title': '邮件投递到 MailHub',
     'receiving.delivery.body': '将域名 MX 指向收信主机。外部邮件服务器会投递到 SMTP 25 端口;MailHub 将邮件存入已有邮箱、别名或未知地址收取目标。',
     'receiving.routing.title': '邮箱、别名与未知地址收取',
@@ -301,10 +307,11 @@ 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. The complete secret is shown only once, so store it outside browser code and public repositories.',
+    '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.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',
+    'api.auth.messages': 'read inbound messages with GET /api/inbound-messages',
     'api.send.title': 'Send a message',
     'api.send.body': 'The From domain must belong to the token account. When verified sending is enabled, complete the domain DNS checks before sending.',
     'api.curl': `curl -X POST https://mail.example.com/api/send \\
@@ -386,7 +393,12 @@ const messages = {
     'mailboxes.lifecycle.temporary': 'A temporary mailbox expires after the requested interval and then stops receiving mail and authenticating.',
     'mailboxes.lifecycle.list': 'Use mailboxes:read to list mailbox metadata; passwords are never returned by the list endpoint.',
     'receiving.title': 'Receiving mail',
-    'receiving.sub': 'Mail arrives through the domain MX record. Read it in MailHub Inbox or through standard IMAP/POP3 clients; there is no separate inbound Bearer API.',
+    'receiving.sub': 'Mail arrives through the domain MX record. Read it in MailHub Inbox, through standard IMAP/POP3 clients, or with a scoped Bearer API.',
+    'receiving.list.label': 'GET /api/inbound-messages - list',
+    'receiving.list.curl': "curl -H 'Authorization: Bearer &lt;INBOUND_API_TOKEN&gt;' 'https://mail.example.com/api/inbound-messages?page=1&amp;pageSize=20&amp;folder=INBOX&amp;read=false'",
+    'receiving.detail.label': 'GET /api/inbound-messages/:id - detail',
+    'receiving.detail.curl': "curl -H 'Authorization: Bearer &lt;INBOUND_API_TOKEN&gt;' https://mail.example.com/api/inbound-messages/123",
+    'receiving.api.note': 'Inbound APIs require messages:read and are limited by the token mailbox access setting. Only administrator tokens explicitly set to all mailboxes can read across accounts.',
     'receiving.delivery.title': 'Inbound delivery',
     'receiving.delivery.body': 'Set the domain MX record to the receiving host. External mail servers then deliver to SMTP port 25, and MailHub stores mail for an existing mailbox, alias, or catch-all target.',
     'receiving.routing.title': 'Mailbox, aliases, and catch-all',

+ 6 - 3
src/frontend/services/api.ts

@@ -3,6 +3,7 @@ import type {
   AdminResourceInventory,
   AdminUser,
   ApiToken,
+  ApiTokenInput,
   Analytics,
   AuditLogEntry,
   DnsCredential,
@@ -107,7 +108,7 @@ export const api = {
     );
   },
   event: (id: number) => request<{ event: SendEvent | null }>(`/api/events/${id}`),
-  inboundMailboxes: () => request<{ mailboxes: InboundMailbox[] }>('/api/inbound-mailboxes'),
+  inboundMailboxes: (all = false) => request<{ mailboxes: InboundMailbox[] }>(`/api/inbound-mailboxes${all ? '?all=true' : ''}`),
   createInboundMailbox: (data: {
     address: string;
     displayName?: string;
@@ -178,10 +179,12 @@ export const api = {
   deleteDnsCredential: (id: number) =>
     request<{ deleted: boolean }>(`/api/dns-credentials/${id}`, { method: 'DELETE' }),
   apiTokens: () => request<{ tokens: ApiToken[] }>('/api/api-tokens'),
-  createApiToken: (data: { name: string; scopes?: string[]; expiresAt?: string | null }) =>
+  createApiToken: (data: ApiTokenInput & { name: string }) =>
     request<{ token: ApiToken }>('/api/api-tokens', { method: 'POST', data }),
-  updateApiToken: (id: number, data: { name?: string; scopes?: string[]; expiresAt?: string | null }) =>
+  updateApiToken: (id: number, data: ApiTokenInput) =>
     request<{ token: ApiToken }>(`/api/api-tokens/${id}`, { method: 'PATCH', data }),
+  rotateApiToken: (id: number) =>
+    request<{ token: ApiToken }>(`/api/api-tokens/${id}/rotate`, { method: 'POST' }),
   deleteApiToken: (id: number) => request<{ deleted: boolean; revoked: boolean; token?: ApiToken | null }>(`/api/api-tokens/${id}`, { method: 'DELETE' }),
   createDomain: (data: AddDomainPayload) => request<{ domain: Domain }>('/api/domains', { method: 'POST', data }),
   patchDomain: (id: number, data: DomainPatchPayload) =>

+ 15 - 1
src/frontend/types.ts

@@ -284,13 +284,27 @@ export interface AuditLogEntry {
   createdAt: string;
 }
 
+export type ApiTokenScope = 'send' | 'mailboxes:read' | 'mailboxes:write' | 'messages:read' | string;
+export type ApiTokenMailboxAccess = 'owner' | 'selected' | 'all';
+
+export interface ApiTokenInput {
+  name?: string;
+  scopes?: ApiTokenScope[];
+  expiresAt?: string | null;
+  mailboxAccess?: ApiTokenMailboxAccess;
+  mailboxIds?: number[];
+}
+
 export interface ApiToken {
   id: number;
   userId?: number;
   name: string;
   tokenPrefix: string;
   token?: string;
-  scopes: Array<'send' | 'mailboxes:read' | 'mailboxes:write' | string>;
+  tokenRecoverable: boolean;
+  scopes: ApiTokenScope[];
+  mailboxAccess: ApiTokenMailboxAccess;
+  mailboxIds: number[];
   expiresAt?: string | null;
   revokedAt?: string | null;
   revokedReason?: string;

+ 15 - 0
src/frontend/use-media-query.ts

@@ -0,0 +1,15 @@
+import { useEffect, useState } from 'react';
+
+export function useMediaQuery(query: string) {
+  const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
+
+  useEffect(() => {
+    const mediaQuery = window.matchMedia(query);
+    const updateMatches = () => setMatches(mediaQuery.matches);
+    updateMatches();
+    mediaQuery.addEventListener('change', updateMatches);
+    return () => mediaQuery.removeEventListener('change', updateMatches);
+  }, [query]);
+
+  return matches;
+}

+ 7 - 3
src/layouts/AdminLayout.tsx

@@ -20,7 +20,6 @@ import {
   Button,
   Drawer,
   Dropdown,
-  Grid,
   Layout,
   Menu,
   Select,
@@ -34,6 +33,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
 
 import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
+import { useMediaQuery } from '../frontend/use-media-query';
 
 const { Header, Sider, Content } = Layout;
 
@@ -108,7 +108,7 @@ export function AdminLayout() {
   const { user, config, logout } = useAppContext();
   const location = useLocation();
   const navigate = useNavigate();
-  const screens = Grid.useBreakpoint();
+  const isDesktop = useMediaQuery('(min-width: 1024px)');
   const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false);
   const mainRef = useRef<HTMLElement>(null);
   const isAdmin = user?.role === 'admin';
@@ -119,6 +119,10 @@ export function AdminLayout() {
     return () => window.cancelAnimationFrame(frame);
   }, [location.pathname]);
 
+  useEffect(() => {
+    if (isDesktop) setMobileNavigationOpen(false);
+  }, [isDesktop]);
+
   const menuItems = useMemo(() => visibleNavigation(isAdmin).map((group) => ({
     type: 'group' as const,
     key: group.key,
@@ -157,7 +161,7 @@ export function AdminLayout() {
         className="mobile-navigation-drawer"
         placement="left"
         width={288}
-        open={mobileNavigationOpen && screens.lg === false}
+        open={mobileNavigationOpen && !isDesktop}
         onClose={() => setMobileNavigationOpen(false)}
         closable={false}
         styles={{ body: { padding: 0, background: '#0F172A' } }}

+ 259 - 43
src/pages/ApiTokens.tsx

@@ -19,6 +19,8 @@ import {
   Input,
   Modal,
   Popconfirm,
+  Radio,
+  Select,
   Skeleton,
   Space,
   Table,
@@ -35,6 +37,7 @@ import { SectionCard } from '../components/common/SectionCard';
 import { StatusPill, type StatusTone } from '../components/common/StatusPill';
 import {
   buildApiUsageExamples,
+  buildInboundMessageApiUsageExamples,
   buildMailboxApiUsageExamples,
   canCopyFullApiToken,
   formatApiTokenPrefix,
@@ -43,47 +46,79 @@ import {
 import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
 import { api } from '../frontend/services/api';
-import type { ApiToken, RuntimeConfig } from '../frontend/types';
+import type {
+  ApiToken,
+  ApiTokenInput,
+  ApiTokenMailboxAccess,
+  InboundMailbox,
+  RuntimeConfig
+} from '../frontend/types';
 
 interface TokenFormValues {
   name: string;
   scopes: string[];
   expiresAt?: string;
+  mailboxAccess: ApiTokenMailboxAccess;
+  mailboxIds: number[];
 }
 
+type SecretModalMode = 'created' | 'rotated';
+
 const scopeOptions = [
   { label: 'send', value: 'send' },
   { label: 'mailboxes:read', value: 'mailboxes:read' },
-  { label: 'mailboxes:write', value: 'mailboxes:write' }
+  { label: 'mailboxes:write', value: 'mailboxes:write' },
+  { label: 'messages:read', value: 'messages:read' }
 ];
 
 export default function ApiTokens() {
   const { message } = AntApp.useApp();
   const { t } = useI18n();
-  const { config } = useAppContext();
+  const { config, user } = useAppContext();
   const [form] = Form.useForm<TokenFormValues>();
   const [tokens, setTokens] = useState<ApiToken[]>([]);
+  const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
   const [loading, setLoading] = useState(true);
   const [loadError, setLoadError] = useState('');
+  const [mailboxError, setMailboxError] = useState('');
   const [actionKey, setActionKey] = useState('');
   const [editorOpen, setEditorOpen] = useState(false);
   const [editingToken, setEditingToken] = useState<ApiToken | null>(null);
   const [selectedToken, setSelectedToken] = useState<ApiToken | null>(null);
-  const [createdToken, setCreatedToken] = useState<ApiToken | null>(null);
+  const [revealedToken, setRevealedToken] = useState<ApiToken | null>(null);
+  const [secretModalMode, setSecretModalMode] = useState<SecretModalMode>('created');
   const [guideOpen, setGuideOpen] = useState(false);
+  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 loadData = useCallback(async () => {
     setLoading(true);
     setLoadError('');
-    try {
-      const tokenResult = await api.apiTokens();
-      setTokens(tokenResult.tokens || []);
-    } catch (error) {
-      setLoadError(error instanceof Error ? error.message : t('common.error'));
-    } finally {
-      setLoading(false);
+    setMailboxError('');
+    const [tokenResult, mailboxResult] = await Promise.allSettled([
+      api.apiTokens(),
+      api.inboundMailboxes(isAdmin)
+    ]);
+
+    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'));
     }
-  }, [t]);
+
+    if (mailboxResult.status === 'fulfilled') {
+      setMailboxes(mailboxResult.value.mailboxes || []);
+    } else {
+      setMailboxError(mailboxResult.reason instanceof Error ? mailboxResult.reason.message : t('common.error'));
+    }
+    setLoading(false);
+  }, [isAdmin, t]);
 
   useEffect(() => {
     void loadData();
@@ -98,17 +133,20 @@ export default function ApiTokens() {
   function openCreate() {
     setEditingToken(null);
     form.resetFields();
-    form.setFieldsValue({ name: '', scopes: ['send'], expiresAt: '' });
+    form.setFieldsValue({ name: '', scopes: ['send'], expiresAt: '', mailboxAccess: 'owner', mailboxIds: [] });
     setEditorOpen(true);
   }
 
   function openEdit(token: ApiToken) {
+    const mailboxAccess = token.mailboxAccess === 'all' && !isAdmin ? 'owner' : token.mailboxAccess || 'owner';
     setEditingToken(token);
     form.resetFields();
     form.setFieldsValue({
       name: token.name,
       scopes: token.scopes || ['send'],
-      expiresAt: toDatetimeLocal(token.expiresAt)
+      expiresAt: toDatetimeLocal(token.expiresAt),
+      mailboxAccess,
+      mailboxIds: token.mailboxIds || []
     });
     setEditorOpen(true);
   }
@@ -123,9 +161,12 @@ export default function ApiTokens() {
         await api.updateApiToken(editingToken.id, payload);
         message.success(t('tokens.updatedSuccess'));
       } else {
-        const result = await api.createApiToken(payload);
+        const result = await api.createApiToken({ ...payload, name: values.name.trim() });
         message.success(t('tokens.createdSuccess'));
-        if (result.token && canCopyFullApiToken(result.token)) setCreatedToken(result.token);
+        if (result.token && canCopyFullApiToken(result.token)) {
+          setSecretModalMode('created');
+          setRevealedToken(result.token);
+        }
       }
       setEditorOpen(false);
       setEditingToken(null);
@@ -138,6 +179,22 @@ export default function ApiTokens() {
     }
   }
 
+  async function rotateToken(token: ApiToken) {
+    setActionKey(`rotate:${token.id}`);
+    try {
+      const result = await api.rotateApiToken(token.id);
+      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'));
+    } finally {
+      setActionKey('');
+    }
+  }
+
   async function revokeToken(token: ApiToken) {
     setActionKey(`revoke:${token.id}`);
     try {
@@ -156,32 +213,49 @@ export default function ApiTokens() {
     {
       title: t('tokens.name'),
       dataIndex: 'name',
+      width: 180,
       render: (value: string) => <Typography.Text strong>{value}</Typography.Text>
     },
     {
-      title: t('tokens.prefix'),
-      dataIndex: 'tokenPrefix',
-      width: 180,
-      render: (_value, token) => <Typography.Text code>{formatApiTokenPrefix(token)}</Typography.Text>
+      title: t('tokens.fullToken'),
+      dataIndex: 'token',
+      width: 380,
+      render: (_value, token) => <TokenValue token={token} onCopy={copyValue} t={t} />
     },
     {
       title: t('tokens.scopes'),
       dataIndex: 'scopes',
+      width: 250,
       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,
+      render: (_value, token) => mailboxAccessSummary(token, t)
+    },
     {
       title: t('tokens.status'),
       dataIndex: 'status',
       width: 120,
       render: (_value, token) => <StatusPill tone={tokenStatusTone(token.status)}>{tokenStatusText(token.status, t)}</StatusPill>
     },
-    { title: t('tokens.lastUsed'), dataIndex: 'lastUsedAt', width: 190, render: (value?: string) => value ? formatOptionalTime(value) : t('tokens.neverUsed') },
+    {
+      title: t('tokens.lastUsed'),
+      dataIndex: 'lastUsedAt',
+      width: 190,
+      responsive: ['xl'],
+      render: (value?: string) => value ? formatOptionalTime(value) : t('tokens.neverUsed')
+    },
     {
       title: t('tokens.actions'),
       width: 180,
+      fixed: 'right',
       render: (_value, token) => (
         <Space onClick={(event) => event.stopPropagation()}>
-          <Button aria-label={t('tokens.copyPrefix')} icon={<CopyOutlined />} onClick={() => void copyValue(token.tokenPrefix)} />
+          {!canCopyFullApiToken(token) ? (
+            <RotateTokenConfirm token={token} loading={actionKey === `rotate:${token.id}`} onRotate={rotateToken} t={t} />
+          ) : null}
           <Button aria-label={`${t('tokens.edit')} ${token.name}`} disabled={token.status === 'revoked'} icon={<EditOutlined />} onClick={() => openEdit(token)} />
           <Popconfirm title={t('tokens.revokeConfirm')} onConfirm={() => void revokeToken(token)} disabled={token.status === 'revoked'}>
             <Button aria-label={`${t('tokens.revoke')} ${token.name}`} danger disabled={token.status === 'revoked'} icon={<DeleteOutlined />} loading={actionKey === `revoke:${token.id}`} />
@@ -191,6 +265,16 @@ export default function ApiTokens() {
     }
   ];
 
+  const mailboxOptions = mailboxes.map((mailbox) => ({
+    value: mailbox.id,
+    label: `${mailbox.address}${isAdmin && mailbox.userId !== user?.id ? ` · UID ${mailbox.userId}` : ''}`
+  }));
+  const mailboxAccessOptions = [
+    { label: t('tokens.mailboxAccessOwner'), value: 'owner' },
+    { label: t('tokens.mailboxAccessSelected'), value: 'selected' },
+    ...(isAdmin ? [{ label: t('tokens.mailboxAccessAll'), value: 'all' }] : [])
+  ];
+
   return (
     <Space direction="vertical" size={20} className="full-width">
       <PageHeader
@@ -211,13 +295,17 @@ export default function ApiTokens() {
             rowKey="id"
             columns={columns}
             dataSource={tokens}
-            scroll={{ x: 980 }}
+            scroll={{ x: 1510 }}
             onRow={(token) => ({
               onClick: () => setSelectedToken(token),
               style: { cursor: 'pointer' },
               tabIndex: 0,
               onKeyDown: (event) => {
-                if (event.key === 'Enter' || event.key === ' ') setSelectedToken(token);
+                if (event.target !== event.currentTarget) return;
+                if (event.key === 'Enter' || event.key === ' ') {
+                  event.preventDefault();
+                  setSelectedToken(token);
+                }
               }
             })}
           />
@@ -226,32 +314,75 @@ export default function ApiTokens() {
 
       <Drawer
         title={editingToken ? t('tokens.editTitle') : t('tokens.createTitle')}
-        width={520}
+        width={560}
         open={editorOpen}
         onClose={() => setEditorOpen(false)}
         destroyOnHidden
         footer={<Space style={{ display: 'flex', justifyContent: 'flex-end' }}><Button onClick={() => setEditorOpen(false)}>{t('common.cancel')}</Button><Button type="primary" icon={<KeyOutlined />} loading={actionKey === 'create' || actionKey.startsWith('save:')} onClick={() => void saveToken()}>{editingToken ? t('common.save') : t('tokens.create')}</Button></Space>}
       >
-        {!editingToken ? <Alert type="warning" showIcon message={t('tokens.createdWarning')} style={{ marginBottom: 20 }} /> : null}
-        <Form form={form} layout="vertical" initialValues={{ scopes: ['send'] }}>
+        {!editingToken ? <Alert type="info" showIcon message={t('tokens.createdWarning')} style={{ marginBottom: 20 }} /> : null}
+        <Form form={form} layout="vertical" initialValues={{ scopes: ['send'], mailboxAccess: 'owner', mailboxIds: [] }}>
           <Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}><Input placeholder={t('tokens.namePlaceholder')} autoComplete="off" /></Form.Item>
           <Form.Item name="expiresAt" label={t('tokens.expiresAt')} extra={t('tokens.expiresAtExtra')}><Input type="datetime-local" /></Form.Item>
           <Form.Item name="scopes" label={t('tokens.scopes')} rules={[{ required: true, type: 'array', min: 1, message: t('tokens.scopesRequired') }]} extra={t('tokens.scopesExtra')}><Checkbox.Group options={scopeOptions} /></Form.Item>
+          {messagesReadEnabled ? (
+            <>
+              <Form.Item name="mailboxAccess" label={t('tokens.mailboxAccess')} extra={t('tokens.mailboxAccessExtra')} rules={[{ required: true }]}>
+                <Radio.Group className="full-width" style={{ display: 'grid', gap: 4 }}>
+                  {mailboxAccessOptions.map((option) => (
+                    <Radio key={option.value} value={option.value} style={{ alignItems: 'center', minHeight: 44 }}>
+                      {option.label}
+                    </Radio>
+                  ))}
+                </Radio.Group>
+              </Form.Item>
+              {selectedMailboxAccess === 'selected' ? (
+                <Form.Item
+                  name="mailboxIds"
+                  label={t('tokens.mailboxIds')}
+                  rules={[{ type: 'array', min: 1, required: true, message: t('tokens.mailboxIdsRequired') }]}
+                >
+                  <Select
+                    mode="multiple"
+                    allowClear
+                    showSearch
+                    optionFilterProp="label"
+                    options={mailboxOptions}
+                    placeholder={t('tokens.mailboxIdsPlaceholder')}
+                    status={mailboxError ? 'error' : undefined}
+                  />
+                </Form.Item>
+              ) : null}
+              {mailboxError ? <Alert type="error" showIcon message={mailboxError} action={<Button size="small" onClick={() => void loadData()}>{t('common.refresh')}</Button>} /> : null}
+            </>
+          ) : null}
         </Form>
       </Drawer>
 
-      <Drawer title={selectedToken?.name} width={520} open={Boolean(selectedToken)} onClose={() => setSelectedToken(null)}>
+      <Drawer title={selectedToken?.name} width={600} open={Boolean(selectedToken)} onClose={() => setSelectedToken(null)}>
         {selectedToken ? (
           <Space direction="vertical" size={20} className="full-width">
             <Descriptions bordered column={1} size="small">
+              <Descriptions.Item label={t('tokens.fullToken')}><TokenValue token={selectedToken} onCopy={copyValue} t={t} /></Descriptions.Item>
               <Descriptions.Item label={t('tokens.prefix')}><Typography.Text code>{formatApiTokenPrefix(selectedToken)}</Typography.Text></Descriptions.Item>
               <Descriptions.Item label={t('tokens.status')}><StatusPill tone={tokenStatusTone(selectedToken.status)}>{tokenStatusText(selectedToken.status, t)}</StatusPill></Descriptions.Item>
               <Descriptions.Item label={t('tokens.scopes')}><Space wrap>{selectedToken.scopes.map((scope) => <Tag key={scope}>{scope}</Tag>)}</Space></Descriptions.Item>
+              <Descriptions.Item label={t('tokens.mailboxAccess')}>{mailboxAccessSummary(selectedToken, t)}</Descriptions.Item>
+              {selectedToken.scopes.includes('messages:read') && selectedToken.mailboxAccess === 'selected' ? (
+                <Descriptions.Item label={t('tokens.mailboxIds')}>
+                  <Space size={[4, 4]} wrap>{selectedToken.mailboxIds.map((id) => <Tag key={id}>{mailboxes.find((mailbox) => mailbox.id === id)?.address || `#${id}`}</Tag>)}</Space>
+                </Descriptions.Item>
+              ) : null}
               <Descriptions.Item label={t('tokens.expiresAt')}>{formatOptionalTime(selectedToken.expiresAt)}</Descriptions.Item>
               <Descriptions.Item label={t('tokens.lastUsed')}>{selectedToken.lastUsedAt ? formatOptionalTime(selectedToken.lastUsedAt) : t('tokens.neverUsed')}</Descriptions.Item>
               <Descriptions.Item label={t('tokens.createdAt')}>{formatOptionalTime(selectedToken.createdAt)}</Descriptions.Item>
             </Descriptions>
-            <Alert type="info" showIcon message={t('tokens.secretUnavailable')} />
+            <Alert
+              type={canCopyFullApiToken(selectedToken) ? 'success' : 'warning'}
+              showIcon
+              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>
           </Space>
         ) : null}
@@ -260,21 +391,21 @@ export default function ApiTokens() {
       <ApiGuideDrawer open={guideOpen} config={config} onClose={() => setGuideOpen(false)} onCopy={copyValue} />
 
       <Modal
-        title={t('tokens.createdTitle')}
-        open={Boolean(createdToken)}
+        title={secretModalMode === 'rotated' ? t('tokens.rotatedTitle') : t('tokens.createdTitle')}
+        open={Boolean(revealedToken)}
         closable={false}
         maskClosable={false}
         keyboard={false}
         destroyOnHidden
         footer={[
-          <Button key="copy" icon={<CopyOutlined />} onClick={() => void copyValue(getCreatedApiTokenSecret(createdToken || {}) || '')}>{t('tokens.copyCreated')}</Button>,
-          <Button key="done" type="primary" onClick={() => setCreatedToken(null)}>{t('common.confirm')}</Button>
+          <Button key="copy" icon={<CopyOutlined />} onClick={() => void copyValue(getCreatedApiTokenSecret(revealedToken || {}) || '')}>{t('tokens.copyCreated')}</Button>,
+          <Button key="done" type="primary" onClick={() => setRevealedToken(null)}>{t('common.confirm')}</Button>
         ]}
       >
-        {createdToken ? (
+        {revealedToken ? (
           <Space direction="vertical" size={16} className="full-width">
-            <Alert type="error" showIcon message={t('tokens.createdWarning')} />
-            <CodeBlock value={getCreatedApiTokenSecret(createdToken) || ''} onCopy={copyValue} />
+            <Alert type={secretModalMode === 'rotated' ? 'warning' : 'info'} showIcon message={secretModalMode === 'rotated' ? t('tokens.rotatedWarning') : t('tokens.createdWarning')} />
+            <CodeBlock value={getCreatedApiTokenSecret(revealedToken) || ''} onCopy={copyValue} />
           </Space>
         ) : null}
       </Modal>
@@ -282,28 +413,98 @@ export default function ApiTokens() {
   );
 }
 
+function TokenValue({ token, onCopy, t }: { token: ApiToken; onCopy: (value: string) => void; t: (key: string) => string }) {
+  if (!canCopyFullApiToken(token)) return <StatusPill tone="warning">{t('tokens.unrecoverable')}</StatusPill>;
+  return (
+    <Space size={8} align="start" style={{ maxWidth: '100%' }}>
+      <Typography.Text code style={{ overflowWrap: 'anywhere', wordBreak: 'break-all' }}>{token.token}</Typography.Text>
+      <Button
+        aria-label={`${t('tokens.copyFull')} ${token.name}`}
+        icon={<CopyOutlined />}
+        onClick={(event) => {
+          event.stopPropagation();
+          void onCopy(token.token || '');
+        }}
+        style={{ minHeight: 44, minWidth: 44 }}
+      />
+    </Space>
+  );
+}
+
+function RotateTokenConfirm({ token, loading, onRotate, t, text = false }: { token: ApiToken; loading: boolean; onRotate: (token: ApiToken) => void; t: (key: string) => string; text?: boolean }) {
+  return (
+    <Popconfirm
+      title={t('tokens.rotateConfirm')}
+      description={t('tokens.rotateConfirmDescription')}
+      okText={t('tokens.rotate')}
+      okButtonProps={{ danger: true }}
+      onConfirm={() => void onRotate(token)}
+      disabled={token.status === 'revoked'}
+    >
+      <Button
+        aria-label={`${t('tokens.rotate')} ${token.name}`}
+        icon={<ReloadOutlined />}
+        danger
+        disabled={token.status === 'revoked'}
+        loading={loading}
+        style={{ minHeight: 44, minWidth: text ? undefined : 44 }}
+      >
+        {text ? t('tokens.rotate') : null}
+      </Button>
+    </Popconfirm>
+  );
+}
+
 function ApiGuideDrawer({ open, config, onClose, onCopy }: { open: boolean; config: RuntimeConfig | null; onClose: () => void; onCopy: (value: string) => void }) {
   const { t } = useI18n();
-  const endpoint = `${config?.appBaseUrl || window.location.origin}/api/send`;
-  const mailboxEndpoint = `${config?.appBaseUrl || window.location.origin}/api/mailboxes`;
+  const baseUrl = config?.appBaseUrl || window.location.origin;
+  const endpoint = `${baseUrl}/api/send`;
+  const mailboxEndpoint = `${baseUrl}/api/mailboxes`;
+  const inboundMessagesEndpoint = `${baseUrl}/api/inbound-messages`;
+  const inboundMailboxesEndpoint = `${baseUrl}/api/inbound-mailboxes`;
   const examples = useMemo(() => buildApiUsageExamples({ endpoint, token: '<USER_API_TOKEN>', from: 'noreply@example.com', to: 'user@example.com' }), [endpoint]);
   const mailboxExamples = useMemo(() => buildMailboxApiUsageExamples({ endpoint: mailboxEndpoint, token: '<MAILBOX_API_TOKEN>' }), [mailboxEndpoint]);
+  const inboundExamples = useMemo(() => buildInboundMessageApiUsageExamples({ endpoint: inboundMessagesEndpoint, mailboxEndpoint: inboundMailboxesEndpoint, token: '<MESSAGES_API_TOKEN>' }), [inboundMailboxesEndpoint, inboundMessagesEndpoint]);
   return (
-    <Drawer title={t('tokens.docsTitle')} width={720} open={open} onClose={onClose}>
+    <Drawer title={t('tokens.docsTitle')} width={760} open={open} onClose={onClose}>
       <Space direction="vertical" size={16} className="full-width">
         <Descriptions column={1} bordered size="small">
           <Descriptions.Item label={t('tokens.endpoint')}><Typography.Text code copyable={{ onCopy: () => onCopy(endpoint) }}>{endpoint}</Typography.Text></Descriptions.Item>
           <Descriptions.Item label={t('tokens.mailboxEndpoint')}><Typography.Text code copyable={{ onCopy: () => onCopy(mailboxEndpoint) }}>{mailboxEndpoint}</Typography.Text></Descriptions.Item>
+          <Descriptions.Item label={t('tokens.messagesApi')}><Typography.Text code copyable={{ onCopy: () => onCopy(inboundMessagesEndpoint) }}>{inboundMessagesEndpoint}</Typography.Text></Descriptions.Item>
           <Descriptions.Item label={t('tokens.authHeader')}><Typography.Text code>{t('tokens.authHeaderValue')}</Typography.Text></Descriptions.Item>
         </Descriptions>
         <Collapse
           defaultActiveKey={['send']}
           items={[
             {
-              key: 'send', label: t('tokens.sendApi'), children: <Space direction="vertical" size={12} className="full-width"><Typography.Text strong>{t('tokens.curlExample')}</Typography.Text><CodeBlock value={examples.curl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.requestExample')}</Typography.Text><CodeBlock value={examples.requestBody} onCopy={onCopy} /><Typography.Text strong>{t('tokens.responseExample')}</Typography.Text><CodeBlock value={examples.successResponse} onCopy={onCopy} /></Space>
+              key: 'send',
+              label: t('tokens.sendApi'),
+              children: <Space direction="vertical" size={12} className="full-width"><Typography.Text strong>{t('tokens.curlExample')}</Typography.Text><CodeBlock value={examples.curl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.requestExample')}</Typography.Text><CodeBlock value={examples.requestBody} onCopy={onCopy} /><Typography.Text strong>{t('tokens.responseExample')}</Typography.Text><CodeBlock value={examples.successResponse} onCopy={onCopy} /></Space>
+            },
+            {
+              key: 'mailboxes',
+              label: t('tokens.mailboxApi'),
+              children: <Space direction="vertical" size={12} className="full-width"><Alert type="info" showIcon message={t('tokens.mailboxApiHint')} /><Typography.Text strong>{t('tokens.permanentMailbox')}</Typography.Text><CodeBlock value={mailboxExamples.permanentCurl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.temporaryMailbox')}</Typography.Text><CodeBlock value={mailboxExamples.temporaryCurl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.mailboxResponse')}</Typography.Text><CodeBlock value={mailboxExamples.successResponse} onCopy={onCopy} /></Space>
             },
             {
-              key: 'mailboxes', label: t('tokens.mailboxApi'), children: <Space direction="vertical" size={12} className="full-width"><Alert type="info" showIcon message={t('tokens.mailboxApiHint')} /><Typography.Text strong>{t('tokens.permanentMailbox')}</Typography.Text><CodeBlock value={mailboxExamples.permanentCurl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.temporaryMailbox')}</Typography.Text><CodeBlock value={mailboxExamples.temporaryCurl} onCopy={onCopy} /><Typography.Text strong>{t('tokens.mailboxResponse')}</Typography.Text><CodeBlock value={mailboxExamples.successResponse} onCopy={onCopy} /></Space>
+              key: 'messages',
+              label: t('tokens.messagesApi'),
+              children: (
+                <Space direction="vertical" size={12} className="full-width">
+                  <Alert type="info" showIcon message={t('tokens.messagesApiHint')} description={t('tokens.messagesAccess')} />
+                  <Typography.Text>{t('tokens.messagesFilters')}</Typography.Text>
+                  <Typography.Text strong>{t('tokens.messagesList')}</Typography.Text>
+                  <CodeBlock value={inboundExamples.listCurl} onCopy={onCopy} />
+                  <CodeBlock value={inboundExamples.listResponse} onCopy={onCopy} />
+                  <Typography.Text strong>{t('tokens.messageDetail')}</Typography.Text>
+                  <CodeBlock value={inboundExamples.detailCurl} onCopy={onCopy} />
+                  <CodeBlock value={inboundExamples.detailResponse} onCopy={onCopy} />
+                  <Typography.Text strong>{t('tokens.mailboxFolders')}</Typography.Text>
+                  <CodeBlock value={inboundExamples.foldersCurl} onCopy={onCopy} />
+                  <CodeBlock value={inboundExamples.foldersResponse} onCopy={onCopy} />
+                </Space>
+              )
             }
           ]}
         />
@@ -313,8 +514,23 @@ function ApiGuideDrawer({ open, config, onClose, onCopy }: { open: boolean; conf
   );
 }
 
-function toTokenPayload(values: TokenFormValues) {
-  return { name: values.name.trim(), scopes: values.scopes, expiresAt: values.expiresAt ? new Date(values.expiresAt).toISOString() : null };
+function toTokenPayload(values: TokenFormValues): ApiTokenInput {
+  const messagesRead = values.scopes.includes('messages:read');
+  const mailboxAccess = messagesRead ? values.mailboxAccess || 'owner' : 'owner';
+  return {
+    name: values.name.trim(),
+    scopes: values.scopes,
+    expiresAt: values.expiresAt ? new Date(values.expiresAt).toISOString() : null,
+    mailboxAccess,
+    mailboxIds: messagesRead && mailboxAccess === 'selected' ? values.mailboxIds || [] : []
+  };
+}
+
+function mailboxAccessSummary(token: ApiToken, t: (key: string) => string) {
+  if (!token.scopes.includes('messages:read')) return '—';
+  if (token.mailboxAccess === 'all') return <StatusPill tone="warning">{t('tokens.mailboxAccessAll')}</StatusPill>;
+  if (token.mailboxAccess === 'selected') return <StatusPill tone="info">{t('tokens.mailboxAccessSelected')} · {token.mailboxIds.length}</StatusPill>;
+  return <StatusPill tone="neutral">{t('tokens.mailboxAccessOwner')}</StatusPill>;
 }
 
 function toDatetimeLocal(value?: string | null) {

+ 5 - 3
src/pages/Inbox.tsx

@@ -51,6 +51,7 @@ import { useI18n } from '../frontend/i18n/react';
 import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
 import { api } from '../frontend/services/api';
 import type { Domain, InboundFolder, InboundMailbox, InboundMessage, MailboxClientConfig, RuntimeConfig } from '../frontend/types';
+import { useMediaQuery } from '../frontend/use-media-query';
 
 type MailMessage = InboundMessage & { folder?: string };
 
@@ -71,6 +72,7 @@ type MessageTab = 'text' | 'html' | 'raw';
 export default function Inbox() {
   const { message } = AntApp.useApp();
   const screens = Grid.useBreakpoint();
+  const isDesktop = useMediaQuery('(min-width: 1024px)');
   const { locale, t } = useI18n();
   const { config } = useAppContext();
   const location = useLocation();
@@ -425,7 +427,7 @@ export default function Inbox() {
       />
       {workspace === 'messages' ? (
         mailboxes.length ? (
-          <div style={{ display: 'grid', gridTemplateColumns: screens.lg ? '220px minmax(320px, 380px) minmax(0, 1fr)' : screens.md ? '220px minmax(0, 1fr)' : 'minmax(0, 1fr)', gap: 16, minWidth: 0 }}>
+          <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}
             <Card styles={{ body: { padding: 0, minWidth: 0 } }}>
               <div style={{ padding: 12, borderBottom: '1px solid #EAECF0' }}>
@@ -451,7 +453,7 @@ 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>
-            {screens.lg ? <Card styles={{ body: { padding: 20, minWidth: 0 } }}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Card> : null}
+            {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}
           </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>} />
       ) : (
@@ -465,7 +467,7 @@ export default function Inbox() {
         </Space>
       )}
 
-      {!screens.lg ? <Drawer title={t('inbox.messageDetail')} width={screens.md ? 680 : '100%'} open={Boolean(routeMessageId)} onClose={closeMessage}><MessageDetail message={selectedMessage} loading={detailLoading} error={detailError} activeTab={messageTab} onTabChange={changeMessageTab} onCopy={copyValue} t={t} /></Drawer> : null}
+      {!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}
 
       <Drawer title={t('inbox.createMailbox')} width={560} open={mailboxOpen} onClose={() => setMailboxOpen(false)} destroyOnHidden footer={<Space style={{ display: 'flex', justifyContent: 'flex-end' }}><Button onClick={() => setMailboxOpen(false)}>{t('common.cancel')}</Button><Button type="primary" loading={actionKey === 'mailbox:create'} onClick={() => void createMailbox()}>{t('inbox.createMailbox')}</Button></Space>}>
         {!domains.length ? <Alert type="warning" showIcon message={t('inbox.noDomain')} /> : (

+ 106 - 21
src/server.js

@@ -60,6 +60,7 @@ import {
   markUserEmailVerified,
   previewUserMerge,
   replayWebhookDelivery,
+  rotateApiToken,
   rotateWebhookSecret,
   saveDnsCredential,
   saveDomainStatus,
@@ -255,7 +256,7 @@ const server = http.createServer(async (req, res) => {
     if (req.method === 'POST' && url.pathname === '/api/auth/forgot-password') return await handleForgotPassword(req, res);
     if (req.method === 'POST' && url.pathname === '/api/auth/reset-password') return await handleResetPassword(req, res);
 
-    const user = getRequestUser(req, url.pathname);
+    const user = getRequestUser(req, req.method || 'GET', url.pathname);
 
     if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname === '/landing.html') {
       if (user && (url.pathname === '/' || url.pathname === '/index.html')) {
@@ -394,7 +395,13 @@ async function handleApi(req, res, url, user) {
     }
   }
   if (method === 'GET' && pathname === '/api/inbound-mailboxes') {
-    return sendJson(res, 200, { mailboxes: listInboundMailboxes(user.id) });
+    const includeAllUsers = url.searchParams.get('all') === 'true';
+    if (includeAllUsers && (user.role !== 'admin' || req.mailhubAuthType !== 'session')) {
+      return sendJson(res, 403, { error: '只有管理员可以查看全部邮箱。' });
+    }
+    return sendJson(res, 200, {
+      mailboxes: listInboundMailboxes(user.id, { includeAllUsers })
+    });
   }
   if (method === 'POST' && pathname === '/api/inbound-mailboxes') {
     const body = await readJson(req);
@@ -429,22 +436,33 @@ async function handleApi(req, res, url, user) {
     }
   }
   if (method === 'GET' && pathname === '/api/inbound-messages') {
+    if (!requireApiTokenScope(req, res, 'messages:read')) return;
     try {
-      return sendJson(res, 200, searchInboundMessages(user.id, inboundMessageListFilters(url.searchParams)));
+      return sendJson(res, 200, searchInboundMessages(
+        user.id,
+        inboundMessageListFilters(url.searchParams),
+        apiTokenMailboxAccess(req, user)
+      ));
     } catch (error) {
       return sendJson(res, 400, { error: error.message || '收件查询参数不正确。' });
     }
   }
   const inboundMailboxFoldersMatch = pathname.match(/^\/api\/inbound-mailboxes\/(\d+)\/folders$/);
   if (inboundMailboxFoldersMatch && method === 'GET') {
-    const folders = listInboundMailboxFolders(user.id, Number(inboundMailboxFoldersMatch[1]));
+    if (!requireApiTokenScope(req, res, 'messages:read')) return;
+    const folders = listInboundMailboxFolders(
+      user.id,
+      Number(inboundMailboxFoldersMatch[1]),
+      apiTokenMailboxAccess(req, user)
+    );
     return sendJson(res, folders ? 200 : 404, { folders: folders || [] });
   }
   const inboundMessageMatch = pathname.match(/^\/api\/inbound-messages\/(\d+)$/);
   if (inboundMessageMatch) {
     const id = Number(inboundMessageMatch[1]);
     if (method === 'GET') {
-      const message = getInboundMessage(user.id, id);
+      if (!requireApiTokenScope(req, res, 'messages:read')) return;
+      const message = getInboundMessage(user.id, id, apiTokenMailboxAccess(req, user));
       return sendJson(res, message ? 200 : 404, { message });
     }
     if (method === 'PATCH') {
@@ -566,7 +584,9 @@ async function handleApi(req, res, url, user) {
 
   if (method === 'GET' && pathname === '/api/mailboxes') {
     if (!requireApiTokenScope(req, res, 'mailboxes:read')) return;
-    return sendJson(res, 200, { mailboxes: listInboundMailboxes(user.id) });
+    return sendJson(res, 200, {
+      mailboxes: listInboundMailboxes(user.id, apiTokenMailboxAccess(req, user))
+    });
   }
   if (method === 'POST' && pathname === '/api/mailboxes') {
     if (!requireApiTokenScope(req, res, 'mailboxes:write')) return;
@@ -587,15 +607,19 @@ async function handleApi(req, res, url, user) {
   }
 
   if (method === 'GET' && pathname === '/api/api-tokens') {
-    return sendJson(res, 200, { tokens: listApiTokens(user.id) });
+    return sendPrivateJson(res, 200, {
+      tokens: listApiTokens(user.id, { includeToken: req.mailhubAuthType === 'session' })
+    });
   }
   if (method === 'POST' && pathname === '/api/api-tokens') {
     const body = await readJson(req);
     try {
-      return sendJson(res, 201, {
+      return sendPrivateJson(res, 201, {
         token: createApiToken(user.id, body.name, {
           scopes: body.scopes,
-          expiresAt: body.expiresAt
+          expiresAt: body.expiresAt,
+          mailboxAccess: body.mailboxAccess,
+          mailboxIds: body.mailboxIds
         })
       });
     } catch (error) {
@@ -606,18 +630,38 @@ async function handleApi(req, res, url, user) {
   if (tokenMatch && (method === 'PATCH' || method === 'PUT')) {
     const body = await readJson(req);
     try {
-      const token = updateApiToken(Number(tokenMatch[1]), user.id, body);
-      return sendJson(res, token ? 200 : 404, token ? { token } : { error: 'API Token 不存在。' });
+      const id = Number(tokenMatch[1]);
+      const updated = updateApiToken(id, user.id, body);
+      const token = updated && req.mailhubAuthType === 'session'
+        ? getApiToken(id, user.id, { includeToken: true })
+        : updated;
+      return sendPrivateJson(res, token ? 200 : 404, token ? { token } : { error: 'API Token 不存在。' });
     } catch (error) {
       return sendJson(res, 400, { error: error.message || 'API Token 更新失败。' });
     }
   }
+  const tokenRotateMatch = pathname.match(/^\/api\/api-tokens\/(\d+)\/rotate$/);
+  if (tokenRotateMatch && method === 'POST') {
+    try {
+      const token = rotateApiToken(Number(tokenRotateMatch[1]), user.id);
+      return sendPrivateJson(
+        res,
+        token ? 200 : 404,
+        token ? { token } : { error: 'API Token 不存在。' }
+      );
+    } catch (error) {
+      return sendJson(res, 400, { error: error.message || 'API Token 重新生成失败。' });
+    }
+  }
   if (tokenMatch && method === 'DELETE') {
-    const revoked = revokeApiToken(Number(tokenMatch[1]), user.id);
-    return sendJson(res, revoked ? 200 : 404, {
+    const id = Number(tokenMatch[1]);
+    const revoked = revokeApiToken(id, user.id);
+    return sendPrivateJson(res, revoked ? 200 : 404, {
       revoked,
       deleted: revoked,
-      token: revoked ? getApiToken(Number(tokenMatch[1]), user.id) : null
+      token: revoked
+        ? getApiToken(id, user.id, { includeToken: req.mailhubAuthType === 'session' })
+        : null
     });
   }
 
@@ -1103,7 +1147,12 @@ async function handleAdminApi(req, res, url, user) {
         password: body.password
       });
     } catch (error) {
-      if (['用户状态不正确。', '密码至少需要 8 位。'].includes(error?.message)) {
+      if ([
+        '用户角色不正确。',
+        '用户状态不正确。',
+        '密码至少需要 8 位。',
+        '至少保留一个启用的管理员账号。'
+      ].includes(error?.message)) {
         return sendJson(res, 400, { error: error.message });
       }
       throw error;
@@ -1683,24 +1732,32 @@ function handleLogout(res) {
   res.end(JSON.stringify({ ok: true }));
 }
 
-function getRequestUser(req, pathname) {
+function getRequestUser(req, method, pathname) {
   const sessionUser = getSessionUser(req);
-  if (sessionUser) return sessionUser;
+  if (sessionUser) {
+    req.mailhubAuthType = 'session';
+    return sessionUser;
+  }
   const auth = req.headers.authorization || '';
   if (auth.startsWith('Basic ')) {
     const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
     const index = decoded.indexOf(':');
     const user = authenticateUser(decoded.slice(0, index), decoded.slice(index + 1));
-    if (user) return user;
+    if (user) {
+      req.mailhubAuthType = 'basic';
+      return user;
+    }
   }
-  if (isTokenApiPath(pathname) && auth.startsWith('Bearer ')) {
+  if (isTokenApiPath(method, pathname) && auth.startsWith('Bearer ')) {
     const token = auth.slice(7);
     const authenticated = authenticateApiToken(token);
     if (authenticated) {
+      req.mailhubAuthType = 'bearer';
       req.mailhubApiToken = authenticated.token;
       return authenticated.user;
     }
     if (pathname === '/api/send' && envConfig.legacyApiToken && safeEqual(token, envConfig.legacyApiToken)) {
+      req.mailhubAuthType = 'bearer';
       req.mailhubApiToken = { scopes: ['send'], tokenPrefix: 'legacy' };
       return getAdminUser();
     }
@@ -1708,8 +1765,14 @@ function getRequestUser(req, pathname) {
   return null;
 }
 
-function isTokenApiPath(pathname) {
-  return pathname === '/api/send' || pathname === '/api/mailboxes';
+function isTokenApiPath(method, pathname) {
+  const normalizedMethod = String(method || 'GET').toUpperCase();
+  if (pathname === '/api/send') return normalizedMethod === 'POST';
+  if (pathname === '/api/mailboxes') return normalizedMethod === 'GET' || normalizedMethod === 'POST';
+  if (normalizedMethod !== 'GET') return false;
+  return pathname === '/api/inbound-messages'
+    || /^\/api\/inbound-messages\/\d+$/.test(pathname)
+    || /^\/api\/inbound-mailboxes\/\d+\/folders$/.test(pathname);
 }
 
 function requireApiTokenScope(req, res, scope) {
@@ -1719,6 +1782,22 @@ function requireApiTokenScope(req, res, scope) {
   return false;
 }
 
+function apiTokenMailboxAccess(req, user) {
+  const token = req.mailhubApiToken;
+  if (!token) return {};
+  if (token.mailboxAccess === 'owner') return {};
+  if (token.mailboxAccess === 'selected') {
+    return {
+      includeAllUsers: user.role === 'admin',
+      mailboxIds: Array.isArray(token.mailboxIds) ? token.mailboxIds : []
+    };
+  }
+  if (token.mailboxAccess === 'all' && user.role === 'admin') {
+    return { includeAllUsers: true };
+  }
+  return { mailboxIds: [] };
+}
+
 function getSessionUser(req) {
   const token = parseCookies(req.headers.cookie || '').mailhub_session;
   if (!token || !token.includes('.')) return null;
@@ -1989,6 +2068,12 @@ function sendJson(res, status, payload) {
   res.end(JSON.stringify(payload));
 }
 
+function sendPrivateJson(res, status, payload) {
+  res.setHeader('Cache-Control', 'private, no-store, no-cache, max-age=0');
+  res.setHeader('Pragma', 'no-cache');
+  return sendJson(res, status, payload);
+}
+
 function redirect(res, location, status = 302, headers = {}) {
   res.writeHead(status, { ...headers, Location: location });
   res.end();

+ 216 - 0
test/api-token-db.test.js

@@ -0,0 +1,216 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  authenticateApiToken,
+  createApiToken,
+  createDomain,
+  createInboundMailbox,
+  createInboundMessage,
+  createUser,
+  getAdminResourceInventory,
+  getApiToken,
+  getInboundMessage,
+  initDatabase,
+  listApiTokens,
+  listInboundMailboxFolders,
+  listInboundMailboxes,
+  revokeApiToken,
+  rotateApiToken,
+  searchInboundMessages,
+  updateUser
+} from '../src/db.js';
+
+test('API tokens are recoverable only for explicit owner views and rotate without weakening authentication', () => {
+  const database = initDatabase(tempDataDir(), 'api-token-recovery-secret');
+  const user = createUser({
+    username: 'token-owner',
+    email: 'token-owner@example.com',
+    password: 'password123'
+  });
+  const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
+  const created = createApiToken(user.id, 'Receive worker', {
+    scopes: ['send', 'messages:read'],
+    expiresAt,
+    mailboxAccess: 'owner'
+  });
+
+  assert.match(created.token, /^mh_/);
+  assert.equal(created.tokenRecoverable, true);
+  assert.equal(created.mailboxAccess, 'owner');
+  assert.deepEqual(created.mailboxIds, []);
+
+  const defaultListItem = listApiTokens(user.id)[0];
+  assert.equal('token' in defaultListItem, false);
+  assert.equal(defaultListItem.tokenRecoverable, true);
+  assert.equal(listApiTokens(user.id, { includeToken: true })[0].token, created.token);
+  assert.equal('token' in authenticateApiToken(created.token).token, false);
+  assert.equal('token' in getAdminResourceInventory().users[0].apiTokens[0], false);
+
+  authenticateApiToken(created.token);
+  assert.ok(getApiToken(created.id, user.id).lastUsedAt);
+
+  database.prepare('UPDATE api_tokens SET token_secret = ? WHERE id = ?').run('', created.id);
+  const historical = getApiToken(created.id, user.id, { includeToken: true });
+  assert.equal(historical.tokenRecoverable, false);
+  assert.equal('token' in historical, false);
+  assert.equal(authenticateApiToken(created.token).user.id, user.id);
+
+  const rotated = rotateApiToken(created.id, user.id);
+  assert.match(rotated.token, /^mh_/);
+  assert.notEqual(rotated.token, created.token);
+  assert.deepEqual(rotated.scopes, ['send', 'messages:read']);
+  assert.equal(rotated.mailboxAccess, 'owner');
+  assert.equal(rotated.expiresAt, expiresAt);
+  assert.equal(rotated.lastUsedAt, null);
+  assert.equal(authenticateApiToken(created.token), null);
+  assert.equal(authenticateApiToken(rotated.token).user.id, user.id);
+
+  assert.equal(revokeApiToken(created.id, user.id), true);
+  assert.throws(() => rotateApiToken(created.id, user.id), /已撤销/);
+});
+
+test('API token mailbox scopes validate ownership and keep message queries fail closed', () => {
+  const database = initDatabase(tempDataDir(), 'api-token-mailbox-secret');
+  const admin = createUser({
+    username: 'scope-admin',
+    email: 'scope-admin@example.com',
+    password: 'password123',
+    role: 'admin'
+  });
+  const alice = createUser({
+    username: 'scope-alice',
+    email: 'scope-alice@example.com',
+    password: 'password123'
+  });
+  const bob = createUser({
+    username: 'scope-bob',
+    email: 'scope-bob@example.com',
+    password: 'password123'
+  });
+  createDomain(alice.id, domainFixture('scope-alice.example'));
+  createDomain(bob.id, domainFixture('scope-bob.example'));
+  const aliceMailbox = createInboundMailbox(alice.id, { address: 'inbox@scope-alice.example' });
+  const bobMailbox = createInboundMailbox(bob.id, { address: 'inbox@scope-bob.example' });
+  const aliceMessage = createInboundMessage(aliceMailbox, messageFixture('Alice message'));
+  const bobMessage = createInboundMessage(bobMailbox, messageFixture('Bob message'));
+
+  assert.throws(
+    () => createApiToken(alice.id, 'Empty selection', {
+      scopes: ['messages:read'],
+      mailboxAccess: 'selected',
+      mailboxIds: []
+    }),
+    /至少一个/
+  );
+  assert.throws(
+    () => createApiToken(alice.id, 'Cross-user selection', {
+      scopes: ['messages:read'],
+      mailboxAccess: 'selected',
+      mailboxIds: [bobMailbox.id]
+    }),
+    /不存在或无权访问/
+  );
+  assert.throws(
+    () => createApiToken(alice.id, 'All mailboxes', {
+      scopes: ['messages:read'],
+      mailboxAccess: 'all'
+    }),
+    /只有管理员/
+  );
+
+  const aliceSelected = createApiToken(alice.id, 'Alice selected', {
+    scopes: ['messages:read'],
+    mailboxAccess: 'selected',
+    mailboxIds: [aliceMailbox.id, aliceMailbox.id]
+  });
+  assert.deepEqual(aliceSelected.mailboxIds, [aliceMailbox.id]);
+  assert.equal(searchInboundMessages(alice.id, {}, { mailboxIds: aliceSelected.mailboxIds }).total, 1);
+  assert.equal(
+    searchInboundMessages(
+      alice.id,
+      { mailboxId: bobMailbox.id },
+      { mailboxIds: aliceSelected.mailboxIds }
+    ).total,
+    0
+  );
+  assert.equal(getInboundMessage(alice.id, bobMessage.id, { mailboxIds: aliceSelected.mailboxIds }), null);
+  assert.equal(listInboundMailboxFolders(alice.id, bobMailbox.id, { mailboxIds: aliceSelected.mailboxIds }), null);
+
+  const adminSelected = createApiToken(admin.id, 'Admin selected', {
+    scopes: ['messages:read'],
+    mailboxAccess: 'selected',
+    mailboxIds: [bobMailbox.id]
+  });
+  const selectedAccess = { includeAllUsers: true, mailboxIds: adminSelected.mailboxIds };
+  const selectedMessages = searchInboundMessages(admin.id, {}, selectedAccess);
+  assert.equal(selectedMessages.total, 1);
+  assert.equal(selectedMessages.messages[0].id, bobMessage.id);
+  assert.equal(getInboundMessage(admin.id, aliceMessage.id, selectedAccess), null);
+  assert.equal(getInboundMessage(admin.id, bobMessage.id, selectedAccess).subject, 'Bob message');
+  assert.equal(listInboundMailboxFolders(admin.id, aliceMailbox.id, selectedAccess), null);
+  assert.equal(
+    listInboundMailboxFolders(admin.id, bobMailbox.id, selectedAccess)
+      .find((folder) => folder.name === 'INBOX').messageCount,
+    1
+  );
+
+  const adminAll = createApiToken(admin.id, 'Admin all', {
+    scopes: ['messages:read'],
+    mailboxAccess: 'all'
+  });
+  assert.equal(adminAll.mailboxAccess, 'all');
+  assert.equal(searchInboundMessages(admin.id, {}, { includeAllUsers: true }).total, 2);
+  assert.equal(listInboundMailboxes(admin.id, { includeAllUsers: true }).length, 2);
+
+  assert.throws(() => updateUser(admin.id, { role: 'user' }), /至少保留一个/);
+  createUser({
+    username: 'scope-backup-admin',
+    email: 'scope-backup-admin@example.com',
+    password: 'password123',
+    role: 'admin'
+  });
+  assert.equal(updateUser(admin.id, { role: 'user' }).role, 'user');
+  assert.equal(authenticateApiToken(adminAll.token).user.role, 'user');
+
+  database
+    .prepare('UPDATE api_tokens SET mailbox_access_json = ? WHERE id = ?')
+    .run('{"mode":"selected","mailboxIds":"broken"}', aliceSelected.id);
+  const corrupted = getApiToken(aliceSelected.id, alice.id);
+  assert.equal(corrupted.mailboxAccess, 'selected');
+  assert.deepEqual(corrupted.mailboxIds, []);
+  assert.equal(searchInboundMessages(alice.id, {}, { mailboxIds: corrupted.mailboxIds }).total, 0);
+});
+
+function tempDataDir() {
+  return mkdtempSync(path.join(tmpdir(), 'mailhub-api-token-db-'));
+}
+
+function domainFixture(domain) {
+  return {
+    domain,
+    selector: 'mh',
+    verificationToken: `verify-${domain}`,
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: `mail.${domain}`,
+    sendingIp: '192.0.2.44',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  };
+}
+
+function messageFixture(subject) {
+  return {
+    sender: 'sender@example.net',
+    recipients: ['inbox@example.net'],
+    subject,
+    messageId: `<${subject.toLowerCase().replaceAll(' ', '-')}@example.net>`,
+    rawMessage: `Subject: ${subject}\r\n\r\n${subject}`,
+    textBody: subject
+  };
+}

+ 22 - 0
test/frontend-api-token-model.test.js

@@ -3,6 +3,7 @@ import { test } from 'node:test';
 
 import {
   buildApiUsageExamples,
+  buildInboundMessageApiUsageExamples,
   buildMailboxApiUsageExamples,
   canCopyFullApiToken,
   formatApiTokenPrefix,
@@ -12,11 +13,14 @@ import {
 
 test('only exposes full API token when the create response includes the secret', () => {
   const created = { tokenPrefix: 'mh_123456789', token: 'mh_123456789.full-secret' };
+  const explicitlyUnrecoverable = { ...created, tokenRecoverable: false };
   const listed = { tokenPrefix: 'mh_987654321' };
 
+  // Keep compatibility with older create-response fixtures that predate tokenRecoverable.
   assert.equal(canCopyFullApiToken(created), true);
   assert.equal(getCreatedApiTokenSecret(created), 'mh_123456789.full-secret');
   assert.equal(getCopyableApiToken(created), 'mh_123456789.full-secret');
+  assert.equal(canCopyFullApiToken(explicitlyUnrecoverable), false);
 
   assert.equal(canCopyFullApiToken(listed), false);
   assert.equal(getCreatedApiTokenSecret(listed), '');
@@ -56,3 +60,21 @@ test('builds persistent and temporary mailbox API examples', () => {
   assert.match(examples.temporary, /"expiresInMinutes": 60/);
   assert.match(examples.successResponse, /"temporary": true/);
 });
+
+test('builds inbound message list detail and folder API examples', () => {
+  const examples = buildInboundMessageApiUsageExamples({
+    endpoint: 'https://mailhub.example.com/api/inbound-messages',
+    mailboxEndpoint: 'https://mailhub.example.com/api/inbound-mailboxes',
+    token: 'mh_messages_token',
+    mailboxId: 42,
+    messageId: 108
+  });
+
+  assert.match(examples.listCurl, /page=1&pageSize=20&mailboxId=42&folder=INBOX&read=false&q=invoice/);
+  assert.match(examples.detailCurl, /api\/inbound-messages\/108/);
+  assert.match(examples.foldersCurl, /api\/inbound-mailboxes\/42\/folders/);
+  assert.match(examples.listCurl, /Authorization: Bearer mh_messages_token/);
+  assert.match(examples.listResponse, /"total": 1/);
+  assert.match(examples.detailResponse, /"textBody": "Invoice details"/);
+  assert.equal(JSON.parse(examples.foldersResponse).folders[1].specialUse, '\\Sent');
+});

+ 532 - 0
test/server-api-token-inbound-auth.test.js

@@ -0,0 +1,532 @@
+import assert from 'node:assert/strict';
+import { spawn, spawnSync } from 'node:child_process';
+import { mkdtempSync } from 'node:fs';
+import net from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import process from 'node:process';
+import { test } from 'node:test';
+
+test('API tokens enforce inbound mailbox scope and keep token management session-only', async (t) => {
+  const fixture = await startTestServer();
+
+  try {
+    const seeded = seedInboundAccessFixtures(fixture.dataDir, fixture.sessionSecret);
+    const adminCookie = await login(fixture.baseUrl, 'admin', 'password123');
+    const securityAdminCookie = await login(fixture.baseUrl, 'token-security-admin', 'password123');
+    const aliceCookie = await login(fixture.baseUrl, 'token-alice', 'password123');
+    const bobCookie = await login(fixture.baseUrl, 'token-bob', 'password123');
+
+    const sendOnly = await createToken(fixture.baseUrl, aliceCookie, {
+      name: 'alice send only',
+      scopes: ['send']
+    });
+    const aliceOwner = await createToken(fixture.baseUrl, aliceCookie, {
+      name: 'alice owner messages',
+      scopes: ['messages:read'],
+      mailboxAccess: 'owner'
+    });
+    const aliceSelected = await createToken(fixture.baseUrl, aliceCookie, {
+      name: 'alice selected messages',
+      scopes: ['messages:read', 'mailboxes:read'],
+      mailboxAccess: 'selected',
+      mailboxIds: [seeded.alicePrimaryMailboxId]
+    });
+    const adminSelected = await createToken(fixture.baseUrl, adminCookie, {
+      name: 'admin selected bob',
+      scopes: ['messages:read', 'mailboxes:read'],
+      mailboxAccess: 'selected',
+      mailboxIds: [seeded.bobMailboxId]
+    });
+    const adminAll = await createToken(fixture.baseUrl, adminCookie, {
+      name: 'admin all messages',
+      scopes: ['messages:read', 'mailboxes:read'],
+      mailboxAccess: 'all'
+    });
+
+    await t.test('Bearer message reads are scope-limited, isolated, and read-only', async () => {
+      const deniedByScope = await requestJson(fixture.baseUrl, '/api/inbound-messages', {
+        bearer: sendOnly.token
+      });
+      assert.equal(deniedByScope.status, 403);
+      assert.match(deniedByScope.body.error, /messages:read/);
+
+      const ownerList = await requestJson(fixture.baseUrl, '/api/inbound-messages?page=1&pageSize=100', {
+        bearer: aliceOwner.token
+      });
+      assert.equal(ownerList.status, 200);
+      assert.equal(ownerList.body.total, 2);
+      assert.deepEqual(
+        new Set(ownerList.body.messages.map((message) => message.mailboxId)),
+        new Set([seeded.alicePrimaryMailboxId, seeded.aliceSecondaryMailboxId])
+      );
+      assert.equal(ownerList.body.messages.some((message) => message.mailboxId === seeded.bobMailboxId), false);
+
+      const ownerCrossUserFilter = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages?mailboxId=${seeded.bobMailboxId}`,
+        { bearer: aliceOwner.token }
+      );
+      assert.equal(ownerCrossUserFilter.status, 200);
+      assert.equal(ownerCrossUserFilter.body.total, 0);
+      assert.deepEqual(ownerCrossUserFilter.body.messages, []);
+
+      const selectedList = await requestJson(fixture.baseUrl, '/api/inbound-messages?page=1&pageSize=100', {
+        bearer: aliceSelected.token
+      });
+      assert.equal(selectedList.status, 200);
+      assert.equal(selectedList.body.total, 1);
+      assert.deepEqual(selectedList.body.messages.map((message) => message.id), [seeded.alicePrimaryMessageId]);
+
+      const selectedMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', {
+        bearer: aliceSelected.token
+      });
+      assert.equal(selectedMailboxes.status, 200);
+      assert.deepEqual(selectedMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.alicePrimaryMailboxId]);
+
+      const selectedUnlistedFilter = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages?mailboxId=${seeded.aliceSecondaryMailboxId}`,
+        { bearer: aliceSelected.token }
+      );
+      assert.equal(selectedUnlistedFilter.status, 200);
+      assert.equal(selectedUnlistedFilter.body.total, 0);
+
+      const selectedAuthorizedDetail = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages/${seeded.alicePrimaryMessageId}`,
+        { bearer: aliceSelected.token }
+      );
+      assert.equal(selectedAuthorizedDetail.status, 200);
+      assert.equal(selectedAuthorizedDetail.body.message.subject, 'Alice primary message');
+
+      for (const [pathname, bearer] of [
+        [`/api/inbound-messages/${seeded.bobMessageId}`, aliceOwner.token],
+        [`/api/inbound-messages/${seeded.aliceSecondaryMessageId}`, aliceSelected.token],
+        [`/api/inbound-mailboxes/${seeded.bobMailboxId}/folders`, aliceOwner.token],
+        [`/api/inbound-mailboxes/${seeded.aliceSecondaryMailboxId}/folders`, aliceSelected.token]
+      ]) {
+        const hidden = await requestJson(fixture.baseUrl, pathname, { bearer });
+        assert.equal(hidden.status, 404, pathname);
+      }
+
+      const adminSelectedList = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
+        bearer: adminSelected.token
+      });
+      assert.equal(adminSelectedList.status, 200);
+      assert.equal(adminSelectedList.body.total, 1);
+      assert.deepEqual(adminSelectedList.body.messages.map((message) => message.id), [seeded.bobMessageId]);
+
+      const adminSelectedMailboxes = await requestJson(fixture.baseUrl, '/api/mailboxes', {
+        bearer: adminSelected.token
+      });
+      assert.equal(adminSelectedMailboxes.status, 200);
+      assert.deepEqual(adminSelectedMailboxes.body.mailboxes.map((mailbox) => mailbox.id), [seeded.bobMailboxId]);
+
+      const adminSelectedDetail = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages/${seeded.bobMessageId}`,
+        { bearer: adminSelected.token }
+      );
+      assert.equal(adminSelectedDetail.status, 200);
+      assert.equal(adminSelectedDetail.body.message.subject, 'Bob private message');
+
+      const adminAllList = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
+        bearer: adminAll.token
+      });
+      assert.equal(adminAllList.status, 200);
+      assert.equal(adminAllList.body.total, 4);
+      assert.equal(adminAllList.body.messages.length, 4);
+
+      const adminAllMailboxList = await requestJson(fixture.baseUrl, '/api/mailboxes', {
+        bearer: adminAll.token
+      });
+      assert.equal(adminAllMailboxList.status, 200);
+      assert.equal(adminAllMailboxList.body.mailboxes.length, 4);
+
+      const deniedMutation = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages/${seeded.alicePrimaryMessageId}`,
+        {
+          method: 'PATCH',
+          bearer: aliceSelected.token,
+          body: { read: true }
+        }
+      );
+      assert.equal(deniedMutation.status, 401);
+
+      const unchanged = await requestJson(
+        fixture.baseUrl,
+        `/api/inbound-messages/${seeded.alicePrimaryMessageId}`,
+        { cookie: aliceCookie }
+      );
+      assert.equal(unchanged.status, 200);
+      assert.equal(unchanged.body.message.read, false);
+    });
+
+    await t.test('session token management reveals recoverable values and enforces admin-only all-mailbox access', async () => {
+      const aliceTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: aliceCookie });
+      assert.equal(aliceTokens.status, 200);
+      assert.match(aliceTokens.response.headers.get('cache-control') || '', /no-store/i);
+
+      for (const created of [sendOnly, aliceOwner, aliceSelected]) {
+        const listed = aliceTokens.body.tokens.find((token) => token.id === created.id);
+        assert.ok(listed, created.name);
+        assert.equal(listed.tokenRecoverable, true);
+        assert.equal(listed.token, created.token);
+      }
+
+      const adminTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: adminCookie });
+      assert.equal(adminTokens.status, 200);
+      assert.match(adminTokens.response.headers.get('cache-control') || '', /no-store/i);
+      const historical = adminTokens.body.tokens.find((token) => token.id === seeded.historicalTokenId);
+      assert.ok(historical);
+      assert.equal(historical.name, 'historical unrecoverable');
+      assert.equal(historical.tokenRecoverable, false);
+      assert.equal(Object.hasOwn(historical, 'token'), false);
+
+      const basicAdminTokens = await requestJson(fixture.baseUrl, '/api/api-tokens', {
+        basic: ['admin', 'password123']
+      });
+      assert.equal(basicAdminTokens.status, 200);
+      assert.equal(basicAdminTokens.body.tokens.every((token) => !Object.hasOwn(token, 'token')), true);
+
+      const ordinaryAll = await requestJson(fixture.baseUrl, '/api/api-tokens', {
+        method: 'POST',
+        cookie: aliceCookie,
+        body: {
+          name: 'ordinary all denied',
+          scopes: ['messages:read'],
+          mailboxAccess: 'all'
+        }
+      });
+      assert.equal(ordinaryAll.status, 400);
+      assert.match(ordinaryAll.body.error, /管理员/);
+
+      const ordinaryCrossUserSelected = await requestJson(fixture.baseUrl, '/api/api-tokens', {
+        method: 'POST',
+        cookie: aliceCookie,
+        body: {
+          name: 'ordinary cross-user selected denied',
+          scopes: ['messages:read'],
+          mailboxAccess: 'selected',
+          mailboxIds: [seeded.bobMailboxId]
+        }
+      });
+      assert.equal(ordinaryCrossUserSelected.status, 400);
+      assert.match(ordinaryCrossUserSelected.body.error, /不存在|无权访问/);
+
+      const adminAllMailboxes = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?all=true', {
+        cookie: adminCookie
+      });
+      assert.equal(adminAllMailboxes.status, 200);
+      assert.equal(adminAllMailboxes.body.mailboxes.length, 4);
+      assert.deepEqual(
+        new Set(adminAllMailboxes.body.mailboxes.map((mailbox) => mailbox.id)),
+        new Set([
+          seeded.adminMailboxId,
+          seeded.alicePrimaryMailboxId,
+          seeded.aliceSecondaryMailboxId,
+          seeded.bobMailboxId
+        ])
+      );
+
+      const ordinaryAllMailboxes = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?all=true', {
+        cookie: bobCookie
+      });
+      assert.equal(ordinaryAllMailboxes.status, 403);
+
+      const basicAdminAllMailboxes = await requestJson(fixture.baseUrl, '/api/inbound-mailboxes?all=true', {
+        basic: ['admin', 'password123']
+      });
+      assert.equal(basicAdminAllMailboxes.status, 403);
+
+      const rotated = await requestJson(fixture.baseUrl, `/api/api-tokens/${aliceOwner.id}/rotate`, {
+        method: 'POST',
+        cookie: aliceCookie
+      });
+      assert.equal(rotated.status, 200);
+      assert.match(rotated.response.headers.get('cache-control') || '', /no-store/i);
+      assert.equal(rotated.body.token.id, aliceOwner.id);
+      assert.equal(rotated.body.token.tokenRecoverable, true);
+      assert.ok(rotated.body.token.token);
+      assert.notEqual(rotated.body.token.token, aliceOwner.token);
+
+      const oldTokenRejected = await requestJson(fixture.baseUrl, '/api/inbound-messages', {
+        bearer: aliceOwner.token
+      });
+      assert.equal(oldTokenRejected.status, 401);
+
+      const newTokenAccepted = await requestJson(fixture.baseUrl, '/api/inbound-messages?pageSize=100', {
+        bearer: rotated.body.token.token
+      });
+      assert.equal(newTokenAccepted.status, 200);
+      assert.equal(newTokenAccepted.body.total, 2);
+
+      const listAfterRotate = await requestJson(fixture.baseUrl, '/api/api-tokens', { cookie: aliceCookie });
+      const rotatedSummary = listAfterRotate.body.tokens.find((token) => token.id === aliceOwner.id);
+      assert.equal(rotatedSummary.token, rotated.body.token.token);
+      assert.notEqual(rotatedSummary.token, aliceOwner.token);
+
+      const demoted = await requestJson(fixture.baseUrl, `/api/admin/users/${seeded.adminUserId}`, {
+        method: 'PATCH',
+        cookie: securityAdminCookie,
+        body: { role: 'user' }
+      });
+      assert.equal(demoted.status, 200);
+      assert.equal(demoted.body.user.role, 'user');
+
+      const allMessagesAfterDemotion = await requestJson(fixture.baseUrl, '/api/inbound-messages', {
+        bearer: adminAll.token
+      });
+      assert.equal(allMessagesAfterDemotion.status, 200);
+      assert.equal(allMessagesAfterDemotion.body.total, 0);
+
+      const allMailboxesAfterDemotion = await requestJson(fixture.baseUrl, '/api/mailboxes', {
+        bearer: adminAll.token
+      });
+      assert.equal(allMailboxesAfterDemotion.status, 200);
+      assert.deepEqual(allMailboxesAfterDemotion.body.mailboxes, []);
+    });
+  } finally {
+    fixture.child.kill('SIGTERM');
+    await waitForExit(fixture.child, 1000);
+  }
+});
+
+async function createToken(baseUrl, cookie, input) {
+  const result = await requestJson(baseUrl, '/api/api-tokens', {
+    method: 'POST',
+    cookie,
+    body: input
+  });
+  assert.equal(result.status, 201, JSON.stringify(result.body));
+  assert.match(result.response.headers.get('cache-control') || '', /no-store/i);
+  assert.ok(result.body.token.token);
+  return result.body.token;
+}
+
+function seedInboundAccessFixtures(dataDir, sessionSecret) {
+  const script = `
+    import { DatabaseSync } from 'node:sqlite';
+    import path from 'node:path';
+    import {
+      createApiToken,
+      createDomain,
+      createInboundMailbox,
+      createInboundMessage,
+      createUser,
+      getUserByLogin,
+      initDatabase
+    } from './src/db.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    const admin = getUserByLogin('admin');
+    const alice = createUser({
+      username: 'token-alice',
+      email: 'token-alice@example.test',
+      password: 'password123',
+      status: 'active'
+    });
+    const bob = createUser({
+      username: 'token-bob',
+      email: 'token-bob@example.test',
+      password: 'password123',
+      status: 'active'
+    });
+    createUser({
+      username: 'token-security-admin',
+      email: 'token-security-admin@example.test',
+      password: 'password123',
+      role: 'admin',
+      status: 'active'
+    });
+
+    const createUserDomain = (user, name) => createDomain(user.id, {
+      domain: name,
+      selector: 'mh',
+      verificationToken: 'verify-' + name,
+      dkimPublic: 'public-' + name,
+      dkimPrivate: 'private-' + name,
+      senderHost: 'mail.' + name,
+      sendingIp: '127.0.0.1',
+      spfExtra: '',
+      dmarcPolicy: 'none',
+      dmarcRua: ''
+    });
+    createUserDomain(admin, 'token-admin.example');
+    createUserDomain(alice, 'token-alice.example');
+    createUserDomain(bob, 'token-bob.example');
+
+    const adminMailbox = createInboundMailbox(admin.id, {
+      address: 'inbox@token-admin.example',
+      password: 'mailbox-password'
+    });
+    const alicePrimaryMailbox = createInboundMailbox(alice.id, {
+      address: 'primary@token-alice.example',
+      password: 'mailbox-password'
+    });
+    const aliceSecondaryMailbox = createInboundMailbox(alice.id, {
+      address: 'secondary@token-alice.example',
+      password: 'mailbox-password'
+    });
+    const bobMailbox = createInboundMailbox(bob.id, {
+      address: 'inbox@token-bob.example',
+      password: 'mailbox-password'
+    });
+
+    const createMessage = (mailbox, subject, sequence) => createInboundMessage(mailbox, {
+      sender: 'sender@example.net',
+      recipients: [mailbox.address],
+      subject,
+      messageId: '<token-inbound-' + sequence + '@example.net>',
+      rawMessage: 'Subject: ' + subject + '\\r\\n\\r\\n' + subject,
+      textBody: subject,
+      receivedAt: '2026-07-14T0' + sequence + ':00:00.000Z'
+    });
+    const adminMessage = createMessage(adminMailbox, 'Admin private message', 1);
+    const alicePrimaryMessage = createMessage(alicePrimaryMailbox, 'Alice primary message', 2);
+    const aliceSecondaryMessage = createMessage(aliceSecondaryMailbox, 'Alice secondary message', 3);
+    const bobMessage = createMessage(bobMailbox, 'Bob private message', 4);
+
+    const historical = createApiToken(admin.id, 'historical unrecoverable', {
+      scopes: ['messages:read'],
+      mailboxAccess: 'owner'
+    });
+    const database = new DatabaseSync(path.join(process.env.DATA_DIR, 'mailhub.sqlite'));
+    database.prepare("UPDATE api_tokens SET token_secret = '' WHERE id = ?").run(historical.id);
+    database.close();
+
+    console.log(JSON.stringify({
+      adminUserId: admin.id,
+      adminMailboxId: adminMailbox.id,
+      alicePrimaryMailboxId: alicePrimaryMailbox.id,
+      aliceSecondaryMailboxId: aliceSecondaryMailbox.id,
+      bobMailboxId: bobMailbox.id,
+      adminMessageId: adminMessage.id,
+      alicePrimaryMessageId: alicePrimaryMessage.id,
+      aliceSecondaryMessageId: aliceSecondaryMessage.id,
+      bobMessageId: bobMessage.id,
+      historicalTokenId: historical.id
+    }));
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  return JSON.parse(result.stdout);
+}
+
+async function requestJson(baseUrl, pathname, {
+  method = 'GET',
+  cookie = '',
+  bearer = '',
+  basic = null,
+  body
+} = {}) {
+  const headers = {};
+  if (cookie) headers.Cookie = cookie;
+  if (bearer) headers.Authorization = `Bearer ${bearer}`;
+  if (basic) headers.Authorization = `Basic ${Buffer.from(basic.join(':')).toString('base64')}`;
+  if (body !== undefined) headers['Content-Type'] = 'application/json';
+  const response = await fetch(`${baseUrl}${pathname}`, {
+    method,
+    headers,
+    body: body === undefined ? undefined : JSON.stringify(body),
+    redirect: 'manual'
+  });
+  const text = await response.text();
+  return {
+    response,
+    status: response.status,
+    body: text ? JSON.parse(text) : null
+  };
+}
+
+async function login(baseUrl, username, password) {
+  const response = await fetch(`${baseUrl}/api/login`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ username, password })
+  });
+  assert.equal(response.status, 200);
+  const cookie = response.headers.get('set-cookie')?.split(';')[0] || '';
+  assert.ok(cookie);
+  return cookie;
+}
+
+async function startTestServer() {
+  const port = await freePort();
+  const dataDir = mkdtempSync(path.join(tmpdir(), 'mailhub-token-inbound-auth-'));
+  const sessionSecret = 'token-inbound-auth-session-secret';
+  const child = spawn(process.execPath, ['src/server.js'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      PORT: String(port),
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret,
+      ADMIN_USER: 'admin',
+      ADMIN_EMAIL: 'admin@example.test',
+      ADMIN_PASSWORD: 'password123',
+      DNS_AUTO_CHECK_ENABLED: 'false',
+      DELIVERY_TRACKING_ENABLED: 'false',
+      WEBHOOK_WORKER_ENABLED: 'false',
+      SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false'
+    },
+    stdio: ['ignore', 'pipe', 'pipe']
+  });
+  await waitForOutput(child, 'MailHub listening');
+  return { child, baseUrl: `http://127.0.0.1:${port}`, dataDir, sessionSecret };
+}
+
+function freePort() {
+  return new Promise((resolve, reject) => {
+    const server = net.createServer();
+    server.listen(0, '127.0.0.1', () => {
+      const address = server.address();
+      server.close(() => {
+        if (address && typeof address === 'object') resolve(address.port);
+        else reject(new Error('Unable to allocate a test port.'));
+      });
+    });
+  });
+}
+
+function waitForOutput(child, text) {
+  return new Promise((resolve, reject) => {
+    const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${text}`)), 5000);
+    let output = '';
+    const onData = (chunk) => {
+      output += chunk.toString();
+      if (!output.includes(text)) return;
+      clearTimeout(timeout);
+      child.stdout.off('data', onData);
+      resolve();
+    };
+    child.stdout.on('data', onData);
+    child.once('exit', (code) => {
+      clearTimeout(timeout);
+      reject(new Error(`Server exited before startup with code ${code}`));
+    });
+  });
+}
+
+function waitForExit(child, timeoutMs) {
+  if (child.exitCode !== null) return Promise.resolve(child.exitCode);
+  return new Promise((resolve) => {
+    const timeout = setTimeout(() => resolve(null), timeoutMs);
+    child.once('exit', (code) => {
+      clearTimeout(timeout);
+      resolve(code);
+    });
+  });
+}

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

@@ -44,8 +44,31 @@ describe('AdminLayout navigation', () => {
     await user.click(close);
     await waitFor(() => expect(screen.queryByRole('button', { name: '关闭主导航' })).toBeNull());
   });
+
+  it('keeps mobile navigation available between Ant Design lg and the 1024px product breakpoint', async () => {
+    vi.spyOn(window, 'matchMedia').mockImplementation((query) => mediaQueryList(query === '(min-width: 992px)', query));
+    const user = userEvent.setup();
+    renderShell('/overview', { role: 'user' });
+
+    await user.click(screen.getByRole('button', { name: '打开主导航' }));
+
+    expect(await screen.findByRole('button', { name: '关闭主导航' })).toBeTruthy();
+  });
 });
 
+function mediaQueryList(matches: boolean, media: string): MediaQueryList {
+  return {
+    matches,
+    media,
+    onchange: null,
+    addListener: () => undefined,
+    removeListener: () => undefined,
+    addEventListener: () => undefined,
+    removeEventListener: () => undefined,
+    dispatchEvent: () => false
+  };
+}
+
 function renderShell(path: string, { role }: { role: 'admin' | 'user' }) {
   const context: AppContextValue = {
     user: {

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

@@ -7,31 +7,23 @@ import { AppContext, type AppContextValue } from '../../src/frontend/app-context
 import { I18nProvider } from '../../src/frontend/i18n/react';
 import { api } from '../../src/frontend/services/api';
 import { mailhubTheme } from '../../src/frontend/theme';
-import type { ApiToken, RuntimeConfig } from '../../src/frontend/types';
+import type { ApiToken, InboundMailbox, RuntimeConfig, UserRole } from '../../src/frontend/types';
 import ApiTokens from '../../src/pages/ApiTokens';
 
-describe('API token one-time secret', () => {
-  it('removes the full token after the explicit acknowledgement', async () => {
+describe('API token secrets and message access', () => {
+  it('keeps a newly created full token copyable after acknowledgement', async () => {
     const user = userEvent.setup();
-    const summary: ApiToken = {
-      id: 9,
-      name: 'CI sender',
-      tokenPrefix: 'mh_12345678',
-      scopes: ['send'],
-      status: 'active',
-      createdAt: '2026-07-14T00:00:00.000Z'
-    };
     const fullToken = 'mh_12345678.full-secret-value';
+    const summary = tokenFixture({ token: fullToken, tokenRecoverable: true });
 
     vi.spyOn(api, 'apiTokens')
       .mockResolvedValueOnce({ tokens: [] })
       .mockResolvedValue({ tokens: [summary] });
-    const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({
-      token: { ...summary, token: fullToken }
-    });
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
+    const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({ token: summary });
 
     renderPage();
-    await screen.findByText('创建 Token 后可复制完整密钥;历史 Token 只显示前缀,示例中使用占位符。');
+    await screen.findByText(/新 Token 会加密保存并支持完整复制/);
     await user.click(screen.getAllByRole('button', { name: /创建 Token/ })[0]);
 
     const editor = await screen.findByRole('dialog');
@@ -41,22 +33,83 @@ describe('API token one-time secret', () => {
     await waitFor(() => expect(createToken).toHaveBeenCalledWith({
       name: 'CI sender',
       scopes: ['send'],
-      expiresAt: null
+      expiresAt: null,
+      mailboxAccess: 'owner',
+      mailboxIds: []
     }));
-    expect(await screen.findByText(fullToken)).not.toBeNull();
+    expect((await screen.findAllByText(fullToken)).length).toBeGreaterThan(0);
 
     const reveal = screen.getByRole('dialog', { name: 'API Token 已创建' });
     await user.click(within(reveal).getByRole('button', { name: /确.*认/ }));
 
     await waitFor(() => expect(screen.queryByRole('dialog', { name: 'API Token 已创建' })).toBeNull());
-    expect(await screen.findByText('mh_12345678...')).not.toBeNull();
-    expect(screen.queryByRole('dialog', { name: 'API Token 已创建' })).toBeNull();
+    await waitFor(() => expect(screen.getAllByText(fullToken)).toHaveLength(1));
+    const copyButtons = screen.getAllByRole('button', { name: '复制完整 Token CI sender' });
+    expect(copyButtons).toHaveLength(1);
+    await user.click(copyButtons[0]);
+    expect(screen.queryByRole('dialog', { name: 'CI sender' })).toBeNull();
+  });
+
+  it('progressively requires selected mailboxes for messages:read and loads all choices for admins', async () => {
+    const user = userEvent.setup();
+    const mailbox = mailboxFixture();
+    const created = tokenFixture({
+      token: 'mh_selected.full-secret',
+      tokenRecoverable: true,
+      scopes: ['send', 'messages:read'],
+      mailboxAccess: 'selected',
+      mailboxIds: [mailbox.id]
+    });
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [] });
+    const loadMailboxes = vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
+    const createToken = vi.spyOn(api, 'createApiToken').mockResolvedValue({ token: created });
+
+    renderPage('admin');
+    await waitFor(() => expect(loadMailboxes).toHaveBeenCalledWith(true));
+    await user.click(screen.getAllByRole('button', { name: /创建 Token/ })[0]);
+    const editor = await screen.findByRole('dialog');
+    expect(within(editor).queryByText('邮件读取范围')).toBeNull();
+
+    await user.type(within(editor).getByLabelText('名称'), 'Message reader');
+    await user.click(within(editor).getByRole('checkbox', { name: 'messages:read' }));
+    expect(await within(editor).findByText('邮件读取范围')).not.toBeNull();
+    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 waitFor(() => expect(createToken).toHaveBeenCalledWith({
+      name: 'Message reader',
+      scopes: ['send', 'messages:read'],
+      expiresAt: null,
+      mailboxAccess: 'selected',
+      mailboxIds: [mailbox.id]
+    }));
+  });
+
+  it('regenerates an unrecoverable legacy token only after destructive confirmation', async () => {
+    const user = userEvent.setup();
+    const legacy = tokenFixture({ tokenRecoverable: false, token: undefined, name: 'Legacy worker' });
+    const rotated = tokenFixture({ tokenRecoverable: true, token: 'mh_rotated.new-secret', name: 'Legacy worker' });
+    vi.spyOn(api, 'apiTokens').mockResolvedValue({ tokens: [legacy] });
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [] });
+    const rotate = vi.spyOn(api, 'rotateApiToken').mockResolvedValue({ token: rotated });
+
+    renderPage();
+    await user.click(await screen.findByRole('button', { name: '重新生成 Legacy worker' }));
+    expect(screen.getByText('旧 Token 会立即失效,所有仍使用旧值的调用都会失败。此操作无法撤销。')).not.toBeNull();
+    const confirmations = screen.getAllByRole('button', { name: '重新生成' });
+    await user.click(confirmations[confirmations.length - 1]);
+
+    await waitFor(() => expect(rotate).toHaveBeenCalledWith(9));
+    expect(await screen.findByText('API Token 已重新生成')).not.toBeNull();
+    expect((await screen.findAllByText('mh_rotated.new-secret')).length).toBeGreaterThan(0);
   });
 });
 
-function renderPage() {
+function renderPage(role: UserRole = 'admin') {
   const context: AppContextValue = {
-    user: { id: 1, username: 'operator', email: 'operator@example.test', role: 'admin', status: 'active' },
+    user: { id: 1, username: 'operator', email: 'operator@example.test', role, status: 'active' },
     config,
     refreshBootstrap: vi.fn(async () => undefined),
     logout: vi.fn(async () => undefined)
@@ -74,6 +127,44 @@ function renderPage() {
   );
 }
 
+function tokenFixture(overrides: Partial<ApiToken> = {}): ApiToken {
+  return {
+    id: 9,
+    name: 'CI sender',
+    tokenPrefix: 'mh_12345678',
+    tokenRecoverable: false,
+    scopes: ['send'],
+    mailboxAccess: 'owner',
+    mailboxIds: [],
+    status: 'active',
+    createdAt: '2026-07-14T00:00:00.000Z',
+    ...overrides
+  };
+}
+
+function mailboxFixture(): InboundMailbox {
+  return {
+    id: 42,
+    userId: 2,
+    domainId: 5,
+    domain: 'example.test',
+    address: 'billing@example.test',
+    localPart: 'billing',
+    displayName: 'Billing',
+    aliases: [],
+    forwardTo: [],
+    keepForwarded: true,
+    quotaMb: 1024,
+    passwordSet: true,
+    passwordRecoverable: false,
+    status: 'active',
+    messageCount: 1,
+    unreadCount: 1,
+    createdAt: '2026-07-14T00:00:00.000Z',
+    updatedAt: '2026-07-14T00:00:00.000Z'
+  };
+}
+
 const config: RuntimeConfig = {
   appBaseUrl: 'https://mail.example.test',
   mailHostname: 'mail.example.test',

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

@@ -93,8 +93,32 @@ describe('Inbox detail return path', () => {
     expect(router.state.location.pathname).toBe('/overview');
     expect(router.state.location.pathname).not.toContain('/messages/');
   });
+
+  it('uses a detail drawer below the 1024px 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]);
+    vi.spyOn(api, 'inboundMessage').mockResolvedValue({ message: deepLinked });
+    const router = createInboxRouter(['/inbox/messages/30'], 0);
+    renderRouter(router);
+
+    expect(await screen.findByRole('button', { name: 'Close' })).toBeTruthy();
+  });
 });
 
+function mediaQueryList(matches: boolean, media: string): MediaQueryList {
+  return {
+    matches,
+    media,
+    onchange: null,
+    addListener: () => undefined,
+    removeListener: () => undefined,
+    addEventListener: () => undefined,
+    removeEventListener: () => undefined,
+    dispatchEvent: () => false
+  };
+}
+
 function createInboxRouter(initialEntries: string[], initialIndex: number) {
   return createMemoryRouter([
     { path: '/inbox', element: <Inbox /> },

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