Jelajahi Sumber

feat: add mailbox provisioning api

AI-Co-Authored-By: Codex
chendeben 1 bulan lalu
induk
melakukan
aff3a08384

+ 89 - 15
landing.html

@@ -15,8 +15,10 @@
         </a>
         <nav class="nav-links" aria-label="Primary">
           <a href="#features" data-i18n="nav.features">Features</a>
-          <a href="#integrate" data-i18n="nav.integrate">Integrate</a>
+          <a href="#quickstart" data-i18n="nav.quickstart">Quick start</a>
           <a href="#api" data-i18n="nav.api">API</a>
+          <a href="#mailboxes" data-i18n="nav.mailboxes">Mailboxes</a>
+          <a href="#domains" data-i18n="nav.domains">Domain setup</a>
           <a href="#smtp" data-i18n="nav.smtp">SMTP</a>
           <a href="#webhooks" data-i18n="nav.webhooks">Webhooks</a>
         </nav>
@@ -77,32 +79,42 @@
             <article class="card"><h3 data-i18n="features.dns.title">Domains &amp; DNS</h3><p data-i18n="features.dns.body">Guided SPF, DKIM, DMARC, and one-click DNS provider writes.</p></article>
             <article class="card"><h3 data-i18n="features.smtp.title">SMTP Submission</h3><p data-i18n="features.smtp.body">App-friendly SMTP listeners with credentials you control.</p></article>
             <article class="card"><h3 data-i18n="features.api.title">HTTP Send API</h3><p data-i18n="features.api.body">Bearer tokens and a simple JSON send endpoint for apps and services.</p></article>
+            <article class="card"><h3 data-i18n="features.mailboxes.title">Mailboxes &amp; receiving</h3><p data-i18n="features.mailboxes.body">Create persistent or expiring inboxes with IMAP, POP3, forwarding, and catch-all routing.</p></article>
             <article class="card"><h3 data-i18n="features.logs.title">Delivery logs</h3><p data-i18n="features.logs.body">Track queue and MTA outcomes with searchable send history.</p></article>
             <article class="card"><h3 data-i18n="features.hooks.title">Webhooks</h3><p data-i18n="features.hooks.body">Signed callbacks for sent, bounced, and failed terminal events.</p></article>
             <article class="card"><h3 data-i18n="features.multi.title">Multi-user isolation</h3><p data-i18n="features.multi.body">Domains, tokens, and logs stay scoped to each account.</p></article>
           </div>
         </section>
 
-        <section id="integrate" class="section section-alt">
+        <section id="quickstart" class="section section-alt">
           <div class="section-head">
-            <h2 data-i18n="integrate.title">Three ways to integrate</h2>
-            <p data-i18n="integrate.sub">Pick the path that matches your stack — configure after sign-in.</p>
+            <h2 data-i18n="quickstart.title">Start in four steps</h2>
+            <p data-i18n="quickstart.sub">The console keeps domain, token, mailbox, and delivery configuration in one place.</p>
           </div>
-          <div class="integrate-grid">
+          <div class="steps-grid">
             <article class="card">
-              <h3 data-i18n="integrate.api.title">API</h3>
-              <p data-i18n="integrate.api.body">Create an API token, then POST JSON to /api/send with Bearer auth.</p>
-              <a class="text-link" href="#api" data-i18n="integrate.api.link">See API sample →</a>
+              <span class="step-number">1</span>
+              <h3 data-i18n="quickstart.domain.title">Add a domain</h3>
+              <p data-i18n="quickstart.domain.body">Add your sending domain and publish the DNS records shown by MailHub.</p>
+              <a class="text-link" href="#domains" data-i18n="quickstart.domain.link">Review domain setup →</a>
             </article>
             <article class="card">
-              <h3 data-i18n="integrate.smtp.title">SMTP</h3>
-              <p data-i18n="integrate.smtp.body">Point your app at MailHub submission ports with per-user credentials.</p>
-              <a class="text-link" href="#smtp" data-i18n="integrate.smtp.link">See SMTP details →</a>
+              <span class="step-number">2</span>
+              <h3 data-i18n="quickstart.token.title">Create a scoped token</h3>
+              <p data-i18n="quickstart.token.body">Give each integration only the send or mailbox permissions it needs.</p>
+              <a class="text-link" href="#api" data-i18n="quickstart.token.link">Review API access →</a>
             </article>
             <article class="card">
-              <h3 data-i18n="integrate.hooks.title">Webhooks</h3>
-              <p data-i18n="integrate.hooks.body">Subscribe endpoints to terminal delivery events with HMAC signatures.</p>
-              <a class="text-link" href="#webhooks" data-i18n="integrate.hooks.link">See webhook events →</a>
+              <span class="step-number">3</span>
+              <h3 data-i18n="quickstart.mailbox.title">Provision mailboxes</h3>
+              <p data-i18n="quickstart.mailbox.body">Create persistent mailboxes in the console or create persistent and temporary mailboxes by API.</p>
+              <a class="text-link" href="#mailboxes" data-i18n="quickstart.mailbox.link">Review mailbox API →</a>
+            </article>
+            <article class="card">
+              <span class="step-number">4</span>
+              <h3 data-i18n="quickstart.client.title">Connect your client</h3>
+              <p data-i18n="quickstart.client.body">Use SMTP to send and IMAP or POP3 to receive with the mailbox's full address and password.</p>
+              <a class="text-link" href="#smtp" data-i18n="quickstart.client.link">Review protocol settings →</a>
             </article>
           </div>
         </section>
@@ -127,6 +139,68 @@
           </ul>
         </section>
 
+        <section id="mailboxes" class="section section-alt">
+          <div class="section-head">
+            <h2 data-i18n="mailboxes.title">Mailbox API and clients</h2>
+            <p data-i18n="mailboxes.sub">Create a persistent address for ongoing work, or an expiring address for a short-lived workflow.</p>
+          </div>
+          <div class="doc-grid">
+            <article class="card">
+              <h3 data-i18n="mailboxes.api.title">Create a mailbox</h3>
+              <p data-i18n="mailboxes.api.body">POST to /api/mailboxes with a token holding mailboxes:write. The target domain must belong to that account.</p>
+              <ul class="field-list compact-list">
+                <li><code>mode</code> — <span data-i18n="mailboxes.api.mode">permanent or temporary</span></li>
+                <li><code>address</code> / <code>domain</code> — <span data-i18n="mailboxes.api.address">use a full address, or let a temporary mailbox generate one from domain</span></li>
+                <li><code>expiresInMinutes</code> — <span data-i18n="mailboxes.api.expires">required for temporary mailboxes; 5 minutes to 30 days</span></li>
+                <li><code>password</code> — <span data-i18n="mailboxes.api.password">optional; a secure password is returned once when omitted</span></li>
+              </ul>
+            </article>
+            <article class="card">
+              <h3 data-i18n="mailboxes.client.title">Use the returned configuration</h3>
+              <p data-i18n="mailboxes.client.body">Creation returns the address, password, expiration, and ready-to-use IMAP, POP3, and SMTP settings.</p>
+              <ul class="field-list compact-list">
+                <li><strong>IMAP</strong> — <span data-i18n="mailboxes.client.imap">recommended for synchronized folders and sent mail</span></li>
+                <li><strong>POP3</strong> — <span data-i18n="mailboxes.client.pop3">for download-oriented clients</span></li>
+                <li><strong>SMTP</strong> — <span data-i18n="mailboxes.client.smtp">use the same full mailbox address and password to send</span></li>
+              </ul>
+            </article>
+          </div>
+          <div class="code-card doc-code-card">
+            <div class="code-card-bar">
+              <span>POST /api/mailboxes</span>
+              <button type="button" class="copy-btn" data-copy-target="mailbox-curl" data-i18n="common.copy">Copy</button>
+            </div>
+            <pre id="mailbox-curl"><code data-i18n-html="mailboxes.curl"></code></pre>
+          </div>
+          <p class="note" data-i18n="mailboxes.note">Use GET /api/mailboxes with mailboxes:read to list the account's mailboxes. A temporary mailbox stops accepting mail and authenticating at its expiration time.</p>
+        </section>
+
+        <section id="domains" class="section">
+          <div class="section-head">
+            <h2 data-i18n="domainsDoc.title">Domain configuration</h2>
+            <p data-i18n="domainsDoc.sub">Create the domain in MailHub first. Its DNS panel is the source of truth for your exact verification, DKIM, SPF, DMARC, and sender-host values.</p>
+          </div>
+          <div class="table-wrap">
+            <table>
+              <thead>
+                <tr>
+                  <th data-i18n="domainsDoc.col.purpose">Purpose</th>
+                  <th data-i18n="domainsDoc.col.record">Record</th>
+                  <th data-i18n="domainsDoc.col.requirement">Requirement</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr><td data-i18n="domainsDoc.row.verify">Domain ownership</td><td><code>TXT _mailhub.&lt;domain&gt;</code></td><td data-i18n="domainsDoc.row.verifyValue">Publish the verification value generated in the console.</td></tr>
+                <tr><td>DKIM</td><td><code>TXT &lt;selector&gt;._domainkey.&lt;domain&gt;</code></td><td data-i18n="domainsDoc.row.dkim">Publish the public key generated for this domain.</td></tr>
+                <tr><td>SPF</td><td><code>TXT &lt;domain&gt;</code></td><td data-i18n="domainsDoc.row.spf">Keep one SPF record and include the sender IP/host required by MailHub.</td></tr>
+                <tr><td>DMARC</td><td><code>TXT _dmarc.&lt;domain&gt;</code></td><td data-i18n="domainsDoc.row.dmarc">Start with monitoring, then strengthen the policy after alignment is verified.</td></tr>
+                <tr><td data-i18n="domainsDoc.row.mx">Inbound mail</td><td><code>MX &lt;domain&gt;</code></td><td data-i18n="domainsDoc.row.mxValue">Point MX to the MailHub receiving host and ensure that host has an A/AAAA record.</td></tr>
+              </tbody>
+            </table>
+          </div>
+          <p class="note" data-i18n="domainsDoc.note">For reliable delivery, also configure the sending IP's PTR to match the MailHub sending hostname and allow the documented SMTP, IMAP, and POP3 ports through your firewall.</p>
+        </section>
+
         <section id="smtp" class="section section-alt">
           <div class="section-head">
             <h2 data-i18n="smtp.title">SMTP submission</h2>
@@ -138,7 +212,7 @@
             <div class="card mono"><strong>465</strong><span>smtps</span></div>
             <div class="card mono"><strong>2525</strong><span>smtp</span></div>
           </div>
-          <p class="note" data-i18n="smtp.note">Create SMTP login credentials in the console after registration. Host example: mail.example.com</p>
+          <p class="note" data-i18n="smtp.note">Use a mailbox's full address and password for SMTP, IMAP, and POP3. Prefer TLS ports 465, 993, and 995; use STARTTLS/STLS on the plain ports when needed.</p>
         </section>
 
         <section id="webhooks" class="section">

File diff ditekan karena terlalu besar
+ 0 - 1
public/assets/index-C9xTFghB.js


File diff ditekan karena terlalu besar
+ 0 - 0
public/assets/landing-C6XYaH1w.css


File diff ditekan karena terlalu besar
+ 0 - 32
public/assets/landing-DZ480DaL.js


File diff ditekan karena terlalu besar
+ 8 - 0
public/assets/landing-OL30yro5.js


File diff ditekan karena terlalu besar
+ 0 - 0
public/assets/login-DDwQSt-8.js


File diff ditekan karena terlalu besar
+ 0 - 0
public/assets/styles-C2bA_lrB.css


File diff ditekan karena terlalu besar
+ 0 - 0
public/assets/styles-CroXClmW.js


File diff ditekan karena terlalu besar
+ 0 - 0
public/assets/styles-xyyjYU3P.css


+ 3 - 3
public/index.html

@@ -4,10 +4,10 @@
     <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-zLWtIAEO.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-DoFV9R_h.js">
+    <script type="module" crossorigin src="/assets/index-C9xTFghB.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-CroXClmW.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-C2bA_lrB.css">
+    <link rel="stylesheet" crossorigin href="/assets/styles-xyyjYU3P.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>
   <body>

+ 91 - 17
public/landing.html

@@ -5,9 +5,9 @@
     <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-DZ480DaL.js"></script>
+    <script type="module" crossorigin src="/assets/landing-OL30yro5.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
-    <link rel="stylesheet" crossorigin href="/assets/landing-CC2SsPE-.css">
+    <link rel="stylesheet" crossorigin href="/assets/landing-C6XYaH1w.css">
   </head>
   <body>
     <div class="page">
@@ -18,8 +18,10 @@
         </a>
         <nav class="nav-links" aria-label="Primary">
           <a href="#features" data-i18n="nav.features">Features</a>
-          <a href="#integrate" data-i18n="nav.integrate">Integrate</a>
+          <a href="#quickstart" data-i18n="nav.quickstart">Quick start</a>
           <a href="#api" data-i18n="nav.api">API</a>
+          <a href="#mailboxes" data-i18n="nav.mailboxes">Mailboxes</a>
+          <a href="#domains" data-i18n="nav.domains">Domain setup</a>
           <a href="#smtp" data-i18n="nav.smtp">SMTP</a>
           <a href="#webhooks" data-i18n="nav.webhooks">Webhooks</a>
         </nav>
@@ -80,32 +82,42 @@
             <article class="card"><h3 data-i18n="features.dns.title">Domains &amp; DNS</h3><p data-i18n="features.dns.body">Guided SPF, DKIM, DMARC, and one-click DNS provider writes.</p></article>
             <article class="card"><h3 data-i18n="features.smtp.title">SMTP Submission</h3><p data-i18n="features.smtp.body">App-friendly SMTP listeners with credentials you control.</p></article>
             <article class="card"><h3 data-i18n="features.api.title">HTTP Send API</h3><p data-i18n="features.api.body">Bearer tokens and a simple JSON send endpoint for apps and services.</p></article>
+            <article class="card"><h3 data-i18n="features.mailboxes.title">Mailboxes &amp; receiving</h3><p data-i18n="features.mailboxes.body">Create persistent or expiring inboxes with IMAP, POP3, forwarding, and catch-all routing.</p></article>
             <article class="card"><h3 data-i18n="features.logs.title">Delivery logs</h3><p data-i18n="features.logs.body">Track queue and MTA outcomes with searchable send history.</p></article>
             <article class="card"><h3 data-i18n="features.hooks.title">Webhooks</h3><p data-i18n="features.hooks.body">Signed callbacks for sent, bounced, and failed terminal events.</p></article>
             <article class="card"><h3 data-i18n="features.multi.title">Multi-user isolation</h3><p data-i18n="features.multi.body">Domains, tokens, and logs stay scoped to each account.</p></article>
           </div>
         </section>
 
-        <section id="integrate" class="section section-alt">
+        <section id="quickstart" class="section section-alt">
           <div class="section-head">
-            <h2 data-i18n="integrate.title">Three ways to integrate</h2>
-            <p data-i18n="integrate.sub">Pick the path that matches your stack — configure after sign-in.</p>
+            <h2 data-i18n="quickstart.title">Start in four steps</h2>
+            <p data-i18n="quickstart.sub">The console keeps domain, token, mailbox, and delivery configuration in one place.</p>
           </div>
-          <div class="integrate-grid">
+          <div class="steps-grid">
             <article class="card">
-              <h3 data-i18n="integrate.api.title">API</h3>
-              <p data-i18n="integrate.api.body">Create an API token, then POST JSON to /api/send with Bearer auth.</p>
-              <a class="text-link" href="#api" data-i18n="integrate.api.link">See API sample →</a>
+              <span class="step-number">1</span>
+              <h3 data-i18n="quickstart.domain.title">Add a domain</h3>
+              <p data-i18n="quickstart.domain.body">Add your sending domain and publish the DNS records shown by MailHub.</p>
+              <a class="text-link" href="#domains" data-i18n="quickstart.domain.link">Review domain setup →</a>
             </article>
             <article class="card">
-              <h3 data-i18n="integrate.smtp.title">SMTP</h3>
-              <p data-i18n="integrate.smtp.body">Point your app at MailHub submission ports with per-user credentials.</p>
-              <a class="text-link" href="#smtp" data-i18n="integrate.smtp.link">See SMTP details →</a>
+              <span class="step-number">2</span>
+              <h3 data-i18n="quickstart.token.title">Create a scoped token</h3>
+              <p data-i18n="quickstart.token.body">Give each integration only the send or mailbox permissions it needs.</p>
+              <a class="text-link" href="#api" data-i18n="quickstart.token.link">Review API access →</a>
             </article>
             <article class="card">
-              <h3 data-i18n="integrate.hooks.title">Webhooks</h3>
-              <p data-i18n="integrate.hooks.body">Subscribe endpoints to terminal delivery events with HMAC signatures.</p>
-              <a class="text-link" href="#webhooks" data-i18n="integrate.hooks.link">See webhook events →</a>
+              <span class="step-number">3</span>
+              <h3 data-i18n="quickstart.mailbox.title">Provision mailboxes</h3>
+              <p data-i18n="quickstart.mailbox.body">Create persistent mailboxes in the console or create persistent and temporary mailboxes by API.</p>
+              <a class="text-link" href="#mailboxes" data-i18n="quickstart.mailbox.link">Review mailbox API →</a>
+            </article>
+            <article class="card">
+              <span class="step-number">4</span>
+              <h3 data-i18n="quickstart.client.title">Connect your client</h3>
+              <p data-i18n="quickstart.client.body">Use SMTP to send and IMAP or POP3 to receive with the mailbox's full address and password.</p>
+              <a class="text-link" href="#smtp" data-i18n="quickstart.client.link">Review protocol settings →</a>
             </article>
           </div>
         </section>
@@ -130,6 +142,68 @@
           </ul>
         </section>
 
+        <section id="mailboxes" class="section section-alt">
+          <div class="section-head">
+            <h2 data-i18n="mailboxes.title">Mailbox API and clients</h2>
+            <p data-i18n="mailboxes.sub">Create a persistent address for ongoing work, or an expiring address for a short-lived workflow.</p>
+          </div>
+          <div class="doc-grid">
+            <article class="card">
+              <h3 data-i18n="mailboxes.api.title">Create a mailbox</h3>
+              <p data-i18n="mailboxes.api.body">POST to /api/mailboxes with a token holding mailboxes:write. The target domain must belong to that account.</p>
+              <ul class="field-list compact-list">
+                <li><code>mode</code> — <span data-i18n="mailboxes.api.mode">permanent or temporary</span></li>
+                <li><code>address</code> / <code>domain</code> — <span data-i18n="mailboxes.api.address">use a full address, or let a temporary mailbox generate one from domain</span></li>
+                <li><code>expiresInMinutes</code> — <span data-i18n="mailboxes.api.expires">required for temporary mailboxes; 5 minutes to 30 days</span></li>
+                <li><code>password</code> — <span data-i18n="mailboxes.api.password">optional; a secure password is returned once when omitted</span></li>
+              </ul>
+            </article>
+            <article class="card">
+              <h3 data-i18n="mailboxes.client.title">Use the returned configuration</h3>
+              <p data-i18n="mailboxes.client.body">Creation returns the address, password, expiration, and ready-to-use IMAP, POP3, and SMTP settings.</p>
+              <ul class="field-list compact-list">
+                <li><strong>IMAP</strong> — <span data-i18n="mailboxes.client.imap">recommended for synchronized folders and sent mail</span></li>
+                <li><strong>POP3</strong> — <span data-i18n="mailboxes.client.pop3">for download-oriented clients</span></li>
+                <li><strong>SMTP</strong> — <span data-i18n="mailboxes.client.smtp">use the same full mailbox address and password to send</span></li>
+              </ul>
+            </article>
+          </div>
+          <div class="code-card doc-code-card">
+            <div class="code-card-bar">
+              <span>POST /api/mailboxes</span>
+              <button type="button" class="copy-btn" data-copy-target="mailbox-curl" data-i18n="common.copy">Copy</button>
+            </div>
+            <pre id="mailbox-curl"><code data-i18n-html="mailboxes.curl"></code></pre>
+          </div>
+          <p class="note" data-i18n="mailboxes.note">Use GET /api/mailboxes with mailboxes:read to list the account's mailboxes. A temporary mailbox stops accepting mail and authenticating at its expiration time.</p>
+        </section>
+
+        <section id="domains" class="section">
+          <div class="section-head">
+            <h2 data-i18n="domainsDoc.title">Domain configuration</h2>
+            <p data-i18n="domainsDoc.sub">Create the domain in MailHub first. Its DNS panel is the source of truth for your exact verification, DKIM, SPF, DMARC, and sender-host values.</p>
+          </div>
+          <div class="table-wrap">
+            <table>
+              <thead>
+                <tr>
+                  <th data-i18n="domainsDoc.col.purpose">Purpose</th>
+                  <th data-i18n="domainsDoc.col.record">Record</th>
+                  <th data-i18n="domainsDoc.col.requirement">Requirement</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr><td data-i18n="domainsDoc.row.verify">Domain ownership</td><td><code>TXT _mailhub.&lt;domain&gt;</code></td><td data-i18n="domainsDoc.row.verifyValue">Publish the verification value generated in the console.</td></tr>
+                <tr><td>DKIM</td><td><code>TXT &lt;selector&gt;._domainkey.&lt;domain&gt;</code></td><td data-i18n="domainsDoc.row.dkim">Publish the public key generated for this domain.</td></tr>
+                <tr><td>SPF</td><td><code>TXT &lt;domain&gt;</code></td><td data-i18n="domainsDoc.row.spf">Keep one SPF record and include the sender IP/host required by MailHub.</td></tr>
+                <tr><td>DMARC</td><td><code>TXT _dmarc.&lt;domain&gt;</code></td><td data-i18n="domainsDoc.row.dmarc">Start with monitoring, then strengthen the policy after alignment is verified.</td></tr>
+                <tr><td data-i18n="domainsDoc.row.mx">Inbound mail</td><td><code>MX &lt;domain&gt;</code></td><td data-i18n="domainsDoc.row.mxValue">Point MX to the MailHub receiving host and ensure that host has an A/AAAA record.</td></tr>
+              </tbody>
+            </table>
+          </div>
+          <p class="note" data-i18n="domainsDoc.note">For reliable delivery, also configure the sending IP's PTR to match the MailHub sending hostname and allow the documented SMTP, IMAP, and POP3 ports through your firewall.</p>
+        </section>
+
         <section id="smtp" class="section section-alt">
           <div class="section-head">
             <h2 data-i18n="smtp.title">SMTP submission</h2>
@@ -141,7 +215,7 @@
             <div class="card mono"><strong>465</strong><span>smtps</span></div>
             <div class="card mono"><strong>2525</strong><span>smtp</span></div>
           </div>
-          <p class="note" data-i18n="smtp.note">Create SMTP login credentials in the console after registration. Host example: mail.example.com</p>
+          <p class="note" data-i18n="smtp.note">Use a mailbox's full address and password for SMTP, IMAP, and POP3. Prefer TLS ports 465, 993, and 995; use STARTTLS/STLS on the plain ports when needed.</p>
         </section>
 
         <section id="webhooks" class="section">

+ 3 - 3
public/login.html

@@ -4,10 +4,10 @@
     <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-CT-R_g49.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-DoFV9R_h.js">
+    <script type="module" crossorigin src="/assets/login-DDwQSt-8.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-CroXClmW.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
-    <link rel="stylesheet" crossorigin href="/assets/styles-C2bA_lrB.css">
+    <link rel="stylesheet" crossorigin href="/assets/styles-xyyjYU3P.css">
   </head>
   <body>
     <div id="auth-root"></div>

+ 146 - 17
src/db.js

@@ -20,12 +20,14 @@ 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']);
 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;
 const auditDescriptorWrapperKeyPattern = /^(change|context|descriptor|meta)$/i;
 const auditValueLikeKeyPattern = /^(value|from|to|old|new|old_?value|new_?value|before|after)$/i;
 const maxAccountTokenTtlMinutes = 7 * 24 * 60;
+const defaultApiTokenScopes = ['send'];
 
 export function initDatabase(dataDir, secret = '') {
   secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
@@ -169,6 +171,7 @@ export function initDatabase(dataDir, secret = '') {
       created_at TEXT NOT NULL,
       updated_at TEXT NOT NULL,
       deleted_at TEXT,
+      expires_at TEXT,
       FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
       FOREIGN KEY(domain_id) REFERENCES domains(id) ON DELETE CASCADE
     );
@@ -217,6 +220,10 @@ export function initDatabase(dataDir, secret = '') {
       name TEXT NOT NULL,
       token_hash TEXT NOT NULL UNIQUE,
       token_prefix TEXT NOT NULL,
+      scopes_json TEXT NOT NULL DEFAULT '["send"]',
+      expires_at TEXT,
+      revoked_at TEXT,
+      revoked_reason TEXT NOT NULL DEFAULT '',
       last_used_at TEXT,
       created_at TEXT NOT NULL,
       FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -330,7 +337,12 @@ export function initDatabase(dataDir, secret = '') {
   ensureColumn('inbound_mailboxes', 'forward_to_json', "TEXT NOT NULL DEFAULT '[]'");
   ensureColumn('inbound_mailboxes', 'keep_forwarded', "TEXT NOT NULL DEFAULT 'true'");
   ensureColumn('inbound_mailboxes', 'quota_mb', 'INTEGER');
+  ensureColumn('inbound_mailboxes', 'expires_at', 'TEXT');
   ensureColumn('inbound_messages', 'folder', "TEXT NOT NULL DEFAULT 'INBOX'");
+  ensureColumn('api_tokens', 'scopes_json', "TEXT NOT NULL DEFAULT '[\"send\"]'");
+  ensureColumn('api_tokens', 'expires_at', 'TEXT');
+  ensureColumn('api_tokens', 'revoked_at', 'TEXT');
+  ensureColumn('api_tokens', 'revoked_reason', "TEXT NOT NULL DEFAULT ''");
   db.exec(`
     CREATE INDEX IF NOT EXISTS idx_domains_user_id ON domains(user_id);
     CREATE INDEX IF NOT EXISTS idx_domains_smtp_relay_id ON domains(smtp_relay_id);
@@ -343,6 +355,7 @@ export function initDatabase(dataDir, secret = '') {
       WHERE tracking_token_hash IS NOT NULL AND tracking_token_hash != '';
     CREATE INDEX IF NOT EXISTS idx_smtp_credentials_user_id ON smtp_credentials(user_id);
     CREATE INDEX IF NOT EXISTS idx_smtp_relays_user_id ON smtp_relays(user_id);
+    CREATE INDEX IF NOT EXISTS idx_api_tokens_user_status ON api_tokens(user_id, revoked_at, expires_at);
     CREATE INDEX IF NOT EXISTS idx_inbound_messages_mailbox_folder_received ON inbound_messages(mailbox_id, folder, received_at);
     CREATE INDEX IF NOT EXISTS idx_inbound_folders_mailbox ON inbound_folders(mailbox_id, deleted_at);
   `);
@@ -878,13 +891,14 @@ export function createInboundMailbox(userId, mailbox = {}) {
   const forwardTo = normalizeRecipientList(mailbox.forwardTo);
   const keepForwarded = boolString(mailbox.keepForwarded ?? true);
   const quotaMb = normalizeQuotaMb(mailbox.quotaMb);
+  const expiresAt = normalizeInboundMailboxExpiresAt(mailbox.expiresAt);
   const createdAt = now();
   const result = requireDb()
     .prepare(`
       INSERT INTO inbound_mailboxes (
         user_id, domain_id, address, local_part, display_name, password_hash, password_secret,
-        aliases_json, forward_to_json, keep_forwarded, quota_mb, status, created_at, updated_at
-      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
+        aliases_json, forward_to_json, keep_forwarded, quota_mb, status, expires_at, created_at, updated_at
+      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)
     `)
     .run(
       userId,
@@ -898,6 +912,7 @@ export function createInboundMailbox(userId, mailbox = {}) {
       JSON.stringify(forwardTo),
       keepForwarded,
       quotaMb,
+      expiresAt,
       createdAt,
       createdAt
     );
@@ -918,6 +933,7 @@ export function updateInboundMailbox(userId, id, patch = {}) {
     forwardTo: Object.hasOwn(patch, 'forwardTo') ? normalizeRecipientList(patch.forwardTo) : current.forwardTo,
     keepForwarded: Object.hasOwn(patch, 'keepForwarded') ? Boolean(patch.keepForwarded) : current.keepForwarded,
     quotaMb: Object.hasOwn(patch, 'quotaMb') ? normalizeQuotaMb(patch.quotaMb) : current.quotaMb,
+    expiresAt: Object.hasOwn(patch, 'expiresAt') ? normalizeInboundMailboxExpiresAt(patch.expiresAt) : current.expiresAt,
     status: patch.status === undefined ? current.status : normalizeInboundMailboxStatus(patch.status),
     updatedAt: now()
   };
@@ -926,7 +942,7 @@ export function updateInboundMailbox(userId, id, patch = {}) {
     .prepare(`
       UPDATE inbound_mailboxes
       SET display_name = ?, password_hash = ?, password_secret = ?, aliases_json = ?, forward_to_json = ?,
-          keep_forwarded = ?, quota_mb = ?, status = ?, updated_at = ?
+          keep_forwarded = ?, quota_mb = ?, expires_at = ?, status = ?, updated_at = ?
       WHERE id = ? AND user_id = ? AND deleted_at IS NULL
     `)
     .run(
@@ -937,6 +953,7 @@ export function updateInboundMailbox(userId, id, patch = {}) {
       JSON.stringify(next.forwardTo),
       boolString(next.keepForwarded),
       next.quotaMb,
+      next.expiresAt,
       next.status,
       next.updatedAt,
       Number(id),
@@ -996,10 +1013,11 @@ export function getInboundMailboxByAddress(address, { includeHash = false, inclu
       WHERE m.address = ?
         AND m.status = 'active'
         AND m.deleted_at IS NULL
+        AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
         AND u.status = 'active'
       LIMIT 1
     `)
-    .get(cleanAddress);
+    .get(cleanAddress, now());
   return publicInboundMailbox(row, { includeHash, includeSecret });
 }
 
@@ -1025,9 +1043,10 @@ export function verifyInboundMailboxCredential(username, password) {
       WHERE m.address = ?
         AND m.status = 'active'
         AND m.deleted_at IS NULL
+        AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
       LIMIT 1
     `)
-    .get(mailboxAddress);
+    .get(mailboxAddress, now());
   if (!row?.password_hash || row.user_status !== 'active' || !verifyPassword(password, row.password_hash)) return null;
   return {
     user: {
@@ -2794,15 +2813,25 @@ export function verifySmtpCredential(username, password) {
   } : null;
 }
 
-export function createApiToken(userId, name) {
+export function createApiToken(userId, name, { scopes, expiresAt } = {}) {
   const token = `mh_${crypto.randomBytes(32).toString('base64url')}`;
   const createdAt = now();
+  const cleanScopes = normalizeApiTokenScopes(scopes);
+  const cleanExpiresAt = normalizeApiTokenExpiresAt(expiresAt);
   const result = requireDb()
     .prepare(`
-      INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, created_at)
-      VALUES (?, ?, ?, ?, ?)
+      INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes_json, expires_at, created_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?)
     `)
-    .run(userId, String(name || 'API Token').trim() || 'API Token', tokenHash(token), token.slice(0, 12), createdAt);
+    .run(
+      userId,
+      normalizeApiTokenName(name),
+      tokenHash(token),
+      token.slice(0, 12),
+      JSON.stringify(cleanScopes),
+      cleanExpiresAt,
+      createdAt
+    );
   return {
     ...getApiToken(result.lastInsertRowid, userId),
     token
@@ -2823,12 +2852,46 @@ export function getApiToken(id, userId) {
   return publicApiToken(row);
 }
 
+export function updateApiToken(id, userId, patch = {}) {
+  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 name = Object.hasOwn(patch, 'name') ? normalizeApiTokenName(patch.name) : current.name;
+  const scopes = Object.hasOwn(patch, 'scopes')
+    ? normalizeApiTokenScopes(patch.scopes)
+    : storedApiTokenScopes(current.scopes_json);
+  const expiresAt = Object.hasOwn(patch, 'expiresAt')
+    ? normalizeApiTokenExpiresAt(patch.expiresAt)
+    : current.expires_at || null;
+  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);
+  return getApiToken(id, userId);
+}
+
+export function revokeApiToken(id, userId, reason = '') {
+  const result = requireDb()
+    .prepare(`
+      UPDATE api_tokens
+      SET revoked_at = ?, revoked_reason = ?
+      WHERE id = ? AND user_id = ? AND revoked_at IS NULL
+    `)
+    .run(now(), String(reason || '').trim().slice(0, 200), Number(id), userId);
+  return result.changes > 0;
+}
+
 export function deleteApiToken(id, userId) {
   const result = requireDb().prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(id, userId);
   return result.changes > 0;
 }
 
 export function verifyApiToken(token) {
+  return authenticateApiToken(token)?.user || null;
+}
+
+export function authenticateApiToken(token) {
   const hash = tokenHash(token);
   const row = requireDb()
     .prepare(`
@@ -2838,14 +2901,17 @@ export function verifyApiToken(token) {
       WHERE t.token_hash = ?
     `)
     .get(hash);
-  if (!row || row.status !== 'active') return null;
+  if (!row || row.status !== 'active' || apiTokenStatus(row) !== 'active') return null;
   requireDb().prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id);
   return {
-    id: row.auth_user_id,
-    username: row.username,
-    email: row.email,
-    role: row.role,
-    status: row.status
+    user: {
+      id: row.auth_user_id,
+      username: row.username,
+      email: row.email,
+      role: row.role,
+      status: row.status
+    },
+    token: publicApiToken(row)
   };
 }
 
@@ -3333,6 +3399,7 @@ function privateDomainRow(row) {
 function publicInboundMailbox(row, { includeHash = false, includeSecret = false } = {}) {
   if (!row) return null;
   const passwordRecoverable = Boolean(row.password_secret && decryptSecret(row.password_secret));
+  const expiresAt = row.expires_at || null;
   return {
     id: row.id,
     userId: row.user_id,
@@ -3347,7 +3414,9 @@ function publicInboundMailbox(row, { includeHash = false, includeSecret = false
     quotaMb: row.quota_mb === null || row.quota_mb === undefined ? null : Number(row.quota_mb),
     passwordSet: Boolean(row.password_hash),
     passwordRecoverable,
-    status: row.status,
+    status: inboundMailboxStatus(row),
+    expiresAt,
+    temporary: Boolean(expiresAt),
     messageCount: Number(row.message_count || 0),
     unreadCount: Number(row.unread_count || 0),
     lastMessageAt: row.last_message_at || null,
@@ -3431,6 +3500,11 @@ function publicApiToken(row) {
     userId: row.user_id,
     name: row.name,
     tokenPrefix: row.token_prefix,
+    scopes: storedApiTokenScopes(row.scopes_json),
+    expiresAt: row.expires_at || null,
+    revokedAt: row.revoked_at || null,
+    revokedReason: row.revoked_reason || '',
+    status: apiTokenStatus(row),
     lastUsedAt: row.last_used_at,
     createdAt: row.created_at
   };
@@ -3803,6 +3877,60 @@ function normalizeInboundMailboxStatus(value) {
   throw new Error('收信邮箱状态不正确。');
 }
 
+function normalizeInboundMailboxExpiresAt(value) {
+  if (value === null || value === undefined || value === '') return null;
+  const timestamp = Date.parse(String(value));
+  if (!Number.isFinite(timestamp) || timestamp <= Date.now()) throw new Error('临时邮箱到期时间不正确。');
+  return new Date(timestamp).toISOString();
+}
+
+function inboundMailboxStatus(row) {
+  if (!row) return 'disabled';
+  const expiresAt = String(row.expires_at || '');
+  if (expiresAt && Date.parse(expiresAt) <= Date.now()) return 'expired';
+  return row.status;
+}
+
+function normalizeApiTokenName(value) {
+  const name = String(value || '').trim();
+  if (!name) throw new Error('API Token 名称不能为空。');
+  if (name.length > 100) throw new Error('API Token 名称不能超过 100 个字符。');
+  return name;
+}
+
+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))];
+  if (!scopes.length || scopes.some((scope) => !API_TOKEN_SCOPES.has(scope))) {
+    throw new Error('API Token 权限范围不正确。');
+  }
+  return scopes;
+}
+
+function storedApiTokenScopes(value) {
+  try {
+    const parsed = JSON.parse(value || '');
+    const scopes = Array.isArray(parsed) ? parsed.filter((scope) => API_TOKEN_SCOPES.has(scope)) : [];
+    return scopes.length ? scopes : [...defaultApiTokenScopes];
+  } catch {
+    return [...defaultApiTokenScopes];
+  }
+}
+
+function normalizeApiTokenExpiresAt(value) {
+  if (value === null || value === undefined || value === '') return null;
+  const timestamp = Date.parse(String(value));
+  if (!Number.isFinite(timestamp) || timestamp <= Date.now()) throw new Error('API Token 到期时间不正确。');
+  return new Date(timestamp).toISOString();
+}
+
+function apiTokenStatus(row) {
+  if (row?.revoked_at) return 'revoked';
+  const expiresAt = String(row?.expires_at || '');
+  if (expiresAt && Date.parse(expiresAt) <= Date.now()) return 'expired';
+  return 'active';
+}
+
 function normalizeMailboxAliases(values, domain, ownLocalPart) {
   const list = Array.isArray(values)
     ? values
@@ -3847,9 +3975,10 @@ function getInboundMailboxByAliasAddress(address) {
       WHERE d.domain = ?
         AND m.status = 'active'
         AND m.deleted_at IS NULL
+        AND (m.expires_at IS NULL OR m.expires_at = '' OR m.expires_at > ?)
         AND u.status = 'active'
     `)
-    .all(domainName);
+    .all(domainName, now());
   const row = rows.find((item) => safeJson(item.aliases_json, []).includes(localPart));
   return publicInboundMailbox(row);
 }

+ 20 - 7
src/frontend/App.tsx

@@ -451,17 +451,29 @@ function MailHubConsole() {
     return inboundMessage;
   }
 
-  async function createApiToken(name: string) {
-    const result = await runAction(async () => api.createApiToken(name), t('tokens.createdSuccess'));
+  async function createApiToken(values: { name: string; scopes: string[]; expiresAt?: string | null }) {
+    const result = await runAction(async () => api.createApiToken(values), t('tokens.createdSuccess'));
     if (!result?.token) return null;
     setData((current) => ({ ...current, apiTokens: [result.token, ...current.apiTokens] }));
     return result.token;
   }
 
-  async function deleteApiToken(token: ApiToken) {
-    const result = await runAction(async () => api.deleteApiToken(token.id), t('tokens.deletedSuccess'));
-    if (!result?.deleted) return;
-    setData((current) => ({ ...current, apiTokens: current.apiTokens.filter((item) => item.id !== token.id) }));
+  async function updateApiToken(token: ApiToken, values: { name: string; scopes: string[]; expiresAt?: string | null }) {
+    const result = await runAction(async () => api.updateApiToken(token.id, values), t('tokens.updatedSuccess'));
+    if (!result?.token) return;
+    setData((current) => ({
+      ...current,
+      apiTokens: current.apiTokens.map((item) => item.id === result.token.id ? result.token : item)
+    }));
+  }
+
+  async function revokeApiToken(token: ApiToken) {
+    const result = await runAction(async () => api.deleteApiToken(token.id), t('tokens.revokedSuccess'));
+    if (!result?.token) return;
+    setData((current) => ({
+      ...current,
+      apiTokens: current.apiTokens.map((item) => item.id === result.token?.id ? result.token : item)
+    }));
   }
 
   async function saveSettings(values: Partial<RuntimeConfig>) {
@@ -655,7 +667,8 @@ function MailHubConsole() {
           config={data.config}
           loading={actionLoading}
           onCreate={createApiToken}
-          onDelete={deleteApiToken}
+          onUpdate={updateApiToken}
+          onRevoke={revokeApiToken}
           onCopy={copy}
         />
       );

+ 43 - 0
src/frontend/api-token-model.js

@@ -58,6 +58,49 @@ const result = await response.json();`
   };
 }
 
+export function buildMailboxApiUsageExamples({
+  endpoint = '/api/mailboxes',
+  token = '<USER_API_TOKEN>',
+  domain = 'example.com'
+} = {}) {
+  const permanentBody = {
+    mode: 'permanent',
+    address: `support@${domain}`,
+    password: '<MAILBOX_PASSWORD>',
+    displayName: 'Support'
+  };
+  const temporaryBody = {
+    mode: 'temporary',
+    domain,
+    expiresInMinutes: 60
+  };
+  return {
+    permanent: JSON.stringify(permanentBody, null, 2),
+    temporary: JSON.stringify(temporaryBody, null, 2),
+    permanentCurl: buildBearerCurl(endpoint, token, permanentBody),
+    temporaryCurl: buildBearerCurl(endpoint, token, temporaryBody),
+    successResponse: JSON.stringify({
+      mailbox: {
+        address: `tmp-abc123@${domain}`,
+        temporary: true,
+        expiresAt: '2026-07-14T12:00:00.000Z'
+      },
+      password: '<GENERATED_MAILBOX_PASSWORD>',
+      clientConfig: {
+        incoming: { protocol: 'IMAP', port: 993, security: 'SSL/TLS' },
+        outgoing: { protocol: 'SMTP', port: 465, security: 'SSL/TLS' }
+      }
+    }, null, 2)
+  };
+}
+
+function buildBearerCurl(endpoint, token, body) {
+  return `curl -X POST ${endpoint} \\
+  -H 'Authorization: Bearer ${token}' \\
+  -H 'Content-Type: application/json' \\
+  -d '${JSON.stringify(body, null, 2)}'`;
+}
+
 function domainFromAddress(value) {
   return String(value || '').split('@')[1] || '';
 }

+ 50 - 2
src/frontend/i18n/index.js

@@ -392,12 +392,24 @@ const messages = {
     'status.error': '配置错误',
     'status.idle': '未配置',
     'tokens.createTitle': '创建 API Token',
+    'tokens.subtitle': '按最小权限原则创建、更新或撤销用于发信和邮箱自动化的访问令牌。',
     'tokens.name': '名称',
     'tokens.namePlaceholder': 'Production API',
     'tokens.nameRequired': '请输入 Token 名称',
     'tokens.create': '创建 Token',
+    'tokens.edit': '编辑 Token',
+    'tokens.editTitle': '编辑 API Token',
     'tokens.listTitle': 'API Tokens',
     'tokens.prefix': 'Token 前缀',
+    'tokens.scopes': '权限范围',
+    'tokens.scopesRequired': '请至少选择一项权限',
+    'tokens.scopesExtra': '发信使用 send;邮箱 API 读取和创建分别需要 mailboxes:read 与 mailboxes:write。',
+    'tokens.status': '状态',
+    'tokens.statusActive': '生效中',
+    'tokens.statusExpired': '已过期',
+    'tokens.statusRevoked': '已撤销',
+    'tokens.expiresAt': '到期时间',
+    'tokens.expiresAtExtra': '留空表示长期有效;使用本地时间。',
     'tokens.fullToken': '完整 Token',
     'tokens.copyFull': '复制完整 Token',
     'tokens.copyPrefix': '复制前缀',
@@ -405,16 +417,21 @@ const messages = {
     'tokens.createdWarning': '完整 Token 只会显示这一次。关闭后无法再次查看,请立即复制并保存到安全位置。',
     'tokens.createdSuccess': 'Token 已创建',
     'tokens.deletedSuccess': 'Token 已删除',
+    'tokens.updatedSuccess': 'Token 已更新',
+    'tokens.revokedSuccess': 'Token 已撤销',
     'tokens.secretUnavailable': '完整 Token 仅在创建时显示',
     'tokens.lastUsed': '最近使用',
     'tokens.neverUsed': '未使用',
     'tokens.createdAt': '创建时间',
     'tokens.actions': '操作',
     'tokens.deleteConfirm': '确认删除该 Token?',
+    'tokens.revoke': '撤销 Token',
+    'tokens.revokeConfirm': '撤销后该 Token 将立即失效,确认继续?',
     'tokens.copyCreated': '复制完整 Token',
     'tokens.prefixOnlyHelp': '历史 Token 不保存明文,只能复制前缀用于识别。',
-    'tokens.docsTitle': '发送 API 使用文档',
+    'tokens.docsTitle': 'API 使用文档',
     'tokens.endpoint': 'API Endpoint',
+    'tokens.mailboxEndpoint': '邮箱 API Endpoint',
     'tokens.authHeader': '认证方式',
     'tokens.authHeaderValue': 'Authorization: Bearer <USER_API_TOKEN>',
     'tokens.contentType': 'Content-Type',
@@ -428,9 +445,16 @@ const messages = {
     'tokens.nodeExample': 'Node / Fetch 示例',
     'tokens.requestExample': '请求 Body 示例',
     'tokens.responseExample': '成功响应示例',
+    'tokens.sendApi': '发信 API',
+    'tokens.mailboxApi': '创建邮箱 API',
+    'tokens.mailboxApiHint': '长期邮箱使用 mode=permanent;临时邮箱使用 mode=temporary 并提供 expiresInMinutes。临时邮箱可省略 address,服务端会按 domain 自动生成地址和密码。',
+    'tokens.permanentMailbox': '创建长期邮箱',
+    'tokens.temporaryMailbox': '创建临时邮箱',
+    'tokens.mailboxResponse': '创建结果与客户端配置',
     'tokens.securityTips': '安全建议',
     'tokens.securityTipStore': '只在服务端环境保存 Token,不要放进浏览器前端代码或公开仓库。',
     'tokens.securityTipRotate': '不同环境使用不同 Token,泄露后立即删除并重新创建。',
+    'tokens.securityTipScopes': '只授予实际需要的权限;给自动化邮箱创建任务时无需授予 send。',
     'tokens.securityTipDomain': 'From 域名必须属于当前账号,建议先完成 DNS 验证再接入生产发送。',
     'tokens.noTokenHint': '创建 Token 后可复制完整密钥;历史 Token 只显示前缀,示例中使用占位符。',
     'webhooks.title': 'Webhooks',
@@ -931,12 +955,24 @@ const messages = {
     'status.error': 'Misconfigured',
     'status.idle': 'Not configured',
     'tokens.createTitle': 'Create API Token',
+    'tokens.subtitle': 'Create, update, and revoke scoped tokens for sending and mailbox automation.',
     'tokens.name': 'Name',
     'tokens.namePlaceholder': 'Production API',
     'tokens.nameRequired': 'Enter a token name',
     'tokens.create': 'Create Token',
+    'tokens.edit': 'Edit token',
+    'tokens.editTitle': 'Edit API Token',
     'tokens.listTitle': 'API Tokens',
     'tokens.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.status': 'Status',
+    'tokens.statusActive': 'Active',
+    'tokens.statusExpired': 'Expired',
+    'tokens.statusRevoked': 'Revoked',
+    'tokens.expiresAt': 'Expires at',
+    'tokens.expiresAtExtra': 'Leave empty for no expiry. Uses local time.',
     'tokens.fullToken': 'Full Token',
     'tokens.copyFull': 'Copy full token',
     'tokens.copyPrefix': 'Copy prefix',
@@ -944,16 +980,21 @@ const messages = {
     'tokens.createdWarning': 'The full token is shown only once. Copy it now and store it somewhere secure.',
     'tokens.createdSuccess': 'Token created',
     'tokens.deletedSuccess': 'Token deleted',
+    'tokens.updatedSuccess': 'Token updated',
+    'tokens.revokedSuccess': 'Token revoked',
     'tokens.secretUnavailable': 'Full token is only shown at creation time',
     'tokens.lastUsed': 'Last used',
     'tokens.neverUsed': 'Never used',
     'tokens.createdAt': 'Created at',
     'tokens.actions': 'Actions',
     'tokens.deleteConfirm': 'Delete this token?',
+    'tokens.revoke': 'Revoke token',
+    'tokens.revokeConfirm': 'This token will stop working immediately. Continue?',
     'tokens.copyCreated': 'Copy full token',
     'tokens.prefixOnlyHelp': 'Historical tokens do not store plaintext. The prefix is only for identification.',
-    'tokens.docsTitle': 'Sending API guide',
+    'tokens.docsTitle': 'API guide',
     'tokens.endpoint': 'API Endpoint',
+    'tokens.mailboxEndpoint': 'Mailbox API Endpoint',
     'tokens.authHeader': 'Authentication',
     'tokens.authHeaderValue': 'Authorization: Bearer <USER_API_TOKEN>',
     'tokens.contentType': 'Content-Type',
@@ -967,9 +1008,16 @@ const messages = {
     'tokens.nodeExample': 'Node / Fetch example',
     'tokens.requestExample': 'Request body example',
     'tokens.responseExample': 'Success response example',
+    'tokens.sendApi': 'Send API',
+    'tokens.mailboxApi': 'Create mailbox API',
+    'tokens.mailboxApiHint': 'Use mode=permanent for a persistent mailbox. Use mode=temporary with expiresInMinutes for a temporary mailbox. When address is omitted, the server generates an address and password from domain.',
+    'tokens.permanentMailbox': 'Create a persistent mailbox',
+    'tokens.temporaryMailbox': 'Create a temporary mailbox',
+    'tokens.mailboxResponse': 'Creation result and client configuration',
     '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.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.',
     'webhooks.title': 'Webhooks',

+ 110 - 2
src/frontend/landing/i18n.js

@@ -5,7 +5,10 @@ const messages = {
   'zh-CN': {
     'nav.features': '功能',
     'nav.integrate': '接入',
+    'nav.quickstart': '使用说明',
     'nav.api': 'API',
+    'nav.mailboxes': '邮箱',
+    'nav.domains': '域名配置',
     'nav.smtp': 'SMTP',
     'nav.webhooks': 'Webhooks',
     'cta.login': '登录',
@@ -36,6 +39,8 @@ const messages = {
     'features.smtp.body': '面向应用的 SMTP 接入,凭据由你掌控。',
     'features.api.title': 'HTTP 发送 API',
     'features.api.body': 'Bearer Token + 简洁 JSON 发送接口,适合服务端集成。',
+    'features.mailboxes.title': '邮箱与收信',
+    'features.mailboxes.body': '创建长期或到期邮箱,支持 IMAP、POP3、转发和未知地址收取。',
     'features.logs.title': '投递日志',
     'features.logs.body': '跟踪入队与 MTA 结果,支持检索发送历史。',
     'features.hooks.title': 'Webhooks',
@@ -53,6 +58,20 @@ const messages = {
     'integrate.hooks.title': 'Webhooks',
     'integrate.hooks.body': '为终态投递事件订阅端点,校验 HMAC 签名。',
     'integrate.hooks.link': '查看 Webhook 事件 →',
+    'quickstart.title': '四步开始使用',
+    'quickstart.sub': '在同一个控制台完成域名、Token、邮箱和投递配置。',
+    'quickstart.domain.title': '添加域名',
+    'quickstart.domain.body': '添加发信域名,并发布 MailHub 展示的 DNS 记录。',
+    'quickstart.domain.link': '查看域名配置 →',
+    'quickstart.token.title': '创建最小权限 Token',
+    'quickstart.token.body': '只为每个接入授予所需的发信或邮箱权限。',
+    'quickstart.token.link': '查看 API 权限 →',
+    'quickstart.mailbox.title': '创建邮箱',
+    'quickstart.mailbox.body': '可在控制台创建长期邮箱,也可通过 API 创建长期或临时邮箱。',
+    'quickstart.mailbox.link': '查看邮箱 API →',
+    'quickstart.client.title': '接入客户端',
+    'quickstart.client.body': '使用 SMTP 发信,用 IMAP 或 POP3 收信,均使用完整邮箱地址和密码。',
+    'quickstart.client.link': '查看协议配置 →',
     'api.title': '发送 API',
     'api.sub': '最小 JSON 契约。下列主机名为占位符,请替换为你的 MailHub 地址。',
     'api.curl': `curl -X POST https://mail.example.com/api/send \\
@@ -68,9 +87,44 @@ const messages = {
     'api.field.to': '收件人字符串或数组',
     'api.field.subject': '邮件主题',
     'api.field.body': '纯文本或 HTML 正文',
+    'mailboxes.title': '邮箱 API 与客户端接入',
+    'mailboxes.sub': '为持续使用创建长期邮箱;为短流程创建可自动过期的临时邮箱。',
+    'mailboxes.api.title': '创建邮箱',
+    'mailboxes.api.body': '使用拥有 mailboxes:write 权限的 Token 调用 POST /api/mailboxes,目标域名必须属于该账号。',
+    'mailboxes.api.mode': 'permanent 或 temporary',
+    'mailboxes.api.address': '传入完整邮箱,或只传 domain 让临时邮箱自动生成地址',
+    'mailboxes.api.expires': '临时邮箱必填,范围为 5 分钟到 30 天',
+    'mailboxes.api.password': '可选;不传时会仅在创建响应中返回安全密码',
+    'mailboxes.client.title': '使用返回的客户端配置',
+    'mailboxes.client.body': '创建响应包含邮箱地址、密码、到期时间,以及可直接使用的 IMAP、POP3、SMTP 配置。',
+    'mailboxes.client.imap': '推荐,支持同步文件夹与已发送邮件',
+    'mailboxes.client.pop3': '适合下载到本地的客户端',
+    'mailboxes.client.smtp': '发信时同样使用完整邮箱地址和密码',
+    'mailboxes.curl': `curl -X POST https://mail.example.com/api/mailboxes \\
+  -H 'Authorization: Bearer &lt;MAILBOX_API_TOKEN&gt;' \\
+  -H 'Content-Type: application/json' \\
+  -d '{
+    "mode": "temporary",
+    "domain": "example.com",
+    "expiresInMinutes": 60
+  }'`,
+    'mailboxes.note': '使用带有 mailboxes:read 权限的 GET /api/mailboxes 查询该账号的邮箱。临时邮箱到期后会停止收信与认证。',
+    'domainsDoc.title': '域名配置',
+    'domainsDoc.sub': '先在 MailHub 中添加域名。控制台 DNS 面板展示的验证、DKIM、SPF、DMARC 与发信主机值是该域名的最终准则。',
+    'domainsDoc.col.purpose': '用途',
+    'domainsDoc.col.record': '记录',
+    'domainsDoc.col.requirement': '要求',
+    'domainsDoc.row.verify': '域名归属验证',
+    'domainsDoc.row.verifyValue': '发布控制台生成的验证值。',
+    'domainsDoc.row.dkim': '发布该域名生成的公钥。',
+    'domainsDoc.row.spf': '仅保留一条 SPF,并包含 MailHub 要求的发信 IP/主机。',
+    'domainsDoc.row.dmarc': '先以监控策略开始,确认对齐后再逐步增强策略。',
+    'domainsDoc.row.mx': '收信',
+    'domainsDoc.row.mxValue': 'MX 指向 MailHub 收信主机,并确保该主机存在 A/AAAA 记录。',
+    'domainsDoc.note': '为提高投递可靠性,还需将发信 IP 的 PTR 配置为 MailHub 发信主机名,并在防火墙放行文档所列 SMTP、IMAP、POP3 端口。',
     'smtp.title': 'SMTP Submission',
     'smtp.sub': '默认监听端口(部署时可覆盖):',
-    'smtp.note': '注册后在控制台创建 SMTP 登录凭据。主机示例:mail.example.com',
+    'smtp.note': 'SMTP、IMAP、POP3 均使用完整邮箱地址和密码。优先使用 465、993、995 TLS 端口;必要时在普通端口启用 STARTTLS/STLS。',
     'webhooks.title': '投递 Webhooks',
     'webhooks.sub': '仅终态事件 —— 向你的系统发送签名 HTTPS 回调。',
     'webhooks.col.status': '状态',
@@ -83,7 +137,10 @@ const messages = {
   'en-US': {
     'nav.features': 'Features',
     'nav.integrate': 'Integrate',
+    'nav.quickstart': 'Quick start',
     'nav.api': 'API',
+    'nav.mailboxes': 'Mailboxes',
+    'nav.domains': 'Domain setup',
     'nav.smtp': 'SMTP',
     'nav.webhooks': 'Webhooks',
     'cta.login': 'Log in',
@@ -114,6 +171,8 @@ const messages = {
     'features.smtp.body': 'App-friendly SMTP listeners with credentials you control.',
     'features.api.title': 'HTTP Send API',
     'features.api.body': 'Bearer tokens and a simple JSON send endpoint for apps and services.',
+    'features.mailboxes.title': 'Mailboxes & receiving',
+    'features.mailboxes.body': 'Create persistent or expiring inboxes with IMAP, POP3, forwarding, and catch-all routing.',
     'features.logs.title': 'Delivery logs',
     'features.logs.body': 'Track queue and MTA outcomes with searchable send history.',
     'features.hooks.title': 'Webhooks',
@@ -131,6 +190,20 @@ const messages = {
     'integrate.hooks.title': 'Webhooks',
     'integrate.hooks.body': 'Subscribe endpoints to terminal delivery events with HMAC signatures.',
     'integrate.hooks.link': 'See webhook events →',
+    'quickstart.title': 'Start in four steps',
+    'quickstart.sub': 'Keep domain, token, mailbox, and delivery configuration in one console.',
+    'quickstart.domain.title': 'Add a domain',
+    'quickstart.domain.body': 'Add your sending domain and publish the DNS records shown by MailHub.',
+    'quickstart.domain.link': 'Review domain setup →',
+    'quickstart.token.title': 'Create a scoped token',
+    'quickstart.token.body': 'Give each integration only the send or mailbox permissions it needs.',
+    'quickstart.token.link': 'Review API access →',
+    'quickstart.mailbox.title': 'Provision mailboxes',
+    'quickstart.mailbox.body': 'Create persistent mailboxes in the console or create persistent and temporary mailboxes by API.',
+    'quickstart.mailbox.link': 'Review mailbox API →',
+    'quickstart.client.title': 'Connect your client',
+    'quickstart.client.body': 'Use SMTP to send and IMAP or POP3 to receive with the full mailbox address and password.',
+    'quickstart.client.link': 'Review protocol settings →',
     'api.title': 'Send API',
     'api.sub': 'Minimal JSON contract. Hostnames below are placeholders — replace with your MailHub host.',
     'api.curl': `curl -X POST https://mail.example.com/api/send \\
@@ -146,9 +219,44 @@ const messages = {
     'api.field.to': 'recipient string or array',
     'api.field.subject': 'message subject',
     'api.field.body': 'plain text or HTML body',
+    'mailboxes.title': 'Mailbox API and clients',
+    'mailboxes.sub': 'Create a persistent address for ongoing work, or an expiring address for a short-lived workflow.',
+    'mailboxes.api.title': 'Create a mailbox',
+    'mailboxes.api.body': 'POST to /api/mailboxes with a token holding mailboxes:write. The target domain must belong to that account.',
+    'mailboxes.api.mode': 'permanent or temporary',
+    'mailboxes.api.address': 'use a full address, or let a temporary mailbox generate one from domain',
+    'mailboxes.api.expires': 'required for temporary mailboxes; 5 minutes to 30 days',
+    'mailboxes.api.password': 'optional; a secure password is returned once when omitted',
+    'mailboxes.client.title': 'Use the returned configuration',
+    'mailboxes.client.body': 'Creation returns the address, password, expiration, and ready-to-use IMAP, POP3, and SMTP settings.',
+    'mailboxes.client.imap': 'recommended for synchronized folders and sent mail',
+    'mailboxes.client.pop3': 'for download-oriented clients',
+    'mailboxes.client.smtp': 'use the same full mailbox address and password to send',
+    'mailboxes.curl': `curl -X POST https://mail.example.com/api/mailboxes \\
+  -H 'Authorization: Bearer &lt;MAILBOX_API_TOKEN&gt;' \\
+  -H 'Content-Type: application/json' \\
+  -d '{
+    "mode": "temporary",
+    "domain": "example.com",
+    "expiresInMinutes": 60
+  }'`,
+    'mailboxes.note': 'Use GET /api/mailboxes with mailboxes:read to list the account\'s mailboxes. A temporary mailbox stops accepting mail and authenticating at its expiration time.',
+    'domainsDoc.title': 'Domain configuration',
+    'domainsDoc.sub': 'Create the domain in MailHub first. Its DNS panel is the source of truth for your exact verification, DKIM, SPF, DMARC, and sender-host values.',
+    'domainsDoc.col.purpose': 'Purpose',
+    'domainsDoc.col.record': 'Record',
+    'domainsDoc.col.requirement': 'Requirement',
+    'domainsDoc.row.verify': 'Domain ownership',
+    'domainsDoc.row.verifyValue': 'Publish the verification value generated in the console.',
+    'domainsDoc.row.dkim': 'Publish the public key generated for this domain.',
+    'domainsDoc.row.spf': 'Keep one SPF record and include the sender IP/host required by MailHub.',
+    'domainsDoc.row.dmarc': 'Start with monitoring, then strengthen the policy after alignment is verified.',
+    'domainsDoc.row.mx': 'Inbound mail',
+    'domainsDoc.row.mxValue': 'Point MX to the MailHub receiving host and ensure that host has an A/AAAA record.',
+    'domainsDoc.note': 'For reliable delivery, also configure the sending IP\'s PTR to match the MailHub sending hostname and allow the documented SMTP, IMAP, and POP3 ports through your firewall.',
     'smtp.title': 'SMTP submission',
     'smtp.sub': 'Default listener ports (override in your deployment):',
-    'smtp.note': 'Create SMTP login credentials in the console after registration. Host example: mail.example.com',
+    'smtp.note': 'Use a mailbox\'s full address and password for SMTP, IMAP, and POP3. Prefer TLS ports 465, 993, and 995; use STARTTLS/STLS on the plain ports when needed.',
     'webhooks.title': 'Delivery webhooks',
     'webhooks.sub': 'Terminal events only — signed HTTPS callbacks to your systems.',
     'webhooks.col.status': 'Status',

+ 34 - 1
src/frontend/landing/landing.css

@@ -295,12 +295,35 @@ main {
 }
 
 .feature-grid,
-.integrate-grid {
+.integrate-grid,
+.steps-grid,
+.doc-grid {
   display: grid;
   grid-template-columns: repeat(3, minmax(0, 1fr));
   gap: 16px;
 }
 
+.steps-grid {
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+.doc-grid {
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.step-number {
+  display: inline-grid;
+  place-items: center;
+  width: 28px;
+  height: 28px;
+  margin-bottom: 14px;
+  border-radius: 50%;
+  background: var(--mh-primary-soft);
+  color: var(--mh-primary);
+  font-size: 13px;
+  font-weight: 700;
+}
+
 .card {
   background: var(--mh-surface);
   border: 1px solid var(--mh-border);
@@ -339,6 +362,14 @@ main {
   font-weight: 600;
 }
 
+.compact-list {
+  margin-top: 12px;
+}
+
+.doc-code-card {
+  margin-top: 16px;
+}
+
 .port-grid {
   display: grid;
   grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -456,6 +487,8 @@ tr:last-child td {
   .hero,
   .feature-grid,
   .integrate-grid,
+  .steps-grid,
+  .doc-grid,
   .port-grid {
     grid-template-columns: 1fr;
   }

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

@@ -162,9 +162,11 @@ export const api = {
   deleteDnsCredential: (id: number) =>
     request<{ deleted: boolean }>(`/api/dns-credentials/${id}`, { method: 'DELETE' }),
   apiTokens: () => request<{ tokens: ApiToken[] }>('/api/api-tokens'),
-  createApiToken: (name: string) =>
-    request<{ token: ApiToken }>('/api/api-tokens', { method: 'POST', data: { name } }),
-  deleteApiToken: (id: number) => request<{ deleted: boolean }>(`/api/api-tokens/${id}`, { method: 'DELETE' }),
+  createApiToken: (data: { name: string; scopes?: string[]; expiresAt?: string | null }) =>
+    request<{ token: ApiToken }>('/api/api-tokens', { method: 'POST', data }),
+  updateApiToken: (id: number, data: { name?: string; scopes?: string[]; expiresAt?: string | null }) =>
+    request<{ token: ApiToken }>(`/api/api-tokens/${id}`, { method: 'PATCH', data }),
+  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) =>
     request<{ domain: Domain }>(`/api/domains/${id}`, { method: 'PATCH', data }),

+ 14 - 0
src/frontend/styles.css

@@ -249,6 +249,12 @@ body {
   margin-bottom: 16px;
 }
 
+.token-form-grid {
+  display: grid;
+  gap: 12px;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
 .token-secret-alert {
   border: 1px solid #fecaca;
   background: #fef2f2;
@@ -259,6 +265,10 @@ body {
   font-weight: 600;
 }
 
+.api-doc-subheading {
+  margin: 4px 0 0 !important;
+}
+
 .api-doc-list {
   color: #475569;
   margin: 0;
@@ -894,6 +904,10 @@ body {
     grid-template-columns: 1fr;
   }
 
+  .token-form-grid {
+    grid-template-columns: 1fr;
+  }
+
   .delivery-funnel-row {
     grid-template-columns: 1fr;
   }

+ 7 - 0
src/frontend/types.ts

@@ -280,6 +280,11 @@ export interface ApiToken {
   name: string;
   tokenPrefix: string;
   token?: string;
+  scopes: Array<'send' | 'mailboxes:read' | 'mailboxes:write' | string>;
+  expiresAt?: string | null;
+  revokedAt?: string | null;
+  revokedReason?: string;
+  status?: 'active' | 'expired' | 'revoked' | string;
   lastUsedAt?: string;
   createdAt: string;
 }
@@ -299,6 +304,8 @@ export interface InboundMailbox {
   passwordSet: boolean;
   passwordRecoverable: boolean;
   status: string;
+  expiresAt?: string | null;
+  temporary?: boolean;
   messageCount: number;
   unreadCount: number;
   lastMessageAt?: string | null;

+ 205 - 76
src/pages/ApiTokens.tsx

@@ -1,14 +1,15 @@
-import { CopyOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons';
-import { Alert, Button, Collapse, Descriptions, Form, Input, Modal, Popconfirm, Space, Table, Tooltip, Typography } from 'antd';
+import { CopyOutlined, DeleteOutlined, EditOutlined, KeyOutlined } from '@ant-design/icons';
+import { Alert, Button, Checkbox, Collapse, Descriptions, Form, Input, Modal, Popconfirm, Space, Table, Tag, Tooltip, Typography } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import { useState } from 'react';
 
 import { CodeBlock } from '../components/common/CodeBlock';
 import { PageHeader } from '../components/common/PageHeader';
 import { SectionCard } from '../components/common/SectionCard';
-import { StatusPill } from '../components/common/StatusPill';
+import { StatusPill, type StatusTone } from '../components/common/StatusPill';
 import {
   buildApiUsageExamples,
+  buildMailboxApiUsageExamples,
   canCopyFullApiToken,
   formatApiTokenPrefix,
   getCreatedApiTokenSecret
@@ -16,82 +17,148 @@ import {
 import { useI18n } from '../frontend/i18n/react';
 import type { ApiToken, RuntimeConfig } from '../frontend/types';
 
+interface TokenFormValues {
+  name: string;
+  scopes: string[];
+  expiresAt?: string;
+}
+
 interface ApiTokensProps {
   tokens: ApiToken[];
   config: RuntimeConfig | null;
   loading?: boolean;
-  onCreate: (name: string) => Promise<ApiToken | null | void>;
-  onDelete: (token: ApiToken) => void;
+  onCreate: (values: { name: string; scopes: string[]; expiresAt?: string | null }) => Promise<ApiToken | null | void>;
+  onUpdate: (token: ApiToken, values: { name: string; scopes: string[]; expiresAt?: string | null }) => Promise<void>;
+  onRevoke: (token: ApiToken) => Promise<void>;
   onCopy: (value: string) => void;
 }
 
-export default function ApiTokens({ tokens, config, loading, onCreate, onDelete, onCopy }: ApiTokensProps) {
+const scopeOptions = [
+  { label: 'send', value: 'send' },
+  { label: 'mailboxes:read', value: 'mailboxes:read' },
+  { label: 'mailboxes:write', value: 'mailboxes:write' }
+];
+
+export default function ApiTokens({ tokens, config, loading, onCreate, onUpdate, onRevoke, onCopy }: ApiTokensProps) {
   const { t } = useI18n();
-  const [form] = Form.useForm<{ name: string }>();
+  const [form] = Form.useForm<TokenFormValues>();
+  const [editForm] = Form.useForm<TokenFormValues>();
   const [createdToken, setCreatedToken] = useState<ApiToken | null>(null);
+  const [editingToken, setEditingToken] = useState<ApiToken | null>(null);
   const endpoint = `${config?.appBaseUrl || window.location.origin}/api/send`;
+  const mailboxEndpoint = `${config?.appBaseUrl || window.location.origin}/api/mailboxes`;
   const examples = buildApiUsageExamples({
     endpoint,
     token: '<USER_API_TOKEN>',
     from: 'noreply@example.com',
     to: 'user@example.com'
   });
+  const mailboxExamples = buildMailboxApiUsageExamples({
+    endpoint: mailboxEndpoint,
+    token: '<MAILBOX_API_TOKEN>'
+  });
 
   const columns: ColumnsType<ApiToken> = [
-    { title: t('tokens.name'), dataIndex: 'name' },
+    { title: t('tokens.name'), dataIndex: 'name', width: 190 },
     {
       title: t('tokens.prefix'),
       dataIndex: 'tokenPrefix',
-      render: (_, token) => <Typography.Text code>{formatApiTokenPrefix(token)}</Typography.Text>
+      width: 168,
+      render: (_value, token) => <Typography.Text code>{formatApiTokenPrefix(token)}</Typography.Text>
+    },
+    {
+      title: t('tokens.scopes'),
+      dataIndex: 'scopes',
+      width: 260,
+      render: (scopes: string[]) => (
+        <Space size={[4, 4]} wrap>
+          {(scopes || []).map((scope) => <Tag key={scope}>{scope}</Tag>)}
+        </Space>
+      )
+    },
+    {
+      title: t('tokens.status'),
+      dataIndex: 'status',
+      width: 104,
+      render: (_value, token) => <StatusPill tone={tokenStatusTone(token.status)}>{tokenStatusText(token.status, t)}</StatusPill>
     },
-    { title: t('tokens.lastUsed'), dataIndex: 'lastUsedAt', render: (value) => value ? new Date(value).toLocaleString() : t('tokens.neverUsed') },
-    { title: t('tokens.createdAt'), dataIndex: 'createdAt', render: (value) => new Date(value).toLocaleString() },
+    { title: t('tokens.expiresAt'), dataIndex: 'expiresAt', width: 176, render: formatOptionalTime },
+    { title: t('tokens.lastUsed'), dataIndex: 'lastUsedAt', width: 176, render: (value) => value ? formatOptionalTime(value) : t('tokens.neverUsed') },
     {
       title: t('tokens.actions'),
-      render: (_, token) => (
-        <Space>
+      fixed: 'right',
+      width: 142,
+      render: (_value, token) => (
+        <Space size={4}>
           <Tooltip title={t('tokens.copyPrefix')}>
-            <Button icon={<CopyOutlined />} onClick={() => onCopy(token.tokenPrefix)} />
+            <Button aria-label={t('tokens.copyPrefix')} icon={<CopyOutlined />} onClick={() => onCopy(token.tokenPrefix)} />
           </Tooltip>
-          <Tooltip title={t('tokens.secretUnavailable')}>
-            <Button disabled icon={<KeyOutlined />} />
+          <Tooltip title={t('tokens.edit')}>
+            <Button
+              aria-label={t('tokens.edit')}
+              disabled={token.status === 'revoked'}
+              icon={<EditOutlined />}
+              onClick={() => openEdit(token)}
+            />
           </Tooltip>
-          <Popconfirm title={t('tokens.deleteConfirm')} onConfirm={() => onDelete(token)}>
-            <Button danger icon={<DeleteOutlined />} />
+          <Popconfirm title={t('tokens.revokeConfirm')} onConfirm={() => void onRevoke(token)} disabled={token.status === 'revoked'}>
+            <Button aria-label={t('tokens.revoke')} danger disabled={token.status === 'revoked'} icon={<DeleteOutlined />} />
           </Popconfirm>
         </Space>
       )
     }
   ];
 
-  async function submit(values: { name: string }) {
-    const token = await onCreate(values.name);
+  async function submit(values: TokenFormValues) {
+    const token = await onCreate(toTokenPayload(values));
     if (token && canCopyFullApiToken(token)) setCreatedToken(token);
     form.resetFields();
+    form.setFieldsValue({ scopes: ['send'] });
+  }
+
+  function openEdit(token: ApiToken) {
+    setEditingToken(token);
+    editForm.setFieldsValue({
+      name: token.name,
+      scopes: token.scopes || ['send'],
+      expiresAt: toDatetimeLocal(token.expiresAt)
+    });
+  }
+
+  async function saveEdit() {
+    if (!editingToken) return;
+    const values = await editForm.validateFields();
+    await onUpdate(editingToken, toTokenPayload(values));
+    setEditingToken(null);
   }
 
   return (
     <>
       <Space direction="vertical" size={20} className="full-width">
-        <PageHeader title={t('nav.tokens')} />
+        <PageHeader title={t('nav.tokens')} subtitle={t('tokens.subtitle')} />
 
         <SectionCard title={t('tokens.createTitle')}>
-          <Form form={form} layout="inline" onFinish={submit}>
-            <Form.Item name="name" rules={[{ required: true, message: t('tokens.nameRequired') }]}>
-              <Input placeholder={t('tokens.namePlaceholder')} />
+          <Form form={form} layout="vertical" initialValues={{ scopes: ['send'] }} onFinish={submit}>
+            <div className="token-form-grid">
+              <Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}>
+                <Input placeholder={t('tokens.namePlaceholder')} autoComplete="off" />
+              </Form.Item>
+              <Form.Item name="expiresAt" label={t('tokens.expiresAt')} extra={t('tokens.expiresAtExtra')}>
+                <Input type="datetime-local" />
+              </Form.Item>
+            </div>
+            <Form.Item name="scopes" label={t('tokens.scopes')} rules={[{ required: true, type: 'array', min: 1, message: t('tokens.scopesRequired') }]} extra={t('tokens.scopesExtra')}>
+              <Checkbox.Group options={scopeOptions} />
             </Form.Item>
-            <Button type="primary" htmlType="submit" loading={loading}>
+            <Button type="primary" htmlType="submit" loading={loading} icon={<KeyOutlined />}>
               {t('tokens.create')}
             </Button>
           </Form>
         </SectionCard>
 
-        <SectionCard
-          title={t('tokens.listTitle')}
-          extra={<StatusPill tone="neutral">{tokens.length}</StatusPill>}
-        >
+        <SectionCard title={t('tokens.listTitle')} extra={<StatusPill tone="neutral">{tokens.length}</StatusPill>}>
           <Alert type="info" showIcon message={t('tokens.prefixOnlyHelp')} className="token-list-alert" />
-          <Table rowKey="id" columns={columns} dataSource={tokens} scroll={{ x: 900 }} />
+          <Table rowKey="id" columns={columns} dataSource={tokens} scroll={{ x: 1260 }} />
         </SectionCard>
 
         <SectionCard title={t('tokens.docsTitle')}>
@@ -99,34 +166,42 @@ export default function ApiTokens({ tokens, config, loading, onCreate, onDelete,
             <Alert type="info" showIcon message={t('tokens.noTokenHint')} />
             <Descriptions column={1} bordered size="small">
               <Descriptions.Item label={t('tokens.endpoint')}>{copyable(endpoint, onCopy)}</Descriptions.Item>
+              <Descriptions.Item label={t('tokens.mailboxEndpoint')}>{copyable(mailboxEndpoint, onCopy)}</Descriptions.Item>
               <Descriptions.Item label={t('tokens.authHeader')}>{copyable(t('tokens.authHeaderValue'), onCopy)}</Descriptions.Item>
               <Descriptions.Item label={t('tokens.contentType')}><Typography.Text code>application/json</Typography.Text></Descriptions.Item>
             </Descriptions>
-            <SectionCard title={t('tokens.requestFields')}>
-              <ul className="api-doc-list">
-                <li>{t('tokens.fieldFrom')}</li>
-                <li>{t('tokens.fieldTo')}</li>
-                <li>{t('tokens.fieldSubject')}</li>
-                <li>{t('tokens.fieldText')}</li>
-                <li>{t('tokens.fieldHtml')}</li>
-              </ul>
-            </SectionCard>
             <Collapse
-              defaultActiveKey={['curl']}
+              defaultActiveKey={['send']}
               items={[
-                { key: 'curl', label: t('tokens.curlExample'), children: <CodeBlock value={examples.curl} onCopy={onCopy} /> },
-                { key: 'node', label: t('tokens.nodeExample'), children: <CodeBlock value={examples.nodeFetch} onCopy={onCopy} /> },
-                { key: 'body', label: t('tokens.requestExample'), children: <CodeBlock value={examples.requestBody} onCopy={onCopy} /> },
-                { key: 'response', label: t('tokens.responseExample'), children: <CodeBlock value={examples.successResponse} onCopy={onCopy} /> }
+                {
+                  key: 'send',
+                  label: t('tokens.sendApi'),
+                  children: <ApiExamples examples={examples} onCopy={onCopy} labels={{ curl: t('tokens.curlExample'), request: t('tokens.requestExample'), response: t('tokens.responseExample') }} />
+                },
+                {
+                  key: 'mailboxes',
+                  label: t('tokens.mailboxApi'),
+                  children: (
+                    <Space direction="vertical" size={12} className="full-width">
+                      <Alert type="info" showIcon message={t('tokens.mailboxApiHint')} />
+                      <Typography.Text strong>{t('tokens.permanentMailbox')}</Typography.Text>
+                      <CodeBlock value={mailboxExamples.permanentCurl} onCopy={onCopy} />
+                      <Typography.Text strong>{t('tokens.temporaryMailbox')}</Typography.Text>
+                      <CodeBlock value={mailboxExamples.temporaryCurl} onCopy={onCopy} />
+                      <Typography.Text strong>{t('tokens.mailboxResponse')}</Typography.Text>
+                      <CodeBlock value={mailboxExamples.successResponse} onCopy={onCopy} />
+                    </Space>
+                  )
+                }
               ]}
             />
-            <SectionCard title={t('tokens.securityTips')}>
-              <ul className="api-doc-list">
-                <li>{t('tokens.securityTipStore')}</li>
-                <li>{t('tokens.securityTipRotate')}</li>
-                <li>{t('tokens.securityTipDomain')}</li>
-              </ul>
-            </SectionCard>
+            <Typography.Title level={5} className="api-doc-subheading">{t('tokens.securityTips')}</Typography.Title>
+            <ul className="api-doc-list">
+              <li>{t('tokens.securityTipStore')}</li>
+              <li>{t('tokens.securityTipRotate')}</li>
+              <li>{t('tokens.securityTipScopes')}</li>
+              <li>{t('tokens.securityTipDomain')}</li>
+            </ul>
           </Space>
         </SectionCard>
       </Space>
@@ -136,44 +211,98 @@ export default function ApiTokens({ tokens, config, loading, onCreate, onDelete,
         open={Boolean(createdToken)}
         onCancel={() => setCreatedToken(null)}
         footer={[
-          <Button key="close" onClick={() => setCreatedToken(null)}>
-            {t('common.cancel')}
-          </Button>,
-          <Button
-            key="copy"
-            type="primary"
-            icon={<CopyOutlined />}
-            onClick={() => {
-              const secret = getCreatedApiTokenSecret(createdToken || {});
-              if (secret) onCopy(secret);
-            }}
-          >
-            {t('tokens.copyCreated')}
-          </Button>
+          <Button key="close" onClick={() => setCreatedToken(null)}>{t('common.cancel')}</Button>,
+          <Button key="copy" type="primary" icon={<CopyOutlined />} onClick={() => {
+            const secret = getCreatedApiTokenSecret(createdToken || {});
+            if (secret) onCopy(secret);
+          }}>{t('tokens.copyCreated')}</Button>
         ]}
       >
         <Space direction="vertical" size={16} className="full-width">
-          <Alert
-            type="error"
-            showIcon
-            message={t('tokens.createdWarning')}
-            className="token-secret-alert"
-          />
-          <CodeBlock
-            value={getCreatedApiTokenSecret(createdToken || {}) || ''}
-            onCopy={onCopy}
-          />
+          <Alert type="error" showIcon message={t('tokens.createdWarning')} className="token-secret-alert" />
+          <CodeBlock value={getCreatedApiTokenSecret(createdToken || {}) || ''} onCopy={onCopy} />
         </Space>
       </Modal>
+
+      <Modal
+        title={t('tokens.editTitle')}
+        open={Boolean(editingToken)}
+        confirmLoading={loading}
+        onCancel={() => setEditingToken(null)}
+        onOk={() => void saveEdit()}
+      >
+        <Form form={editForm} layout="vertical">
+          <Form.Item name="name" label={t('tokens.name')} rules={[{ required: true, message: t('tokens.nameRequired') }]}>
+            <Input autoComplete="off" />
+          </Form.Item>
+          <Form.Item name="expiresAt" label={t('tokens.expiresAt')} extra={t('tokens.expiresAtExtra')}>
+            <Input type="datetime-local" />
+          </Form.Item>
+          <Form.Item name="scopes" label={t('tokens.scopes')} rules={[{ required: true, type: 'array', min: 1, message: t('tokens.scopesRequired') }]}>
+            <Checkbox.Group options={scopeOptions} />
+          </Form.Item>
+        </Form>
+      </Modal>
     </>
   );
 }
 
+function ApiExamples({ examples, onCopy, labels }: {
+  examples: ReturnType<typeof buildApiUsageExamples>;
+  onCopy: (value: string) => void;
+  labels: { curl: string; request: string; response: string };
+}) {
+  return (
+    <Space direction="vertical" size={12} className="full-width">
+      <Typography.Text strong>{labels.curl}</Typography.Text>
+      <CodeBlock value={examples.curl} onCopy={onCopy} />
+      <Typography.Text strong>{labels.request}</Typography.Text>
+      <CodeBlock value={examples.requestBody} onCopy={onCopy} />
+      <Typography.Text strong>{labels.response}</Typography.Text>
+      <CodeBlock value={examples.successResponse} onCopy={onCopy} />
+    </Space>
+  );
+}
+
+function toTokenPayload(values: TokenFormValues) {
+  return {
+    name: values.name,
+    scopes: values.scopes,
+    expiresAt: values.expiresAt ? new Date(values.expiresAt).toISOString() : null
+  };
+}
+
+function toDatetimeLocal(value?: string | null) {
+  if (!value) return '';
+  const date = new Date(value);
+  if (Number.isNaN(date.getTime())) return '';
+  const offset = date.getTimezoneOffset() * 60_000;
+  return new Date(date.getTime() - offset).toISOString().slice(0, 16);
+}
+
+function tokenStatusTone(status?: string): StatusTone {
+  if (status === 'active') return 'success';
+  if (status === 'expired') return 'warning';
+  if (status === 'revoked') return 'error';
+  return 'neutral';
+}
+
+function tokenStatusText(status: string | undefined, t: (key: string) => string) {
+  if (status === 'active') return t('tokens.statusActive');
+  if (status === 'expired') return t('tokens.statusExpired');
+  if (status === 'revoked') return t('tokens.statusRevoked');
+  return '-';
+}
+
+function formatOptionalTime(value?: string | null) {
+  return value ? new Date(value).toLocaleString() : '-';
+}
+
 function copyable(value: string, onCopy: (value: string) => void) {
   return (
     <Space>
       <Typography.Text code className="inline-code-value">{value}</Typography.Text>
-      <Button size="small" icon={<CopyOutlined />} onClick={() => onCopy(value)} />
+      <Button aria-label="Copy" size="small" icon={<CopyOutlined />} onClick={() => onCopy(value)} />
     </Space>
   );
 }

+ 123 - 9
src/server.js

@@ -6,6 +6,7 @@ import path from 'node:path';
 import { fileURLToPath, domainToASCII } from 'node:url';
 import {
   authenticateUser,
+  authenticateApiToken,
   approveUser,
   claimLegacyData,
   createApiToken,
@@ -17,7 +18,6 @@ import {
   createUserWithAccountToken,
   createWebhook,
   consumeAccountToken,
-  deleteApiToken,
   deleteDnsCredential,
   deleteDomain,
   deleteSmtpCredential,
@@ -28,6 +28,7 @@ import {
   getDnsCredential,
   getDomain,
   getDomainByName,
+  getApiToken,
   getInboundMessage,
   getSendEvent,
   getSendAnalytics,
@@ -72,6 +73,7 @@ import {
   transferDnsCredential,
   transferDomain,
   updateInboundMailbox,
+  updateApiToken,
   updateDkim,
   updateDomain,
   updateUser,
@@ -81,7 +83,7 @@ import {
   findSendEventByTrackingToken,
   findTrackingLinkByToken,
   recordTrackingEvent,
-  verifyApiToken,
+  revokeApiToken,
   verifyUserCredentials
 } from './db.js';
 import { applyDnsSetup, testDnsCredential } from './dns-providers.js';
@@ -539,6 +541,7 @@ async function handleApi(req, res, url, user) {
     }
   }
   if (method === 'POST' && pathname === '/api/send') {
+    if (!requireApiTokenScope(req, res, 'send')) return;
     const body = await readJson(req);
     const smtpRelayId = Number(body.smtpRelayId || 0) || null;
     if (smtpRelayId && !getSmtpRelay(smtpRelayId, user.id)) {
@@ -548,17 +551,61 @@ async function handleApi(req, res, url, user) {
     return sendJson(res, 202, result);
   }
 
+  if (method === 'GET' && pathname === '/api/mailboxes') {
+    if (!requireApiTokenScope(req, res, 'mailboxes:read')) return;
+    return sendJson(res, 200, { mailboxes: listInboundMailboxes(user.id) });
+  }
+  if (method === 'POST' && pathname === '/api/mailboxes') {
+    if (!requireApiTokenScope(req, res, 'mailboxes:write')) return;
+    const body = await readJson(req);
+    try {
+      const request = mailboxApiRequest(body);
+      const mailbox = createInboundMailbox(user.id, request.mailbox);
+      return sendJson(res, 201, {
+        mailbox,
+        mode: request.mode,
+        password: request.password,
+        clientConfig: mailboxClientConfig(mailbox, { password: request.password })
+      });
+    } catch (error) {
+      if (isUniqueError(error)) return sendJson(res, 409, { error: '该收信邮箱已存在。' });
+      return sendJson(res, 400, { error: error.message || '邮箱创建失败。' });
+    }
+  }
+
   if (method === 'GET' && pathname === '/api/api-tokens') {
     return sendJson(res, 200, { tokens: listApiTokens(user.id) });
   }
   if (method === 'POST' && pathname === '/api/api-tokens') {
     const body = await readJson(req);
-    return sendJson(res, 201, { token: createApiToken(user.id, body.name) });
+    try {
+      return sendJson(res, 201, {
+        token: createApiToken(user.id, body.name, {
+          scopes: body.scopes,
+          expiresAt: body.expiresAt
+        })
+      });
+    } catch (error) {
+      return sendJson(res, 400, { error: error.message || 'API Token 创建失败。' });
+    }
   }
   const tokenMatch = pathname.match(/^\/api\/api-tokens\/(\d+)$/);
+  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 不存在。' });
+    } catch (error) {
+      return sendJson(res, 400, { error: error.message || 'API Token 更新失败。' });
+    }
+  }
   if (tokenMatch && method === 'DELETE') {
-    const deleted = deleteApiToken(Number(tokenMatch[1]), user.id);
-    return sendJson(res, deleted ? 200 : 404, { deleted });
+    const revoked = revokeApiToken(Number(tokenMatch[1]), user.id);
+    return sendJson(res, revoked ? 200 : 404, {
+      revoked,
+      deleted: revoked,
+      token: revoked ? getApiToken(Number(tokenMatch[1]), user.id) : null
+    });
   }
 
   if (method === 'GET' && pathname === '/api/dns-credentials') {
@@ -1529,15 +1576,32 @@ function getRequestUser(req, pathname) {
     const user = authenticateUser(decoded.slice(0, index), decoded.slice(index + 1));
     if (user) return user;
   }
-  if (pathname === '/api/send' && auth.startsWith('Bearer ')) {
+  if (isTokenApiPath(pathname) && auth.startsWith('Bearer ')) {
     const token = auth.slice(7);
-    const user = verifyApiToken(token);
-    if (user) return user;
-    if (envConfig.legacyApiToken && safeEqual(token, envConfig.legacyApiToken)) return getAdminUser();
+    const authenticated = authenticateApiToken(token);
+    if (authenticated) {
+      req.mailhubApiToken = authenticated.token;
+      return authenticated.user;
+    }
+    if (pathname === '/api/send' && envConfig.legacyApiToken && safeEqual(token, envConfig.legacyApiToken)) {
+      req.mailhubApiToken = { scopes: ['send'], tokenPrefix: 'legacy' };
+      return getAdminUser();
+    }
   }
   return null;
 }
 
+function isTokenApiPath(pathname) {
+  return pathname === '/api/send' || pathname === '/api/mailboxes';
+}
+
+function requireApiTokenScope(req, res, scope) {
+  const token = req.mailhubApiToken;
+  if (!token || token.scopes?.includes(scope)) return true;
+  sendJson(res, 403, { error: `当前 API Token 缺少 ${scope} 权限。` });
+  return false;
+}
+
 function getSessionUser(req) {
   const token = parseCookies(req.headers.cookie || '').mailhub_session;
   if (!token || !token.includes('.')) return null;
@@ -1881,6 +1945,56 @@ function smtpRelayPatch(body) {
   return patch;
 }
 
+function mailboxApiRequest(body) {
+  const mode = normalizeMailboxApiMode(body.mode);
+  if (!mode) throw new Error('邮箱类型必须为 permanent 或 temporary。');
+  const address = resolveMailboxApiAddress(body, mode);
+  const password = String(body.password || '') || crypto.randomBytes(18).toString('base64url');
+  if (password.length < 8) throw new Error('邮箱密码至少需要 8 位。');
+  const expiresAt = mode === 'temporary' ? temporaryMailboxExpiresAt(body.expiresInMinutes) : null;
+  return {
+    mode,
+    password,
+    mailbox: {
+      address,
+      password,
+      displayName: body.displayName,
+      aliases: body.aliases,
+      forwardTo: body.forwardTo,
+      keepForwarded: body.keepForwarded,
+      quotaMb: body.quotaMb,
+      expiresAt
+    }
+  };
+}
+
+function normalizeMailboxApiMode(value) {
+  const mode = String(value || 'permanent').trim().toLowerCase();
+  if (['permanent', 'long'].includes(mode)) return 'permanent';
+  if (['temporary', 'temp'].includes(mode)) return 'temporary';
+  return '';
+}
+
+function resolveMailboxApiAddress(body, mode) {
+  const address = String(body.address || '').trim().toLowerCase();
+  if (address) return address;
+  const domain = normalizeDomain(body.domain);
+  if (!domain) throw new Error('请提供已添加域名的 address,或提供有效的 domain。');
+  const specifiedLocalPart = String(body.localPart || '').trim().toLowerCase();
+  const localPart = specifiedLocalPart || (mode === 'temporary' ? `tmp-${crypto.randomBytes(7).toString('hex')}` : '');
+  if (!localPart || !/^[^@\s]+$/.test(localPart)) throw new Error('邮箱 localPart 格式不正确。');
+  return `${localPart}@${domain}`;
+}
+
+function temporaryMailboxExpiresAt(value) {
+  const minutes = Number(value);
+  const maxMinutes = 30 * 24 * 60;
+  if (!Number.isInteger(minutes) || minutes < 5 || minutes > maxMinutes) {
+    throw new Error(`临时邮箱 expiresInMinutes 必须是 5 到 ${maxMinutes} 分钟之间的整数。`);
+  }
+  return new Date(Date.now() + minutes * 60 * 1000).toISOString();
+}
+
 function inboundMailboxPatch(body) {
   const patch = {};
   for (const key of ['address', 'displayName', 'password', 'aliases', 'forwardTo', 'keepForwarded', 'quotaMb', 'status']) {

+ 28 - 0
test/db.test.js

@@ -7,6 +7,7 @@ import { test } from 'node:test';
 import { DatabaseSync } from 'node:sqlite';
 import {
   authenticateUser,
+  authenticateApiToken,
   claimLegacyData,
   consumeAccountToken,
   createApiToken,
@@ -33,6 +34,7 @@ import {
   getUser,
   initDatabase,
   listAuditLogs,
+  listApiTokens,
   listDomains,
   listInboundMailboxes,
   listInboundMessages,
@@ -56,6 +58,7 @@ import {
   transferApiTokens,
   transferDnsCredential,
   transferDomain,
+  updateApiToken,
   updateUser,
   updateUserStatus,
   executeUserMerge,
@@ -63,6 +66,7 @@ import {
   findSendEventByTrackingToken,
   findTrackingLinkByToken,
   verifyApiToken,
+  revokeApiToken,
   verifySmtpCredential
 } from '../src/db.js';
 import {
@@ -583,6 +587,30 @@ test('isolates domains, smtp credentials, and api tokens by user', () => {
   assert.equal(verifyApiToken(token.token).id, alice.id);
 });
 
+test('scopes, updates, and revokes API tokens without exposing their secret', () => {
+  initDatabase(tempDataDir(), 'test-secret');
+  const user = seedAdminUser({ username: 'admin', email: 'admin@example.com', password: 'password123' });
+  const token = createApiToken(user.id, 'Mailbox automation', {
+    scopes: ['mailboxes:read', 'mailboxes:write'],
+    expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString()
+  });
+
+  assert.deepEqual(token.scopes, ['mailboxes:read', 'mailboxes:write']);
+  assert.equal(token.status, 'active');
+  assert.equal(token.token.startsWith('mh_'), true);
+  assert.equal(authenticateApiToken(token.token)?.user.id, user.id);
+  assert.deepEqual(authenticateApiToken(token.token)?.token.scopes, ['mailboxes:read', 'mailboxes:write']);
+
+  const updated = updateApiToken(token.id, user.id, { name: 'Mailbox worker', scopes: ['send'] });
+  assert.equal(updated.name, 'Mailbox worker');
+  assert.deepEqual(updated.scopes, ['send']);
+
+  assert.equal(revokeApiToken(token.id, user.id, 'worker retired'), true);
+  assert.equal(authenticateApiToken(token.token), null);
+  assert.equal(listApiTokens(user.id)[0].status, 'revoked');
+  assert.equal(listApiTokens(user.id)[0].revokedReason, 'worker retired');
+});
+
 test('stores multiple smtp login credentials per user', () => {
   initDatabase(tempDataDir(), 'test-secret');
   const alice = createUser({ username: 'alice', email: 'alice@example.com', password: 'password123' });

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

@@ -3,6 +3,7 @@ import { test } from 'node:test';
 
 import {
   buildApiUsageExamples,
+  buildMailboxApiUsageExamples,
   canCopyFullApiToken,
   formatApiTokenPrefix,
   getCopyableApiToken,
@@ -42,3 +43,16 @@ test('builds API usage examples with endpoint, bearer token, and message body',
   assert.match(examples.requestBody, /"to": "user@example.com"/);
   assert.match(examples.successResponse, /"queued": true/);
 });
+
+test('builds persistent and temporary mailbox API examples', () => {
+  const examples = buildMailboxApiUsageExamples({
+    endpoint: 'https://mailhub.example.com/api/mailboxes',
+    token: 'mh_mailbox_token',
+    domain: 'example.com'
+  });
+
+  assert.match(examples.permanentCurl, /"mode": "permanent"/);
+  assert.match(examples.temporaryCurl, /Authorization: Bearer mh_mailbox_token/);
+  assert.match(examples.temporary, /"expiresInMinutes": 60/);
+  assert.match(examples.successResponse, /"temporary": true/);
+});

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

@@ -219,6 +219,106 @@ test('users can manage inbound mailboxes and read inbound messages', async () =>
   }
 });
 
+test('scoped API tokens create persistent and temporary mailboxes', async () => {
+  const { child, baseUrl } = await startTestServer();
+
+  try {
+    const cookie = await login(baseUrl, 'admin', 'password123');
+    await createSendingDomain(baseUrl, cookie, { domain: 'mailbox-token-api.example' });
+
+    const sendOnlyResponse = await fetch(`${baseUrl}/api/api-tokens`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Cookie: cookie },
+      body: JSON.stringify({ name: 'send only', scopes: ['send'] })
+    });
+    assert.equal(sendOnlyResponse.status, 201);
+    const sendOnly = (await sendOnlyResponse.json()).token;
+
+    const denied = await fetch(`${baseUrl}/api/mailboxes`, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${sendOnly.token}`,
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({ mode: 'temporary', domain: 'mailbox-token-api.example', expiresInMinutes: 60 })
+    });
+    assert.equal(denied.status, 403);
+
+    const tokenResponse = await fetch(`${baseUrl}/api/api-tokens`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Cookie: cookie },
+      body: JSON.stringify({ name: 'mailboxes', scopes: ['mailboxes:read', 'mailboxes:write'] })
+    });
+    assert.equal(tokenResponse.status, 201);
+    const mailboxToken = (await tokenResponse.json()).token;
+
+    const temporary = await fetch(`${baseUrl}/api/mailboxes`, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${mailboxToken.token}`,
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({ mode: 'temporary', domain: 'mailbox-token-api.example', expiresInMinutes: 60 })
+    });
+    assert.equal(temporary.status, 201);
+    const temporaryBody = await temporary.json();
+    assert.match(temporaryBody.mailbox.address, /^tmp-[a-f0-9]+@mailbox-token-api\.example$/);
+    assert.equal(temporaryBody.mailbox.temporary, true);
+    assert.ok(temporaryBody.mailbox.expiresAt);
+    assert.equal(temporaryBody.password.length >= 8, true);
+    assert.equal(temporaryBody.clientConfig.incoming.protocol, 'IMAP');
+
+    const permanent = await fetch(`${baseUrl}/api/mailboxes`, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${mailboxToken.token}`,
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({
+        mode: 'permanent',
+        address: 'support@mailbox-token-api.example',
+        password: 'mailbox-pass-123'
+      })
+    });
+    assert.equal(permanent.status, 201);
+    const permanentBody = await permanent.json();
+    assert.equal(permanentBody.mailbox.temporary, false);
+    assert.equal(permanentBody.mailbox.expiresAt, null);
+    assert.equal(permanentBody.clientConfig.outgoing.protocol, 'SMTP');
+
+    const listed = await fetch(`${baseUrl}/api/mailboxes`, {
+      headers: { Authorization: `Bearer ${mailboxToken.token}` }
+    });
+    assert.equal(listed.status, 200);
+    assert.equal((await listed.json()).mailboxes.length, 2);
+
+    const sendDenied = await fetch(`${baseUrl}/api/send`, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${mailboxToken.token}`,
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({ from: 'noreply@mailbox-token-api.example', to: 'user@example.com', text: 'blocked' })
+    });
+    assert.equal(sendDenied.status, 403);
+
+    const revoke = await fetch(`${baseUrl}/api/api-tokens/${mailboxToken.id}`, {
+      method: 'DELETE',
+      headers: { Cookie: cookie }
+    });
+    assert.equal(revoke.status, 200);
+    assert.equal((await revoke.json()).token.status, 'revoked');
+
+    const rejectedAfterRevoke = await fetch(`${baseUrl}/api/mailboxes`, {
+      headers: { Authorization: `Bearer ${mailboxToken.token}` }
+    });
+    assert.equal(rejectedAfterRevoke.status, 401);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
 test('users can manage outbound smtp relays with recoverable passwords and send through a selected relay', async () => {
   const relayServer = await startFakeSmtpServer();
   const { child, baseUrl } = await startTestServer();

+ 2 - 0
test/server-landing.test.js

@@ -18,6 +18,8 @@ test('anonymous root serves landing page with no-store cache header', async () =
     const html = await response.text();
     assert.match(html, /MailHub/i);
     assert.match(html, /data-i18n|hero|Get started|开始使用|landing/i);
+    assert.match(html, /api\/mailboxes|mailboxes|邮箱/i);
+    assert.match(html, /domainsDoc|域名配置|Domain configuration/i);
     assert.doesNotMatch(html, /id="root"/);
   } finally {
     child.kill('SIGTERM');

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini