---
url: /docs/agentbuff-stack/auth-mail.md
---
# 账号与邮件

## 配置入口

网站选项：`websiteConfig.auth`。服务端：`apps/api/lib/auth.ts`。登录组件：`apps/app/components/auth/`。环境与诊断见 [环境变量](./env.md)。

密码、OTP 和 Passkey 的 UI 与服务端使用相同配置。Google / GitHub 的按钮列表来自 `config.socialProviders`，只有完整凭据组才出现。OAuth Client Secret 从不交给前端。

## 第三方登录

* Google 回调：`APP_ORIGIN/api/auth/callback/google`。
* GitHub 回调：`APP_ORIGIN/api/auth/callback/github`。
* 本地示例：`http://localhost:4410/api/auth/callback/github`。

回调路径必须与第三方应用配置相同。参考 [Better Auth GitHub 文档](https://better-auth.com/docs/authentication/github)：GitHub App 还需要读取账号邮箱的权限；OAuth App 与 GitHub App 的设置不同。真实授权仍待验收，不能因按钮出现就标记为完成。

## 邮件

所有模板发送经过 `apps/api/lib/email.ts` 的 `sendEmail()`，提供纯文本与可选 HTML，验证收件邮箱。邮箱密码在非开发环境要求邮件验证；开发环境为了隔离预览不强制验证。

`preview:start` 和 `bun dev` 都使用真实 Workers 本地运行时，通过仅在本地注入的 `LOCAL_MAIL` 绑定捕获邮件，写入被忽略的 `.local/outbox.jsonl`。捕获失败会报错，不会回退为外发；即使电脑上有真实 Resend key，本地运行仍只捕获邮件。文件包含验证码和链接，只留在本机，服务日志不打印 OTP。

云端不配置 `LOCAL_MAIL`，生产 Worker 使用 Resend。线上需要已验证的发信域及有效 API key，真实送达仍需单独验收。

## 即时邮件语言

账号验证、重置、验证码与组织邀请已提供英文 / 西班牙语的主题、预览、HTML 和纯文本。默认仍只启用英文；开启西班牙语后，认证客户端按当前页面发送 `x-agentbuff-locale`，API 拒绝未启用语言并回退英文。邀请使用发起者的页面语言，不自动推断接收者语言。签名链接、验证码、角色和原邀请 ID 不翻译，语言不会改变权限。字典位置和检查步骤见[产品语言](./languages)。

当前验证与重置令牌分别有效 1 小时，验证码默认 5 分钟，邀请 48 小时；验证邮件原有的 24 小时说明已更正为实际期限。任务邮件已有独立的持久化语言选择及重试，旧投递保留原英文载荷，见[任务邮件](./task-notifications)。营销确认邮件也已保存同意语言并按原语言重试，旧投递保留原英文载荷，见[邮件订阅](./marketing-subscriptions)。真实发信域与邮箱投递仍须单独验收。

## 营销邮件与账号邮件分开

公开邮件订阅和候补名单使用单独状态，不会把注册自动当成营销同意，也不创建账号。确认 / 退订和发信重试第一阶段已本地验收；联系人同步及签名回调第二阶段已本地验收，真实投递仍待验。账号验证、邀请和恢复邮件不受营销退订影响，见[邮件订阅与候补名单](./marketing-subscriptions)。

## 团队邀请

在工作台「Members」选择团队，邮箱已验证的 owner / admin 可以输入邮箱及 member / admin 角色并发送邀请。普通成员不会获得邀请表单。邀请有效期 48 小时；待处理邀请可以重新发送、延长有效期或取消，历史按每页 20 条展示。过期邀请再次发送时会生成新 ID，旧记录保留；已拒绝、已取消的地址也能再次邀请。已加入团队的账号不能重复邀请。

邀请邮件的链接是 `APP_ORIGIN/invitations?id=...`，启用西班牙语且发起者当前选择该语言时使用 `APP_ORIGIN/es/invitations?id=...`。未登录者会跳转登录并保留该链接。接收者必须使用被邀请的邮箱且邮箱已验证；本地密码注册允许跳过验证，但邀请流程仍要求验证，可通过邮箱验证码登录完成。接受后由 Better Auth 新增成员、更新会话的 active organization，页面同步刷新工作区。拒绝不会添加成员。错误邮箱、过期、已使用或已取消的 ID 均不能接受。个人文件、任务、结果与积分不会随加入团队共享。

维护入口：`apps/api/lib/organization-mutations.ts`、`apps/api/lib/auth.ts`、`apps/api/routers/organization.ts`、`apps/email/templates/organization-invitation.tsx`。邀请决策与成员降级 / 移除 / 退出使用组织行锁，真实角色与邀请状态在锁内重新读取，成员仍由认证插件写入。认证错误响应会触发事务回滚。新增迁移 `0011_simple_inhumans.sql` 用只覆盖 pending 的邮箱大小写无关索引替换旧的终身唯一约束；保留 0000—0010 历史。

邮件在数据库提交后发送。若发送失败，接口返回 503，明确说明邀请已保存；列表仍显示 pending，可选择 Resend。这里尚未实现持久化邮件任务：若进程在提交后、发送前终止，管理员需要重新发送，I12 将接入可靠事件交付。真实发信域、垃圾箱归类与实际收件仍须独立验证。

运行 `bun organizations:validate` 会创建、迁移并最终删除临时 loopback PostgreSQL，使用八个独立连接检查重复邀请、并发接受 / 拒绝 / 取消、最后一名 owner 和套餐最后一个名额。另用认证插件的可信 `addMember` 接口并发验证实际写入门禁，再启动真实 Worker 检查捕获邮件、接受、角色修改、移除后的旧会话和退出流程。付费人数使用本地订阅夹具；所有 Stripe SDK HTTP 被本地出站服务拦截，邮件仅捕获，不访问真实供应商；不会迁移正常预览。可选 `ORGANIZATION_UI_ACCEPTANCE=1 bun organizations:validate` 保留临时 `http://127.0.0.1:14310/members` 供浏览器操作（需先构建 app）。测试结束创建 `.local/organization-ui-stop`，脚本会销毁服务和临时数据库；不要把临时 fixture 凭据用于真实站点。

## 成员人数与管理

人数来自 `websiteConfig.plans`：Free 1 人、Starter 5 人、Pro 50 人，所有者也占名额。账单页面与成员写入复用 `apps/api/lib/subscription-access.ts`，按团队自身的当前订阅、试用 / 周期结束、取消日期判断；个人订阅不会升级团队。支付未启用或付费访问已过期时使用 Free 上限，既有成员不自动删除，但必须先释放名额或恢复有效套餐才能新增成员。待处理邀请不占名额，满额仍可以准备邀请；接受时名额不足返回错误，并保留 pending，供释放名额后重试。

`apps/api/lib/member-limit-adapter.ts` 在认证插件实际新增成员的数据库写入前锁定组织行、重新读取人数与套餐，并在同一事务中执行插件写入。HTTP 接受邀请和可信服务端 `addMember` 都经过此门禁；认证组件内部的 adapter 事务回调仍使用带门禁的实例。组织变更的外层事务负责成员 / 邀请决策的整体提交，不能开启 Drizzle 独立 adapter 事务绕过该边界。直接调用服务端组织 API 不代替公开 HTTP 的身份与角色校验；不要将它直接暴露给客户端。

owner / admin 在成员行编辑 admin / member 角色，点击 Apply 后保存；admin 不管理 owner，只有 owner 能降级其他 owner，且最后一位 owner 不能被降级、移除或退出。移除与退出都要求页面内二次确认；实际授权由服务端插件检查。退出成功后重新读取会话、切换个人工作区并刷新团队列表；移除后旧会话立即失去该团队的成员及账单读取权限，个人账号与工作仍保留。所有者转移使用单独的确认操作，公开普通角色接口拒绝 string / array / 逗号组合中的 owner 提升，授予所有权必须经过专用转移操作。

## 所有者转移

在 Members 中，已验证邮箱的 owner 可在另一位已验证、非匿名团队成员旁选择 Transfer ownership。搜索和分页仍可使用，不要求目标在第一页；已有 owner 也可接收。确认内容说明目标将获得团队完整控制、自己会成为 admin，其他 owner 保持角色。转移后重新读取成员列表与会话；原所有者仍可作为管理员工作或再确认退出，不能继续转移或管理 owner。

维护入口为 `apps/api/lib/ownership-transfer.ts`，公开操作是 `POST /api/auth/organization/transfer-ownership`，仅接受显式 `organizationId`、`memberId`。沿用认证客户端发送 Cookie 和同源请求，服务端要求匹配 Origin 与有效会话，在组织行锁内重新读取发起者与目标的团队资格和邮箱验证状态。先调用同一事务绑定的完整 Better Auth handler 提升目标，再调用其实际角色接口将发起者降为 admin；并未直接修改成员表。插件任何错误或数据库故障都会回滚两步。重复或并发请求只有仍为 owner 的发起者可以执行；原请求响应丢失时先刷新核对当前角色，前端不会自动重试转移。

该动作只转移团队角色，不变更个人文件、任务、积分或支付供应商账单账户。云端审计与安全通知将在 I9 / I12 接入；当前不会发送所有权通知邮件。它尚未要求二次密码或近期登录校验，当前门槛为有效登录会话、已验证邮箱与 owner 身份，进一步的账号安全策略随会话模块推进。

`bun organizations:validate` 另外检查八连接并发转移只有一个成功，并在原生 Worker 中用真实 PostgreSQL 触发器拒绝第二步写入，确认第一步也回滚；解除故障后可以重试，降级者的原 Cookie 无法再转移。

## 登录会话管理

设置页的 Signed-in devices 展示当前账号的有效会话，标记 This device，并显示浏览器 / 系统提示、登录时间、到期时间与已有 IP；日期使用当前浏览器时区。客户端提供的 User-Agent 只能帮助辨认，不能当成真实设备证明或地理定位，IP 缺失显示不可用，不填虚构位置。列表仅属于个人账号，不随团队切换共享。

单个 Sign out session 和 Sign out other devices 都有二次确认。批量操作保留当前会话；当前设备退出继续使用侧栏 Sign out。撤销复用 Better Auth 的实际接口，失效 Cookie 在后续私有 API 请求中立即被拒绝；已有页面不会通过推送瞬间清空，需要下一次请求或刷新。已排队的后台任务不因登录会话撤销而自动取消。

设备列表保留认证组件的近期登录校验，当前安装版本默认 24 小时，测试用 25 小时会话确认边界，不关闭该检查。会话仍有效但不够近期时，页面提供 Sign in again：只有服务端成功退出后才清理当前会话并跳转登录，携带安全的 `/settings` 回跳，登录后自动返回。退出失败保留原登录，不把前端清空伪装为成功。已被其他设备撤销的会话同样显示重新登录入口。

维护入口：`apps/app/lib/queries/login-sessions.ts`、`apps/app/components/settings/sessions-card.tsx`。按用户 ID 隔离查询缓存，只使用认证客户端的 `listSessions`、`revokeSession`、`revokeOtherSessions`；没有新建会话写入接口。SDK 按自身契约返回撤销所需 token，前端只在内存中使用，不写入页面、链接、日志或持久存储；不要把认证查询缓存持久化或发送到统计服务。批量撤销并非单个数据库事务，部分删除后报错也是可能的；成功和失败都重新读取实际列表，失败不显示“已全部退出”。按钮操作不自动重试。

`bun sessions:validate` 创建并最终删除临时回环 PostgreSQL，启动生产 Worker bundle，用实际注册 / 密码登录 Cookie 验证用户隔离、单个撤销后的私有 API 拒绝、8 个并发批量撤销保留当前及其他用户会话、近期登录过期与重新登录；所有外部请求拒绝。可选 `SESSION_UI_ACCEPTANCE=1 bun sessions:validate`（先构建 app）保留独立 `http://127.0.0.1:15310/settings` 供浏览器验收。创建 `.local/session-ui-age` 可让夹具账号的会话超过近期登录窗口；完成后创建 `.local/session-ui-stop`，脚本销毁临时资源。控制文件只用于一次性验收数据库，勿用于正式账号。

## 通行密钥管理

设置页的 Passkeys 按个人账号列出已保存的通行密钥，提供添加、改名和删除确认。名称限制 1—80 个字符；只显示名称和创建时间，不把公钥、凭据 ID 或签名计数当成设备识别。用户在支持 WebAuthn 的 HTTPS 或 localhost 浏览器中点击添加，再由系统屏幕锁或安全密钥完成注册；取消或失败不显示成功，不自动重复调用设备弹窗。关闭 `websiteConfig.auth.passkey` 后不显示管理卡片，也不启用插件接口。

列表、注册选项 / 验证、改名和删除统一要求近期登录，复用当前认证组件的 `freshSessionMiddleware`，默认窗口为 24 小时；匿名会话不能管理。不够近期时通过实际退出、重新登录与安全 `/settings` 回跳恢复。读取与变更都使用 Better Auth 客户端；每次变更无论成功或失败都刷新真实列表，响应丢失不自动重试。改名 / 删除无需浏览器支持 WebAuthn，但仍需有效且近期的账号会话。

删除会使该凭据无法用于下一次登录，既有会话保持有效；如果也要退出设备，另用登录会话管理。这里删除的是站点保存的凭据，不能抹掉系统或密码管理器中的副本。成功提示来自实际认证组件结果，刷新后仍保存。

最后一个通行密钥的删除必须保留邮箱恢复底线：当前账号邮箱已验证，并且启用了邮箱验证码登录，或启用了密码登录且该账号确实保存非空密码凭据。其他通行密钥可以作为下一次登录入口；仅有 OAuth 绑定不视为已验证的恢复路径，因为无法保证外部账号仍可访问。未满足条件时保留最后一个并返回 `LAST_LOGIN_METHOD`。本地开发允许未验证邮箱的密码注册，但仍遵守这条删除保护；可先通过已启用的邮箱验证码流程验证账号邮箱，不因开发模式跳过底线。真实邮件送达和外部账号恢复还需线上验收。

插件契约参见 [Better Auth 官方通行密钥文档](https://better-auth.com/docs/plugins/passkey)，实现以当前安装的 1.7.4 源码和实际接口测试为准。维护入口为 `apps/api/lib/passkey-management.ts`、`apps/app/lib/queries/passkeys.ts` 和 `apps/app/components/settings/passkeys-card.tsx`。公开删除由账号行锁串行化，锁内调用绑定到同一事务的完整 Better Auth handler 执行删除，再读取剩余凭据和当前恢复条件；不满足条件时回滚实际插件删除。并发删除不能同时删掉最后两个凭据。公开解绑账号接口也使用同一账号行锁；如果解绑会移除已启用的密码凭据，且已无通行密钥与合格邮箱恢复路径，则回滚解绑，避免删掉通行密钥之后再移除密码底线。仅移除 OAuth 绑定仍按认证插件原规则授权。插件仍是唯一的凭据写入器，不另设自定义凭据表；直接调用可信服务端 `auth.api` 不替代公开 HTTP 边界，不要将它直接暴露给客户端。

`bun passkeys:validate` 在一次性回环 PostgreSQL 和生产 Worker 中，用软件认证器生成真实 ES256 公钥、注册数据和签名消息，验证注册 / 登录、挑战重复使用拒绝、改名、用户隔离、8 个并发删除、最后凭据回滚、近期登录和已删除凭据登录拒绝；验证邮箱恢复条件后再次密码登录，另检查真实解绑密码凭据后，捕获本地验证码并通过实际验证码接口登录。软件认证器仅用于协议验收，不代表 Touch ID、Windows Hello、手机或物理安全密钥已通过。脚本禁止外部请求并销毁临时资源，不迁移正常预览。

可选 `PASSKEY_UI_ACCEPTANCE=1 bun passkeys:validate`（先构建 app）保留独立 `http://passkeys.localhost:16310/settings`，与主预览 Cookie 隔离。浏览器验收使用两个通过协议注册的夹具凭据检查列表 / 改名 / 删除；首次设备注册弹窗仍须人工实测。`.local/passkey-ui-age` 让夹具会话超过近期窗口；`.local/passkey-ui-verify` 只将一次性夹具邮箱标为已验证，不能作为真实验证证据。完成两个凭据的页面删除后创建 `.local/passkey-ui-stop`，脚本再用实际签名登录确认两者均拒绝、密码仍可登录，然后销毁资源。这些控制文件不能用于正式账号。

## 已知边界

* 通行密钥管理和签名协议已本地验收；首次注册 / 登录的真实设备与系统弹窗尚待验收，当前不能用新用户只开通行密钥的配置。
* 团队页已接入邀请、重新发送、取消与历史分页；`/invitations` 可接受或拒绝发送给当前已验证邮箱的邀请。套餐人数写入限制、角色修改、移除与退出已本地验收；所有者转移已本地验收；会话列表与撤销已本地验收；通行密钥列表 / 添加入口 / 改名 / 删除及恢复保护已本地验收；真实收件箱、OAuth 和通行密钥设备流程仍待验收。
* 不使用客户端隐藏菜单替代 API 成员校验。组织账单读取和变更检查实际成员及角色。
* 修改站点 ID 改变 cookie 前缀；需要重新登录，不能复用其他新站的账号数据。

## 验收

测试新账号注册、验证、重复注册、密码错误、过期 OTP、重置链接过期及再次使用、退出后私有 API 拒绝访问、OAuth 取消 / 回调及用户邮箱、组织成员移除后的读写权限。成功与失败路径都要实际运行。
