用 Go 与 Gmail API 构建 ATS 求职邮箱:freehire Gmail 收件箱方案剖析
用 Go 与 Gmail API 构建 ATS 求职邮箱:freehire Gmail 收件箱方案剖析
freehire 的 Gmail 收件箱方案要解决的问题很具体:求职者在 Greenhouse、Lever、Workday 等几十个 ATS 平台上投递后,确认信、面试邀请、拒信会散落在个人 Gmail 里,与求职工具完全脱节。本方案以“一键连接 → ATS 定向同步 → 应用内收件箱”为主线,给出了 OAuth 增量授权、刷新令牌加密存储、游标增量同步与按主题聚合阅读的完整设计。读完本文,你可以掌握一套可直接落地的“只读、最小范围、可撤销”的第三方邮箱接入实现路径,以及 freehire 仓库中对应的源码结构。
一、方案定位:为什么只做 connect → sync → inbox
openspec/changes/archive/2026-07-12-gmail-inbox/proposal.md 开篇就把范围钉死了三句话:用户一键连接 Gmail;系统只拉取 ATS 邮件;在应用内以按主题分组的收件箱呈现。文档明确写着:
Classification/labelling is explicitly a later stage; this change stops at connect → sync → grouped inbox.
这个“克制”是整个设计的技术底色。它意味着本变更不做邮件分类打标、不做邮件与职位库的匹配、不做求职进度(tracker)自动推进——后者在 design.md 中被点名为另一个实验 freehire-apply 的职责。真实仓库里后续确实在同一套管道上叠加了分类与关联能力(例如 internal/application/mailclassify、internal/application/mailrecall),但本文仍以提案所述的三个阶段为主体骨架,并在涉及演进时明确标注。
design.md 还交代了必须遵守的工程约束(来自项目 AGENTS.md 约定):
| 约束 | 具体内容 |
|---|---|
| Web 框架 | Go + Fiber v2 |
| 数据库访问 | PostgreSQL + sqlc,不使用 ORM |
| 前端 | web/ 下的 SvelteKit SPA |
| 迁移 | migrations/ 目录,尚无版本化 runner,需在部署前手工应用 |
| 响应格式 | {"data": …} / {"error": …} 信封 |
| 认证 | 无状态 JWT Cookie |
二、三项新能力(Capabilities)的职责切分
提案把工作拆成三个可独立验收的能力,这一划分在代码中几乎一对一落地:
gmail-connection:gmail.readonly的增量 OAuth 授权流程、每用户加密刷新令牌存储、连接状态查询与断开(revoke + purge)。叠加在既有的 Google OAuth client 之上。gmail-ats-sync:run-once 定时任务,按精选的 ATS 发件域读取每个已连接用户的邮件,幂等写入emails表,并推进每用户游标。Gmail 访问被封装在接口后,因此可用 fake 单测。email-inbox:收件箱读取 API 与/my/inboxSPA 页面(未连接时显示 Connect 按钮,已连接显示分组列表,正文在沙箱 iframe 中打开)。
值得注意的是,design.md 特别声明没有修改型能力(Modified Capabilities):Google 登录本身不被改动,gmail.readonly 是通过增量授权额外申请的,因此“普通登录永远不会索取邮件权限”成为一条可测试的硬约束。
三、连接流程:把增量授权做对
3.1 独立授权而非并入登录
design.md 的 D1 决策否决了“把 gmail.readonly 加到登录 scope 里”的替代方案,理由直白:那会强迫每一次登录都请求邮件访问权。正确做法是同客户端、独立 consent、按需触发。
在 connector.go 中,NewConnector 用 Google OAuth 凭据构造 oauth2.Config,回调固定为 origin + /api/v1/me/gmail/callback,scope 只有 gmail.readonly;AuthCodeURL 则叠加三个关键参数:
func (c *Connector) AuthCodeURL(state string) string {
return c.cfg.AuthCodeURL(state,
oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("prompt", "consent"),
oauth2.SetAuthURLParam("include_granted_scopes", "true"),
)
}
三个参数各有不可替代的作用:
AccessTypeOffline:否则 Google 不下发 refresh token,服务端无法长期同步;prompt=consent:强制再次展示同意页,确保每次都返回 refresh token,而不是复用历史授权静默返回;include_granted_scopes=true:即 Google 的增量授权(incremental authorization),使新授权与用户已有的登录授权合并,不丢失既有 scope。
代码里这条原则被进一步泛化:所有后续 Consent(日历只读、导师日历写入、导师忙碌同步)都经由 scopedAuthCodeURL 复用同一组参数,只是 scope 与 redirect 不同(connector.go)。
3.2 状态校验:每个流程一个 CSRF Cookie
连接是浏览器重定向往返,必须有 CSRF state。这里有个容易踩的坑:不能复用登录流程的 state cookie。代码用独立常量 hire_gmail_state 存储(gmail.go),注释说明了原因——用户可能在一个标签页里正在登录、另一个标签页里点击 Connect,共享 Cookie 会相互覆盖。
回调侧的逻辑被抽成共享函数 runOAuthCallback(gmail.go),处理顺序值得逐条记下,因为任何一步跳过都是安全漏洞或 UX 事故:
- 取当前用户,失败则重定向带
?gmail_error=auth; - 读取并立即清除 state cookie,比对
state查询参数,不匹配则?gmail_error=state; - 先检查
error查询参数(用户点了“拒绝”),有值则?gmail_error=denied——注释特别强调:不能把“缺少 code”简单当作无事发生,否则用户刚拒绝授权就会被提示“已连接”; - 缺失
code则?gmail_error=exchange; - 交换 code,取出 refresh token 与授权 scope;
- 用
tokencrypt.Cipher.Encrypt加密后持久化; - 成功后重定向到
frontendOrigin + /my/integrations?gmail=connected。
失败路径一律重定向而非返回 JSON,因为这是浏览器回跳而非 XHR。相应地,回调路由挂载在 mw.optionalCookie 而非 mw.cookie:如果会话在同意往返期间过期,强制鉴权会把一段 JSON 401 渲染到地址栏,把用户“晾”在那里(gmail.go)。
3.3 连接态与失败降级
Exchange 在 Google 未返回 refresh token 时直接报错 gmail: no refresh token (consent was not offline),避免存入一个无法使用的令牌。随后它调用 Gmail Profile 接口取回邮箱地址——这里有个细节:只依赖已持有的 gmail.readonly scope,不额外索取 email scope(connector.go)。
另一个易被忽视的设计是“未配置即优雅关闭”。inboxHandlers.gmailReady() 要求 connector 与 cipher 均非 nil;为 false 时,OAuth 连接路由根本不注册,状态接口返回 available: false,SPA 据此隐藏 Connect 按钮,而不是让它点出 404(gmail.go、gmail.go)。这与规格中“测试模式下非测试用户被 Google 拦截时,前端给出明确的‘暂不可用’提示而非错误页”的要求同一思路。
3.4 端点清单
register 中与邮箱相关的路由(gmail.go):
| 方法 | 路径 | 鉴权 | 说明 |
|---|---|---|---|
| GET | /api/v1/me/gmail |
API Key(全 scope) | 连接状态、地址、available |
| DELETE | /api/v1/me/gmail |
API Key | 断开:revoke + 清除令牌与已同步邮件 |
| GET | /api/v1/me/gmail/connect |
Cookie | 开始增量授权,设置 state cookie 后 302 到 Google |
| GET | /api/v1/me/gmail/callback |
可选 Cookie | 回调:校验 state、换码、落库、重定向 |
| POST | /api/v1/me/gmail/sync |
API Key | 触发后台手动同步,立即返回 {started: true} |
| GET | /api/v1/me/inbox |
API Key | 收件箱列表 |
| GET | /api/v1/me/emails/:id |
API Key | 单封邮件正文(他人邮件 404) |
为什么读接口用 mw.key 而不是 mw.cookie?源码注释给出了产品理由:用户自建的 agent harness 用全 scope API Key 驱动这些接口,而 mw.key 只接受全 scope,裁剪过的 cv key 仍会被拒。但 OAuth 连接流程必须留在浏览器侧——它要把浏览器重定向到 Google 同意页再跳回来,对 keyed client 毫无意义,也不能对其开放。
四、令牌加密:AES-256-GCM 与一个 32 字节的密钥
D3 决策用一句话概括:只存加密的 refresh token,永不下发给客户端;access token 每次运行时现铸,从不落库。理由是如果存明文,一次数据库泄漏就等于泄漏了他人邮箱的长期访问权。
实现位于 tokencrypt.go,是一个极简但正确的 AEAD 封装:
// Encrypt returns base64(nonce || ciphertext+tag).
func (c *Cipher) Encrypt(plaintext string) (string, error) {
nonce := make([]byte, c.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := c.aead.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(sealed), nil
}
要点逐条:
New(key)强制密钥长度恰为 32 字节,否则返回tokencrypt: key must be 32 bytes, got N;- 每次加密使用
crypto/rand生成全新 nonce,并前置在密文之前;解密时按NonceSize()切分,因此不存在 nonce 复用风险; - 输出 base64,适配
TEXT列; Decrypt对错误密钥、被篡改数据、长度不足分别给出明确错误(ciphertext too short、decrypt failed),GCM 的完整性校验让篡改无法静默通过。
密钥来自环境变量 GMAIL_TOKEN_KEY,按 base64 解码为 32 字节(config.go):
// GmailTokenKey is the 32-byte AES key (from GMAIL_TOKEN_KEY, base64) that
// encrypts stored Gmail refresh tokens. Empty/invalid = the Connect-Gmail
// inbox feature is disabled (like an unset optional integration).
GmailTokenKey []byte
这解释了同步 worker 的启动守卫——cmd/gmail-sync/main.go 在 Google 客户端凭据缺失或密钥长度不为 32 时打印 gmail-sync: not configured (Google OAuth client / GMAIL_TOKEN_KEY) — nothing to do 并 return 0:既不是崩溃也不是失败,因为未配置就是“无事可做”。
断开时的撤销同样体现“不留后门”:GmailDisconnect 先尽力向 Google revoke,再删除该用户 source = 'gmail' 的邮件与本连接行(gmail.go)。注释说明只会清理 Gmail 来源的邮件——托管邮箱(hosted mailbox)的邮件必须保留,这正对应后续迁移引入 source 列的动机。revokeGmailGrant 还做了幂等处理:无连接或功能未配置都算成功,因为“没什么可撤销”不是错误。
五、同步 worker:ATS 定向查询 + 游标水位线
5.1 D2:为什么用 Gmail REST API 而不是 IMAP
design.md 的 D2 明确比较过:Gmail API 的 messages.list 支持服务端查询 q=from:(<ATS domains>) newer_than:…,一次调用同时完成“按发件人过滤”和“按时间增量”,再用 messages.get 取全文。IMAP over SASL XOAUTH2 虽然可用,但要自己重新实现查询语法免费提供的过滤能力,且与 OAuth 令牌模型契合度更差。
5.2 ATS 发件域注册表
senders.go 里的 ATSDomains 是一份人工精选的域名清单(senders.go),既用于拼 Gmail 查询,也用于 IsATSSender 判定。它包含两类条目:
- 平台主域:
greenhouse.io、lever.co、ashbyhq.com、myworkday.com、smartrecruiters.com、icims.com、teamtailor.com、recruitee.com、bamboohr.com、workable.com、applytojob.com、successfactors.com、taleo.net、eightfold.ai等; - 与招聘板域不同的邮件中继域:
workablemail.com、teamtailor-mail.com、comeet-notifications.com、recruitee-mailbox.com、freshteam.com、gupy.com.br、talentlyft.com、join.com、icims.eu、successfactors.eu,以及刻意只写子域的m.personio.com、mail.paylocity.com。
注释解释了第 2 类的必要性:申请人邮件实际来自中继域,只写招聘板域会漏邮件;而只写子域是为了避开同一主域下的产品营销邮件。这是“在真实收件箱里观测出来的”,不是拍脑袋补的。
IsATSSender 的匹配规则是主机名精确相等或作为子域后缀,因此 hire.lever.co 会被 lever.co 覆盖(senders.go)。解析时先剥离 Name <addr> 中的尖括号,再取最后一个 @ 之后的部分并小写化。
5.3 查询构造:OR 起来,再把自身排除在外
真实实现比提案更进一步:BuildQueryFor(selfAddr, afterUnix, extraDomains)(senders.go)把三类信号 OR 成一个大括号组:
clauses := []string{
"from:(" + strings.Join(senders, " OR ") + ")",
}
for _, p := range recallPhrases {
clauses = append(clauses, `"`+p+`"`)
}
q := "(" + strings.Join(clauses, " OR ") + ")"
if selfAddr != "" {
q += " -from:" + selfAddr
}
if afterUnix > 0 {
q += fmt.Sprintf(" after:%d", afterUnix)
}
三个设计点值得注意:
- 召回优先:
recallPhrases包含多语言的申请/面试措辞(英、葡、俄、西),因为很多直接雇主或独立猎头并不在 ATS 域名清单里。代码注释记录了实测来源——120 天内,纯域名查询命中 431 封而“招聘形状”查询命中 1151 封,并且强调加词要加同一说法的兄弟变体,而不是单一说法(例如从your application at扩展到received your application,从invite you to interview扩展到interview invite); - 自身排除必须在括号外:注释点明,若把
-from:放进 alternation 内,它会变成“又一个可匹配项”而不是“每条都必须满足的条件”; - 零游标省略时间子句:
afterUnix > 0才追加after:,首次全量回填由此自然实现。
5.4 Reader 接口与真实实现
D6 要求 Gmail 访问藏在接口后面,以便用 fake 单测、不触网。GmailReader 在 reader.go 中定义(列表 ATS 消息 id、取全文、按 thread 列兄弟消息、任意查询检索),真实实现 apiReader 位于 gmailapi.go:
ListATSMessageIDs:messages?maxResults=100&q=…,循环跟随nextPageToken直到取尽;GetMessage:messages/{id}?format=full,非 200 一律包装成带StatusCode的*APIError;ListThreadMessageIDs:threads/{id}?format=minimal;parseMessage:从internalDate(毫秒)得到ReceivedAt,用net/mail解析From,递归遍历 MIME 树取第一个text/plain与text/html分段,并用容错的 URL-safe base64 解码(自动补齐 padding,解码失败返回空串而非报错)。
令牌侧的适配器是 Connector.HTTPClient / ReaderFactory:用存储的 refresh token 现铸 access token,并把 oauth2 的令牌刷新完全隔离在 safehttp.GuardedOAuth2Context 之后(connector.go)。
5.5 游标、幂等与“尽力而为”
worker 主体在 worker.go。RunOnce 先 ListConnected(查询条件为 status = 'connected' AND email <> ''),再逐个 syncUser,并把结果归为三种结局:outcomeSynced / outcomeFailed / outcomeReconsent,最终汇总成 Stats{Connections, Synced, Failed, Reconsent}。
单用户流程:
encToken, err := w.store.RefreshToken(ctx, u.UserID)
refresh, err := w.cipher.Decrypt(encToken)
learned, _ := w.domains.Promoted(ctx) // 失败则退回硬编码核心域
reader := w.newReader(ctx, refresh, learned)
ids, err := reader.ListATSMessageIDs(ctx, u.Email, u.Cursor)
随后对每个 id 调用 fetch,其中包含四层保护:
- 内存去重:
seen[id]防止同一批与 thread 展开重复处理; - 按 gmail message id 幂等落库:
UpsertEmail对应唯一约束(user_id, external_id)(原gmail_msg_id,见后文迁移演进),重同步不产生重复行; - 跳过自己发出的邮件:
strings.EqualFold(msg.FromAddr, u.Email)命中即返回——收件箱只存入站邮件; - 水位线只进不退且失败回滚:
newest记录本波次最高的ReceivedAt;一旦本波次出现任何失败,SetSynced写入的是原始游标 而非冻结在中间值。注释解释了原因:较新的邮件可能先成功并抬高newest,随后较老的兄弟失败;若只是“冻结”,水位线仍会跨过失败的那封。
thread 展开是提案之外的一处实际增强:命中消息的 thread 会被拉取全部兄弟 id,使“没有 ATS 标记的回复”(例如招聘人员私人邮箱、约面试的后续邮件)也能被纳入。而 thread 展开在已知授权失效后会立刻中止,避免继续产生 403。
失败归因同样值得抄作业:只有 RevokedGrant(err) 为真才标记 needs_reconsent;限流、500、连接重置、进程被中途杀掉都归为 outcomeFailed,下次重试。注释记录了这条规则的原因——这个状态位与日历同步共享,且只能由浏览器重新授权清除,若把一次 Google 抖动当成撤销授权,就会把所有邮箱一起断连。
5.6 退出码即可观测性
cmd/gmail-sync/main.go 用 worker.ExitCode(stats.Failed, stats.Reconsent) 决定退出码,并打印一行结构化日志:
log.Printf("gmail-sync done: connections=%d synced=%d failed=%d needs_reconsent=%d",
stats.Connections, stats.Synced, stats.Failed, stats.Reconsent)
worker.go 的 Stats 注释说明了引入它的动因:没有统计时,“谁都没同步到”的运行与“干净成功”的运行字节级相同(RunOnce 都返回 nil,进程都退出 0),freehire_worker_last_run_success 指标于是穿过一次全面失败仍读作 1。
六、收件箱读取:主题归一的实际演进
6.1 提案中的设计(D5)
原始设计按归一化主题分组:剥离前导 Re: / Fwd:(含本地化变体)、trim 并折叠空白、大小写不敏感;分组是纯函数并有表驱动单测;API 基于持久化的 subject_norm 列分组,使分组键可建索引。D5 也明确比较过按 In-Reply-To 串线程:对真实邮件线程更准确,但 ATS 邮件常常没有规范线程头,且产品明确要求按主题分组。
配套的表结构与索引在 0014_gmail_inbox.sql:
CREATE TABLE IF NOT EXISTS emails (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
gmail_msg_id TEXT NOT NULL,
thread_id TEXT NOT NULL DEFAULT '',
from_addr TEXT NOT NULL DEFAULT '',
from_name TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '',
subject_norm TEXT NOT NULL DEFAULT '',
body_text TEXT NOT NULL DEFAULT '',
body_html TEXT NOT NULL DEFAULT '',
received_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, gmail_msg_id)
);
CREATE INDEX IF NOT EXISTS emails_user_subject_idx ON emails (user_id, subject_norm);
CREATE INDEX IF NOT EXISTS emails_user_received_idx ON emails (user_id, received_at DESC);
连接表同样在此定义(0014_gmail_inbox.sql):user_id 作主键(一用户一连接)、email、refresh_token_enc、status(connected / needs_reconsent)、sync_cursor(最新已同步邮件的 Unix 时间)、connected_at、last_synced_at。两张表都用 ON DELETE CASCADE 指向 users,用户注销时自动清理。
6.2 仓库现状:分组列已被移除
必须如实指出一处与提案不同的事实:按主题聚合的方案后来被替换为平铺列表。0016_drop_subject_norm.sql 直接写明:
Drop the now-dead subject_norm column + its index. The inbox switched from a subject-grouped listing to a flat one, so nothing reads subject_norm anymore.
同时 0015_hosted_mailbox.sql 引入了托管邮箱能力,对邮箱表做了三处关键改造:
- 新增
source TEXT NOT NULL DEFAULT 'gmail'(区分 Gmail 来源与托管邮箱邮件); - 把
gmail_msg_id重命名为通用的external_id; - 唯一约束改为
(user_id, source, external_id),并追加s3_key、read_at列。
因此,读代码时的实际形态是:internal/application/inbox 提供 Service.Search(支持 Source/Status/Link/Q/Unread/Unclassified/WithBody/IncludeOther 过滤、分页与总数),Gmail 连接相关的 HTTP 入口仍是 gmail.go,而 /my/inbox 页面在 web/src/routes/my/inbox/+page.svelte。这一演进不否定提案价值:它说明“阶段化交付”的边界在真实迭代中会前移,而 source 列与通用 external_id 恰好证明当初把 emails 设计成“独立存储 + 唯一键去重”是对的。
6.3 沙箱阅读窗
无论分组还是平铺,正文渲染的安全边界不变:不可信 HTML 必须在沙箱中呈现。提案要求“a message opens its full body in a sandboxed reading pane”,界面实现位于 /my/inbox 页面与 GmailConnectDialog.svelte。
七、迁移与上线顺序(最容易被忽略的一步)
design.md 的 Migration Plan 与 proposal.md 的 Impact 都反复强调同一件事:没有版本化 runner,迁移必须在部署新二进制之前手工应用。0014_gmail_inbox.sql 的文件头注释同样如此:
initdb applies this once on a fresh volume; on an existing prod volume apply it by hand before deploying the new binary (no versioned runner yet).
上线前的最小清单:
- 在 Google Cloud 创建/配置 OAuth 客户端,启用 Gmail API,添加
gmail.readonlyscope,并把使用者加为测试用户(上限 100 人); - 生成 32 字节随机密钥并 base64 编码,写入
GMAIL_TOKEN_KEY; - 在目标库手工执行
gmail_connections与emails的建表 DDL; - 部署二进制,确认
/api/v1/me/gmail返回available: true; - 配置
cmd/gmail-sync的 cron 周期(提案中列为待调项:先观察量与配额再定)。
回滚策略也是“加法式”的:功能是纯新增,断开连接即可按用户清除数据;直接回退二进制也不会留下必须清理的结构依赖(0016 的 DROP 顺序注释还特别说明:应在停止写该列的二进制部署之后再执行 DROP,靠 NOT NULL DEFAULT '' 兼容仍在运行的旧版本)。
八、风险与权衡:提案里已经想清楚的事
design.md 的 Risks 表格几乎每条都能在代码中找到对应防线:
| 风险 | 提案对策 | 代码/配置落点 |
|---|---|---|
| 受限 scope 需 Google 验证 | 先以测试模式 + 测试用户运行,生产验证与 CASA 审计后置 | GmailReadonlyScope 常量注释、available 标志 |
| 存储用户邮件正文带来隐私与容量压力 | 断开即全清;ATS 定向限制体量;预留保留策略接缝 | GmailDisconnect 的按 source 清理 |
| 刷新令牌泄漏 | 静态加密、断开即 revoke、永不下发客户端 | tokencrypt、revokeGmailGrant |
| Gmail API 配额 | 批量请求 + 退避;按用户尽力而为,不因一人失败中断整轮 | RunOnce 的 outcome 计数 |
historyId 过期 / 游标陈旧 |
回退到接收时间水位线,重叠部分由消息 id 去重吸收 | sync_cursor 为 Unix 时间戳 |
| 失效授权被误判 | 只有 RevokedGrant 才落 needs_reconsent,其余归为失败重试 |
syncUser 的错误分类 |
design.md 的 Open Questions 也没有被草率关闭:游标机制最终选了“接收时间水位线”(简单、稳健)而非 historyId(高效但会过期);同步频率、正文保留策略被显式标记为“观察后再调 / 后续接缝”。
九、如何验证这套实现
仓库提供了分层测试,覆盖提案里每条 Requirement 的关键场景:
- worker_test.go:用 fake
Store+ fakeGmailReader验证增量拉取、幂等落库、撤销授权标记、失败不中断整轮; - gmailapi_test.go:覆盖 MIME 遍历、base64url 解码、header 解析等纯函数;
- senders_test.go:查询拼接与
IsATSSender的域/子域判定; - grant_test.go、connector_test.go:授权交换与 scope 记录;
- gmail_integration_test.go、gmail_callback_test.go:连接回调落库、状态查询、断开清空数据;
- gmail_connections_integration_test.go:sqlc 查询层与约束行为。
端到端验证在 tasks.md 中已勾选:连接一个测试用户 Gmail,同步拉取到 ATS 邮件,/my/inbox 能展示并按可读正文打开。唯一未勾选的是 10.1(go build ./... && go vet ./...、go test ./...、web check 全量通过),说明归档时该验证项未被标记完成,阅读源码时应以实际 CI 结果为准。
十、可复用的工程结论
从这份提案到落地代码,能提炼出五条与项目无关、可迁移到任何“第三方邮箱接入”场景的经验:
- 把敏感 scope 从登录中剥离,用增量授权按需索取,并让“登录不请求邮件权限”成为一条可测规格;
- 加密只保令牌、不保业务数据是不够的,撤销路径(revoke + purge + 按来源精确删除)才是隐私承诺的实际兑现方式;
- 幂等键选对方能救命:以 Gmail message id 唯一约束吸收重试与游标重叠,比精巧的增量算法更耐用;
- 把失败分类:区分“用户撤销授权”(需重新授权,停止重试)与“服务端抖动”(可重试),否则一次外部故障会连带废掉共享同一状态位的其他功能;
- 未配置要优雅降级,并在 API 上暴露
available让前端隐藏入口——这比让用户点出一个 404 体面得多。
原始设计文档见 proposal.md、design.md、tasks.md 以及三份能力规格 gmail-connection、gmail-ats-sync、email-inbox。