فهرست منبع

feat: add one-click Webmail login

AI-Co-Authored-By: Codex
chendeben 1 ماه پیش
والد
کامیت
39b7c16d4c
72فایلهای تغییر یافته به همراه2930 افزوده شده و 59 حذف شده
  1. 9 0
      .env.example
  2. 4 0
      docker-compose.yml
  3. 14 1
      docker/dovecot/auth.lua
  4. 14 0
      docker/dovecot/mailhub.conf
  5. 22 0
      docker/roundcube/config.inc.php.example
  6. 15 0
      docker/roundcube/plugins/mailhub_sso/config.inc.php.dist
  7. 3 0
      docker/roundcube/plugins/mailhub_sso/localization/en_US.inc
  8. 3 0
      docker/roundcube/plugins/mailhub_sso/localization/zh_CN.inc
  9. 317 0
      docker/roundcube/plugins/mailhub_sso/mailhub_sso.php
  10. 102 0
      docs/webmail-sso.md
  11. 0 0
      public/assets/Account-D0Oinw-4.js
  12. 0 0
      public/assets/Account-nhuoQQlz.js
  13. 0 0
      public/assets/Admin-Dps5Iat-.js
  14. 0 0
      public/assets/Admin-xFOA6-bp.js
  15. 0 0
      public/assets/ApiTokens-Wdpy_Ba_.js
  16. 1 1
      public/assets/CodeBlock-z4Jy8-Be.js
  17. 1 1
      public/assets/CopyOutlined-UbpGsh_G.js
  18. 0 1
      public/assets/Dashboard-pDsDO0ai.js
  19. 2 2
      public/assets/DashboardCharts-u4ifs3zc.js
  20. 0 0
      public/assets/DnsApi-BBzKTBIZ.js
  21. 0 0
      public/assets/DomainDetail-DHFMM8tt.js
  22. 0 0
      public/assets/Domains-BxstGUcc.js
  23. 1 1
      public/assets/EditOutlined-Xr2lIYb3.js
  24. 1 1
      public/assets/EllipsisOutlined-Dtb0gNnp.js
  25. 1 0
      public/assets/ExportOutlined-CUq8majW.js
  26. 0 0
      public/assets/Inbox-7UH19KPt.js
  27. 0 0
      public/assets/Inbox-Dm_szY_4.js
  28. 1 1
      public/assets/MailboxPermissionTags-W1srDjlA.js
  29. 1 1
      public/assets/PlusOutlined-DoGMjNQS.js
  30. 1 1
      public/assets/ReloadOutlined-9AZGcoed.js
  31. 1 1
      public/assets/SearchOutlined-CGjQXOn5.js
  32. 0 0
      public/assets/SectionCard--w8cgqOe.js
  33. 0 0
      public/assets/SendingLogs-Bf9dLYVC.js
  34. 0 0
      public/assets/Settings-vN7jHaFO.js
  35. 0 0
      public/assets/SmtpCredentials-Br2jaH-D.js
  36. 1 1
      public/assets/StatusTag-BJWRAXJ8.js
  37. 1 1
      public/assets/ThunderboltOutlined-CLLfW6Eb.js
  38. 0 0
      public/assets/Webhooks-C3xF4Kb3.js
  39. 1 1
      public/assets/form-Dp5ppmPu.js
  40. 1 1
      public/assets/grid-DzYWI3CD.js
  41. 0 1
      public/assets/index-DG1WRIOp.js
  42. 0 0
      public/assets/list-BnsJjSCP.js
  43. 0 0
      public/assets/login-CI8wBEa5.js
  44. 1 0
      public/assets/modal-BGjs3E6e.js
  45. 0 1
      public/assets/modal-Ba-m1ypG.js
  46. 0 0
      public/assets/popconfirm-cq-yGQ-9.js
  47. 1 0
      public/assets/row-CUs1UAJQ.js
  48. 0 1
      public/assets/row-Cd6f9U9h.js
  49. 0 0
      public/assets/table-DJa-z-YA.js
  50. 0 0
      public/assets/theme-Dpxy803y.js
  51. 2 2
      public/index.html
  52. 4 4
      public/login.html
  53. 43 19
      scripts/prepare-dovecot.sh
  54. 480 0
      src/db.js
  55. 44 3
      src/dovecot-auth-server.js
  56. 35 0
      src/frontend/services/api.ts
  57. 7 0
      src/frontend/types.ts
  58. 76 4
      src/pages/Account.tsx
  59. 80 3
      src/pages/Inbox.tsx
  60. 48 0
      src/server.js
  61. 30 0
      src/submission.js
  62. 230 0
      src/webmail-sso.js
  63. 126 0
      test/dovecot-auth-server.test.js
  64. 12 1
      test/dovecot-config.test.js
  65. 6 1
      test/prepare-dovecot-script.test.js
  66. 47 0
      test/roundcube-webmail-sso-config.test.js
  67. 416 0
      test/server-webmail-sso.test.js
  68. 144 0
      test/submission-webmail-sso.test.js
  69. 39 1
      test/ui/inbox-navigation.test.tsx
  70. 89 2
      test/ui/mailbox-access.test.tsx
  71. 256 0
      test/webmail-sso-db.test.js
  72. 196 0
      test/webmail-sso-http.test.js

+ 9 - 0
.env.example

@@ -47,6 +47,15 @@ UV_THREADPOOL_SIZE=2
 MAILDIR_ROOT=./data/maildir
 MAILDIR_SYNC_INTERVAL_MS=300000
 
+# Optional Roundcube one-click login. The browser receives only a single-use
+# POST ticket; Roundcube exchanges it over the private network for an expiring
+# mhw_ credential. Generate a separate hexadecimal secret at
+# ./data/secrets/webmail_sso_secret and expose it read-only to Roundcube.
+WEBMAIL_SSO_URL=
+WEBMAIL_SSO_SECRET_FILE=./data/secrets/webmail_sso_secret
+WEBMAIL_SSO_TICKET_TTL_SECONDS=60
+WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS=43200
+
 # Outbound hostname is authorized through the SPF a: mechanism and used for HELO/Postfix.
 # The sending IP drives the managed A record and PTR health check, not a direct SPF ip4 entry.
 MAIL_HOSTNAME=smtp.mailhub.example.com

+ 4 - 0
docker-compose.yml

@@ -17,6 +17,10 @@ services:
       DOVECOT_AUTH_HOST: 0.0.0.0
       DOVECOT_AUTH_PORT: 3001
       DOVECOT_AUTH_SECRET_FILE: /run/secrets/dovecot_auth_secret
+      WEBMAIL_SSO_URL: ${WEBMAIL_SSO_URL:-}
+      WEBMAIL_SSO_SECRET_FILE: /data/secrets/webmail_sso_secret
+      WEBMAIL_SSO_TICKET_TTL_SECONDS: ${WEBMAIL_SSO_TICKET_TTL_SECONDS:-60}
+      WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS: ${WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS:-43200}
     ports:
       - "127.0.0.1:${APP_PORT:-3025}:3000"
       - "${SUBMISSION_BIND:-0.0.0.0}:25:25"

+ 14 - 1
docker/dovecot/auth.lua

@@ -2,6 +2,7 @@ local json = require "json"
 
 local auth_url = "http://app:3001/internal/dovecot/auth"
 local secret_file = "/run/secrets/dovecot_auth_secret"
+local readonly_acl_group = "mailhub_webmail_readonly"
 local http_client
 local shared_secret
 
@@ -115,5 +116,17 @@ function auth_password_verify(request, password)
     return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
   end
 
-  return dovecot.auth.PASSDB_RESULT_OK, { user = string.lower(payload.user) }
+  local fields = { user = string.lower(payload.user) }
+  if payload.aclGroups ~= nil then
+    if payload.aclGroups ~= readonly_acl_group then
+      return failure(dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE)
+    end
+    fields.userdb_acl_groups = readonly_acl_group
+    -- A private namespace grants implicit owner rights, including root-level
+    -- mailbox creation. Public type removes those implicit rights; the fixed
+    -- ACL group below grants back only lookup/read/Seen.
+    fields["userdb_namespace/inbox/type"] = "public"
+  end
+
+  return dovecot.auth.PASSDB_RESULT_OK, fields
 end

+ 14 - 0
docker/dovecot/mailhub.conf

@@ -11,12 +11,26 @@ mailbox_list_utf8 = no
 mail_uid = 1000
 mail_gid = 1000
 
+mail_plugins {
+  acl = yes
+}
+acl_driver = vfile
+acl_globals_only = yes
+
 ssl = yes
 
 namespace inbox {
   inbox = yes
   separator = /
 
+  # A shared-mailbox Webmail session is still authenticated as the mailbox
+  # address, so Dovecot otherwise treats it as the owner. This group-override
+  # rule takes precedence over owner rights and limits delegates to listing,
+  # reading, and updating only the \Seen flag.
+  acl group-override=mailhub_webmail_readonly {
+    rights = lrs
+  }
+
   mailbox Archive {
     auto = subscribe
     special_use = \Archive

+ 22 - 0
docker/roundcube/config.inc.php.example

@@ -0,0 +1,22 @@
+<?php
+
+// Add mailhub_sso to the existing plugin list.
+$config['plugins'][] = 'mailhub_sso';
+
+// Roundcube uses MailHub's temporary mhw_ credential for both protocols.
+$config['imap_host'] = 'ssl://in.ss5.xyz:993';
+$config['smtp_host'] = 'ssl://in.ss5.xyz:465';
+$config['smtp_user'] = '%u';
+$config['smtp_pass'] = '%p';
+
+// TLS terminates at the reverse proxy in this example. Do not enable
+// force_https at the same time; Roundcube documents the two as exclusive.
+$config['use_https'] = true;
+$config['session_samesite'] = 'Lax';
+
+// Plugin-specific settings. These can also live in
+// plugins/mailhub_sso/config.inc.php copied from config.inc.php.dist.
+$config['mailhub_sso_internal_base_url'] = 'http://app:3000';
+$config['mailhub_sso_audience'] = 'https://mail.us.ss5.xyz';
+$config['mailhub_sso_secret_file'] = '/run/secrets/webmail_sso_secret';
+$config['mailhub_sso_imap_host'] = 'ssl://in.ss5.xyz:993';

+ 15 - 0
docker/roundcube/plugins/mailhub_sso/config.inc.php.dist

@@ -0,0 +1,15 @@
+<?php
+
+// MailHub's private HTTP origin as seen from the Roundcube PHP runtime.
+// The plugin appends only the fixed /internal/webmail-sso/* paths.
+$config['mailhub_sso_internal_base_url'] = 'http://app:3000';
+
+// Exact external HTTPS origin of this Roundcube deployment. It must match the
+// origin configured by MailHub's WEBMAIL_SSO_URL (no path or trailing slash).
+$config['mailhub_sso_audience'] = 'https://mail.us.ss5.xyz';
+
+// Read the shared secret from a file mounted read-only into both runtimes.
+$config['mailhub_sso_secret_file'] = '/run/secrets/webmail_sso_secret';
+
+// Users and credentials returned by MailHub are always sent to this host.
+$config['mailhub_sso_imap_host'] = 'ssl://in.ss5.xyz:993';

+ 3 - 0
docker/roundcube/plugins/mailhub_sso/localization/en_US.inc

@@ -0,0 +1,3 @@
+<?php
+
+$labels['ssofailed'] = 'The MailHub Webmail login link is invalid or has expired.';

+ 3 - 0
docker/roundcube/plugins/mailhub_sso/localization/zh_CN.inc

@@ -0,0 +1,3 @@
+<?php
+
+$labels['ssofailed'] = 'MailHub Webmail 登录链接无效或已过期。';

+ 317 - 0
docker/roundcube/plugins/mailhub_sso/mailhub_sso.php

@@ -0,0 +1,317 @@
+<?php
+
+/**
+ * MailHub one-click Webmail login for Roundcube 1.6.x.
+ *
+ * @license MIT
+ */
+class mailhub_sso extends rcube_plugin
+{
+    private $attempted = false;
+    private $ticket;
+    private $credential;
+    private $audience;
+
+    public static function info()
+    {
+        return [
+            'name'    => 'MailHub Webmail SSO',
+            'version' => '1.0.0',
+            'license' => 'MIT',
+        ];
+    }
+
+    public function init()
+    {
+        $this->load_config();
+        $this->add_texts('localization/');
+        $this->add_hook('startup', [$this, 'startup']);
+        $this->add_hook('authenticate', [$this, 'authenticate']);
+        $this->add_hook('login_after', [$this, 'loginAfter']);
+        $this->add_hook('login_failed', [$this, 'loginFailed']);
+        // logout_after runs after Roundcube has erased the encrypted password.
+        // session_destroy covers logout and expiry while it is still available.
+        $this->add_hook('session_destroy', [$this, 'revokeSession']);
+    }
+
+    public function startup($args)
+    {
+        if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST'
+            || !array_key_exists('mailhub_ticket', $_POST)
+        ) {
+            return $args;
+        }
+
+        $this->attempted = true;
+        $ticket = rcube_utils::get_input_string('mailhub_ticket', rcube_utils::INPUT_POST);
+        if ($this->validTicket($ticket)) {
+            $this->ticket = $ticket;
+        }
+
+        // Roundcube's normal login POST requires its own CSRF token. The
+        // authenticate hook validates and atomically exchanges MailHub's
+        // one-time ticket instead, then marks this request as valid.
+        $args['task'] = 'login';
+        $args['action'] = 'login';
+
+        return $args;
+    }
+
+    public function authenticate($args)
+    {
+        if (!$this->attempted) {
+            return $args;
+        }
+
+        $audience = $this->configuredAudience();
+        $imapHost = $this->configuredImapHost();
+        if (!$this->ticket || !$audience || !$imapHost) {
+            return $this->authenticationFailure($args);
+        }
+
+        $result = $this->request('/internal/webmail-sso/exchange', [
+            'ticket'   => $this->ticket,
+            'audience' => $audience,
+        ], [200]);
+        $this->ticket = null;
+
+        if (!$this->validExchange($result)) {
+            $issuedCredential = is_array($result) ? ($result['credential'] ?? null) : null;
+            if ($this->validCredential($issuedCredential)) {
+                $this->revokeCredential($issuedCredential, $audience);
+            }
+            return $this->authenticationFailure($args);
+        }
+
+        $this->credential = $result['credential'];
+        $this->audience = $audience;
+
+        // A valid ticket may intentionally switch an existing Roundcube
+        // session to another mailbox. Destroy the old session only after the
+        // ticket has been accepted, so invalid cross-site POSTs cannot log a
+        // user out.
+        if (!empty($_SESSION['user_id'])) {
+            rcmail::get_instance()->kill_session();
+        }
+
+        $args['user'] = $result['username'];
+        $args['pass'] = $result['credential'];
+        $args['host'] = $imapHost;
+        $args['cookiecheck'] = false;
+        $args['valid'] = true;
+        $args['abort'] = false;
+        $args['error'] = null;
+
+        return $args;
+    }
+
+    public function loginAfter($args)
+    {
+        if (!$this->credential || !$this->audience) {
+            return $args;
+        }
+
+        // Roundcube already stores the IMAP password encrypted in its session.
+        // Keep only a marker and the non-secret audience for logout revocation.
+        $_SESSION['mailhub_sso_authenticated'] = true;
+        $_SESSION['mailhub_sso_audience'] = $this->audience;
+
+        return [
+            '_task' => 'mail',
+            '_mbox' => 'INBOX',
+        ];
+    }
+
+    public function loginFailed($args)
+    {
+        if ($this->credential && $this->audience) {
+            $this->revokeCredential($this->credential, $this->audience);
+            $this->credential = null;
+        }
+
+        return $args;
+    }
+
+    public function revokeSession($args)
+    {
+        if (empty($_SESSION['mailhub_sso_authenticated'])) {
+            return $args;
+        }
+
+        $rcmail = rcmail::get_instance();
+        $credential = $rcmail->get_user_password();
+        $audience = $_SESSION['mailhub_sso_audience'] ?? null;
+        if ($this->validCredential($credential) && $this->validAudience($audience)) {
+            $this->revokeCredential($credential, $audience);
+        }
+
+        return $args;
+    }
+
+    private function authenticationFailure($args)
+    {
+        $this->ticket = null;
+        $args['valid'] = true;
+        $args['abort'] = true;
+        $args['error'] = 'mailhub_sso.ssofailed';
+
+        return $args;
+    }
+
+    private function revokeCredential($credential, $audience)
+    {
+        $this->request('/internal/webmail-sso/revoke', [
+            'credential' => $credential,
+            'audience'   => $audience,
+        ], [200, 204]);
+    }
+
+    private function request($path, $payload, $acceptedStatuses)
+    {
+        $baseUrl = $this->configuredInternalBaseUrl();
+        $secret = $this->readSecret();
+        if (!$baseUrl || !$secret || !function_exists('curl_init')) {
+            return null;
+        }
+
+        $body = json_encode($payload, JSON_UNESCAPED_SLASHES);
+        if (!is_string($body)) {
+            return null;
+        }
+
+        $curl = curl_init($baseUrl . $path);
+        if (!$curl) {
+            return null;
+        }
+        $options = [
+            CURLOPT_POST            => true,
+            CURLOPT_POSTFIELDS      => $body,
+            CURLOPT_HTTPHEADER      => [
+                'Accept: application/json',
+                'Authorization: Bearer ' . $secret,
+                'Content-Type: application/json',
+            ],
+            CURLOPT_RETURNTRANSFER  => true,
+            CURLOPT_HEADER          => false,
+            CURLOPT_FOLLOWLOCATION  => false,
+            CURLOPT_CONNECTTIMEOUT_MS => 1500,
+            CURLOPT_TIMEOUT_MS      => 5000,
+            CURLOPT_NOSIGNAL        => true,
+        ];
+        if (defined('CURLOPT_PROTOCOLS')) {
+            $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
+        }
+
+        curl_setopt_array($curl, $options);
+        $response = curl_exec($curl);
+        $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
+        curl_close($curl);
+
+        if (!is_string($response) || !in_array($status, $acceptedStatuses, true)) {
+            return null;
+        }
+
+        if ($status === 204 || $response === '') {
+            return [];
+        }
+
+        $decoded = json_decode($response, true);
+
+        return is_array($decoded) ? $decoded : null;
+    }
+
+    private function configuredInternalBaseUrl()
+    {
+        $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_internal_base_url', ''));
+        $parts = parse_url($value);
+        if (!is_array($parts)
+            || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
+            || empty($parts['host'])
+            || isset($parts['user'])
+            || isset($parts['pass'])
+            || isset($parts['query'])
+            || isset($parts['fragment'])
+            || !in_array($parts['path'] ?? '', ['', '/'], true)
+        ) {
+            return null;
+        }
+
+        return rtrim($value, '/');
+    }
+
+    private function configuredAudience()
+    {
+        $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_audience', ''));
+
+        return $this->validAudience($value) ? $value : null;
+    }
+
+    private function validAudience($value)
+    {
+        if (!is_string($value)) {
+            return false;
+        }
+
+        $parts = parse_url($value);
+
+        return is_array($parts)
+            && strtolower($parts['scheme'] ?? '') === 'https'
+            && !empty($parts['host'])
+            && !isset($parts['user'])
+            && !isset($parts['pass'])
+            && !isset($parts['query'])
+            && !isset($parts['fragment'])
+            && !isset($parts['path']);
+    }
+
+    private function configuredImapHost()
+    {
+        $value = trim((string) rcmail::get_instance()->config->get('mailhub_sso_imap_host', ''));
+
+        return preg_match('/\A(?:ssl|tls):\/\/[A-Za-z0-9.-]+(?::\d{1,5})?\z/', $value) ? $value : null;
+    }
+
+    private function readSecret()
+    {
+        $path = (string) rcmail::get_instance()->config->get('mailhub_sso_secret_file', '');
+        if ($path === '' || !is_file($path) || !is_readable($path)) {
+            return null;
+        }
+
+        $secret = trim((string) @file_get_contents($path));
+
+        return preg_match('/\A[0-9a-fA-F]{64,512}\z/', $secret) ? $secret : null;
+    }
+
+    private function validTicket($ticket)
+    {
+        return is_string($ticket)
+            && preg_match('/\Amht_[A-Za-z0-9_-]{28,252}\z/', $ticket);
+    }
+
+    private function validCredential($credential)
+    {
+        return is_string($credential)
+            && preg_match('/\Amhw_[A-Za-z0-9_-]{28,252}\z/', $credential);
+    }
+
+    private function validExchange($result)
+    {
+        if (!is_array($result)
+            || !isset($result['username'], $result['credential'], $result['expiresAt'])
+            || !is_string($result['username'])
+            || !is_string($result['expiresAt'])
+            || strlen($result['username']) < 3
+            || strlen($result['username']) > 320
+            || preg_match('/[\x00-\x20\x7f]/', $result['username'])
+            || !preg_match('/\A[^\s@]+@[^\s@]+\.[^\s@]+\z/u', $result['username'])
+            || !$this->validCredential($result['credential'])
+        ) {
+            return false;
+        }
+
+        $expiresAt = strtotime($result['expiresAt']);
+
+        return $expiresAt !== false && $expiresAt > time() - 30;
+    }
+}

+ 102 - 0
docs/webmail-sso.md

@@ -0,0 +1,102 @@
+# Roundcube 一键登录
+
+MailHub 的“一键登录 Webmail”使用两层短期凭据,不会向浏览器、URL 或 Roundcube 日志暴露邮箱原密码:
+
+1. MailHub 在已登录的同源页面为指定邮箱签发 60 秒、仅可使用一次的启动票据。
+2. 浏览器通过隐藏表单把 `mailhub_ticket` **POST** 到 `WEBMAIL_SSO_URL`,票据不会进入查询字符串或 Referer。
+3. `mailhub_sso` 插件在服务器端用共享密钥调用 MailHub 的 `/internal/webmail-sso/exchange`,取得邮箱地址和 `mhw_` 临时凭据。
+4. Roundcube 使用该凭据连接固定的 IMAP/SMTP 服务;成功后固定进入 `INBOX`。退出或销毁 Roundcube 会话时,插件会尽力调用 `/internal/webmail-sso/revoke`,凭据自身到期仍是最终边界。
+
+同一用户每分钟最多签发 20 个启动票据,同一邮箱最多保留 5 个有效 Webmail 临时会话;超过上限时会淘汰最久未使用的会话。过期或撤销记录会机会式清理,避免 SQLite 表无界增长。
+
+## MailHub 配置
+
+在 `.env` 中设置:
+
+```dotenv
+WEBMAIL_SSO_URL=https://mail.us.ss5.xyz/
+WEBMAIL_SSO_SECRET_FILE=./data/secrets/webmail_sso_secret
+WEBMAIL_SSO_TICKET_TTL_SECONDS=60
+WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS=43200
+```
+
+`WEBMAIL_SSO_URL` 留空时功能关闭,便于未配置 Roundcube 的部署继续运行;一旦设置 URL,Secret 文件必须存在且有效,否则应用会快速失败。Compose 固定从 `/data/secrets/webmail_sso_secret` 读取(宿主机对应 `./data/secrets/webmail_sso_secret`),功能关闭时不会因为旧部署缺少该文件而阻止容器升级。`WEBMAIL_SSO_URL` 必须是固定 HTTPS 起始地址。MailHub 会把它规范化成绝对 origin(例如 `https://mail.us.ss5.xyz`)并写入票据;Roundcube 的 `mailhub_sso_audience` 必须完全一致。
+
+Secret 只接受 64–512 个十六进制字符。首次部署前可直接运行 `npm run prepare:dovecot`;也可以手工生成:
+
+```bash
+install -d -m 0700 data/secrets
+openssl rand -hex 32 > data/secrets/webmail_sso_secret
+chown 1000:1000 data/secrets/webmail_sso_secret
+chmod 0440 data/secrets/webmail_sso_secret
+```
+
+同机 Roundcube 建议使用 `0440`、`1000:1000`,并仅把 Roundcube PHP 进程加入补充组 `1000`;不要改成全局可读。MailHub 的准备脚本会自动维持该权限。若 Roundcube 在另一台机器,复制相同内容到仅由其 PHP 运行用户可读的独立文件即可。
+
+该 Secret 不能复用 Session、Dovecot、Token 或其他业务密钥,也不能提交到 Git。
+
+## Roundcube 1.6.x 安装
+
+Roundcube PHP 运行环境需要启用 cURL 扩展。
+
+1. 将 [`docker/roundcube/plugins/mailhub_sso`](../docker/roundcube/plugins/mailhub_sso) 复制到 Roundcube 的 `plugins/mailhub_sso`。
+2. 将 `plugins/mailhub_sso/config.inc.php.dist` 复制为 `plugins/mailhub_sso/config.inc.php`,按实际网络修改内部地址;不要把 Secret 内容写进 PHP 配置。
+3. 把 `mailhub_sso` 加入 Roundcube `$config['plugins']`。可参考 [`docker/roundcube/config.inc.php.example`](../docker/roundcube/config.inc.php.example)。
+4. 将与 MailHub 完全相同的 Secret 只读挂载到 Roundcube 的 `/run/secrets/webmail_sso_secret`;同机容器为 PHP 用户增加补充组 `1000`,确保它能读取 `0440` 文件。
+5. 保持外部 Webmail 全程 HTTPS,并把 `mailhub_sso_audience` 设置为精确 origin,不带路径和结尾斜杠。
+
+若 HTTPS 终止在 Roundcube 前方的反向代理,将 Roundcube 的 `use_https` 设为 `true`;不要同时启用与它互斥的 `force_https`。
+
+生产示例:
+
+```php
+$config['plugins'][] = 'mailhub_sso';
+$config['imap_host'] = 'ssl://in.ss5.xyz:993';
+$config['smtp_host'] = 'ssl://in.ss5.xyz:465';
+$config['smtp_user'] = '%u';
+$config['smtp_pass'] = '%p';
+
+$config['mailhub_sso_internal_base_url'] = 'http://app:3000';
+$config['mailhub_sso_audience'] = 'https://mail.us.ss5.xyz';
+$config['mailhub_sso_secret_file'] = '/run/secrets/webmail_sso_secret';
+$config['mailhub_sso_imap_host'] = 'ssl://in.ss5.xyz:993';
+```
+
+## 网络拓扑
+
+本仓库的 Compose **不运行 Roundcube**。可按现有部署选择以下方式:
+
+- Roundcube 容器与 MailHub 同机:让 Roundcube 加入 `mailhub` Compose 网络,使用 `http://app:3000`,并把宿主机同一个 Secret 文件以只读方式挂入两个容器。
+- Roundcube 运行在宿主机:使用仅回环监听的 `http://127.0.0.1:3025`。
+- Roundcube 在另一台机器:通过 WireGuard、SSH 隧道或等价私网访问。不得把 `/internal/webmail-sso/*` 无保护地公开到互联网。
+
+Roundcube 使用独立 Compose 时,可将现有 MailHub 网络声明为 external;实际网络名以 `docker network ls` 为准:
+
+```yaml
+services:
+  roundcube:
+    group_add:
+      - "1000"
+    volumes:
+      - /absolute/path/to/mailhub/data/secrets/webmail_sso_secret:/run/secrets/webmail_sso_secret:ro
+    networks:
+      - mailhub
+
+networks:
+  mailhub:
+    external: true
+    name: <mailhub-compose-project>_mailhub
+```
+
+同时在对外反向代理中拒绝公网访问 `/internal/webmail-sso/`;Bearer Secret 是内网接口的第二层校验,不应代替网络隔离。
+
+内部接口只接受 `Authorization: Bearer <shared-secret>` 和 JSON:
+
+```text
+POST /internal/webmail-sso/exchange  { ticket, audience }
+POST /internal/webmail-sso/revoke    { credential, audience }
+```
+
+插件不跟随 HTTP 重定向,不接受 GET 票据,不允许浏览器指定 IMAP 主机,也不记录内部响应、票据、临时凭据或共享密钥。仅有收信权限的临时凭据可以进入 Roundcube,但 SMTP 认证应由 MailHub 拒绝;有发信权限时 Roundcube 才能发信。
+
+被分配用户通过 Webmail 登录时,Dovecot 使用固定 ACL 组将 IMAP 权限限制为列出、读取和更新 `\Seen`;不能删除/移动邮件、EXPUNGE,也不能创建、删除或重命名文件夹。邮箱所有者保持完整 IMAP 权限。撤销收信权限、停用用户或临时凭据到期后,新的 IMAP 认证会立即失败;已经建立的 IMAP TCP 连接会在连接关闭后生效,Roundcube 的典型按请求连接使该窗口通常很短,但它不是逐条 IMAP 命令的强制踢线。

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/Account-D0Oinw-4.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/Account-nhuoQQlz.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/Admin-Dps5Iat-.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/Admin-xFOA6-bp.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/ApiTokens-Wdpy_Ba_.js


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

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

+ 1 - 1
public/assets/CopyOutlined-C9O1hBIm.js → public/assets/CopyOutlined-UbpGsh_G.js

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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
public/assets/Dashboard-pDsDO0ai.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 2 - 2
public/assets/DashboardCharts-u4ifs3zc.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/DnsApi-BBzKTBIZ.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/DomainDetail-DHFMM8tt.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/Domains-BxstGUcc.js


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

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

+ 1 - 1
public/assets/EllipsisOutlined-twSxuZli.js → public/assets/EllipsisOutlined-Dtb0gNnp.js

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

+ 1 - 0
public/assets/ExportOutlined-CUq8majW.js

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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/Inbox-7UH19KPt.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/Inbox-Dm_szY_4.js


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

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

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

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

+ 1 - 1
public/assets/ReloadOutlined-Cmwy5NFg.js → public/assets/ReloadOutlined-9AZGcoed.js

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

+ 1 - 1
public/assets/SearchOutlined-B6SLAKJm.js → public/assets/SearchOutlined-CGjQXOn5.js

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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/SectionCard--w8cgqOe.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/SendingLogs-Bf9dLYVC.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/Settings-vN7jHaFO.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/SmtpCredentials-Br2jaH-D.js


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

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

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

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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/Webhooks-C3xF4Kb3.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 1
public/assets/form-Dp5ppmPu.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 1
public/assets/grid-DzYWI3CD.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 1
public/assets/index-DG1WRIOp.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/list-BnsJjSCP.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/login-CI8wBEa5.js


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

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

+ 0 - 1
public/assets/modal-Ba-m1ypG.js

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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/popconfirm-cq-yGQ-9.js


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

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

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

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

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/table-DJa-z-YA.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
public/assets/theme-Dpxy803y.js


+ 2 - 2
public/index.html

@@ -4,11 +4,11 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-Dztone_T.js"></script>
+    <script type="module" crossorigin src="/assets/index-DG1WRIOp.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/react-dom-stZKkA6x.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-dm82oUI8.js">
-    <link rel="modulepreload" crossorigin href="/assets/theme-B5baUfEh.js">
+    <link rel="modulepreload" crossorigin href="/assets/theme-Dpxy803y.js">
     <link rel="stylesheet" crossorigin href="/assets/theme-QBN1gbX1.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>

+ 4 - 4
public/login.html

@@ -4,15 +4,15 @@
     <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-D7D2knjl.js"></script>
+    <script type="module" crossorigin src="/assets/login-CI8wBEa5.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/react-dom-stZKkA6x.js">
     <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-dm82oUI8.js">
-    <link rel="modulepreload" crossorigin href="/assets/theme-B5baUfEh.js">
-    <link rel="modulepreload" crossorigin href="/assets/grid-DaQoG6GE.js">
+    <link rel="modulepreload" crossorigin href="/assets/theme-Dpxy803y.js">
+    <link rel="modulepreload" crossorigin href="/assets/grid-DzYWI3CD.js">
     <link rel="modulepreload" crossorigin href="/assets/segmented-DUpU-ING.js">
     <link rel="modulepreload" crossorigin href="/assets/useForm-sdDvuRHy.js">
-    <link rel="modulepreload" crossorigin href="/assets/form-GQckeBSC.js">
+    <link rel="modulepreload" crossorigin href="/assets/form-Dp5ppmPu.js">
     <link rel="modulepreload" crossorigin href="/assets/input-0_Xo22Fl.js">
     <link rel="stylesheet" crossorigin href="/assets/theme-QBN1gbX1.css">
   </head>

+ 43 - 19
scripts/prepare-dovecot.sh

@@ -14,6 +14,7 @@ command -v openssl >/dev/null 2>&1 || fail "openssl is required."
 mkdir -p "${data_dir}"
 data_dir="$(cd "${data_dir}" && pwd -P)"
 secret_file="${MAILHUB_DOVECOT_SECRET_FILE:-${data_dir}/secrets/dovecot_auth_secret}"
+webmail_secret_file="${MAILHUB_WEBMAIL_SSO_SECRET_FILE:-${data_dir}/secrets/webmail_sso_secret}"
 maildir_root="${MAILHUB_MAILDIR_ROOT:-${data_dir}/maildir}"
 host_uid="$(id -u)"
 
@@ -25,36 +26,59 @@ case "${secret_file}" in
   "${data_dir}"/*) ;;
   *) fail "secret file must stay inside the MailHub data directory." ;;
 esac
+case "${webmail_secret_file}" in
+  "${data_dir}"/*) ;;
+  *) fail "Webmail SSO secret file must stay inside the MailHub data directory." ;;
+esac
 case "${maildir_root}" in
   "${data_dir}"/*) ;;
   *) fail "Maildir root must stay inside the MailHub data directory." ;;
 esac
 
 [[ ! -L "${secret_file}" ]] || fail "secret file must not be a symbolic link."
+[[ ! -L "${webmail_secret_file}" ]] || fail "Webmail SSO secret file must not be a symbolic link."
 [[ ! -L "${maildir_root}" ]] || fail "Maildir root must not be a symbolic link."
-mkdir -p "$(dirname "${secret_file}")" "${maildir_root}"
-
-if [[ ! -f "${secret_file}" ]]; then
-  umask 077
-  temporary_secret="$(mktemp "$(dirname "${secret_file}")/.dovecot-auth.XXXXXX")"
-  trap 'rm -f -- "${temporary_secret:-}"' EXIT
-  openssl rand -hex 32 >"${temporary_secret}"
-  chmod 0600 "${temporary_secret}"
-  mv "${temporary_secret}" "${secret_file}"
-  trap - EXIT
-fi
+mkdir -p "$(dirname "${secret_file}")" "$(dirname "${webmail_secret_file}")" "${maildir_root}"
+secret_dir="$(dirname "${secret_file}")"
+webmail_secret_dir="$(dirname "${webmail_secret_file}")"
+
+prepare_secret() {
+  local file="$1"
+  local prefix="$2"
+  local temporary_secret
+  if [[ ! -f "${file}" ]]; then
+    umask 077
+    temporary_secret="$(mktemp "$(dirname "${file}")/.${prefix}.XXXXXX")"
+    if ! openssl rand -hex 32 >"${temporary_secret}"; then
+      rm -f -- "${temporary_secret}"
+      fail "unable to generate ${prefix} secret."
+    fi
+    chmod 0600 "${temporary_secret}"
+    mv "${temporary_secret}" "${file}"
+  fi
+  [[ -f "${file}" ]] || fail "${prefix} secret path must be a regular file."
+  local value
+  value="$(tr -d '\r\n' <"${file}")"
+  [[ "${value}" =~ ^[0-9a-fA-F]+$ ]] || fail "${prefix} secret must contain only hexadecimal characters."
+  (( ${#value} >= 64 && ${#value} <= 512 )) || fail "${prefix} secret must contain 64-512 hexadecimal characters."
+}
 
-[[ -f "${secret_file}" ]] || fail "secret path must be a regular file."
-secret="$(tr -d '\r\n' <"${secret_file}")"
-[[ "${secret}" =~ ^[0-9a-fA-F]+$ ]] || fail "secret must contain only hexadecimal characters."
-(( ${#secret} >= 64 && ${#secret} <= 512 )) || fail "secret must contain 64-512 hexadecimal characters."
+prepare_secret "${secret_file}" "dovecot-auth"
+prepare_secret "${webmail_secret_file}" "webmail-sso"
 
 if [[ "${host_uid}" == "0" ]]; then
-  # Both the Node application and Dovecot's rootless mail processes use
-  # uid/gid 1000. Compose file-backed secrets preserve host ownership on Linux.
-  chown 1000:1000 "${secret_file}" "${maildir_root}"
+  # MailHub runs as uid/gid 1000. The Webmail secret is group-readable so a
+  # Roundcube container can join supplemental gid 1000 without making it public.
+  chown 1000:1000 \
+    "${secret_dir}" \
+    "${webmail_secret_dir}" \
+    "${secret_file}" \
+    "${webmail_secret_file}" \
+    "${maildir_root}"
 fi
 chmod 0700 "${maildir_root}"
+chmod 0750 "${secret_dir}" "${webmail_secret_dir}"
 chmod 0400 "${secret_file}"
+chmod 0440 "${webmail_secret_file}"
 
-echo "Dovecot storage and authentication secret are ready."
+echo "Dovecot storage and authentication secrets are ready."

+ 480 - 0
src/db.js

@@ -36,11 +36,24 @@ const auditDescriptorValuePattern = /password|secret|token|key|credential|dkim[_
 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 defaultWebmailTicketTtlSeconds = 60;
+const minWebmailTicketTtlSeconds = 10;
+const maxWebmailTicketTtlSeconds = 5 * 60;
+const defaultWebmailCredentialTtlSeconds = 12 * 60 * 60;
+const minWebmailCredentialTtlSeconds = 5 * 60;
+const maxWebmailCredentialTtlSeconds = 24 * 60 * 60;
+const webmailTicketRateWindowSeconds = 60;
+const maxWebmailTicketsPerRateWindow = 20;
+const maxActiveWebmailSessionsPerMailbox = 5;
+const webmailSessionCleanupIntervalMs = 60 * 1000;
+const webmailSessionRetentionSeconds = 60 * 60;
 const defaultApiTokenScopes = ['send'];
 const pendingLegacyMailboxPasswordUpgrades = new Map();
+let lastWebmailSessionCleanupAtMs = 0;
 
 export function initDatabase(dataDir, secret = '') {
   secretKey = String(secret || process.env.SESSION_SECRET || process.env.API_TOKEN || process.env.ADMIN_PASSWORD || '');
+  lastWebmailSessionCleanupAtMs = 0;
   const databasePath = path.join(dataDir, 'mailhub.sqlite');
   mkdirSync(dataDir, { recursive: true, mode: 0o700 });
   chmodSync(dataDir, 0o700);
@@ -293,6 +306,23 @@ export function initDatabase(dataDir, secret = '') {
       FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
     );
 
+    CREATE TABLE IF NOT EXISTS webmail_sessions (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      actor_user_id INTEGER NOT NULL,
+      mailbox_id INTEGER NOT NULL,
+      audience TEXT NOT NULL,
+      ticket_hash TEXT NOT NULL UNIQUE,
+      ticket_expires_at TEXT NOT NULL,
+      exchanged_at TEXT,
+      credential_hash TEXT UNIQUE,
+      credential_expires_at TEXT NOT NULL,
+      revoked_at TEXT,
+      last_used_at TEXT,
+      created_at TEXT NOT NULL,
+      FOREIGN KEY(actor_user_id) REFERENCES users(id) ON DELETE CASCADE,
+      FOREIGN KEY(mailbox_id) REFERENCES inbound_mailboxes(id) ON DELETE CASCADE
+    );
+
     CREATE TABLE IF NOT EXISTS dns_credentials (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       user_id INTEGER NOT NULL,
@@ -355,6 +385,10 @@ export function initDatabase(dataDir, secret = '') {
     CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON api_tokens(user_id);
     CREATE INDEX IF NOT EXISTS idx_account_tokens_user_purpose ON account_tokens(user_id, purpose);
     CREATE INDEX IF NOT EXISTS idx_account_tokens_expires_at ON account_tokens(expires_at);
+    CREATE INDEX IF NOT EXISTS idx_webmail_sessions_actor_mailbox ON webmail_sessions(actor_user_id, mailbox_id);
+    CREATE INDEX IF NOT EXISTS idx_webmail_sessions_actor_created ON webmail_sessions(actor_user_id, created_at);
+    CREATE INDEX IF NOT EXISTS idx_webmail_sessions_ticket_expiry ON webmail_sessions(ticket_expires_at);
+    CREATE INDEX IF NOT EXISTS idx_webmail_sessions_credential_expiry ON webmail_sessions(credential_expires_at);
     CREATE INDEX IF NOT EXISTS idx_dns_credentials_user_id ON dns_credentials(user_id);
     CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
     CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_user_id ON audit_logs(actor_user_id);
@@ -1414,7 +1448,266 @@ export function getInboundMailboxForSender(userId, address, {
   return hasInboundMailboxGrantPermission(mailbox.id, userId, 'send') ? mailbox : null;
 }
 
+export function createWebmailLoginTicket(actorUserId, mailboxId, {
+  audience,
+  ticketTtlSeconds = defaultWebmailTicketTtlSeconds,
+  credentialTtlSeconds = defaultWebmailCredentialTtlSeconds
+} = {}) {
+  const actorId = normalizeWebmailEntityId(actorUserId, '用户不存在。');
+  const cleanMailboxId = normalizeWebmailEntityId(mailboxId, '收信邮箱不存在。');
+  const cleanAudience = normalizeWebmailAudience(audience);
+  const ticketTtl = normalizeWebmailTtlSeconds(
+    ticketTtlSeconds,
+    minWebmailTicketTtlSeconds,
+    maxWebmailTicketTtlSeconds,
+    'Webmail 登录票据有效期不正确。'
+  );
+  const credentialTtl = normalizeWebmailTtlSeconds(
+    credentialTtlSeconds,
+    minWebmailCredentialTtlSeconds,
+    maxWebmailCredentialTtlSeconds,
+    'Webmail 临时凭据有效期不正确。'
+  );
+  const createdAt = now();
+  const mailboxRow = webmailAuthorizedMailboxRow(actorId, cleanMailboxId, 'receive', createdAt);
+  if (!mailboxRow) throw webmailMailboxAccessDeniedError();
+
+  maybeCleanupWebmailSessions();
+  return withTransaction(() => {
+    const recentSince = new Date(
+      Date.parse(createdAt) - webmailTicketRateWindowSeconds * 1000
+    ).toISOString();
+    const recentCount = Number(requireDb()
+      .prepare(`
+        SELECT COUNT(*) AS count
+        FROM webmail_sessions
+        WHERE actor_user_id = ? AND created_at > ?
+      `)
+      .get(actorId, recentSince)?.count || 0);
+    if (recentCount >= maxWebmailTicketsPerRateWindow) {
+      throw webmailTicketRateLimitError();
+    }
+
+    // Keep one pending launch per actor/mailbox. Revoking instead of deleting
+    // preserves the short rate-limit window while preventing ticket replay.
+    requireDb()
+      .prepare(`
+        UPDATE webmail_sessions
+        SET revoked_at = ?
+        WHERE actor_user_id = ?
+          AND mailbox_id = ?
+          AND exchanged_at IS NULL
+          AND revoked_at IS NULL
+      `)
+      .run(createdAt, actorId, cleanMailboxId);
+
+    const ticket = `mht_${crypto.randomBytes(32).toString('base64url')}`;
+    const ticketExpiresAt = new Date(Date.parse(createdAt) + ticketTtl * 1000).toISOString();
+    const credentialExpiresAt = new Date(Date.parse(createdAt) + credentialTtl * 1000).toISOString();
+    const result = requireDb()
+      .prepare(`
+        INSERT INTO webmail_sessions (
+          actor_user_id, mailbox_id, audience, ticket_hash, ticket_expires_at,
+          credential_expires_at, created_at
+        ) VALUES (?, ?, ?, ?, ?, ?, ?)
+      `)
+      .run(
+        actorId,
+        cleanMailboxId,
+        cleanAudience,
+        webmailTokenHash('ticket', ticket),
+        ticketExpiresAt,
+        credentialExpiresAt,
+        createdAt
+      );
+    return {
+      id: Number(result.lastInsertRowid),
+      actorUserId: actorId,
+      mailboxId: cleanMailboxId,
+      address: mailboxRow.address,
+      audience: cleanAudience,
+      ticket,
+      ticketExpiresAt,
+      credentialExpiresAt,
+      mailbox: publicInboundMailbox(mailboxRow)
+    };
+  });
+}
+
+export function exchangeWebmailLoginTicket(ticket, { audience } = {}) {
+  const rawTicket = normalizeWebmailToken(ticket, 'mht_');
+  if (!rawTicket) return null;
+  const cleanAudience = normalizeWebmailAudience(audience);
+  const exchangedAt = now();
+  const credential = `mhw_${crypto.randomBytes(32).toString('base64url')}`;
+  return withTransaction(() => {
+    const row = requireDb()
+      .prepare(`
+        UPDATE webmail_sessions
+        SET exchanged_at = ?, credential_hash = ?
+        WHERE ticket_hash = ?
+          AND audience = ?
+          AND exchanged_at IS NULL
+          AND revoked_at IS NULL
+          AND ticket_expires_at > ?
+          AND credential_expires_at > ?
+          AND EXISTS (
+            ${webmailCurrentPermissionSql('receive')}
+          )
+        RETURNING *
+      `)
+      .get(
+        exchangedAt,
+        webmailTokenHash('credential', credential),
+        webmailTokenHash('ticket', rawTicket),
+        cleanAudience,
+        exchangedAt,
+        exchangedAt,
+        exchangedAt
+      );
+    if (!row) return null;
+
+    // Retire only after the new ticket was successfully exchanged, so a
+    // failed Roundcube launch never logs out an otherwise valid session.
+    requireDb()
+      .prepare(`
+        UPDATE webmail_sessions
+        SET revoked_at = ?
+        WHERE id IN (
+          SELECT id
+          FROM webmail_sessions
+          WHERE actor_user_id = ?
+            AND mailbox_id = ?
+            AND exchanged_at IS NOT NULL
+            AND revoked_at IS NULL
+            AND credential_expires_at > ?
+          ORDER BY COALESCE(last_used_at, exchanged_at, created_at) DESC, id DESC
+          LIMIT -1 OFFSET ?
+        )
+      `)
+      .run(
+        exchangedAt,
+        row.actor_user_id,
+        row.mailbox_id,
+        exchangedAt,
+        maxActiveWebmailSessionsPerMailbox
+      );
+
+    const mailboxRow = webmailAuthorizedMailboxRow(row.actor_user_id, row.mailbox_id, 'receive', exchangedAt);
+    if (!mailboxRow) return null;
+    return {
+      id: row.id,
+      actorUserId: row.actor_user_id,
+      mailboxId: row.mailbox_id,
+      address: mailboxRow.address,
+      audience: row.audience,
+      credential,
+      expiresAt: row.credential_expires_at,
+      credentialExpiresAt: row.credential_expires_at,
+      mailbox: publicInboundMailbox(mailboxRow)
+    };
+  });
+}
+
+export function verifyWebmailCredential(username, credential, { permission = 'receive' } = {}) {
+  const address = normalizeInboundAddress(username);
+  const rawCredential = normalizeWebmailToken(credential, 'mhw_');
+  if (!address || !rawCredential) return null;
+  const cleanPermission = normalizeWebmailPermission(permission);
+  const usedAt = now();
+  const consumed = requireDb()
+    .prepare(`
+      UPDATE webmail_sessions
+      SET last_used_at = ?
+      WHERE credential_hash = ?
+        AND exchanged_at IS NOT NULL
+        AND revoked_at IS NULL
+        AND credential_expires_at > ?
+        AND EXISTS (
+          ${webmailCurrentPermissionSql(cleanPermission, { address: true })}
+        )
+      RETURNING id
+    `)
+    .get(
+      usedAt,
+      webmailTokenHash('credential', rawCredential),
+      usedAt,
+      address,
+      usedAt
+    );
+  if (!consumed) return null;
+  const row = webmailCredentialResultRow(consumed.id, address, cleanPermission, usedAt);
+  if (!row) return null;
+  return {
+    user: {
+      id: row.auth_user_id,
+      username: row.auth_username,
+      email: row.auth_email,
+      role: row.auth_role,
+      status: row.auth_status
+    },
+    mailbox: publicInboundMailbox(row),
+    webmailSession: {
+      id: row.webmail_session_id,
+      actorUserId: row.actor_user_id,
+      mailboxId: row.mailbox_id,
+      audience: row.audience,
+      expiresAt: row.credential_expires_at,
+      lastUsedAt: row.last_used_at
+    }
+  };
+}
+
+export async function verifyWebmailCredentialAsync(username, credential, options = {}) {
+  return verifyWebmailCredential(username, credential, options);
+}
+
+export function webmailSessionHasPermission(sessionId, address, { permission = 'receive' } = {}) {
+  const id = Number(sessionId);
+  const cleanAddress = normalizeInboundAddress(address);
+  if (!Number.isSafeInteger(id) || id <= 0 || !cleanAddress) return false;
+  const cleanPermission = normalizeWebmailPermission(permission);
+  return Boolean(webmailCredentialResultRow(id, cleanAddress, cleanPermission, now()));
+}
+
+export function revokeWebmailCredential(credential, { audience } = {}) {
+  const rawCredential = normalizeWebmailToken(credential, 'mhw_');
+  if (!rawCredential) return false;
+  const hasAudience = audience !== undefined && audience !== null && audience !== '';
+  const cleanAudience = hasAudience ? normalizeWebmailAudience(audience) : '';
+  const result = requireDb()
+    .prepare(`
+      UPDATE webmail_sessions
+      SET revoked_at = ?
+      WHERE credential_hash = ?
+        AND exchanged_at IS NOT NULL
+        AND revoked_at IS NULL
+        ${hasAudience ? 'AND audience = ?' : ''}
+    `)
+    .run(now(), webmailTokenHash('credential', rawCredential), ...(hasAudience ? [cleanAudience] : []));
+  return result.changes > 0;
+}
+
+export function cleanupWebmailSessions({ retentionSeconds = 24 * 60 * 60 } = {}) {
+  const retention = Number(retentionSeconds);
+  if (!Number.isSafeInteger(retention) || retention < 0 || retention > 30 * 24 * 60 * 60) {
+    throw new Error('Webmail 会话保留时间不正确。');
+  }
+  const cutoff = new Date(Date.now() - retention * 1000).toISOString();
+  return requireDb()
+    .prepare(`
+      DELETE FROM webmail_sessions
+      WHERE credential_expires_at <= ?
+        OR (revoked_at IS NOT NULL AND revoked_at <= ?)
+        OR (exchanged_at IS NULL AND ticket_expires_at <= ?)
+    `)
+    .run(cutoff, cutoff, cutoff).changes;
+}
+
 export function verifyInboundMailboxCredential(username, password) {
+  if (normalizeWebmailToken(password, 'mhw_')) {
+    return verifyWebmailCredential(username, password, { permission: 'receive' });
+  }
   const mailboxAddress = normalizeInboundAddress(username);
   if (!mailboxAddress) {
     consumeDummyPasswordVerification(password);
@@ -1450,6 +1743,9 @@ export function verifyInboundMailboxCredential(username, password) {
 }
 
 export async function verifyInboundMailboxCredentialAsync(username, password) {
+  if (normalizeWebmailToken(password, 'mhw_')) {
+    return verifyWebmailCredentialAsync(username, password, { permission: 'receive' });
+  }
   const mailboxAddress = normalizeInboundAddress(username);
   if (!mailboxAddress) {
     await consumeDummyPasswordVerificationAsync(password);
@@ -4154,6 +4450,18 @@ function clearDefaultSmtpRelay(userId) {
 
 export function verifySmtpCredential(username, password) {
   const cleanUsername = String(username || '').trim();
+  if (normalizeWebmailToken(password, 'mhw_')) {
+    const webmailAuth = verifyWebmailCredential(cleanUsername, password, { permission: 'send' });
+    return webmailAuth ? {
+      user: webmailAuth.user,
+      mailbox: webmailAuth.mailbox,
+      webmailSession: webmailAuth.webmailSession,
+      credential: {
+        username: webmailAuth.mailbox.address,
+        type: 'webmail_session'
+      }
+    } : null;
+  }
   const row = requireDb()
     .prepare(`
       SELECT c.*, u.id AS auth_user_id, u.username AS auth_username, u.email, u.role, u.status
@@ -5529,6 +5837,178 @@ function truncateWebhookBodyPreview(value, maxLength = 2048) {
   return text.slice(0, maxLength);
 }
 
+function normalizeWebmailEntityId(value, message) {
+  const id = Number(value);
+  if (!Number.isSafeInteger(id) || id <= 0) throw new Error(message);
+  return id;
+}
+
+function webmailMailboxAccessDeniedError() {
+  const error = new Error('收信邮箱不存在或无权登录 Webmail。');
+  error.code = 'WEBMAIL_MAILBOX_ACCESS_DENIED';
+  return error;
+}
+
+function webmailTicketRateLimitError() {
+  const error = new Error('Webmail 登录请求过于频繁,请稍后重试。');
+  error.code = 'WEBMAIL_TICKET_RATE_LIMIT';
+  error.retryAfterSeconds = webmailTicketRateWindowSeconds;
+  return error;
+}
+
+function maybeCleanupWebmailSessions() {
+  const currentTimeMs = Date.now();
+  if (currentTimeMs - lastWebmailSessionCleanupAtMs < webmailSessionCleanupIntervalMs) return;
+  cleanupWebmailSessions({ retentionSeconds: webmailSessionRetentionSeconds });
+  lastWebmailSessionCleanupAtMs = currentTimeMs;
+}
+
+function normalizeWebmailAudience(value) {
+  const rawAudience = String(value || '').trim();
+  if (!rawAudience || rawAudience.length > 2048) throw new Error('Webmail 受众地址不正确。');
+  let audienceUrl;
+  try {
+    audienceUrl = new URL(rawAudience);
+  } catch {
+    throw new Error('Webmail 受众地址不正确。');
+  }
+  if (!['http:', 'https:'].includes(audienceUrl.protocol)
+    || audienceUrl.username
+    || audienceUrl.password
+    || audienceUrl.pathname !== '/'
+    || audienceUrl.search
+    || audienceUrl.hash) {
+    throw new Error('Webmail 受众地址不正确。');
+  }
+  return audienceUrl.origin;
+}
+
+function normalizeWebmailTtlSeconds(value, min, max, message) {
+  const ttl = Number(value);
+  if (!Number.isSafeInteger(ttl) || ttl < min || ttl > max) throw new Error(message);
+  return ttl;
+}
+
+function normalizeWebmailPermission(value) {
+  const permission = String(value || '').trim().toLowerCase();
+  if (!['receive', 'send'].includes(permission)) throw new Error('Webmail 邮箱权限不正确。');
+  return permission;
+}
+
+function normalizeWebmailToken(value, prefix) {
+  const token = String(value || '').trim();
+  return new RegExp(`^${prefix}[A-Za-z0-9_-]{43}$`).test(token) ? token : '';
+}
+
+function webmailTokenHash(kind, token) {
+  return crypto
+    .createHmac('sha256', encryptionKey())
+    .update(`mailhub-webmail-${kind}\u0000${String(token || '')}`)
+    .digest('hex');
+}
+
+function webmailAuthorizedMailboxRow(actorUserId, mailboxId, permission, currentTime) {
+  const cleanPermission = normalizeWebmailPermission(permission);
+  const grantColumn = cleanPermission === 'send' ? 'can_send' : 'can_receive';
+  return requireDb()
+    .prepare(`
+      SELECT
+        m.*,
+        d.domain,
+        0 AS message_count,
+        0 AS unread_count,
+        NULL AS last_message_at
+      FROM inbound_mailboxes m
+      JOIN domains d ON d.id = m.domain_id
+      JOIN users mailbox_owner ON mailbox_owner.id = m.user_id
+      JOIN users webmail_actor ON webmail_actor.id = ?
+      LEFT JOIN inbound_mailbox_grants webmail_grant
+        ON webmail_grant.mailbox_id = m.id AND webmail_grant.user_id = webmail_actor.id
+      WHERE m.id = ?
+        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 mailbox_owner.status = 'active'
+        AND webmail_actor.status = 'active'
+        AND (m.user_id = webmail_actor.id OR webmail_grant.${grantColumn} = 'true')
+      LIMIT 1
+    `)
+    .get(actorUserId, mailboxId, currentTime);
+}
+
+function webmailCurrentPermissionSql(permission, { address = false } = {}) {
+  const cleanPermission = normalizeWebmailPermission(permission);
+  const grantColumn = cleanPermission === 'send' ? 'can_send' : 'can_receive';
+  return `
+    SELECT 1
+    FROM inbound_mailboxes webmail_mailbox
+    JOIN users webmail_actor ON webmail_actor.id = webmail_sessions.actor_user_id
+    JOIN users webmail_owner ON webmail_owner.id = webmail_mailbox.user_id
+    LEFT JOIN inbound_mailbox_grants webmail_grant
+      ON webmail_grant.mailbox_id = webmail_mailbox.id
+      AND webmail_grant.user_id = webmail_sessions.actor_user_id
+    WHERE webmail_mailbox.id = webmail_sessions.mailbox_id
+      ${address ? 'AND webmail_mailbox.address = ?' : ''}
+      AND webmail_mailbox.status = 'active'
+      AND webmail_mailbox.deleted_at IS NULL
+      AND (
+        webmail_mailbox.expires_at IS NULL
+        OR webmail_mailbox.expires_at = ''
+        OR webmail_mailbox.expires_at > ?
+      )
+      AND webmail_actor.status = 'active'
+      AND webmail_owner.status = 'active'
+      AND (
+        webmail_mailbox.user_id = webmail_sessions.actor_user_id
+        OR webmail_grant.${grantColumn} = 'true'
+      )
+  `;
+}
+
+function webmailCredentialResultRow(sessionId, address, permission, currentTime) {
+  const cleanPermission = normalizeWebmailPermission(permission);
+  const grantColumn = cleanPermission === 'send' ? 'can_send' : 'can_receive';
+  return requireDb()
+    .prepare(`
+      SELECT
+        m.*,
+        d.domain,
+        0 AS message_count,
+        0 AS unread_count,
+        NULL AS last_message_at,
+        s.id AS webmail_session_id,
+        s.actor_user_id,
+        s.audience,
+        s.credential_expires_at,
+        s.last_used_at,
+        actor.id AS auth_user_id,
+        actor.username AS auth_username,
+        actor.email AS auth_email,
+        actor.role AS auth_role,
+        actor.status AS auth_status
+      FROM webmail_sessions s
+      JOIN inbound_mailboxes m ON m.id = s.mailbox_id
+      JOIN domains d ON d.id = m.domain_id
+      JOIN users actor ON actor.id = s.actor_user_id
+      JOIN users mailbox_owner ON mailbox_owner.id = m.user_id
+      LEFT JOIN inbound_mailbox_grants webmail_grant
+        ON webmail_grant.mailbox_id = m.id AND webmail_grant.user_id = s.actor_user_id
+      WHERE s.id = ?
+        AND m.address = ?
+        AND s.exchanged_at IS NOT NULL
+        AND s.revoked_at IS NULL
+        AND s.credential_expires_at > ?
+        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 actor.status = 'active'
+        AND mailbox_owner.status = 'active'
+        AND (m.user_id = s.actor_user_id OR webmail_grant.${grantColumn} = 'true')
+      LIMIT 1
+    `)
+    .get(sessionId, address, currentTime, currentTime);
+}
+
 function normalizeUsername(value) {
   const username = String(value || '').trim().toLowerCase();
   return /^[a-z0-9][a-z0-9_.-]{2,31}$/.test(username) ? username : '';

+ 44 - 3
src/dovecot-auth-server.js

@@ -11,6 +11,7 @@ const defaultBodyLimit = 8 * 1024;
 const defaultRequestTimeoutMs = 30_000;
 const defaultAuthCacheTtlMs = 600_000;
 const defaultAuthCacheMaxEntries = 4096;
+const webmailReadonlyAclGroup = 'mailhub_webmail_readonly';
 
 export function createDovecotAuthServer(options = {}) {
   const sharedSecretDigest = digestSecret(readSharedSecret(options.secretFile));
@@ -93,7 +94,11 @@ async function handleRequest(req, res, context) {
       authenticate: () => verifyCachedCredential(request, context)
     });
     if (!authenticated) return sendJson(res, 200, { authenticated: false });
-    return sendJson(res, 200, { authenticated: true, user: authenticated.user });
+    const response = { authenticated: true, user: authenticated.user };
+    if (authenticated.aclGroups === webmailReadonlyAclGroup) {
+      response.aclGroups = webmailReadonlyAclGroup;
+    }
+    return sendJson(res, 200, response);
   } catch {
     context.logger.error?.('Dovecot authentication bridge request failed.');
     return sendJson(res, 503, { error: 'Service unavailable.' });
@@ -101,6 +106,17 @@ async function handleRequest(req, res, context) {
 }
 
 async function verifyCachedCredential(request, context) {
+  // Webmail SSO credentials are short lived and their mailbox grants are
+  // checked on every use. Never let the positive password cache extend a
+  // revoked or expired Webmail session.
+  if (isWebmailCredential(request.password)) {
+    return context.inFlightAuth.run(request.username, request.password, async () => {
+      const authenticated = await context.verifyCredential(request.username, request.password);
+      if (!authenticated) return null;
+      return dovecotAuthIdentity(authenticated, { webmailCredential: true });
+    });
+  }
+
   const cachedUser = context.authCache?.get(request.username, request.password);
   if (cachedUser) return { user: cachedUser };
 
@@ -111,13 +127,38 @@ async function verifyCachedCredential(request, context) {
     const authenticated = await context.verifyCredential(request.username, request.password);
     if (!authenticated) return null;
 
-    const user = canonicalMailboxAddress(authenticated);
-    if (!user) throw new Error('Credential verifier returned an invalid mailbox');
+    const { user } = dovecotAuthIdentity(authenticated);
     context.authCache?.set(request.username, request.password, user);
     return { user };
   });
 }
 
+function dovecotAuthIdentity(authenticated, { webmailCredential = false } = {}) {
+  const user = canonicalMailboxAddress(authenticated);
+  if (!user) throw new Error('Credential verifier returned an invalid mailbox');
+  if (!webmailCredential) return { user };
+
+  const actorUserId = positiveEntityId(authenticated?.webmailSession?.actorUserId);
+  const ownerUserId = positiveEntityId(
+    authenticated?.mailbox?.ownerUserId ?? authenticated?.mailbox?.userId
+  );
+  if (!actorUserId || !ownerUserId) {
+    throw new Error('Credential verifier returned invalid Webmail ownership metadata');
+  }
+  return actorUserId === ownerUserId
+    ? { user }
+    : { user, aclGroups: webmailReadonlyAclGroup };
+}
+
+function positiveEntityId(value) {
+  const id = Number(value);
+  return Number.isSafeInteger(id) && id > 0 ? id : 0;
+}
+
+function isWebmailCredential(value) {
+  return String(value || '').startsWith('mhw_');
+}
+
 function readSharedSecret(filePath) {
   if (!filePath || typeof filePath !== 'string') {
     throw new Error('Dovecot authentication secret file is required');

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

@@ -31,6 +31,7 @@ import type {
   UserMergeResult,
   UserRole,
   UserStatus,
+  WebmailLogin,
   Webhook,
   WebhookDelivery,
   WebhookDeliveryFilters,
@@ -150,6 +151,10 @@ export const api = {
   },
   inboundFolders: (mailboxId: number) =>
     request<{ folders: InboundFolder[] }>(`/api/inbound-mailboxes/${mailboxId}/folders`),
+  createWebmailLogin: (mailboxId: number) =>
+    request<{ webmailLogin: WebmailLogin }>(`/api/inbound-mailboxes/${mailboxId}/webmail-login`, {
+      method: 'POST'
+    }),
   inboundMessage: (id: number) => request<{ message: InboundMessage | null }>(`/api/inbound-messages/${id}`),
   markInboundMessageRead: (id: number, read = true) =>
     request<{ message: InboundMessage | null }>(`/api/inbound-messages/${id}`, { method: 'PATCH', data: { read } }),
@@ -322,6 +327,36 @@ export const api = {
     request<{ delivery: WebhookDelivery }>(`/api/webhook-deliveries/${id}/replay`, { method: 'POST' })
 };
 
+export function submitWebmailLogin(webmailLogin: WebmailLogin) {
+  const form = document.createElement('form');
+  form.hidden = true;
+  form.method = 'POST';
+  form.action = webmailLogin.action;
+  form.target = '_self';
+  form.acceptCharset = 'UTF-8';
+
+  const fields = {
+    mailhub_ticket: webmailLogin.ticket,
+    _task: 'mail',
+    _mbox: 'INBOX'
+  };
+  Object.entries(fields).forEach(([name, value]) => {
+    const input = document.createElement('input');
+    input.type = 'hidden';
+    input.name = name;
+    input.value = value;
+    form.append(input);
+  });
+
+  document.body.append(form);
+  try {
+    if (typeof form.requestSubmit === 'function') form.requestSubmit();
+    else form.submit();
+  } finally {
+    form.remove();
+  }
+}
+
 function queryString(values: object) {
   const params = new URLSearchParams();
   Object.entries(values).forEach(([key, value]) => {

+ 7 - 0
src/frontend/types.ts

@@ -53,6 +53,7 @@ export interface AdminUser extends User {
 
 export interface RuntimeConfig {
   appBaseUrl: string;
+  webmailSsoEnabled?: boolean;
   mailHostname: string;
   sendingIp: string;
   defaultSpfMechanisms: string;
@@ -360,6 +361,12 @@ export interface InboundMailbox {
   updatedAt: string;
 }
 
+export interface WebmailLogin {
+  action: string;
+  ticket: string;
+  expiresAt: string;
+}
+
 export interface AdminMailboxAccessGrant {
   user: User;
   permissions: MailboxPermissions;

+ 76 - 4
src/pages/Account.tsx

@@ -1,10 +1,12 @@
 import {
+  ExportOutlined,
   InboxOutlined,
   ReloadOutlined,
   SettingOutlined
 } from '@ant-design/icons';
 import {
   Alert,
+  App as AntApp,
   Button,
   Card,
   Descriptions,
@@ -13,6 +15,7 @@ import {
   Space,
   Table,
   Tag,
+  Tooltip,
   Typography
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
@@ -25,18 +28,20 @@ import { SectionCard } from '../components/common/SectionCard';
 import { MailboxPermissionTags } from '../components/mailbox/MailboxPermissionTags';
 import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
-import { api } from '../frontend/services/api';
+import { api, submitWebmailLogin } from '../frontend/services/api';
 import type { InboundMailbox, MailboxAccess } from '../frontend/types';
 import { useMediaQuery } from '../frontend/use-media-query';
 
 export default function Account() {
-  const { user } = useAppContext();
-  const { t } = useI18n();
+  const { message } = AntApp.useApp();
+  const { config, user } = useAppContext();
+  const { locale, t } = useI18n();
   const navigate = useNavigate();
   const isTableView = useMediaQuery('(min-width: 768px)');
   const [mailboxes, setMailboxes] = useState<InboundMailbox[]>([]);
   const [loading, setLoading] = useState(true);
   const [loadError, setLoadError] = useState('');
+  const [webmailLoadingIds, setWebmailLoadingIds] = useState<Set<number>>(() => new Set());
 
   const loadMailboxes = useCallback(async () => {
     setLoading(true);
@@ -68,6 +73,23 @@ export default function Account() {
     navigate(`/inbox?workspace=routing&mailboxId=${mailbox.id}`);
   }
 
+  async function openWebmail(mailbox: InboundMailbox) {
+    if (config?.webmailSsoEnabled !== true || !canOpenWebmail(mailbox) || mailbox.status !== 'active' || webmailLoadingIds.has(mailbox.id)) return;
+    setWebmailLoadingIds((current) => new Set(current).add(mailbox.id));
+    try {
+      const result = await api.createWebmailLogin(mailbox.id);
+      submitWebmailLogin(result.webmailLogin);
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setWebmailLoadingIds((current) => {
+        const next = new Set(current);
+        next.delete(mailbox.id);
+        return next;
+      });
+    }
+  }
+
   const columns: ColumnsType<InboundMailbox> = [
     {
       title: t('inbox.mailboxAddress'),
@@ -90,7 +112,7 @@ export default function Account() {
     },
     {
       title: t('common.actions'),
-      width: 250,
+      width: 430,
       render: (_, mailbox) => (
         <Space wrap>
           {mailboxAccess(mailbox).permissions.receive ? (
@@ -98,6 +120,14 @@ export default function Account() {
               {t('account.openInbox')}
             </Button>
           ) : null}
+          {config?.webmailSsoEnabled === true && canOpenWebmail(mailbox) ? (
+            <WebmailButton
+              mailbox={mailbox}
+              locale={locale}
+              loading={webmailLoadingIds.has(mailbox.id)}
+              onClick={() => void openWebmail(mailbox)}
+            />
+          ) : null}
           {mailboxAccess(mailbox).type === 'owner' ? (
             <Button aria-label={`${t('account.manageMailbox')} · ${mailbox.address}`} icon={<SettingOutlined />} style={{ minHeight: 44 }} onClick={() => manageMailbox(mailbox)}>
               {t('account.manageMailbox')}
@@ -148,6 +178,15 @@ export default function Account() {
                           {t('account.openInbox')}
                         </Button>
                       ) : null}
+                      {config?.webmailSsoEnabled === true && canOpenWebmail(mailbox) ? (
+                        <WebmailButton
+                          block
+                          mailbox={mailbox}
+                          locale={locale}
+                          loading={webmailLoadingIds.has(mailbox.id)}
+                          onClick={() => void openWebmail(mailbox)}
+                        />
+                      ) : null}
                       {mailboxAccess(mailbox).type === 'owner' ? (
                         <Button block aria-label={`${t('account.manageMailbox')} · ${mailbox.address}`} icon={<SettingOutlined />} style={{ minHeight: 44 }} onClick={() => manageMailbox(mailbox)}>
                           {t('account.manageMailbox')}
@@ -179,3 +218,36 @@ function mailboxAccess(mailbox: InboundMailbox): MailboxAccess {
     permissions: { view: true, receive: true, send: true }
   };
 }
+
+function canOpenWebmail(mailbox: InboundMailbox) {
+  const access = mailboxAccess(mailbox);
+  return (access.type === 'owner' || access.type === 'assigned') && access.permissions.receive;
+}
+
+function WebmailButton({ block = false, mailbox, locale, loading, onClick }: {
+  block?: boolean;
+  mailbox: InboundMailbox;
+  locale: string;
+  loading: boolean;
+  onClick: () => void;
+}) {
+  const label = locale.startsWith('en') ? 'Open Webmail' : '一键登录 Webmail';
+  const disabledReason = mailbox.status === 'active'
+    ? ''
+    : (locale.startsWith('en') ? 'This mailbox is disabled and cannot sign in to Webmail.' : '该邮箱已停用,无法登录 Webmail。');
+  const button = (
+    <Button
+      block={block}
+      aria-label={`${label} · ${mailbox.address}${disabledReason ? ` · ${disabledReason}` : ''}`}
+      disabled={Boolean(disabledReason)}
+      icon={<ExportOutlined />}
+      loading={loading}
+      onClick={onClick}
+      style={{ minHeight: 44 }}
+      title={disabledReason || undefined}
+    >
+      {label}
+    </Button>
+  );
+  return disabledReason ? <Tooltip title={disabledReason}><span style={block ? { display: 'block' } : undefined}>{button}</span></Tooltip> : button;
+}

+ 80 - 3
src/pages/Inbox.tsx

@@ -3,6 +3,7 @@ import {
   CopyOutlined,
   DeleteOutlined,
   EditOutlined,
+  ExportOutlined,
   FileTextOutlined,
   FolderOutlined,
   InboxOutlined,
@@ -36,6 +37,7 @@ import {
   Table,
   Tabs,
   Tag,
+  Tooltip,
   Typography
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
@@ -51,7 +53,7 @@ import { MailboxPermissionTags } from '../components/mailbox/MailboxPermissionTa
 import { useAppContext } from '../frontend/app-context';
 import { useI18n } from '../frontend/i18n/react';
 import { detailHistoryLocation, detailHistoryState } from '../frontend/navigation-state';
-import { api } from '../frontend/services/api';
+import { api, submitWebmailLogin } from '../frontend/services/api';
 import type { Domain, InboundFolder, InboundMailbox, InboundMailboxDomain, InboundMessage, MailboxClientConfig, RuntimeConfig } from '../frontend/types';
 import { useMediaQuery } from '../frontend/use-media-query';
 
@@ -105,6 +107,7 @@ export default function Inbox() {
   const [foldersError, setFoldersError] = useState('');
   const [readMutationError, setReadMutationError] = useState('');
   const [actionKey, setActionKey] = useState('');
+  const [webmailLoadingIds, setWebmailLoadingIds] = useState<Set<number>>(() => new Set());
   const [searchDraft, setSearchDraft] = useState(searchParams.get('q') || '');
   const [mailboxOpen, setMailboxOpen] = useState(false);
   const [editingMailbox, setEditingMailbox] = useState<InboundMailbox | null>(null);
@@ -431,6 +434,23 @@ export default function Inbox() {
     message.success(t('common.copied'));
   }
 
+  async function openWebmail(mailbox: InboundMailbox) {
+    if (config?.webmailSsoEnabled !== true || !canOpenWebmail(mailbox) || mailbox.status !== 'active' || webmailLoadingIds.has(mailbox.id)) return;
+    setWebmailLoadingIds((current) => new Set(current).add(mailbox.id));
+    try {
+      const result = await api.createWebmailLogin(mailbox.id);
+      submitWebmailLogin(result.webmailLogin);
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : t('common.error'));
+    } finally {
+      setWebmailLoadingIds((current) => {
+        const next = new Set(current);
+        next.delete(mailbox.id);
+        return next;
+      });
+    }
+  }
+
   function openCreateMailbox() {
     setEditingMailbox(null);
     mailboxForm.resetFields();
@@ -524,6 +544,30 @@ export default function Inbox() {
     }
   }
 
+  function mailboxActions(item: InboundMailbox, block = false) {
+    const owner = isMailboxOwner(item, user?.id);
+    return (
+      <Space direction="vertical" size={8} className={block ? 'full-width' : undefined}>
+        {config?.webmailSsoEnabled === true && canOpenWebmail(item) ? (
+          <WebmailButton
+            block={block}
+            mailbox={item}
+            locale={locale}
+            loading={webmailLoadingIds.has(item.id)}
+            onClick={() => void openWebmail(item)}
+          />
+        ) : null}
+        {owner ? (
+          <Space wrap>
+            <Button icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button>
+            <Button icon={<SettingOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button>
+            <Button icon={<MailOutlined />} onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button>
+          </Space>
+        ) : <Typography.Text type="secondary">{t('inbox.ownerManaged')}</Typography.Text>}
+      </Space>
+    );
+  }
+
   const mailboxColumns: ColumnsType<InboundMailbox> = [
     {
       title: t('inbox.mailboxAddress'), dataIndex: 'address', render: (value: string, item) => <Space direction="vertical" size={0}><Typography.Text strong>{value}</Typography.Text>{item.displayName ? <Typography.Text type="secondary">{item.displayName}</Typography.Text> : null}</Space>
@@ -543,7 +587,7 @@ export default function Inbox() {
     { title: t('inbox.unread'), dataIndex: 'unreadCount', width: 90, render: countLabel },
     { title: t('inbox.messageCount'), dataIndex: 'messageCount', width: 100, render: countLabel },
     {
-      title: t('common.actions'), width: 330, render: (_, item) => isMailboxOwner(item, user?.id) ? <Space wrap><Button icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button><Button icon={<SettingOutlined />} onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button icon={<MailOutlined />} onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space> : <Typography.Text type="secondary">{t('inbox.ownerManaged')}</Typography.Text>
+      title: t('common.actions'), width: 390, render: (_, item) => mailboxActions(item)
     }
   ];
 
@@ -648,7 +692,7 @@ export default function Inbox() {
                 {screens.md ? (
                   <Table rowKey="id" columns={mailboxColumns} dataSource={visibleMailboxes} scroll={{ x: 1420 }} />
                 ) : (
-                  <List dataSource={visibleMailboxes} renderItem={(item) => <List.Item><Card size="small" className="full-width" title={item.address}><Space direction="vertical" className="full-width"><MailboxAccessSource mailbox={item} t={t} /><MailboxPermissionTags permissions={mailboxPermissions(item)} /><Typography.Text type="secondary">{countLabel(item.messageCount)} {t('inbox.messageCount')} · {countLabel(item.unreadCount)} {t('inbox.unread')}</Typography.Text>{isMailboxOwner(item, user?.id) ? <Space wrap><Button icon={<EditOutlined />} onClick={() => openEditMailbox(item)}>{t('common.edit')}</Button><Button onClick={() => setClientConfig(buildMailboxClientConfig(item, config))}>{t('inbox.clientConfig')}</Button><Button onClick={() => navigate(`/integrations/webhooks?mailboxId=${item.id}`)}>{t('inbox.mailboxWebhooks')}</Button></Space> : <Typography.Text type="secondary">{t('inbox.ownerManaged')}</Typography.Text>}</Space></Card></List.Item>} />
+                  <List dataSource={visibleMailboxes} renderItem={(item) => <List.Item><Card size="small" className="full-width" title={item.address}><Space direction="vertical" className="full-width"><MailboxAccessSource mailbox={item} t={t} /><MailboxPermissionTags permissions={mailboxPermissions(item)} /><Typography.Text type="secondary">{countLabel(item.messageCount)} {t('inbox.messageCount')} · {countLabel(item.unreadCount)} {t('inbox.unread')}</Typography.Text>{mailboxActions(item, true)}</Space></Card></List.Item>} />
                 )}
               </Space>
             ) : loadError ? null : <EmptyState description={mailboxDomains.length ? (locale.startsWith('en') ? 'No receiving mailbox has been created yet.' : '尚未创建收信邮箱。') : t('inbox.noMailboxDomain')} action={<Button icon={<PlusOutlined />} disabled={!mailboxDomains.length} onClick={openCreateMailbox}>{t('inbox.createMailbox')}</Button>} />}
@@ -1016,6 +1060,39 @@ function mailboxPermissions(mailbox: InboundMailbox) {
   };
 }
 
+function canOpenWebmail(mailbox: InboundMailbox) {
+  const accessType = mailbox.access?.type || 'owner';
+  return (accessType === 'owner' || accessType === 'assigned') && mailboxPermissions(mailbox).receive;
+}
+
+function WebmailButton({ block = false, mailbox, locale, loading, onClick }: {
+  block?: boolean;
+  mailbox: InboundMailbox;
+  locale: string;
+  loading: boolean;
+  onClick: () => void;
+}) {
+  const label = locale.startsWith('en') ? 'Open Webmail' : '一键登录 Webmail';
+  const disabledReason = mailbox.status === 'active'
+    ? ''
+    : (locale.startsWith('en') ? 'This mailbox is disabled and cannot sign in to Webmail.' : '该邮箱已停用,无法登录 Webmail。');
+  const button = (
+    <Button
+      block={block}
+      aria-label={`${label} · ${mailbox.address}${disabledReason ? ` · ${disabledReason}` : ''}`}
+      disabled={Boolean(disabledReason)}
+      icon={<ExportOutlined />}
+      loading={loading}
+      onClick={onClick}
+      style={{ minHeight: 44 }}
+      title={disabledReason || undefined}
+    >
+      {label}
+    </Button>
+  );
+  return disabledReason ? <Tooltip title={disabledReason}><span style={block ? { display: 'block' } : undefined}>{button}</span></Tooltip> : button;
+}
+
 function isMailboxOwner(mailbox: InboundMailbox, userId?: number) {
   if (!userId) return false;
   return mailbox.access?.type === 'owner' || (mailbox.ownerUserId || mailbox.userId) === userId;

+ 48 - 0
src/server.js

@@ -17,6 +17,7 @@ import {
   createTrackingLink,
   createUserWithAccountToken,
   createWebhook,
+  createWebmailLoginTicket,
   consumeAccountToken,
   deleteDnsCredential,
   deleteDomain,
@@ -31,6 +32,7 @@ import {
   getApiToken,
   getInboundMessage,
   getInboundMessageMaildirStorage,
+  getInboundMailbox,
   getInboundMailboxForSender,
   getSendEvent,
   getSendAnalytics,
@@ -141,6 +143,11 @@ import {
   trackingReplayKey
 } from './tracking.js';
 import { startTrackingRetentionWorker } from './tracking-retention.js';
+import {
+  handleInternalWebmailSso,
+  isSameOriginRequest,
+  loadWebmailSsoConfig
+} from './webmail-sso.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 loadDotEnv();
@@ -236,6 +243,7 @@ const defaultSettings = {
 
 const emailVerificationPurpose = 'email_verification';
 const passwordResetPurpose = 'password_reset';
+const webmailSsoConfig = loadWebmailSsoConfig(process.env);
 
 initDatabase(envConfig.dataDir, envConfig.sessionSecret);
 try {
@@ -292,6 +300,7 @@ const server = http.createServer(async (req, res) => {
     const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
     if (url.pathname === '/healthz') return sendJson(res, 200, { ok: true });
     if (url.pathname.startsWith('/t/') && await handleTrackingRequest(req, res, url)) return;
+    if (await handleInternalWebmailSso(req, res, url, webmailSsoConfig)) return;
     if (req.method === 'POST' && (url.pathname === '/api/register' || url.pathname === '/register')) return await handleRegister(req, res);
     if (req.method === 'POST' && (url.pathname === '/api/login' || url.pathname === '/login')) return await handleLogin(req, res);
     if (req.method === 'POST' && url.pathname === '/api/logout') return handleLogout(res);
@@ -475,6 +484,44 @@ async function handleApi(req, res, url, user) {
       domains: listInboundMailboxDomains(user.id)
     });
   }
+  const webmailLoginMatch = pathname.match(/^\/api\/inbound-mailboxes\/(\d+)\/webmail-login$/);
+  if (webmailLoginMatch && method === 'POST') {
+    if (req.mailhubAuthType !== 'session') {
+      return sendJson(res, 403, { error: '一键登录仅支持当前 MailHub 登录会话。' });
+    }
+    if (!webmailSsoConfig.enabled) {
+      return sendJson(res, 503, { error: 'Webmail 一键登录尚未配置。' });
+    }
+    if (!isSameOriginRequest(req, runtimeSettings().appBaseUrl, { trustProxy: envConfig.trustProxy })) {
+      return sendJson(res, 403, { error: '请求来源校验失败,请刷新页面后重试。' });
+    }
+    const mailboxId = Number(webmailLoginMatch[1]);
+    const mailbox = getInboundMailbox(mailboxId, user.id, { permission: 'receive' });
+    if (!mailbox) return sendJson(res, 404, { error: '邮箱不存在或当前账号没有收信权限。' });
+    try {
+      const login = createWebmailLoginTicket(user.id, mailboxId, {
+        audience: webmailSsoConfig.audience,
+        ticketTtlSeconds: webmailSsoConfig.ticketTtlSeconds,
+        credentialTtlSeconds: webmailSsoConfig.credentialTtlSeconds
+      });
+      return sendPrivateJson(res, 201, {
+        webmailLogin: {
+          action: webmailSsoConfig.url,
+          ticket: login.ticket,
+          expiresAt: login.ticketExpiresAt
+        }
+      });
+    } catch (error) {
+      if (error?.code === 'WEBMAIL_MAILBOX_ACCESS_DENIED') {
+        return sendJson(res, 404, { error: '邮箱不存在或当前账号没有收信权限。' });
+      }
+      if (error?.code === 'WEBMAIL_TICKET_RATE_LIMIT') {
+        res.setHeader('Retry-After', String(error.retryAfterSeconds || 60));
+        return sendJson(res, 429, { error: error.message });
+      }
+      return sendJson(res, 400, { error: error?.message || 'Webmail 登录票据创建失败。' });
+    }
+  }
   if (method === 'POST' && pathname === '/api/inbound-mailboxes') {
     const body = await readJson(req);
     const password = String(body.password || '');
@@ -2022,6 +2069,7 @@ function publicConfig(user) {
   const smtpCredential = getSmtpCredential(user.id);
   return {
     ...settings,
+    webmailSsoEnabled: webmailSsoConfig.enabled,
     smtpHost: envConfig.smtpHost ? 'configured' : '',
     submission: {
       enabled: envConfig.submissionEnabled,

+ 30 - 0
src/submission.js

@@ -13,6 +13,7 @@ import {
   getSmtpRelay,
   logSendEvent,
   resolveInboundRecipient,
+  webmailSessionHasPermission,
   verifySmtpCredential
 } from './db.js';
 import { parseInboundMessage } from './inbound-mail.js';
@@ -223,6 +224,7 @@ class SubmissionSession {
     this.authRateLimiter = config.authRateLimiter || authenticationRateLimiter;
     this.user = null;
     this.authMailbox = null;
+    this.authWebmailSession = null;
     this.authenticated = false;
     this.mailFrom = '';
     this.mailFromAccepted = false;
@@ -325,6 +327,7 @@ class SubmissionSession {
     this.authenticated = false;
     this.user = null;
     this.authMailbox = null;
+    this.authWebmailSession = null;
     this.authState = '';
     this.config = {
       ...this.config,
@@ -390,17 +393,23 @@ class SubmissionSession {
     if (auth?.user) {
       this.user = auth.user;
       this.authMailbox = auth.mailbox || null;
+      this.authWebmailSession = auth.webmailSession || null;
       this.authenticated = true;
       return this.write(235, 'Authentication successful');
     }
     this.user = null;
     this.authMailbox = null;
+    this.authWebmailSession = null;
     this.authenticated = false;
     return this.write(535, 'Authentication failed');
   }
 
   mail(argument) {
     if (!this.authenticated && !this.config.inboundEnabled) return this.write(530, 'Authentication required');
+    if (!this.webmailAuthorizationValid()) {
+      this.clearAuthentication();
+      return this.write(530, 'Authentication no longer valid');
+    }
     const address = extractPathAddress(argument, { allowEmpty: !this.authenticated });
     if (address === null) return this.write(501, 'Invalid MAIL FROM');
     if (this.authMailbox && !mailboxAllowsSender(this.authMailbox, address)) {
@@ -456,6 +465,11 @@ class SubmissionSession {
     }
     const rawMessageBytes = Buffer.from(`${this.dataLines.join('\r\n')}\r\n`, 'latin1');
     if (!this.authenticated) return await this.finishInboundData(rawMessageBytes);
+    if (!this.webmailAuthorizationValid()) {
+      this.resetEnvelope(false);
+      this.clearAuthentication();
+      return this.write(530, 'Authentication no longer valid');
+    }
     const rawMessage = rawMessageBytes.toString('utf8');
     const headerFrom = extractHeader(rawMessage, 'from');
     const subject = decodeHeader(extractHeader(rawMessage, 'subject')) || '(no subject)';
@@ -694,6 +708,22 @@ class SubmissionSession {
     if (reply) this.write(250, 'OK');
   }
 
+  webmailAuthorizationValid() {
+    if (!this.authWebmailSession) return true;
+    return webmailSessionHasPermission(
+      this.authWebmailSession.id,
+      this.authMailbox?.address,
+      { permission: 'send' }
+    );
+  }
+
+  clearAuthentication() {
+    this.authenticated = false;
+    this.user = null;
+    this.authMailbox = null;
+    this.authWebmailSession = null;
+  }
+
   write(code, message) {
     this.socket.write(`${code} ${message}\r\n`);
   }

+ 230 - 0
src/webmail-sso.js

@@ -0,0 +1,230 @@
+import crypto from 'node:crypto';
+import { readFileSync } from 'node:fs';
+
+import {
+  exchangeWebmailLoginTicket,
+  revokeWebmailCredential
+} from './db.js';
+
+const internalPrefix = '/internal/webmail-sso/';
+const bodyLimit = 8 * 1024;
+
+export function loadWebmailSsoConfig(env = process.env) {
+  const url = normalizeWebmailUrl(env.WEBMAIL_SSO_URL);
+  const secretFile = String(env.WEBMAIL_SSO_SECRET_FILE || '').trim();
+  if (!url) {
+    return {
+      enabled: false,
+      url: '',
+      audience: '',
+      ticketTtlSeconds: 60,
+      credentialTtlSeconds: 43_200,
+      secretDigest: null
+    };
+  }
+  if (!secretFile) {
+    throw new Error('Webmail SSO URL and secret file must be configured together');
+  }
+  return {
+    enabled: true,
+    url,
+    audience: new URL(url).origin,
+    ticketTtlSeconds: boundedInteger(env.WEBMAIL_SSO_TICKET_TTL_SECONDS, 60, 10, 300),
+    credentialTtlSeconds: boundedInteger(env.WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS, 43_200, 300, 86_400),
+    secretDigest: digestSecret(readSharedSecret(secretFile))
+  };
+}
+
+export async function handleInternalWebmailSso(req, res, url, config, dependencies = {}) {
+  if (!url.pathname.startsWith(internalPrefix)) return false;
+  setPrivateHeaders(res);
+  if (!config.enabled) {
+    sendJson(res, 503, { error: 'Webmail SSO is unavailable.' });
+    return true;
+  }
+  if (req.method !== 'POST') {
+    res.setHeader('Allow', 'POST');
+    sendJson(res, 405, { error: 'Method not allowed.' });
+    return true;
+  }
+  if (!validBearerSecret(req.headers.authorization, config.secretDigest)) {
+    sendJson(res, 401, { error: 'Unauthorized.' });
+    return true;
+  }
+  if (requestContentType(req) !== 'application/json') {
+    sendJson(res, 415, { error: 'Unsupported media type.' });
+    return true;
+  }
+
+  let body;
+  try {
+    body = await readJson(req, bodyLimit);
+  } catch (error) {
+    sendJson(res, error instanceof RequestTooLargeError ? 413 : 400, {
+      error: error instanceof RequestTooLargeError ? 'Request too large.' : 'Invalid request.'
+    });
+    return true;
+  }
+  if (body.audience !== config.audience) {
+    sendJson(res, 400, { error: 'Invalid request.' });
+    return true;
+  }
+
+  const exchange = dependencies.exchangeWebmailLoginTicket || exchangeWebmailLoginTicket;
+  const revoke = dependencies.revokeWebmailCredential || revokeWebmailCredential;
+  if (url.pathname === `${internalPrefix}exchange`) {
+    const ticket = normalizeOpaqueCredential(body.ticket, 'mht_');
+    if (!ticket) {
+      sendJson(res, 400, { error: 'Invalid or expired login.' });
+      return true;
+    }
+    const result = exchange(ticket, { audience: config.audience });
+    if (!result) {
+      sendJson(res, 400, { error: 'Invalid or expired login.' });
+      return true;
+    }
+    sendJson(res, 200, {
+      username: result.mailbox.address,
+      credential: result.credential,
+      expiresAt: result.expiresAt
+    });
+    return true;
+  }
+
+  if (url.pathname === `${internalPrefix}revoke`) {
+    const credential = normalizeOpaqueCredential(body.credential, 'mhw_');
+    if (!credential) {
+      sendJson(res, 400, { error: 'Invalid request.' });
+      return true;
+    }
+    revoke(credential, { audience: config.audience });
+    sendJson(res, 200, { revoked: true });
+    return true;
+  }
+
+  sendJson(res, 404, { error: 'Not found.' });
+  return true;
+}
+
+export function isSameOriginRequest(req, appBaseUrl, { trustProxy = false } = {}) {
+  const suppliedOrigin = normalizeOrigin(req.headers.origin);
+  if (!suppliedOrigin) return false;
+  const allowed = new Set();
+  const configuredOrigin = normalizeOrigin(appBaseUrl);
+  if (configuredOrigin) allowed.add(configuredOrigin);
+  const requestOrigin = externalRequestOrigin(req, trustProxy);
+  if (requestOrigin) allowed.add(requestOrigin);
+  return allowed.has(suppliedOrigin);
+}
+
+function externalRequestOrigin(req, trustProxy) {
+  const forwardedHost = trustProxy ? firstForwardedValue(req.headers['x-forwarded-host']) : '';
+  const host = String(forwardedHost || req.headers.host || '').trim();
+  if (!host || /[\s/\\]/.test(host)) return '';
+  const forwardedProto = trustProxy ? firstForwardedValue(req.headers['x-forwarded-proto']) : '';
+  const protocol = String(forwardedProto || (req.socket?.encrypted ? 'https' : 'http')).toLowerCase();
+  if (!['http', 'https'].includes(protocol)) return '';
+  return normalizeOrigin(`${protocol}://${host}`);
+}
+
+function firstForwardedValue(value) {
+  return String(value || '').split(',', 1)[0].trim();
+}
+
+function normalizeWebmailUrl(value) {
+  const raw = String(value || '').trim();
+  if (!raw) return '';
+  let parsed;
+  try {
+    parsed = new URL(raw);
+  } catch {
+    throw new Error('Webmail SSO URL is invalid');
+  }
+  const localDevelopment = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
+  if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && localDevelopment)) {
+    throw new Error('Webmail SSO URL must use HTTPS');
+  }
+  if (parsed.username || parsed.password) throw new Error('Webmail SSO URL must not contain credentials');
+  parsed.search = '';
+  parsed.hash = '';
+  return parsed.toString();
+}
+
+function normalizeOrigin(value) {
+  try {
+    const parsed = new URL(String(value || '').trim());
+    if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return '';
+    return parsed.origin;
+  } catch {
+    return '';
+  }
+}
+
+function normalizeOpaqueCredential(value, prefix) {
+  const clean = String(value || '');
+  if (!clean.startsWith(prefix) || !/^[A-Za-z0-9_-]+$/.test(clean)) return '';
+  if (clean.length < 32 || clean.length > 256) return '';
+  return clean;
+}
+
+function readSharedSecret(filePath) {
+  const secret = readFileSync(filePath, 'utf8').trim();
+  if (!/^[0-9a-fA-F]{64,512}$/.test(secret)) {
+    throw new Error('Webmail SSO secret must contain 64-512 hexadecimal characters');
+  }
+  return secret;
+}
+
+function validBearerSecret(header, expectedDigest) {
+  if (!expectedDigest) return false;
+  const match = String(header || '').match(/^Bearer\s+([^\s]+)$/i);
+  const actualDigest = digestSecret(match?.[1] || '');
+  return Boolean(match) && crypto.timingSafeEqual(actualDigest, expectedDigest);
+}
+
+function digestSecret(value) {
+  return crypto.createHash('sha256').update(value).digest();
+}
+
+function requestContentType(req) {
+  return String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase();
+}
+
+async function readJson(req, limit) {
+  const chunks = [];
+  let bytes = 0;
+  for await (const chunk of req) {
+    bytes += chunk.length;
+    if (bytes > limit) throw new RequestTooLargeError();
+    chunks.push(chunk);
+  }
+  if (!chunks.length) throw new Error('Request body is required');
+  const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+  if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Object body is required');
+  return body;
+}
+
+function setPrivateHeaders(res) {
+  res.setHeader('Cache-Control', 'no-store');
+  res.setHeader('Pragma', 'no-cache');
+  res.setHeader('X-Content-Type-Options', 'nosniff');
+}
+
+function sendJson(res, status, payload) {
+  const body = JSON.stringify(payload);
+  res.writeHead(status, {
+    'Content-Type': 'application/json; charset=utf-8',
+    'Content-Length': String(Buffer.byteLength(body))
+  });
+  res.end(body);
+}
+
+function boundedInteger(value, fallback, minimum, maximum) {
+  const parsed = Number(value ?? fallback);
+  if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
+    throw new Error(`Webmail SSO TTL must be between ${minimum} and ${maximum} seconds`);
+  }
+  return parsed;
+}
+
+class RequestTooLargeError extends Error {}

+ 126 - 0
test/dovecot-auth-server.test.js

@@ -148,6 +148,132 @@ test('Dovecot authentication bridge caches only successful credential checks', a
   }
 });
 
+test('Dovecot authentication bridge never caches short-lived Webmail credentials', async () => {
+  let verifierCalls = 0;
+  let allowed = true;
+  const server = createDovecotAuthServer({
+    secretFile: writeSecret(sharedSecret),
+    authCacheTtlMs: 60_000,
+    verifyCredential(_username, password) {
+      verifierCalls += 1;
+      if (!allowed || password !== 'mhw_short-lived-session') return null;
+      return {
+        mailbox: { address: 'Alice@Example.com', ownerUserId: 42 },
+        webmailSession: { actorUserId: 42 }
+      };
+    }
+  });
+  await listen(server);
+
+  try {
+    const first = await request(server, {
+      body: authBody({ password: 'mhw_short-lived-session' })
+    });
+    assert.deepEqual(first.json, { authenticated: true, user: 'alice@example.com' });
+
+    allowed = false;
+    const revoked = await request(server, {
+      body: authBody({ password: 'mhw_short-lived-session' })
+    });
+    assert.deepEqual(revoked.json, { authenticated: false });
+    assert.equal(verifierCalls, 2);
+  } finally {
+    await close(server);
+  }
+});
+
+test('Dovecot authentication bridge restricts delegated Webmail sessions with a fixed ACL group', async () => {
+  const server = createDovecotAuthServer({
+    secretFile: writeSecret(sharedSecret),
+    verifyCredential(_username, password) {
+      if (password === 'mhw_owner-session') {
+        return {
+          mailbox: { address: 'Alice@Example.com', ownerUserId: 42 },
+          webmailSession: { actorUserId: 42 }
+        };
+      }
+      if (password === 'mhw_delegate-session') {
+        return {
+          mailbox: { address: 'Alice@Example.com', ownerUserId: 42 },
+          webmailSession: { actorUserId: 84 }
+        };
+      }
+      if (password === 'mhw_untrusted-groups') {
+        return {
+          mailbox: {
+            address: 'Alice@Example.com',
+            ownerUserId: 42,
+            aclGroups: 'mailhub_webmail_full_access'
+          },
+          webmailSession: {
+            actorUserId: 84,
+            aclGroups: 'mailhub_webmail_full_access'
+          }
+        };
+      }
+      return null;
+    }
+  });
+  await listen(server);
+
+  try {
+    const owner = await request(server, {
+      body: authBody({ password: 'mhw_owner-session' })
+    });
+    assert.deepEqual(owner.json, {
+      authenticated: true,
+      user: 'alice@example.com'
+    });
+
+    const delegate = await request(server, {
+      body: authBody({ password: 'mhw_delegate-session' })
+    });
+    assert.deepEqual(delegate.json, {
+      authenticated: true,
+      user: 'alice@example.com',
+      aclGroups: 'mailhub_webmail_readonly'
+    });
+
+    const ignoresVerifierGroups = await request(server, {
+      body: authBody({ password: 'mhw_untrusted-groups' })
+    });
+    assert.deepEqual(ignoresVerifierGroups.json, {
+      authenticated: true,
+      user: 'alice@example.com',
+      aclGroups: 'mailhub_webmail_readonly'
+    });
+  } finally {
+    await close(server);
+  }
+});
+
+test('Dovecot authentication bridge fails closed when Webmail ownership metadata is missing', async () => {
+  const errors = [];
+  const server = createDovecotAuthServer({
+    secretFile: writeSecret(sharedSecret),
+    verifyCredential() {
+      return { mailbox: { address: 'Alice@Example.com' } };
+    },
+    logger: {
+      error(message) {
+        errors.push(message);
+      }
+    }
+  });
+  await listen(server);
+
+  try {
+    const response = await request(server, {
+      body: authBody({ password: 'mhw_missing-ownership' })
+    });
+    assert.equal(response.status, 503);
+    assert.deepEqual(response.json, { error: 'Service unavailable.' });
+    assert.deepEqual(errors, ['Dovecot authentication bridge request failed.']);
+  } finally {
+    await close(server);
+  }
+});
+
 test('Dovecot authentication bridge coalesces concurrent credential checks', async () => {
   let verifierCalls = 0;
   let releaseVerifier;

+ 12 - 1
test/dovecot-config.test.js

@@ -79,6 +79,13 @@ test('Dovecot uses Lua passdb, a static rootless userdb, and Maildir storage', (
   assert.match(mailConfig, /^mailbox_list_layout = maildir\+\+$/m);
   assert.match(mailConfig, /^mailbox_list_storage_escape_char = \^$/m);
   assert.match(mailConfig, /^mailbox_list_utf8 = no$/m);
+  assert.match(mailConfig, /mail_plugins \{[\s\S]*acl = yes/);
+  assert.match(mailConfig, /^acl_driver = vfile$/m);
+  assert.match(mailConfig, /^acl_globals_only = yes$/m);
+  assert.match(
+    mailConfig,
+    /namespace inbox \{[\s\S]*acl group-override=mailhub_webmail_readonly \{[\s\S]*rights = lrs/
+  );
   for (const [mailbox, specialUse] of [
     ['Archive', 'Archive'],
     ['Drafts', 'Drafts'],
@@ -128,6 +135,10 @@ test('Lua passdb sends both IMAP and POP3 to the private auth bridge', () => {
     /payload\.authenticated == false[\s\S]*PASSDB_RESULT_PASSWORD_MISMATCH[\s\S]*payload\.authenticated ~= true[\s\S]*PASSDB_RESULT_INTERNAL_FAILURE/
   );
   assert.match(authLua, /valid_user\(payload\.user\)/);
-  assert.match(authLua, /PASSDB_RESULT_OK, \{ user = string\.lower\(payload\.user\) \}/);
+  assert.match(authLua, /readonly_acl_group = "mailhub_webmail_readonly"/);
+  assert.match(authLua, /payload\.aclGroups ~= readonly_acl_group/);
+  assert.match(authLua, /fields\.userdb_acl_groups = readonly_acl_group/);
+  assert.match(authLua, /fields\["userdb_namespace\/inbox\/type"\] = "public"/);
+  assert.match(authLua, /PASSDB_RESULT_OK, fields/);
   assert.doesNotMatch(authLua, /log_(?:debug|info|warning|error).*password/i);
 });

+ 6 - 1
test/prepare-dovecot-script.test.js

@@ -1,5 +1,5 @@
 import assert from 'node:assert/strict';
-import { mkdtempSync, statSync } from 'node:fs';
+import { mkdtempSync, readFileSync, statSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
 import { spawnSync } from 'node:child_process';
@@ -26,10 +26,15 @@ test('Dovecot preparation creates a private secret and Maildir root', () => {
   assert.equal(result.status, 0, result.stderr || result.stdout);
 
   const secretPath = path.join(projectDir, 'data', 'secrets', 'dovecot_auth_secret');
+  const webmailSecretPath = path.join(projectDir, 'data', 'secrets', 'webmail_sso_secret');
   const maildirRoot = path.join(projectDir, 'data', 'maildir');
   const secretStat = statSync(secretPath);
   assert.equal(secretStat.isFile(), true);
   assert.equal(secretStat.mode & 0o777, 0o400);
+  assert.equal(statSync(webmailSecretPath).isFile(), true);
+  assert.equal(statSync(webmailSecretPath).mode & 0o777, 0o440);
+  assert.equal(statSync(path.dirname(webmailSecretPath)).mode & 0o777, 0o750);
+  assert.notEqual(readFileSync(secretPath, 'utf8'), readFileSync(webmailSecretPath, 'utf8'));
   assert.equal(statSync(maildirRoot).isDirectory(), true);
   assert.equal(statSync(maildirRoot).mode & 0o777, 0o700);
 });

+ 47 - 0
test/roundcube-webmail-sso-config.test.js

@@ -0,0 +1,47 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+
+const read = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
+
+test('Roundcube SSO accepts the launch ticket from POST only', async () => {
+  const plugin = await read('docker/roundcube/plugins/mailhub_sso/mailhub_sso.php');
+
+  assert.match(plugin, /REQUEST_METHOD[^\n]+POST/);
+  assert.match(plugin, /get_input_string\('mailhub_ticket',\s*rcube_utils::INPUT_POST\)/);
+  assert.doesNotMatch(plugin, /\$_GET\s*\[/);
+  assert.doesNotMatch(plugin, /\$_REQUEST\s*\[/);
+  assert.match(plugin, /'task'\]\s*=\s*'login'/);
+  assert.match(plugin, /'action'\]\s*=\s*'login'/);
+});
+
+test('Roundcube SSO keeps sensitive values out of output and error logging', async () => {
+  const plugin = await read('docker/roundcube/plugins/mailhub_sso/mailhub_sso.php');
+
+  assert.doesNotMatch(plugin, /\b(?:echo|print|print_r|var_dump|error_log)\b/);
+  assert.doesNotMatch(plugin, /CURLOPT_VERBOSE\s*=>\s*true/);
+  assert.match(plugin, /CURLOPT_FOLLOWLOCATION\s*=>\s*false/);
+  assert.match(plugin, /Authorization: Bearer /);
+  assert.match(plugin, /'credential'\s*=>\s*\$credential/);
+  assert.match(plugin, /add_hook\('session_destroy'/);
+  assert.match(plugin, /mailhub_sso\.ssofailed/);
+});
+
+test('Compose and examples use a dedicated file-backed Webmail SSO secret', async () => {
+  const [compose, env, pluginConfig, docs] = await Promise.all([
+    read('docker-compose.yml'),
+    read('.env.example'),
+    read('docker/roundcube/plugins/mailhub_sso/config.inc.php.dist'),
+    read('docs/webmail-sso.md'),
+  ]);
+
+  assert.match(compose, /WEBMAIL_SSO_SECRET_FILE:\s*\/data\/secrets\/webmail_sso_secret/);
+  assert.doesNotMatch(compose, /webmail_sso_secret:\s*\n\s+file:/);
+  assert.doesNotMatch(compose, /^\s{2}roundcube:/m);
+  assert.match(env, /^WEBMAIL_SSO_URL=\s*$/m);
+  assert.match(env, /WEBMAIL_SSO_TICKET_TTL_SECONDS=60/);
+  assert.match(env, /WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS=43200/);
+  assert.match(pluginConfig, /mailhub_sso_secret_file'\]\s*=\s*'\/run\/secrets\/webmail_sso_secret'/);
+  assert.match(pluginConfig, /mailhub_sso_audience'\]\s*=\s*'https:\/\//);
+  assert.match(docs, /不运行 Roundcube/);
+});

+ 416 - 0
test/server-webmail-sso.test.js

@@ -0,0 +1,416 @@
+import assert from 'node:assert/strict';
+import { spawn, spawnSync } from 'node:child_process';
+import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import net from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import process from 'node:process';
+import { test } from 'node:test';
+
+const webmailUrl = 'https://mail.us.ss5.xyz/';
+const webmailAudience = 'https://mail.us.ss5.xyz';
+const sharedSecret = 'cd'.repeat(32);
+const userPassword = 'password123';
+
+test('Webmail SSO requires an authorized MailHub session and safely exchanges one-time tickets', async (t) => {
+  const fixture = await startTestServer();
+  t.after(async () => {
+    await stopChild(fixture.child);
+    rmSync(fixture.tempDir, { recursive: true, force: true });
+  });
+
+  const ownerCookie = await login(fixture.baseUrl, 'sso-owner', userPassword);
+  const receiverCookie = await login(fixture.baseUrl, 'sso-receiver', userPassword);
+  const viewerCookie = await login(fixture.baseUrl, 'sso-viewer', userPassword);
+  const adminCookie = await login(fixture.baseUrl, 'sso-admin', userPassword);
+  const loginPath = `/api/inbound-mailboxes/${fixture.seed.mailboxId}/webmail-login`;
+
+  await t.test('anonymous, Basic, and Bearer requests cannot issue a login ticket', async () => {
+    const anonymous = await requestJson(fixture.baseUrl, loginPath, {
+      method: 'POST',
+      origin: fixture.baseUrl
+    });
+    assert.equal(anonymous.status, 401);
+    assertNoOpaqueCredential(anonymous.raw);
+
+    const basic = await requestJson(fixture.baseUrl, loginPath, {
+      method: 'POST',
+      origin: fixture.baseUrl,
+      authorization: `Basic ${Buffer.from(`sso-owner:${userPassword}`).toString('base64')}`
+    });
+    assert.equal(basic.status, 403);
+    assertNoOpaqueCredential(basic.raw);
+
+    const bearer = await requestJson(fixture.baseUrl, loginPath, {
+      method: 'POST',
+      origin: fixture.baseUrl,
+      authorization: `Bearer ${fixture.seed.ownerToken}`
+    });
+    assert.equal(bearer.status, 401);
+    assertNoOpaqueCredential(bearer.raw);
+  });
+
+  await t.test('ticket issuance rejects missing and cross-origin requests', async () => {
+    const missingOrigin = await requestJson(fixture.baseUrl, loginPath, {
+      method: 'POST',
+      cookie: ownerCookie
+    });
+    assert.equal(missingOrigin.status, 403);
+    assertNoOpaqueCredential(missingOrigin.raw);
+
+    const crossOrigin = await requestJson(fixture.baseUrl, loginPath, {
+      method: 'POST',
+      cookie: ownerCookie,
+      origin: 'https://evil.example'
+    });
+    assert.equal(crossOrigin.status, 403);
+    assertNoOpaqueCredential(crossOrigin.raw);
+  });
+
+  let ownerTicket = '';
+  await t.test('the owner and receive delegate can issue private one-time tickets', async () => {
+    const owner = await issueTicket(fixture.baseUrl, loginPath, ownerCookie);
+    ownerTicket = owner.body.webmailLogin.ticket;
+    assert.equal(owner.body.webmailLogin.action, webmailUrl);
+    assert.match(ownerTicket, /^mht_[A-Za-z0-9_-]{43}$/);
+    assert.ok(Date.parse(owner.body.webmailLogin.expiresAt) > Date.now());
+
+    const receiver = await issueTicket(fixture.baseUrl, loginPath, receiverCookie);
+    assert.equal(receiver.body.webmailLogin.action, webmailUrl);
+    assert.match(receiver.body.webmailLogin.ticket, /^mht_[A-Za-z0-9_-]{43}$/);
+    assert.notEqual(receiver.body.webmailLogin.ticket, ownerTicket);
+  });
+
+  await t.test('view-only users and administrators have no implicit login access', async () => {
+    for (const cookie of [viewerCookie, adminCookie]) {
+      const response = await requestJson(fixture.baseUrl, loginPath, {
+        method: 'POST',
+        cookie,
+        origin: fixture.baseUrl
+      });
+      assert.equal(response.status, 404);
+      assertNoOpaqueCredential(response.raw);
+      assert.equal(response.raw.includes(fixture.seed.mailboxAddress), false);
+    }
+  });
+
+  await t.test('the internal exchange authenticates, consumes once, and revokes the credential', async () => {
+    const exchangePath = '/internal/webmail-sso/exchange';
+    const wrongSecret = await requestJson(fixture.baseUrl, exchangePath, {
+      method: 'POST',
+      authorization: 'Bearer definitely-wrong-secret',
+      body: { ticket: ownerTicket, audience: webmailAudience }
+    });
+    assert.equal(wrongSecret.status, 401);
+    assertPrivateResponse(wrongSecret);
+    assert.equal(wrongSecret.raw.includes(ownerTicket), false);
+    assertNoOpaqueCredential(wrongSecret.raw);
+
+    const exchanged = await requestJson(fixture.baseUrl, exchangePath, {
+      method: 'POST',
+      authorization: `Bearer ${sharedSecret}`,
+      body: { ticket: ownerTicket, audience: webmailAudience }
+    });
+    assert.equal(exchanged.status, 200, exchanged.raw);
+    assertPrivateResponse(exchanged);
+    assert.equal(exchanged.body.username, fixture.seed.mailboxAddress);
+    assert.match(exchanged.body.credential, /^mhw_[A-Za-z0-9_-]{43}$/);
+    assert.ok(Date.parse(exchanged.body.expiresAt) > Date.now());
+    const credential = exchanged.body.credential;
+
+    const replay = await requestJson(fixture.baseUrl, exchangePath, {
+      method: 'POST',
+      authorization: `Bearer ${sharedSecret}`,
+      body: { ticket: ownerTicket, audience: webmailAudience }
+    });
+    assert.equal(replay.status, 400);
+    assertPrivateResponse(replay);
+    assert.equal(replay.raw.includes(ownerTicket), false);
+    assert.equal(replay.raw.includes(credential), false);
+    assertNoOpaqueCredential(replay.raw);
+
+    const revoked = await requestJson(fixture.baseUrl, '/internal/webmail-sso/revoke', {
+      method: 'POST',
+      authorization: `Bearer ${sharedSecret}`,
+      body: { credential, audience: webmailAudience }
+    });
+    assert.equal(revoked.status, 200, revoked.raw);
+    assertPrivateResponse(revoked);
+    assert.deepEqual(revoked.body, { revoked: true });
+    assert.equal(revoked.raw.includes(credential), false);
+  });
+
+  await t.test('ticket issuance returns 429 after the bounded per-user burst', async () => {
+    // The owner already created one ticket earlier in this fixture.
+    for (let index = 1; index < 20; index += 1) {
+      const response = await requestJson(fixture.baseUrl, loginPath, {
+        method: 'POST',
+        cookie: ownerCookie,
+        origin: fixture.baseUrl
+      });
+      assert.equal(response.status, 201, response.raw);
+    }
+    const limited = await requestJson(fixture.baseUrl, loginPath, {
+      method: 'POST',
+      cookie: ownerCookie,
+      origin: fixture.baseUrl
+    });
+    assert.equal(limited.status, 429, limited.raw);
+    assert.equal(limited.headers.get('retry-after'), '60');
+    assert.match(limited.body.error, /请求过于频繁/);
+    assertNoOpaqueCredential(limited.raw);
+  });
+});
+
+async function issueTicket(baseUrl, pathname, cookie) {
+  const response = await requestJson(baseUrl, pathname, {
+    method: 'POST',
+    cookie,
+    origin: baseUrl
+  });
+  assert.equal(response.status, 201, response.raw);
+  assertPrivateResponse(response);
+  return response;
+}
+
+async function startTestServer() {
+  const port = await freePort();
+  const baseUrl = `http://127.0.0.1:${port}`;
+  const tempDir = mkdtempSync(path.join(tmpdir(), 'mailhub-server-webmail-sso-'));
+  const dataDir = path.join(tempDir, 'data');
+  const secretFile = path.join(tempDir, 'webmail-sso-secret');
+  const sessionSecret = 'mailhub-webmail-server-session-secret';
+  writeFileSync(secretFile, `${sharedSecret}\n`, { mode: 0o600 });
+  chmodSync(secretFile, 0o600);
+  const seed = seedFixture(dataDir, sessionSecret);
+
+  const child = spawn(process.execPath, ['src/server.js'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      PORT: String(port),
+      DATA_DIR: dataDir,
+      APP_BASE_URL: baseUrl,
+      SESSION_SECRET: sessionSecret,
+      ADMIN_USER: 'sso-admin',
+      ADMIN_EMAIL: 'sso-admin@example.test',
+      ADMIN_PASSWORD: userPassword,
+      WEBMAIL_SSO_URL: webmailUrl,
+      WEBMAIL_SSO_SECRET_FILE: secretFile,
+      WEBMAIL_SSO_TICKET_TTL_SECONDS: '60',
+      WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS: '3600',
+      DNS_AUTO_CHECK_ENABLED: 'false',
+      DELIVERY_TRACKING_ENABLED: 'false',
+      WEBHOOK_WORKER_ENABLED: 'false',
+      SUBMISSION_ENABLED: 'false',
+      IMAP_ENABLED: 'false',
+      POP3_ENABLED: 'false',
+      DOVECOT_AUTH_ENABLED: 'false',
+      MAIL_ACCESS_BACKEND: 'legacy'
+    },
+    stdio: ['ignore', 'pipe', 'pipe']
+  });
+
+  try {
+    await waitForServer(child, baseUrl);
+    return { child, baseUrl, tempDir, seed };
+  } catch (error) {
+    await stopChild(child);
+    rmSync(tempDir, { recursive: true, force: true });
+    throw error;
+  }
+}
+
+function seedFixture(dataDir, sessionSecret) {
+  const script = `
+    import {
+      createApiToken,
+      createDomain,
+      createInboundMailbox,
+      createUser,
+      initDatabase,
+      replaceInboundMailboxGrants
+    } from './src/db.js';
+
+    initDatabase(process.env.DATA_DIR, process.env.SESSION_SECRET);
+    const admin = createUser({
+      username: 'sso-admin',
+      email: 'sso-admin@example.test',
+      password: '${userPassword}',
+      role: 'admin',
+      status: 'active'
+    });
+    const owner = createUser({
+      username: 'sso-owner',
+      email: 'sso-owner@example.test',
+      password: '${userPassword}',
+      status: 'active'
+    });
+    const receiver = createUser({
+      username: 'sso-receiver',
+      email: 'sso-receiver@example.test',
+      password: '${userPassword}',
+      status: 'active'
+    });
+    const viewer = createUser({
+      username: 'sso-viewer',
+      email: 'sso-viewer@example.test',
+      password: '${userPassword}',
+      status: 'active'
+    });
+    createDomain(owner.id, {
+      domain: 'webmail-sso.example',
+      selector: 'mh',
+      verificationToken: 'verify-webmail-sso',
+      dkimPublic: 'test-public-key',
+      dkimPrivate: 'test-private-key',
+      senderHost: 'mail.webmail-sso.example',
+      sendingIp: '127.0.0.1',
+      spfExtra: '',
+      dmarcPolicy: 'none',
+      dmarcRua: ''
+    });
+    const mailbox = createInboundMailbox(owner.id, {
+      address: 'shared@webmail-sso.example',
+      password: 'mailbox-password'
+    });
+    replaceInboundMailboxGrants(mailbox.id, [
+      { userId: receiver.id, permissions: ['receive'] },
+      { userId: viewer.id, permissions: ['view'] }
+    ]);
+    const ownerToken = createApiToken(owner.id, 'Webmail route rejection', {
+      scopes: ['messages:read'],
+      mailboxAccess: 'owner'
+    });
+    console.log(JSON.stringify({
+      adminUserId: admin.id,
+      ownerUserId: owner.id,
+      receiverUserId: receiver.id,
+      viewerUserId: viewer.id,
+      mailboxId: mailbox.id,
+      mailboxAddress: mailbox.address,
+      ownerToken: ownerToken.token
+    }));
+  `;
+  const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      DATA_DIR: dataDir,
+      SESSION_SECRET: sessionSecret
+    },
+    encoding: 'utf8'
+  });
+  assert.equal(result.status, 0, result.stderr || result.stdout);
+  return JSON.parse(result.stdout);
+}
+
+async function login(baseUrl, username, password) {
+  const response = await requestJson(baseUrl, '/api/login', {
+    method: 'POST',
+    body: { username, password }
+  });
+  assert.equal(response.status, 200, response.raw);
+  const cookie = response.headers.get('set-cookie')?.split(';', 1)[0] || '';
+  assert.ok(cookie);
+  return cookie;
+}
+
+async function requestJson(baseUrl, pathname, {
+  method = 'GET',
+  cookie = '',
+  origin = '',
+  authorization = '',
+  body
+} = {}) {
+  const headers = {};
+  if (cookie) headers.Cookie = cookie;
+  if (origin) headers.Origin = origin;
+  if (authorization) headers.Authorization = authorization;
+  if (body !== undefined) headers['Content-Type'] = 'application/json';
+  const response = await fetch(`${baseUrl}${pathname}`, {
+    method,
+    headers,
+    body: body === undefined ? undefined : JSON.stringify(body),
+    redirect: 'manual'
+  });
+  const raw = await response.text();
+  return {
+    status: response.status,
+    headers: response.headers,
+    raw,
+    body: raw ? JSON.parse(raw) : null
+  };
+}
+
+function assertPrivateResponse(response) {
+  assert.match(response.headers.get('cache-control') || '', /(?:^|[,\s])no-store(?:[,\s]|$)/);
+  assert.equal(response.headers.get('pragma'), 'no-cache');
+}
+
+function assertNoOpaqueCredential(raw) {
+  assert.doesNotMatch(raw, /\bmht_[A-Za-z0-9_-]+\b/);
+  assert.doesNotMatch(raw, /\bmhw_[A-Za-z0-9_-]+\b/);
+}
+
+async function waitForServer(child, baseUrl, timeoutMs = 8000) {
+  let output = '';
+  child.stdout.on('data', (chunk) => { output += chunk.toString(); });
+  child.stderr.on('data', (chunk) => { output += chunk.toString(); });
+  const deadline = Date.now() + timeoutMs;
+  while (Date.now() < deadline) {
+    if (child.exitCode !== null) {
+      throw new Error(`MailHub exited before becoming ready (code ${child.exitCode}).\n${output}`);
+    }
+    try {
+      const response = await fetch(`${baseUrl}/healthz`);
+      if (response.status === 200) return;
+    } catch {
+      // The child process may still be binding its HTTP listener.
+    }
+    await delay(50);
+  }
+  throw new Error(`Timed out waiting for MailHub.\n${output}`);
+}
+
+function freePort() {
+  return new Promise((resolve, reject) => {
+    const server = net.createServer();
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => {
+      const address = server.address();
+      server.close((error) => {
+        if (error) return reject(error);
+        if (address && typeof address === 'object') return resolve(address.port);
+        reject(new Error('Unable to allocate a test port.'));
+      });
+    });
+  });
+}
+
+async function stopChild(child) {
+  if (!child || child.exitCode !== null) return;
+  child.kill('SIGTERM');
+  if (await waitForExit(child, 1500)) return;
+  child.kill('SIGKILL');
+  await waitForExit(child, 1000);
+}
+
+function waitForExit(child, timeoutMs) {
+  if (child.exitCode !== null) return Promise.resolve(true);
+  return new Promise((resolve) => {
+    const onExit = () => {
+      clearTimeout(timeout);
+      resolve(true);
+    };
+    const timeout = setTimeout(() => {
+      child.off('exit', onExit);
+      resolve(false);
+    }, timeoutMs);
+    child.once('exit', onExit);
+  });
+}
+
+function delay(milliseconds) {
+  return new Promise((resolve) => setTimeout(resolve, milliseconds));
+}

+ 144 - 0
test/submission-webmail-sso.test.js

@@ -0,0 +1,144 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import net from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  createDomain,
+  createInboundMailbox,
+  createUser,
+  createWebmailLoginTicket,
+  exchangeWebmailLoginTicket,
+  initDatabase,
+  replaceInboundMailboxGrants
+} from '../src/db.js';
+import { startSubmissionServer } from '../src/submission.js';
+
+test('an established Webmail SMTP session rechecks live send permission', async () => {
+  initDatabase(mkdtempSync(path.join(tmpdir(), 'mailhub-submission-webmail-')), 'webmail-submission-secret');
+  const owner = createUser({
+    username: 'webmail-owner',
+    email: 'webmail-owner@example.com',
+    password: 'password123'
+  });
+  const delegate = createUser({
+    username: 'webmail-delegate',
+    email: 'webmail-delegate@example.com',
+    password: 'password123'
+  });
+  createDomain(owner.id, {
+    domain: 'webmail-submit.example',
+    selector: 'mh',
+    verificationToken: 'verify-webmail-submit',
+    dkimPublic: 'public',
+    dkimPrivate: 'private',
+    senderHost: 'in.webmail-submit.example',
+    sendingIp: '192.0.2.10',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  });
+  const mailbox = createInboundMailbox(owner.id, {
+    address: 'shared@webmail-submit.example',
+    password: 'mailbox-password'
+  });
+  replaceInboundMailboxGrants(mailbox.id, [{
+    userId: delegate.id,
+    permissions: { receive: true, send: true }
+  }]);
+  const ticket = createWebmailLoginTicket(delegate.id, mailbox.id, {
+    audience: 'https://mail.us.ss5.xyz'
+  });
+  const login = exchangeWebmailLoginTicket(ticket.ticket, {
+    audience: 'https://mail.us.ss5.xyz'
+  });
+  const [server] = startSubmissionServer({
+    enabled: true,
+    listeners: [{ port: 0, protocol: 'smtp' }],
+    hostname: 'in.webmail-submit.example',
+    allowInsecureAuth: true,
+    inboundEnabled: true
+  });
+  await waitForListening(server);
+  const client = await connectSmtp(server.address().port);
+
+  try {
+    assert.match(await client.readReply(), /^220 /);
+    client.write('EHLO roundcube.example');
+    assert.match(await client.readReply(), /^250 HELP$/m);
+    const auth = Buffer.from(`\u0000${mailbox.address}\u0000${login.credential}`).toString('base64');
+    client.write(`AUTH PLAIN ${auth}`);
+    assert.match(await client.readReply(), /^235 /);
+
+    replaceInboundMailboxGrants(mailbox.id, [{
+      userId: delegate.id,
+      permissions: { receive: true, send: false }
+    }]);
+    client.write(`MAIL FROM:<${mailbox.address}>`);
+    assert.match(await client.readReply(), /^530 Authentication no longer valid$/);
+
+    client.write(`AUTH PLAIN ${auth}`);
+    assert.match(await client.readReply(), /^535 Authentication failed$/);
+  } finally {
+    client.close();
+    await closeServer(server);
+  }
+});
+
+function waitForListening(server) {
+  if (server.listening) return Promise.resolve();
+  return new Promise((resolve, reject) => {
+    server.once('listening', resolve);
+    server.once('error', reject);
+  });
+}
+
+function closeServer(server) {
+  return new Promise((resolve) => server.close(resolve));
+}
+
+function connectSmtp(port) {
+  return new Promise((resolve, reject) => {
+    const socket = net.connect(port, '127.0.0.1');
+    socket.setEncoding('utf8');
+    let buffer = '';
+    const pending = [];
+    socket.on('data', (chunk) => {
+      buffer += chunk;
+      flush();
+    });
+    socket.once('error', reject);
+    socket.once('connect', () => {
+      socket.off('error', reject);
+      resolve({
+        write(line) {
+          socket.write(`${line}\r\n`);
+        },
+        readReply() {
+          return new Promise((replyResolve) => {
+            pending.push(replyResolve);
+            flush();
+          });
+        },
+        close() {
+          socket.destroy();
+        }
+      });
+    });
+
+    function flush() {
+      if (!pending.length) return;
+      const lines = buffer.split('\r\n');
+      if (lines.length < 2) return;
+      const complete = lines.slice(0, -1);
+      const finalIndex = complete.findIndex((line) => /^\d{3} /.test(line));
+      if (finalIndex === -1) return;
+      const reply = complete.slice(0, finalIndex + 1).join('\n');
+      buffer = `${complete.slice(finalIndex + 1).join('\r\n')}${lines.at(-1) ? `\r\n${lines.at(-1)}` : ''}`;
+      pending.shift()(reply);
+      flush();
+    }
+  });
+}

+ 39 - 1
test/ui/inbox-navigation.test.tsx

@@ -9,7 +9,7 @@ import { I18nProvider } from '../../src/frontend/i18n/react';
 import { detailHistoryState } from '../../src/frontend/navigation-state';
 import { api } from '../../src/frontend/services/api';
 import { mailhubTheme } from '../../src/frontend/theme';
-import type { Domain, InboundMailbox, InboundMessage, RuntimeConfig } from '../../src/frontend/types';
+import type { Domain, InboundMailbox, InboundMessage, RuntimeConfig, WebmailLogin } from '../../src/frontend/types';
 import Inbox from '../../src/pages/Inbox';
 
 describe('Inbox detail return path', () => {
@@ -254,6 +254,43 @@ describe('Inbox request and mailbox behavior', () => {
     expect(listMessages).not.toHaveBeenCalledWith(expect.objectContaining({ mailboxId: 1 }));
   });
 
+  it('offers the Webmail shortcut in mailbox routing only to owners and assigned receivers', async () => {
+    const user = userEvent.setup();
+    const owner = mailboxFixture(1);
+    const assigned = mailboxFixture(2, {
+      userId: 2,
+      ownerUserId: 2,
+      access: { type: 'assigned', permissions: { view: true, receive: true, send: false } }
+    });
+    const viewOnly = mailboxFixture(3, {
+      userId: 2,
+      ownerUserId: 2,
+      access: { type: 'assigned', permissions: { view: true, receive: false, send: false } },
+      messageCount: null,
+      unreadCount: null
+    });
+    mockInboxApis([owner, assigned, viewOnly], []);
+    const webmailLogin: WebmailLogin = {
+      action: 'https://mail.us.ss5.xyz/',
+      ticket: 'mht_routing-ticket',
+      expiresAt: '2026-07-18T01:00:00.000Z'
+    };
+    const login = vi.spyOn(api, 'createWebmailLogin').mockResolvedValue({ webmailLogin });
+    const requestSubmit = vi.spyOn(HTMLFormElement.prototype, 'requestSubmit').mockImplementation(() => undefined);
+    const router = createInboxRouter(['/inbox?workspace=routing'], 0);
+    renderRouter(router);
+
+    const ownerCard = (await screen.findByText(owner.address)).closest('.ant-card') as HTMLElement;
+    const assignedCard = screen.getByText(assigned.address).closest('.ant-card') as HTMLElement;
+    const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card') as HTMLElement;
+    expect(within(ownerCard).getByRole('button', { name: /一键登录 Webmail/ })).toBeTruthy();
+    expect(within(viewOnlyCard).queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
+
+    await user.click(within(assignedCard).getByRole('button', { name: /一键登录 Webmail/ }));
+    await waitFor(() => expect(login).toHaveBeenCalledWith(assigned.id));
+    expect(requestSubmit).toHaveBeenCalledTimes(1);
+  });
+
   it('updates mailbox settings from the routing workspace edit drawer', async () => {
     const user = userEvent.setup();
     const mailbox = mailboxFixture(1);
@@ -439,6 +476,7 @@ function messageFixture(id: number, mailboxId: number, folder: string, subject:
 
 const runtimeConfig: RuntimeConfig = {
   appBaseUrl: 'https://mail.example.test',
+  webmailSsoEnabled: true,
   mailHostname: 'mail.example.test',
   sendingIp: '192.0.2.10',
   defaultSpfMechanisms: '',

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

@@ -1,5 +1,5 @@
 import { App as AntApp, ConfigProvider } from 'antd';
-import { render, screen, waitFor, within } from '@testing-library/react';
+import { act, render, screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { createMemoryRouter, RouterProvider, useLocation } from 'react-router-dom';
 import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -15,7 +15,8 @@ import type {
   MailboxAccessType,
   MailboxPermissions,
   RuntimeConfig,
-  User
+  User,
+  WebmailLogin
 } from '../../src/frontend/types';
 import Account from '../../src/pages/Account';
 import AdminPage from '../../src/pages/Admin';
@@ -93,6 +94,7 @@ describe('Mailbox access UI', () => {
     const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card');
     expect(viewOnlyCard).toBeTruthy();
     expect(within(viewOnlyCard as HTMLElement).queryByRole('button', { name: /打开收件箱/ })).toBeNull();
+    expect(within(viewOnlyCard as HTMLElement).queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
     expect(screen.getAllByRole('button', { name: /管理邮箱/ })).toHaveLength(1);
 
     const assignedCard = screen.getByText(assigned.address).closest('.ant-card');
@@ -100,6 +102,90 @@ describe('Mailbox access UI', () => {
     await browser.click(within(assignedCard as HTMLElement).getByRole('button', { name: /打开收件箱/ }));
     expect(screen.getByTestId('location').textContent).toBe('/inbox?mailboxId=2&folder=INBOX');
   });
+
+  it('exchanges an assigned mailbox for a hidden Webmail POST without exposing the ticket', async () => {
+    const browser = userEvent.setup();
+    const owner = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
+    const assigned = mailboxFixture(2, 'assigned@example.test', 'assigned', { view: true, receive: true, send: false }, 1);
+    const viewOnly = mailboxFixture(3, 'view-only@example.test', 'assigned', { view: true, receive: false, send: false }, 1);
+    const inactive = { ...mailboxFixture(4, 'inactive@example.test', 'assigned', { view: true, receive: true, send: false }, 1), status: 'disabled' };
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [owner, assigned, viewOnly, inactive] });
+
+    const webmailLogin: WebmailLogin = {
+      action: 'https://mail.us.ss5.xyz/',
+      ticket: 'mht_secret-ticket',
+      expiresAt: '2026-07-18T01:00:00.000Z'
+    };
+    let resolveLogin!: (value: { webmailLogin: WebmailLogin }) => void;
+    const login = vi.spyOn(api, 'createWebmailLogin').mockReturnValue(new Promise((resolve) => {
+      resolveLogin = resolve;
+    }));
+    let submittedForm: HTMLFormElement | null = null;
+    const requestSubmit = vi.spyOn(HTMLFormElement.prototype, 'requestSubmit').mockImplementation(function (this: HTMLFormElement) {
+      submittedForm = this.cloneNode(true) as HTMLFormElement;
+    });
+    const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
+    renderWithRouter(router, userContext);
+
+    const assignedCard = (await screen.findByText(assigned.address)).closest('.ant-card') as HTMLElement;
+    const ownerCard = screen.getByText(owner.address).closest('.ant-card') as HTMLElement;
+    const viewOnlyCard = screen.getByText(viewOnly.address).closest('.ant-card') as HTMLElement;
+    const inactiveCard = screen.getByText(inactive.address).closest('.ant-card') as HTMLElement;
+    const assignedButton = within(assignedCard).getByRole('button', { name: /一键登录 Webmail/ });
+    const ownerButton = within(ownerCard).getByRole('button', { name: /一键登录 Webmail/ });
+    expect(within(viewOnlyCard).queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
+    const inactiveButton = within(inactiveCard).getByRole('button', { name: /一键登录 Webmail/ }) as HTMLButtonElement;
+    expect(inactiveButton.disabled).toBe(true);
+    expect(inactiveButton.title).toContain('邮箱已停用');
+
+    const setItem = vi.spyOn(Storage.prototype, 'setItem');
+    await browser.click(assignedButton);
+    await waitFor(() => expect(login).toHaveBeenCalledWith(assigned.id));
+    expect(assignedButton.classList.contains('ant-btn-loading')).toBe(true);
+    expect(ownerButton.classList.contains('ant-btn-loading')).toBe(false);
+
+    await act(async () => resolveLogin({ webmailLogin }));
+    await waitFor(() => expect(requestSubmit).toHaveBeenCalledTimes(1));
+    expect(submittedForm).not.toBeNull();
+    expect(submittedForm!.getAttribute('method')).toBe('POST');
+    expect(submittedForm!.action).toBe(webmailLogin.action);
+    expect(submittedForm!.target).toBe('_self');
+    expect(Object.fromEntries(new FormData(submittedForm!))).toEqual({
+      mailhub_ticket: webmailLogin.ticket,
+      _task: 'mail',
+      _mbox: 'INBOX'
+    });
+    expect(document.querySelector(`form[action="${webmailLogin.action}"]`)).toBeNull();
+    expect(`${router.state.location.pathname}${router.state.location.search}`).toBe('/account');
+    expect(window.location.href).not.toContain(webmailLogin.ticket);
+    expect(setItem.mock.calls.flat().join(' ')).not.toContain(webmailLogin.ticket);
+  });
+
+  it('shows a Webmail login error without navigating away', async () => {
+    const browser = userEvent.setup();
+    const mailbox = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
+    vi.spyOn(api, 'createWebmailLogin').mockRejectedValue(new Error('Webmail 暂时不可用'));
+    const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
+    renderWithRouter(router, userContext);
+
+    await browser.click(await screen.findByRole('button', { name: /一键登录 Webmail/ }));
+    expect(await screen.findByText('Webmail 暂时不可用')).toBeTruthy();
+    expect(router.state.location.pathname).toBe('/account');
+  });
+
+  it('hides the Webmail shortcut until the server explicitly enables SSO', async () => {
+    const mailbox = mailboxFixture(1, 'owned@example.test', 'owner', { view: true, receive: true, send: true }, 2);
+    vi.spyOn(api, 'inboundMailboxes').mockResolvedValue({ mailboxes: [mailbox] });
+    const router = createMemoryRouter([{ path: '/account', element: <Account /> }], { initialEntries: ['/account'] });
+    renderWithRouter(router, {
+      ...userContext,
+      config: { ...config, webmailSsoEnabled: false }
+    });
+
+    expect(await screen.findByText(mailbox.address)).toBeTruthy();
+    expect(screen.queryByRole('button', { name: /一键登录 Webmail/ })).toBeNull();
+  });
 });
 
 function renderWithRouter(router: ReturnType<typeof createMemoryRouter>, context: AppContextValue) {
@@ -188,6 +274,7 @@ function resourceCounts() {
 
 const config: RuntimeConfig = {
   appBaseUrl: 'https://mail.example.test',
+  webmailSsoEnabled: true,
   mailHostname: 'mail.example.test',
   sendingIp: '192.0.2.10',
   defaultSpfMechanisms: '',

+ 256 - 0
test/webmail-sso-db.test.js

@@ -0,0 +1,256 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  createDomain,
+  createInboundMailbox,
+  createUser,
+  createWebmailLoginTicket,
+  cleanupWebmailSessions,
+  exchangeWebmailLoginTicket,
+  initDatabase,
+  replaceInboundMailboxGrants,
+  revokeWebmailCredential,
+  updateUserStatus,
+  verifyInboundMailboxCredentialAsync,
+  verifySmtpCredential,
+  verifyWebmailCredential,
+  verifyWebmailCredentialAsync,
+  webmailSessionHasPermission
+} from '../src/db.js';
+
+const webmailAudience = 'https://mail.us.ss5.xyz';
+
+test('Webmail login tickets are single-use and raw secrets never enter SQLite', async () => {
+  const { database, owner, mailbox } = setupMailbox('single-use');
+  const created = createWebmailLoginTicket(owner.id, mailbox.id, {
+    audience: `${webmailAudience}/`
+  });
+
+  assert.match(created.ticket, /^mht_[A-Za-z0-9_-]{43}$/);
+  assert.equal(created.audience, webmailAudience);
+  const beforeExchange = database
+    .prepare('SELECT * FROM webmail_sessions WHERE id = ?')
+    .get(created.id);
+  assert.equal(beforeExchange.credential_hash, null);
+  assert.match(beforeExchange.ticket_hash, /^[a-f0-9]{64}$/);
+  assert.equal(JSON.stringify(beforeExchange).includes(created.ticket), false);
+
+  const exchanged = exchangeWebmailLoginTicket(created.ticket, { audience: webmailAudience });
+  assert.match(exchanged.credential, /^mhw_[A-Za-z0-9_-]{43}$/);
+  assert.equal(exchanged.address, mailbox.address);
+  assert.equal(exchangeWebmailLoginTicket(created.ticket, { audience: webmailAudience }), null);
+
+  const stored = database
+    .prepare('SELECT * FROM webmail_sessions WHERE id = ?')
+    .get(created.id);
+  assert.match(stored.credential_hash, /^[a-f0-9]{64}$/);
+  assert.equal(JSON.stringify(stored).includes(created.ticket), false);
+  assert.equal(JSON.stringify(stored).includes(exchanged.credential), false);
+
+  const syncAuth = verifyWebmailCredential(mailbox.address, exchanged.credential, { permission: 'receive' });
+  assert.equal(syncAuth.user.id, owner.id);
+  assert.equal(syncAuth.mailbox.id, mailbox.id);
+  assert.ok(syncAuth.webmailSession.lastUsedAt);
+  const asyncAuth = await verifyWebmailCredentialAsync(mailbox.address, exchanged.credential, {
+    permission: 'send'
+  });
+  assert.equal(asyncAuth.user.id, owner.id);
+  assert.equal(
+    (await verifyInboundMailboxCredentialAsync(mailbox.address, exchanged.credential)).user.id,
+    owner.id
+  );
+  assert.equal(verifySmtpCredential(mailbox.address, exchanged.credential).user.id, owner.id);
+});
+
+test('Webmail sessions require owner or explicit current mailbox grants without admin bypass', () => {
+  const { owner, mailbox } = setupMailbox('grants');
+  const admin = createUser({
+    username: 'sso-admin',
+    email: 'sso-admin@example.com',
+    password: 'password123',
+    role: 'admin'
+  });
+  const delegate = createUser({
+    username: 'sso-delegate',
+    email: 'sso-delegate@example.com',
+    password: 'password123'
+  });
+
+  assert.throws(
+    () => createWebmailLoginTicket(admin.id, mailbox.id, { audience: webmailAudience }),
+    /无权登录/
+  );
+  assert.throws(
+    () => createWebmailLoginTicket(delegate.id, mailbox.id, { audience: webmailAudience }),
+    /无权登录/
+  );
+
+  replaceInboundMailboxGrants(mailbox.id, [{
+    userId: delegate.id,
+    permissions: { receive: true, send: false }
+  }]);
+  const ticket = createWebmailLoginTicket(delegate.id, mailbox.id, { audience: webmailAudience });
+  const session = exchangeWebmailLoginTicket(ticket.ticket, { audience: webmailAudience });
+  assert.equal(
+    verifyWebmailCredential(mailbox.address, session.credential, { permission: 'receive' }).user.id,
+    delegate.id
+  );
+  assert.equal(verifyWebmailCredential(mailbox.address, session.credential, { permission: 'send' }), null);
+  assert.equal(verifySmtpCredential(mailbox.address, session.credential), null);
+
+  replaceInboundMailboxGrants(mailbox.id, [{
+    userId: delegate.id,
+    permissions: { receive: true, send: true }
+  }]);
+  assert.equal(
+    verifyWebmailCredential(mailbox.address, session.credential, { permission: 'send' }).user.id,
+    delegate.id
+  );
+  const smtpAuth = verifySmtpCredential(mailbox.address, session.credential);
+  assert.equal(smtpAuth.user.id, delegate.id);
+  assert.equal(smtpAuth.webmailSession.id, session.id);
+  assert.equal(webmailSessionHasPermission(session.id, mailbox.address, { permission: 'send' }), true);
+
+  replaceInboundMailboxGrants(mailbox.id, []);
+  assert.equal(webmailSessionHasPermission(session.id, mailbox.address, { permission: 'send' }), false);
+  assert.equal(verifyWebmailCredential(mailbox.address, session.credential, { permission: 'receive' }), null);
+  assert.equal(verifyWebmailCredential(mailbox.address, session.credential, { permission: 'send' }), null);
+
+  const ownerTicket = createWebmailLoginTicket(owner.id, mailbox.id, { audience: webmailAudience });
+  const ownerSession = exchangeWebmailLoginTicket(ownerTicket.ticket, { audience: webmailAudience });
+  assert.equal(
+    verifyWebmailCredential(mailbox.address, ownerSession.credential, { permission: 'send' }).user.id,
+    owner.id
+  );
+});
+
+test('Webmail tickets and credentials honor expiry, revocation, audience and active users', () => {
+  const { database, owner, mailbox } = setupMailbox('lifecycle');
+  const expiredTicket = createWebmailLoginTicket(owner.id, mailbox.id, { audience: webmailAudience });
+  database
+    .prepare('UPDATE webmail_sessions SET ticket_expires_at = ? WHERE id = ?')
+    .run('2000-01-01T00:00:00.000Z', expiredTicket.id);
+  assert.equal(exchangeWebmailLoginTicket(expiredTicket.ticket, { audience: webmailAudience }), null);
+
+  const audienceTicket = createWebmailLoginTicket(owner.id, mailbox.id, { audience: webmailAudience });
+  assert.equal(
+    exchangeWebmailLoginTicket(audienceTicket.ticket, { audience: 'https://other.example' }),
+    null
+  );
+  const audienceSession = exchangeWebmailLoginTicket(audienceTicket.ticket, { audience: webmailAudience });
+  assert.equal(
+    revokeWebmailCredential(audienceSession.credential, { audience: 'https://other.example' }),
+    false
+  );
+  assert.ok(verifyWebmailCredential(mailbox.address, audienceSession.credential));
+  assert.equal(revokeWebmailCredential(audienceSession.credential, { audience: webmailAudience }), true);
+  assert.equal(revokeWebmailCredential(audienceSession.credential, { audience: webmailAudience }), false);
+  assert.equal(verifyWebmailCredential(mailbox.address, audienceSession.credential), null);
+
+  const expiredCredentialTicket = createWebmailLoginTicket(owner.id, mailbox.id, { audience: webmailAudience });
+  const expiredCredential = exchangeWebmailLoginTicket(expiredCredentialTicket.ticket, {
+    audience: webmailAudience
+  });
+  database
+    .prepare('UPDATE webmail_sessions SET credential_expires_at = ? WHERE id = ?')
+    .run('2000-01-01T00:00:00.000Z', expiredCredential.id);
+  assert.equal(verifyWebmailCredential(mailbox.address, expiredCredential.credential), null);
+
+  const disabledUserTicket = createWebmailLoginTicket(owner.id, mailbox.id, { audience: webmailAudience });
+  const disabledUserSession = exchangeWebmailLoginTicket(disabledUserTicket.ticket, {
+    audience: webmailAudience
+  });
+  updateUserStatus(owner.id, 'disabled');
+  assert.equal(verifyWebmailCredential(mailbox.address, disabledUserSession.credential), null);
+});
+
+test('Webmail SSO validates absolute audiences and bounded TTL values', () => {
+  const { owner, mailbox } = setupMailbox('validation');
+  const create = (overrides = {}) => createWebmailLoginTicket(owner.id, mailbox.id, {
+    audience: webmailAudience,
+    ...overrides
+  });
+
+  assert.throws(() => create({ audience: 'mail.us.ss5.xyz' }), /受众地址/);
+  assert.throws(() => create({ audience: `${webmailAudience}/login` }), /受众地址/);
+  assert.throws(() => create({ ticketTtlSeconds: 9 }), /票据有效期/);
+  assert.throws(() => create({ ticketTtlSeconds: 301 }), /票据有效期/);
+  assert.throws(() => create({ credentialTtlSeconds: 299 }), /临时凭据有效期/);
+  assert.throws(() => create({ credentialTtlSeconds: 86_401 }), /临时凭据有效期/);
+  assert.doesNotThrow(() => create({ ticketTtlSeconds: 10, credentialTtlSeconds: 300 }));
+  assert.doesNotThrow(() => create({ ticketTtlSeconds: 300, credentialTtlSeconds: 86_400 }));
+});
+
+test('Webmail ticket issuance is rate limited and bounds active sessions per mailbox', () => {
+  const { database, owner, mailbox } = setupMailbox('bounded');
+  const credentials = [];
+  for (let index = 0; index < 6; index += 1) {
+    const ticket = createWebmailLoginTicket(owner.id, mailbox.id, { audience: webmailAudience });
+    const session = exchangeWebmailLoginTicket(ticket.ticket, { audience: webmailAudience });
+    credentials.push(session.credential);
+  }
+
+  assert.equal(verifyWebmailCredential(mailbox.address, credentials[0]), null);
+  for (const credential of credentials.slice(1)) {
+    assert.ok(verifyWebmailCredential(mailbox.address, credential));
+  }
+  assert.equal(database.prepare(`
+    SELECT COUNT(*) AS count
+    FROM webmail_sessions
+    WHERE exchanged_at IS NOT NULL AND revoked_at IS NULL
+  `).get().count, 5);
+
+  for (let index = 6; index < 20; index += 1) {
+    createWebmailLoginTicket(owner.id, mailbox.id, { audience: webmailAudience });
+  }
+  assert.throws(
+    () => createWebmailLoginTicket(owner.id, mailbox.id, { audience: webmailAudience }),
+    (error) => error?.code === 'WEBMAIL_TICKET_RATE_LIMIT'
+  );
+
+  database.prepare(`
+    UPDATE webmail_sessions
+    SET ticket_expires_at = '2000-01-01T00:00:00.000Z',
+        credential_expires_at = '2000-01-01T00:00:00.000Z',
+        revoked_at = COALESCE(revoked_at, '2000-01-01T00:00:00.000Z')
+  `).run();
+  assert.ok(cleanupWebmailSessions({ retentionSeconds: 0 }) > 0);
+  assert.equal(database.prepare('SELECT COUNT(*) AS count FROM webmail_sessions').get().count, 0);
+});
+
+function setupMailbox(suffix) {
+  const database = initDatabase(
+    mkdtempSync(path.join(tmpdir(), `mailhub-webmail-sso-${suffix}-`)),
+    `webmail-sso-${suffix}-secret`
+  );
+  const owner = createUser({
+    username: `owner-${suffix}`,
+    email: `owner-${suffix}@example.com`,
+    password: 'password123'
+  });
+  createDomain(owner.id, domainFixture(`${suffix}.example`));
+  const mailbox = createInboundMailbox(owner.id, {
+    address: `inbox@${suffix}.example`,
+    password: 'mailbox-password'
+  });
+  return { database, owner, mailbox };
+}
+
+function domainFixture(domain) {
+  return {
+    domain,
+    selector: 'mh',
+    verificationToken: `verify-${domain}`,
+    dkimPublic: 'public-key',
+    dkimPrivate: 'private-key',
+    senderHost: `in.${domain}`,
+    sendingIp: '192.0.2.10',
+    spfExtra: '',
+    dmarcPolicy: 'none',
+    dmarcRua: ''
+  };
+}

+ 196 - 0
test/webmail-sso-http.test.js

@@ -0,0 +1,196 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync, writeFileSync } from 'node:fs';
+import http from 'node:http';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+  handleInternalWebmailSso,
+  isSameOriginRequest,
+  loadWebmailSsoConfig
+} from '../src/webmail-sso.js';
+
+const secret = 'ab'.repeat(32);
+const audience = 'https://mail.us.ss5.xyz';
+const ticket = `mht_${'a'.repeat(43)}`;
+const credential = `mhw_${'b'.repeat(43)}`;
+
+test('Webmail SSO configuration requires HTTPS and a strong file-backed secret', () => {
+  const secretFile = writeSecret(secret);
+  const config = loadWebmailSsoConfig({
+    WEBMAIL_SSO_URL: `${audience}/?_task=mail`,
+    WEBMAIL_SSO_SECRET_FILE: secretFile,
+    WEBMAIL_SSO_TICKET_TTL_SECONDS: '45',
+    WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS: '3600'
+  });
+  assert.equal(config.enabled, true);
+  assert.equal(config.url, `${audience}/`);
+  assert.equal(config.audience, audience);
+  assert.equal(config.ticketTtlSeconds, 45);
+  assert.equal(config.credentialTtlSeconds, 3600);
+
+  assert.throws(
+    () => loadWebmailSsoConfig({ WEBMAIL_SSO_URL: audience }),
+    /configured together/
+  );
+  assert.throws(
+    () => loadWebmailSsoConfig({
+      WEBMAIL_SSO_URL: 'http://webmail.example.com/',
+      WEBMAIL_SSO_SECRET_FILE: secretFile
+    }),
+    /must use HTTPS/
+  );
+  assert.throws(
+    () => loadWebmailSsoConfig({
+      WEBMAIL_SSO_URL: audience,
+      WEBMAIL_SSO_SECRET_FILE: writeSecret('short')
+    }),
+    /64-512 hexadecimal/
+  );
+  assert.throws(
+    () => loadWebmailSsoConfig({
+      WEBMAIL_SSO_URL: audience,
+      WEBMAIL_SSO_SECRET_FILE: writeSecret('z'.repeat(64))
+    }),
+    /64-512 hexadecimal/
+  );
+});
+
+test('Webmail ticket creation origin check accepts only the configured or proxied application origin', () => {
+  const request = {
+    headers: {
+      origin: 'https://mail-send.ss5.xyz',
+      host: 'app:3000',
+      'x-forwarded-host': 'mail-send.ss5.xyz',
+      'x-forwarded-proto': 'https'
+    },
+    socket: { encrypted: false }
+  };
+  assert.equal(isSameOriginRequest(request, 'https://mail-send.ss5.xyz/', { trustProxy: true }), true);
+  assert.equal(isSameOriginRequest({ ...request, headers: { ...request.headers, origin: 'https://evil.example' } }, 'https://mail-send.ss5.xyz/', { trustProxy: true }), false);
+  assert.equal(isSameOriginRequest({ ...request, headers: { ...request.headers, origin: '' } }, 'https://mail-send.ss5.xyz/', { trustProxy: true }), false);
+});
+
+test('Webmail internal exchange is authenticated, audience-bound, single purpose, and private', async () => {
+  const exchanges = [];
+  const revocations = [];
+  const config = loadWebmailSsoConfig({
+    WEBMAIL_SSO_URL: `${audience}/`,
+    WEBMAIL_SSO_SECRET_FILE: writeSecret(secret)
+  });
+  const server = http.createServer((req, res) => {
+    const url = new URL(req.url || '/', 'http://mailhub.internal');
+    void handleInternalWebmailSso(req, res, url, config, {
+      exchangeWebmailLoginTicket(value, options) {
+        exchanges.push({ value, options });
+        if (exchanges.length > 1) return null;
+        return {
+          mailbox: { address: 'shared@example.com' },
+          credential,
+          expiresAt: '2030-01-01T00:00:00.000Z'
+        };
+      },
+      revokeWebmailCredential(value, options) {
+        revocations.push({ value, options });
+        return true;
+      }
+    });
+  });
+  await listen(server);
+
+  try {
+    const unauthorized = await request(server, '/internal/webmail-sso/exchange', {
+      authorization: 'Bearer wrong-secret',
+      body: { ticket, audience }
+    });
+    assert.equal(unauthorized.status, 401);
+    assert.equal(exchanges.length, 0);
+
+    const wrongAudience = await request(server, '/internal/webmail-sso/exchange', {
+      body: { ticket, audience: 'https://other.example' }
+    });
+    assert.equal(wrongAudience.status, 400);
+    assert.equal(exchanges.length, 0);
+
+    const exchanged = await request(server, '/internal/webmail-sso/exchange', {
+      body: { ticket, audience }
+    });
+    assert.equal(exchanged.status, 200);
+    assert.equal(exchanged.headers['cache-control'], 'no-store');
+    assert.deepEqual(exchanged.json, {
+      username: 'shared@example.com',
+      credential,
+      expiresAt: '2030-01-01T00:00:00.000Z'
+    });
+    assert.deepEqual(exchanges, [{ value: ticket, options: { audience } }]);
+
+    const replayed = await request(server, '/internal/webmail-sso/exchange', {
+      body: { ticket, audience }
+    });
+    assert.equal(replayed.status, 400);
+    assert.equal(JSON.stringify(replayed.json).includes(ticket), false);
+
+    const revoked = await request(server, '/internal/webmail-sso/revoke', {
+      body: { credential, audience }
+    });
+    assert.equal(revoked.status, 200);
+    assert.deepEqual(revocations, [{ value: credential, options: { audience } }]);
+  } finally {
+    await close(server);
+  }
+});
+
+function writeSecret(value) {
+  const directory = mkdtempSync(path.join(tmpdir(), 'mailhub-webmail-sso-'));
+  const file = path.join(directory, 'secret');
+  writeFileSync(file, `${value}\n`, { mode: 0o600 });
+  return file;
+}
+
+function listen(server) {
+  server.listen(0, '127.0.0.1');
+  return new Promise((resolve, reject) => {
+    server.once('listening', resolve);
+    server.once('error', reject);
+  });
+}
+
+function close(server) {
+  return new Promise((resolve, reject) => {
+    server.close((error) => error ? reject(error) : resolve());
+  });
+}
+
+function request(server, requestPath, {
+  authorization = `Bearer ${secret}`,
+  body = {}
+} = {}) {
+  const rawBody = JSON.stringify(body);
+  return new Promise((resolve, reject) => {
+    const req = http.request({
+      host: '127.0.0.1',
+      port: server.address().port,
+      path: requestPath,
+      method: 'POST',
+      headers: {
+        Authorization: authorization,
+        'Content-Type': 'application/json',
+        'Content-Length': String(Buffer.byteLength(rawBody))
+      }
+    }, (res) => {
+      const chunks = [];
+      res.on('data', (chunk) => chunks.push(chunk));
+      res.on('end', () => {
+        const raw = Buffer.concat(chunks).toString('utf8');
+        resolve({
+          status: res.statusCode,
+          headers: res.headers,
+          json: raw ? JSON.parse(raw) : null
+        });
+      });
+    });
+    req.once('error', reject);
+    req.end(rawBody);
+  });
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است