freehire Gmail 收件箱设计解析:增量 OAuth、ATS 限定同步与加密刷新令牌
freehire Gmail 收件箱设计解析:增量 OAuth、ATS 限定同步与加密刷新令牌
本文以 freehire 仓库中 Gmail 收件箱的设计文档 design.md 为主线,逐条拆解它的目标边界、六项关键决策、数据模型与迁移策略,并结合真实源码说明这些设计如何落地:增量 OAuth 如何与登录流程解耦、Gmail 查询如何只取招聘相关邮件、刷新令牌如何加密存储、同步 worker 如何在部分用户失败时保持整体可用。读完你可以完整理解这条「连接 → 同步 → 收件箱」链路,并能据此在本地完成配置与运行。
一、问题背景与工程约束
设计文档开篇给出了这个特性的动机:求职者会通过大量 ATS(Applicant Tracking System,如 Greenhouse、Lever、Ashby、Workday)投递职位,而雇主的回复——收件确认、面试邀约、拒信——散落在个人 Gmail 中,与求职工具彼此割裂。由此产生的需求非常具体:在 freehire 后端增加一个「Connect Gmail」按钮、一个仅针对 ATS 邮件的 Gmail 同步任务,以及一个应用内收件箱。
文档同时明确说明它与更早的 freehire-apply 实验的关系:那个实验用个人应用专用密码 + IMAP,并带 LLM 分类;而本特性被重新安置在开源主项目中,用 OAuth 产品化,并且刻意更简单——只做 connect → sync → grouped inbox,不做分类、不做职位匹配、不做投递追踪联动。
实现必须服从项目既有的技术约束(文档中称其为 hire 的 AGENT.md 约定):
| 约束 | 具体要求 | 仓库中的体现 |
|---|---|---|
| Web 框架 | Go + Fiber v2 | internal/api/handler/gmail.go 使用 fiber.Router 注册路由 |
| 数据访问 | PostgreSQL + sqlc,禁止 ORM | internal/platform/db/queries/gmail.sql 提供手写 SQL,再生成 db.Queries |
| 前端 | web/ 下的 SvelteKit SPA |
web/src/routes/my/inbox/+page.svelte |
| 迁移 | 放在 migrations/,尚无版本化 runner,需在部署前手工应用 |
migrations/0014_gmail_inbox.sql 的文件头注释重复了这条警告 |
| 响应格式 | {"data": …} / {"error": …} 信封 |
GmailStatus、SyncGmail、GmailDisconnect 均返回 fiber.Map{"data": …} |
| 认证 | 无状态 JWT Cookie | 连接路由挂 mw.cookie,数据路由挂 mw.key |
其中「无版本化迁移器」这一条值得单独强调:SQL 文件不会被任何工具自动按序执行。设计文档因此在迁移计划里写明 apply before deploy,而 0015_hosted_mailbox.sql 更直接指出,未应用的列重命名会让所有收件箱读取报 42703。这是理解整个特性运维方式的前提。
二、目标与非目标
设计文档把范围切得非常干净,这种切分本身就是设计的一部分。
目标(Goals):
- 通过 Google 增量授权(
gmail.readonly)实现一键「Connect Gmail」; - 按用户加密存储刷新令牌,并支持断开(撤销 + 清除);
- 通过 Gmail API 做 ATS 限定同步,落入
emails表,幂等且增量; /my/inbox按归一化主题分组,正文在沙箱面板中阅读。
非目标(Non-Goals):
- 邮件分类/打标(留待后续阶段);
- 把邮件匹配到职位目录、或推进投递追踪阶段(那是 freehire-apply 实验的职责);
- Google 生产环境验证 / CASA 审计(当前仅测试模式测试用户);
- 非 Gmail 服务商;发信。
这四条非目标在实现里都能找到对应边界。例如同步 worker 只把邮件写进 emails 表,不做任何分类;连接路由仅在配置就绪时注册(if h.gmailReady()),未配置时按钮不会出现;而 gmail.readonly 属于 Google 的 restricted scope,生产验证被明确推迟——docs/google-oauth-verification.md 把这段成本写成了一份运维手册,指出该 scope 需要「验证 + 每年第三方安全评估(CASA)」,在此之前应用停留在 Testing 状态、最多 100 名具名测试用户。
三、六项关键决策及其源码落地
设计文档的 Decisions 一节是全文骨架,下面逐条展开,并给出仓库中的实现证据。
D1:在既有 Google 客户端上做增量授权,而不是新增登录方式
决策:「Connect Gmail」是一次独立的授权,在同一个 Google OAuth 客户端上以增量授权方式申请 gmail.readonly,拥有自己的同意屏幕,仅当用户点击 Connect 时才发起。被否决的替代方案是把 scope 加进登录流程——那会强制每次登录都索取邮箱访问权。
实现证据:连接器在 internal/application/gmailsync/connector.go 中构造授权 URL,关键三个参数缺一不可:
func (c *Connector) AuthCodeURL(state string) string {
return c.cfg.AuthCodeURL(state,
oauth2.AccessTypeOffline, // 需要 refresh token
oauth2.SetAuthURLParam("prompt", "consent"), // 强制出现同意屏幕
oauth2.SetAuthURLParam("include_granted_scopes", "true"), // 增量:保留既有授权
)
}
AccessTypeOffline 保证拿得到 refresh token,prompt=consent 保证用户真的看到这次新的授权请求,include_granted_scopes=true 则是「增量」的字面含义:新授权会叠加在用户已有的登录授权之上,而不是覆盖它。连接器的回调地址固定为 origin + "/api/v1/me/gmail/callback"。
值得注意的是同文件里的 scopedAuthCodeURL 把这个模式抽象成了一个通用函数,后来被日历只读、导师日历写入、导师忙碌同步三个同意流程复用——每个流程一个 scope、一个独立回调,因为「两份成本就是两个决定」。
D2:用 Gmail REST API,而非 IMAP
决策:在 OAuth 前提下,Gmail API 是自然的传输层。服务端一次 messages.list 配 q=from:(<ATS domains>) newer_than:… 就能同时完成 ATS 过滤与增量,再用 messages.get 取全文。被否决的替代方案是 SASL XOAUTH2 之上的 IMAP——它能用,但要重新实现 Gmail 查询白送的过滤能力,且与 OAuth 令牌的契合度更差。
实现证据:查询构造集中在 internal/application/gmailsync/senders.go 的 BuildQueryFor。它把「ATS 发件域」「招聘网站专用发件地址」「自学习域名」「多语言申请措辞」用 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)
}
-from: 放在括号外是有意为之(注释里明确写道:放进括号内它就变成「可选项」而不是「所有消息都必须满足的条件」)。分页与取全文则在 internal/application/gmailsync/gmailapi.go:ListATSMessageIDs 以 maxResults=100 循环翻页,GetMessage 用 format=full 取回并解析 MIME 树。
ATSDomains 里不仅有平台主域(greenhouse.io、lever.co、ashbyhq.com、myworkday.com 等),还单独列出了「与看板域不同的投递邮件中继域」,例如 workablemail.com、teamtailor-mail.com、comeet-notifications.com、m.personio.com、mail.paylocity.com——注释说明这些是真实收件箱中观察到的、主域列表覆盖不到的发信域名。这是一个非常实用的细节:只按平台品牌域名过滤,会漏掉大量实际投递回复。
D3:刷新令牌静态加密,断开即清除
决策:只存加密后的 refresh token(密钥来自环境变量),绝不返回给客户端;access token 每次运行时现取,从不落库。断开连接时向 Google 撤销授权,并清除令牌和该用户已同步的邮件。被否决的方案是明文存储——一旦数据库泄露就等于泄露活体邮箱访问权。
实现证据:加密由 internal/platform/tokencrypt 提供,使用 AES-256-GCM(AEAD):每次加密生成随机 nonce 前置在密文前,整体 base64 编码后存入文本列,因此既有机密性也有完整性校验。密钥必须是 32 字节,配置侧在 internal/platform/config/config.go 中通过 decodeKey(os.Getenv("GMAIL_TOKEN_KEY")) 解析。
断开流程在 GmailDisconnect 中体现为「尽力撤销 → 按来源清除邮件 → 删除连接行」三步:
if err := h.revokeGmailGrant(c.Context(), userID); err != nil {
log.Printf("gmail disconnect: revoke for user %d: %v", userID, err)
}
if err := h.queries.DeleteEmailsBySource(c.Context(), db.DeleteEmailsBySourceParams{UserID: userID, Source: "gmail"}); err != nil {
return err
}
if err := h.queries.DeleteGmailConnection(c.Context(), userID); err != nil {
return err
}
撤销失败只记日志不阻断,因为「删除我们手里的副本」才是必须成功的语义。revokeGmailGrant 调用 Google 的 https://oauth2.googleapis.com/revoke,并且在没有连接行或特性未配置时直接返回 nil——把「无物可撤」当成成功而非失败。
DeleteEmailsBySource 的 source 参数很关键:它只清除 gmail 来源的邮件,不碰托管邮箱的邮件(查询注释原文:"the other source's mail is left untouched")。
D4:存全文正文,按 Gmail message id 去重
决策:emails 表保存头部 + text/HTML 正文,使收件箱可以应用内阅读;gmail_msg_id 唯一,保证重复同步幂等。按用户游标(Gmail historyId 或接收时间水位)驱动增量。被否决的方案是「只存元数据 + 深链到 Gmail」,产品决策要求应用内可读。
实现证据:仓库实际采用的是接收时间水位,而不是 historyId——这正是文档「Open Questions」里写的「先用水位,之后再评估」。写入语句 UpsertEmail 是 ON CONFLICT (user_id, source, external_id) DO NOTHING,配合迁移后的唯一约束 (user_id, source, external_id),重同步天然幂等。
消息解析 parseMessage 从 internalDate(毫秒)推出接收时间,用 net/mail.ParseAddress 拆出姓名与地址,递归遍历 MIME 树取第一个 text/plain 与第一个 text/html,并额外从 text/calendar 部分提取会议 UID 写入 ical_uid 列——一个「invitation 的 UID 识别不出来也仍然是一封邀请」的容错取舍。
D5:按归一化主题分组(后被产品决策推翻)
决策:收件箱按归一化主题分组——剥离前导 Re:/Fwd:(含本地化变体)、裁剪并折叠空白、大小写不敏感;分组是纯函数、有单元测试,API 在存储列 subject_norm 上分组,使分组键可索引。被否决的方案是按 In-Reply-To 线程化——对真实线程更准,但 ATS 邮件常常不成线程,且产品要求主题分组。
这是一个很好的「文档即历史」案例:仓库现状已经不再按主题分组。migrations/0016_drop_subject_norm.sql 的注释直接写明「收件箱从按主题分组改为扁平列表,已经没有任何代码读 subject_norm」,因此 DROP INDEX emails_user_subject_idx 并删除该列。相应的读路径现在是 ListEmails——一条按 received_at DESC, id DESC 排序、支持来源/未读/分类状态/链接状态/关键词多维过滤的扁平查询。集成测试 internal/api/handler/gmail_integration_test.go 的开头注释也改成了 "the inbox is a flat per-user message list (search filters it)"。
阅读这段设计时应当区分「设计时意图」与「当前实现」:D5 说明了为什么要用可索引的存储键而非查询时临时计算,这个工程判断依然成立;而分组本身作为产品形态已被替换。
D6:Gmail 访问藏在接口之后
决策:Gmail 客户端放在一个小接口后面(列出游标之后的 ATS 消息 id;取单封消息),使同步 worker 可以用 fake 做单元测试,无需真实访问 Google;令牌交换/刷新同样通过适配器隔离。
实现证据:internal/application/gmailsync/reader.go 定义了三方法接口:
type GmailReader interface {
ListATSMessageIDs(ctx context.Context, selfAddr string, afterUnix int64) ([]string, error)
ListThreadMessageIDs(ctx context.Context, threadID string) ([]string, error)
GetMessage(ctx context.Context, id string) (Message, error)
}
注意 selfAddr 是参数而不是 reader 的状态——注释解释得很清楚:知道地址的是 worker,而为「收件箱搜索」构造的 reader 没有理由携带它。同文件还定义了独立的 MailboxSearcher(任意查询)与 MessageImporter(把用户确认的一封邮件落地),刻意不与 GmailReader 合并,避免「每个 fake 都被迫实现一个它不该有的方法」。
worker 侧通过 ReaderFactory func(ctx, refreshToken string, learned <a href="https://link.gitcode.com/i/8c17579282a9ee846e89c5a5406679ee" target="_blank">]string) GmailReader 注入;生产实现 Connector.ReaderFactory() 返回基于 HTTPClient(用刷新令牌换取 access token 的 oauth2.NewClient)的真实 reader。测试里注入 fake,[internal/application/gmailsync/worker_test.go 因此能覆盖各种失败语义。
四、同步 worker 的真实执行语义
设计文档只写了「增量、幂等、尽力而为」,实现比这更细致,值得单独说明。入口是 cmd/gmail-sync/main.go:一次性运行后退出(run-once-and-exit),适合 cron 调度。
配置门控写在最前面——缺少 Google OAuth 客户端或 32 字节令牌密钥时打印一行日志并 return 0,即「无事可做」也算正常退出:
g := cfg.OAuth["google"]
if g.ClientID == "" || g.ClientSecret == "" || len(cfg.GmailTokenKey) != 32 {
log.Print("gmail-sync: not configured (Google OAuth client / GMAIL_TOKEN_KEY) — nothing to do")
return 0
}
Worker.RunOnce 遍历 ListConnected 返回的连接,逐个调用 syncUser,并把结果归类为三种 outcome:outcomeSynced、outcomeFailed、outcomeReconsent。一次用户同步的步骤如下(见 internal/application/gmailsync/worker.go):
- 读取并解密该用户的 refresh token;解密失败计为
Failed,下轮重试。 - 载入自学习域名(
Promoted());失败则降级为只用硬编码核心列表——注释明确「自学习缓存是增强,不是闸门」,不能因为它出错就跳过用户。 - 构造 reader,调用
ListATSMessageIDs(ctx, u.Email, u.Cursor)拉取候选 id。 - 逐条
GetMessage→ 跳过自己发出的邮件 →UpsertEmail→ 推进水位。 - 对见过的 thread 调
ListThreadMessageIDs做线程扩展,把不带 ATS 标记的回复(个人招聘官、面试安排)也补进来。 - 调用
SetSynced写回水位与last_synced_at。
三个失败语义尤其重要:
- 撤销与瞬时故障必须区分。
RevokedGrant(err)(grant.go)只在两种情况下返回真:API 返回 401/403,或令牌端点返回invalid_grant。限流、500、连接重置、进程被中断都不算——因为needs_reconsent状态是邮件与日历共享的,且只有浏览器重新同意才能清除,一次 Google 抖动或一次重新部署不应该把所有邮箱一次性断开。注释把这个后果写得很直白:「one Google incident, or one redeploy, disconnected every mailbox we hold」。 - 失败时水位必须回拨到起点,而不是停在已到达的最大值。注释解释:同一批里较新的邮件可能在较旧的兄弟失败之前成功并推进了
newest,若只「冻结」在原地,水位仍会跳过那封失败的信。所以if sawFailure { newest = u.Cursor }是在SetSynced前重置。 - 退出码反映运行质量。
worker.ExitCode(stats.Failed, stats.Reconsent)在任一非零时返回 1,否则 0。Stats结构的注释说明了原因:没有它,「一封都没同步成功」与「干净完成」在退出码和freehire_worker_last_run_success指标上完全一致。
手动同步复用同一条路径:SyncGmail 处理器在后台 goroutine 里调用 worker.SyncUser(ctx, Connection{...}) 后立即返回 {"started": true},因为完整回填可能超过请求写超时,前端改为轮询收件箱看结果。
五、自学习 ATS 域名:让白名单从真实分类中长大
设计文档的 D2 只说了「curated ATS sender-domain list」(人工挑选的发件域列表)。实现里多了一层机制,internal/application/gmailsync/learn.go 称其为「自学习缓存」,用来避免「按轶事策展」式的白名单膨胀:
- 写侧:分类器把邮件判为求职邮件时调用
RecordJobMail,判为other时调用RecordNonJobMail; - 读侧:同步 worker 读取
Promoted()返回的域名,交给BuildQueryFor并入查询。
两个阈值决定一个域名能否留在白名单:
const PromoteThreshold = 3 // 至少 3 次可信命中才晋升
const MaxOtherShare = 1.0 / 3 // 且该域名的邮件中 `other` 占比不超过 1/3
注释给了实测依据:仅按命中数晋升会把 google.com(98% 为 other)和 linkedin.com(87%)也拉进来;真实招聘域名实测低于 20%,取 1/3 留有余量。同时 freeMailDomains 把 gmail.com、outlook.com、yandex.ru 等消费者邮箱域永久排除在可学习集合之外——求职者的同伴、个人招聘官和自己都从这些域发信。
这段设计与 D5 一样体现了「文档先立骨架、实现再补血肉」的协作方式:文档给出「curated list」的意图,代码把「curated」演化成了「可度量、可回退的策展」。
六、数据模型与迁移策略
设计文档的迁移计划列出两张表。以下是原始 DDL 的核心字段(migrations/0014_gmail_inbox.sql):
CREATE TABLE IF NOT EXISTS gmail_connections (
user_id BIGINT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
email TEXT NOT NULL,
refresh_token_enc TEXT NOT NULL, -- AES-256-GCM 密文的 base64
status TEXT NOT NULL DEFAULT 'connected', -- connected | needs_reconsent
sync_cursor BIGINT NOT NULL DEFAULT 0, -- 已同步最新邮件的 Unix 时间
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_synced_at TIMESTAMPTZ
);
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)
);
user_id 作主键意味着「一个用户一条连接」;两级 ON DELETE CASCADE 让删除账号时连接与邮件自动清理——revokeGmailGrant 的注释也说明它被「断开」与「账号删除」共用。
迁移后的演进(这部分超出设计文档,但决定了当前代码形态):
| 迁移 | 变化 | 原因 |
|---|---|---|
| 0015_hosted_mailbox.sql | emails 增加 source TEXT NOT NULL DEFAULT 'gmail';gmail_msg_id 重命名为 external_id;增加 s3_key、read_at;唯一约束改为 (user_id, source, external_id) |
把 Gmail 专用表泛化为多来源邮件存储,让托管邮箱与 Gmail 共用一个收件箱 |
| 0016_drop_subject_norm.sql | 删除 subject_norm 及其索引 |
收件箱改为扁平列表,不再按主题分组 |
| 0020_email_application_linking.sql | 增加 job_id、suggested_job_id、link_source、status_signal、classified_at 等列与分类 outbox |
邮件 → 投递关联与状态分类(原设计的 Non-Goal,后续阶段实现) |
| 0022_emails_deleted_at.sql | 增加 deleted_at 与部分索引 emails_user_live_idx ... WHERE deleted_at IS NULL |
收件箱软删除(可恢复),列表只扫活行 |
这几次迁移共同印证了设计文档发布顺序要求的重要性:0015 的注释写明,在既有生产卷上必须手工在部署新二进制之前应用,否则未应用的重命名会让每次收件箱读取都报 42703;0016 的注释则给出相反方向的顺序要求——DROP COLUMN 要在「停止写该列的二进制」部署之后执行,靠列的 NOT NULL DEFAULT '' 兜住仍在运行的旧进程。
设计文档还列出了回滚策略:特性是增量的,断开即按用户清除数据。就代码而言,这条策略仍然成立——DeleteEmailsBySource('gmail') + DeleteGmailConnection 不触碰任何其他表。
七、配置与本地运行
7.1 环境变量
.env.example 给出了完整说明:
# --- Connect-Gmail inbox (optional) ------------------------------------------
# The "Connect Gmail" ATS inbox reuses the Google OAuth client above, requesting
# the RESTRICTED scope gmail.readonly via incremental authorization on connect.
# Google Cloud setup: enable the Gmail API, add the gmail.readonly scope to the
# OAuth consent screen, register <FRONTEND_ORIGIN>/api/v1/me/gmail/callback as a
# redirect URI, and (until app verification) add your account under "Test users".
# GMAIL_TOKEN_KEY is a base64-encoded 32-byte AES key that encrypts stored refresh
# tokens at rest; unset (or not 32 bytes) disables the feature. Generate one with:
# openssl rand -base64 32
GMAIL_TOKEN_KEY=
前置条件可归纳为四条:
- 复用已有的 Google OAuth 客户端(
OAUTH_GOOGLE_*); - 在 Google Cloud 中启用 Gmail API;
- 在 OAuth 同意屏幕添加
gmail.readonly,并把<FRONTEND_ORIGIN>/api/v1/me/gmail/callback注册为重定向 URI; - 在应用通过验证前,把测试账号加入 Test users(上限 100)。
密钥可用 openssl rand -base64 32 生成;decodeKey 按 base64 解码,长度不等于 32 字节即视为未配置。
7.2 路由与鉴权边界
inboxHandlers.register 中的挂载方式体现了两种凭据的分工:
api.Get("/me/gmail", mw.key, h.GmailStatus)
api.Delete("/me/gmail", mw.key, h.GmailDisconnect)
api.Get("/me/inbox", mw.key, h.GetInbox)
api.Post("/me/inbox/read-all", mw.key, h.MarkAllReadInbox)
api.Get("/me/emails/:id", mw.key, h.GetEmail)
// …
if h.gmailReady() {
api.Get("/me/gmail/connect", mw.cookie, h.GmailConnect)
api.Get("/me/gmail/callback", mw.optionalCookie, h.GmailCallback)
// 日历/导师日历/导师忙碌同步的同意流程…
api.Post("/me/gmail/sync", mw.key, h.SyncGmail)
}
- 读取与断开用
mw.key:用户自带的 agent harness 可以用全权限 API key 驱动这些面,与投递追踪路由一致;只读cv范围的 key 会被拒。 - OAuth 连接流程固定用
mw.cookie:它要把浏览器重定向到 Google 同意屏幕再回来,对 keyed 客户端毫无意义,因此不允许 key 访问。 - 回调挂
mw.optionalCookie而非mw.cookie:从 Google 返回时若会话刚好过期,或回落到未共享 cookie 的主机,渲染一个 JSON 401 到地址栏会把用户卡死;GmailCallback自己用重定向处理这种情况。
未配置时(gmailReady() 为假),连接路由根本不注册,GET /me/gmail 也会返回 available: false,前端据此隐藏按钮。
7.3 手动同步与运行 worker
- 前端:
/my/inbox页面在未连接时展示 Connect 按钮,连接后展示列表;手动刷新调用POST /api/v1/me/gmail/sync,后端后台执行并返回{"started": true}。 - 定时:
cmd/gmail-sync设计为 cron 一次性执行,构建后按调度运行即可;未配置时它安全地以 0 退出。运行结束会打印一行统计:
gmail-sync done: connections=%d synced=%d failed=%d needs_reconsent=%d
八、风险与权衡:设计文档的诊断与实现中的对策
| 风险(设计文档) | 文档中的缓解 | 代码中的落点 |
|---|---|---|
| Restricted scope 验证 | 暂用测试模式(≤100 测试用户),向用户明确「尚不可用」 | available 字段驱动前端隐藏按钮;docs/google-oauth-verification.md 记录完整验证流程与 CASA 成本 |
| 存储用户正文的隐私/容量/GDPR | 断开即全清;预留保留策略缝;ATS 限定压缩体量 | DeleteEmailsBySource + DeleteGmailConnection;查询层 BuildQueryFor 只取招聘形态邮件 |
| 刷新令牌泄露 | 静态加密;断开即撤销;绝不返回客户端 | tokencrypt(AES-256-GCM);GmailStatus 只返回 email/status,不含令牌 |
| Gmail API 配额 | 批处理 + 退避;按用户尽力而为 | 分页 maxResults=100;单个用户失败只累计 Stats.Failed 并继续下一个 |
游标陈旧 / historyId 过期 |
回退到接收时间水位,用 message id 去重吸收重叠 | 实现直接采用水位;UpsertEmail ... DO NOTHING |
在配额与撤销之间的取舍上,实现还额外引入了一层精确性要求:RevokedGrant 只认 401/403 与 invalid_grant,因为「把供应商故障误判为撤销」的代价是全体用户被迫重新走一次 restricted scope 的同意流程。这一点在文档里只写了「best-effort per user so one user's quota error never aborts the run」,代码把它扩展成了「一个用户的故障不应伤害其他用户的连接状态」。
九、测试与验证
设计文档在 Risks 与 Decisions 中反复强调「用 fake 做单元测试」「分组是纯函数、有表驱动测试」。仓库中的测试分布如下:
| 文件 | 覆盖点 |
|---|---|
| connector_test.go | 同意 URL 只索取单一 scope、prompt=consent、offline 参数、各流程回调各不相同 |
| gmailapi_test.go | format=full 报文解析、单部分正文、text/calendar 中的会议 UID 提取与缺失时的空值 |
| senders_test.go | ATS 域匹配(含子域与中继域)、查询构造、水位、自学习域名并入、排除自身地址、招聘措辞覆盖度 |
| worker_test.go | 正常同步、线程扩展、失败时冻结/回拨水位、撤销标记为 needs_reconsent、瞬时故障不断开 |
| grant_test.go | RevokedGrant 的判定边界与 APIError 携带状态码 |
| learn_test.go | 可学习域名过滤、命中晋升、other 计数 |
| gmail_integration_test.go | 端到端:状态、列表、跨用户的邮件 404、断开清除(go test -tags=integration) |
任务清单 tasks.md 里除了 10.1(构建/静态检查/全量测试)之外全部勾选,说明端到端流程已在测试用户邮箱上实际跑通:连接 → 同步拉取 ATS 邮件 → /my/inbox 按主题分组展示可读正文。前端侧后来又在 web/src/routes/my/inbox/+page.svelte 与共享的 InboxView 组件上增加了分页、搜索与手动同步。
十、设计留下的开放问题及其后续
设计文档的 Open Questions 有三条,对照当前代码可以看到各自的状态:
- 游标机制:
historyId还是internalDate水位——文档写「先用水位,之后再评估」。实现采用水位(sync_cursor存 Unix 秒),并用唯一键去重吸收重叠;historyId至今没有落地。 - 同步频率(cron 间隔)——文档要求「观察体量/配额后再调」。仓库中没有把间隔写死在代码里,
cmd/gmail-sync只做一次运行,节奏由外部调度决定。 - 正文保留策略(断开删除之外)——文档称为「a later seam」(预留的缝)。当前
emails表只有deleted_at软删除(0022)保证用户可恢复自己的删除操作,尚未看到按时间的自动清理策略。
同样值得记录的是两个「后续阶段已发生」的变化:分类与投递关联(原 Non-Goal)在 0020_email_application_linking.sql 与 cmd/classify-mail 中实现;主题分组(D5)被 0016 迁移正式移除。这与设计文档的写法形成互补——它固定了「connect → sync → inbox」的最小闭环,而 docs/agents/mail-stack.md 记录了这条管道后来如何扩展成八个包、三个 worker、三种来源(gmail / hosted / external)的邮件栈。
十一、可继续阅读的仓库路径
- 设计与任务:openspec/changes/archive/2026-07-12-gmail-inbox/design.md、proposal.md、tasks.md
- 同步实现:internal/application/gmailsync(
connector.go、gmailapi.go、senders.go、worker.go、learn.go) - HTTP 与前端:internal/api/handler/gmail.go、internal/api/handler/inbox.go、web/src/routes/my/inbox
- 数据层:migrations/0014_gmail_inbox.sql、internal/platform/db/queries/gmail.sql
- 运维与合规:docs/google-oauth-verification.md、docs/hosted-mailbox.md、docs/agents/mail-stack.md
- 令牌加密:internal/platform/tokencrypt