freehire Gmail ATS 收件箱实现全解:从 OAuth 增量授权到 Gmail 同步 Worker 的任务拆解
freehire Gmail ATS 收件箱实现全解:从 OAuth 增量授权到 Gmail 同步 Worker 的任务拆解
本文以 freehire 仓库中 openspec/changes/archive/2026-07-12-gmail-inbox/tasks.md 的任务清单为骨架,逐项拆解「Connect Gmail」功能的落地过程:如何在既有 Google OAuth 客户端上叠加 gmail.readonly 增量授权、如何用 AES-256-GCM 加密保存 refresh token、如何让一个 run-once cron worker 只抓取 ATS 邮件并幂等入库,以及 /my/inbox 页面如何在不信任的发件人 HTML 上做沙箱隔离。读完后你能完整复现这套「连接 → 同步 → 阅读」链路,并清楚哪些设计在实现过程中被后续迁移改写。
一、这份任务清单在讲什么
tasks.md 把功能拆成 11 组任务:数据模型、纯函数、Token 加密、连接流程、Gmail API 客户端、同步 Worker、Inbox API、SPA、配置与 Google 侧准备、验证、以及实测期间追加的 UX 项。它是一份「实现进度台账」,而非设计文档,因此本文在保留其全部条目的前提下,用仓库中的实际代码补齐每一条背后的实现细节。
配套的设计与规格文档位于同一目录:设计文档、提案 以及三份能力规格 gmail-connection、gmail-ats-sync、email-inbox。设计文档确立了几条不可动摇的约束:Go + Fiber v2、PostgreSQL 走 sqlc(不用 ORM)、SvelteKit SPA 位于 web/、迁移文件放在 migrations/ 且没有版本化 runner(部署前手工应用)、响应统一使用 {"data":…} / {"error":…} 信封、无状态 JWT Cookie 鉴权。
功能边界同样明确:不做分类、不做职位匹配、不做 tracker 联动,只做 connect → sync → 可读收件箱。
二、任务 1:数据模型与 sqlc 查询
2.1 建表 SQL
初始迁移见 0014_gmail_inbox.sql。两张表各有明确职责:gmail_connections 是「每用户一行」的授权与游标状态,emails 是同步下来的邮件正文。
CREATE TABLE IF NOT EXISTS gmail_connections (
-- One connection per user; the FK cascade purges it on user delete.
user_id BIGINT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
email TEXT NOT NULL,
-- AES-256-GCM ciphertext (base64) of the OAuth refresh token; never plaintext.
refresh_token_enc TEXT NOT NULL,
-- connected | needs_reconsent (a revoked/expired grant flips this).
status TEXT NOT NULL DEFAULT 'connected',
-- Incremental sync watermark: the Unix time of the newest synced message.
sync_cursor BIGINT NOT NULL DEFAULT 0,
connected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_synced_at TIMESTAMPTZ
);
几个字段值得单独解释:
user_id既是主键又是外键,天然实现「一人一连接」,并在用户注销时级联清除授权。refresh_token_enc存的是密文,注释直接写明算法是 AES-256-GCM、编码为 base64。status只有两个取值:connected和needs_reconsent。后者是「Google 侧授权已失效」的信号,由同步 Worker 写入。sync_cursor是增量水位——Unix 时间戳,不是 Gmail 的historyId。设计文档的 Open Questions 里曾把两种方案并列,最终选了实现更简单、不受 history 过期影响的时间水位。
emails 表初始形态:
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)
);
任务 1.1 要求的两条索引((user_id, subject_norm) 与 (user_id, received_at DESC))也在此创建。UNIQUE (user_id, gmail_msg_id) 是幂等同步的地基:worker 反复拉到同一封邮件时,写库语句是 upsert 而不是 insert。
2.2 后续迁移对模型的改写(重要)
任务清单写于功能落地时,而仓库后续的演进改变了两个关键点,阅读时必须对齐:
-
gmail_msg_id改名为external_id,并引入source维度。见 0015_hosted_mailbox.sql:ALTER TABLE emails ADD COLUMN source TEXT NOT NULL DEFAULT 'gmail'; ALTER TABLE emails RENAME COLUMN gmail_msg_id TO external_id; ALTER TABLE emails ADD COLUMN read_at TIMESTAMPTZ; -- 唯一约束改为 (user_id, source, external_id)原因是「托管邮箱」(hosted mailbox)与「agent 自行推送的 external 邮件」共用同一张表,去重键必须带上来源,否则同一 Message-ID 在不同来源下会互相覆盖。
-
subject_norm已被整列删除。见 0016_drop_subject_norm.sql,注释说得很直白:「The inbox switched from a subject-grouped listing to a flat one, so nothing reads subject_norm anymore.」 因此任务 5.1/7.1/7.2 里的「按归一化主题分组」在当前实现中已不存在,GET /me/inbox返回的是扁平列表,搜索通过 SQL 过滤实现(见 docs/API.md 的/me/inbox章节)。文章后续会分别标注「原设计」与「当前实现」,避免把历史设计当成现状。
2.3 sqlc 查询
查询定义集中在 internal/platform/db/queries/gmail.sql,生成代码在 internal/platform/db/。几条与任务 1.2 直接对应的语句:
UpsertGmailConnection:重连时更新 token 并把状态拉回connected,刻意保留sync_cursor,这样重连不会触发全量回填。ListConnectedGmailUsers:WHERE status = 'connected' AND email <> ''。这两个条件都是必需的——注释解释了为什么用地址而不是scopes判断:日历授权会插入同一行但邮箱为空;若把它纳入同步,Gmail API 会返回 403,被误判为「授权被撤销」,从而把用户真正想要的日历同步一起打死。SetGmailSynced/SetGmailStatus:分别推进水位、写入needs_reconsent。GetGmailConnection:返回scopes与mentor_busy_sync_opted_in,让状态接口能区分「邮箱已连」「日历已连」「导师忙碌同步已授权」三种彼此独立的事实。
三、任务 2:两个纯函数
3.1 主题归一化
任务 2.1 要求把 Re: / Fwd: 前缀剥离、空白折叠、大小写无关地归一化成纯函数。该函数的调用点随后随分组功能一起消失(0016 迁移),但「归一化」这一思路在别处仍然存活,例如 internal/application/mailmatch/resolve.go 里的 normalizeForMatch 用于公司名匹配。
3.2 ATS 发件域注册表与查询构造
任务 2.2 落在 internal/application/gmailsync/senders.go,这是整个同步链路最需要细读的文件。
ATSDomains 是硬编码的精选域名列表,覆盖 Greenhouse、Lever、Ashby、Workday、SmartRecruiters、iCIMS、SuccessFactors 等。注释里有一条来自真实收件箱的经验:
// Mail-relay domains that differ from a platform's board domain — applicant
// mail arrives from these, so they must be listed to be synced (observed in a
// real inbox; the board-domain entries above do not cover them).
"workablemail.com", // Workable (board domain: workable.com)
"teamtailor-mail.com", // Teamtailor (board domain: teamtailor.com)
"comeet-notifications.com", // Comeet
...
"m.personio.com", // Personio applicant relay (subdomain: avoid product mail)
"mail.paylocity.com", // Paylocity applicant relay (subdomain: avoid product mail)
JobBoardSenders 是第二类:域名本身混杂了大量非求职邮件,因此精确到地址而非域名。
var JobBoardSenders = []string{
"indeedapply@indeed.com",
"noreply@indeed.com",
"no-reply-message@indeed.com",
"applyonline@dice.com",
}
注释给出实测依据:indeed.com 作为整体域名时 other 分类占比 75%,而 indeedapply@indeed.com 仅 1%(562 封求职邮件)。这也是「不许把 indeed.com 整域放进来」的数据理由。
真正拼装 Gmail 查询串的是 BuildQueryFor:
func BuildQueryFor(selfAddr string, afterUnix int64, extraDomains []string) string {
senders := make([]string, 0, len(ATSDomains)+len(JobBoardSenders)+len(extraDomains))
senders = append(senders, ATSDomains...)
senders = append(senders, JobBoardSenders...)
senders = append(senders, extraDomains...)
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)
}
return q
}
三个细节决定了查询的正确性:
-from:必须放在括号之外。注释强调:放进 OR 括号内,它就从「每条消息都必须满足的条件」退化成「匹配项之一」。after:用 Unix 秒时间戳,Gmail 支持这种写法;0表示省略,即首次全量回填。- 短语列表
recallPhrases是召回导向的。它包含多语言表述(葡萄牙语、俄语、西班牙语),并且注释记录了一次基于实测的扩充:「120 天内,纯短语查询只找到 431 封,而求职形状查询找到 1151 封」,漏掉的都是措辞变体(如received your applicationvsyour application at)。由此得出的通用教训是——新增时应加入某句话的「兄弟表述」,而不是又一句同义改写。
IsATSSender 则用于对单封邮件做归属判断,匹配规则是「主机名等于域名或为其子域名」,因此 hire.lever.co 会命中 lever.co。测试 senders_test.go 专门锁定了这些 relay 域名场景。
四、任务 3:Refresh token 的落库加密
实现位于 internal/platform/tokencrypt,是 AES-256-GCM(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
}
设计要点:
- 每次加密生成新随机 nonce,并把它前置于密文;
Decrypt按同样顺序切分。 - 密文以 base64 存储,便于放进
TEXT列。 New拒绝长度不为 32 的密钥,错误信息会带出实际长度。- 密钥来源是环境变量
GMAIL_TOKEN_KEY,在 internal/platform/config/config.go 中经decodeKey解析:
func decodeKey(s string) []byte {
if s == "" {
return nil
}
b, err := base64.StdEncoding.DecodeString(s)
if err != nil || len(b) != 32 {
return nil
}
return b
}
注意 decodeKey 对「格式错误」和「长度错误」都返回 nil 而不是报错——这是刻意的降级:配置缺失或非法时功能整体关闭,服务照常启动。同样地,cmd/server 的 buildGmail 在 len(cfg.GmailTokenKey) != 32 时返回 (nil, nil);但如果密钥长度合法却无法初始化 cipher,会打印错误并禁用功能,避免「配置看起来存在、实际静默失效」。
生成密钥的命令写在 .env.example 的注释里:
openssl rand -base64 32
五、任务 4:增量 OAuth 连接流程
5.1 为什么是「增量授权」而不是给登录加 scope
设计文档 D1 给出了明确取舍:gmail.readonly 是受限 scope(restricted scope),若挂在登录流程上,每个用户每次登录都会被要求授权读取邮箱。因此 connect 是独立的第二次授权,只在用户主动点击时发起,并带自己的 CSRF state。
对应实现是 internal/application/gmailsync/connector.go:
func (c *Connector) AuthCodeURL(state string) string {
return c.cfg.AuthCodeURL(state,
oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("prompt", "consent"),
oauth2.SetAuthURLParam("include_granted_scopes", "true"),
)
}
三个参数各有用途:access_type=offline 才会返回 refresh token;prompt=consent 强制每次都走同意页(否则二次连接可能拿不到新的 refresh token);include_granted_scopes=true 是增量授权的关键,即「保留用户已有的登录授权,只追加新 scope」。
回调地址由构造时拼出,注册到 Google 客户端必须是精确匹配:
RedirectURL: origin + "/api/v1/me/gmail/callback",
Scopes: []string{GmailReadonlyScope},
其中常量定义在同文件:
const GmailReadonlyScope = "https://www.googleapis.com/auth/gmail.readonly"
5.2 路由注册与鉴权选择
路由在 internal/api/handler/gmail.go 的 register 中完成,且只在功能已配置时才注册 OAuth 路由:
api.Get("/me/gmail", mw.key, h.GmailStatus)
api.Delete("/me/gmail", mw.key, h.GmailDisconnect)
...
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)
}
两个反直觉但重要的选择,注释都做了解释:
connect使用mw.cookie(仅浏览器会话):它要重定向浏览器到 Google,对持有 API key 的客户端毫无意义。callback使用mw.optionalCookie而非mw.cookie:如果用户在同意页停留过久导致会话过期,强制鉴权会把 JSON 401 渲染到地址栏,让用户卡在 Google 回来的页面上;改成可选鉴权后,回调自己负责用重定向表达失败。- 其余读接口使用
mw.key(全功能 API key),这样用户自己的 agent harness 可以驱动收件箱。
gmailReady() 的判定非常直接:
func (h *inboxHandlers) gmailReady() bool {
return h.gmailConnector != nil && h.gmailCipher != nil
}
即「Google 凭证 + token 密钥」两者齐备。
5.3 CSRF state 与共享回调骨架
state cookie 名刻意不与登录流程共用:
const gmailStateCookieName = "hire_gmail_state"
原因是:已登录用户可能在另一个标签页里正在进行登录 OAuth,共用一个 cookie 会让两个流程互相覆盖 state。
四个 Google 回调(Gmail、只读日历、导师日历写、导师忙碌同步)抽出了一个共享函数 runOAuthCallback,把「校验 state → 处理拒绝 → 取 code → 换 token → 加密 → 落库 → 重定向」这条相同的链条收拢在一处。失败一律重定向到 /my/integrations?gmail_error=...,并把真实原因写进服务端日志:
| 重定向参数 | 触发条件 |
|---|---|
gmail_error=auth |
回调时没有可识别的登录用户 |
gmail_error=state |
state cookie 缺失或与查询参数不一致 |
gmail_error=denied |
用户在 Google 同意页点了拒绝(error=access_denied) |
gmail_error=exchange |
缺 code、换取 token 失败、加密失败或落库失败 |
这里有一个容易写错的地方,注释专门点出:用户拒绝授权时,回调带着 error 而没有 code,若把「没有 code」简单当作「无事发生」,就会在用户明确拒绝后告诉他授权成功。
成功路径写两条数据:
if err := h.queries.UpsertGmailConnection(ctx, db.UpsertGmailConnectionParams{
UserID: userID, Email: email, RefreshTokenEnc: enc,
}); err != nil {
return err
}
// Record what this grant covers. cal-sync selects connections by their
// recorded scopes, so a row that never says what it holds is a row no worker can use.
return h.queries.RecordGrantScopes(ctx, db.RecordGrantScopesParams{UserID: userID, Scopes: scopes})
scopes 来自 Google token 响应里的 scope 字段,而不是「我们请求了什么」:
func GrantedScopes(tok *oauth2.Token) []string {
raw, _ := tok.Extra("scope").(string)
return strings.Fields(raw)
}
注释解释了为何不做并集:并集只增不减,用户若在 Google 侧撤销日历权限后只重连邮箱,并集仍会保留日历 scope,随后 cal-sync 拿到 403、把它当成撤销、翻转共享的 status,把刚连好的邮箱一起打断。
5.4 连接状态接口
GmailStatus 返回的字段比任务清单里写的更细,且每个布尔值都带着判定依据:
"connected": conn.Email != "",
"email": conn.Email,
"status": conn.Status,
"available": h.gmailReady(),
"calendar_connected": slices.Contains(conn.Scopes, gmailsync.CalendarScope),
"mentor_calendar_connected": conn.Status == "connected" && slices.Contains(conn.Scopes, gmailsync.CalendarEventsScope),
"mentor_busy_sync_connected": conn.MentorBusySyncOptedIn && conn.Status == "connected" && slices.Contains(conn.Scopes, gmailsync.CalendarScope),
注意 connected 用 Email != "" 判断,而不是「行是否存在」——因为仅连接日历也会插入同一行,只是地址为空。这个细节在 docs/API.md 的 /me/gmail 章节中体现为 available 字段的语义说明。
5.5 断开连接:先撤销,再清除
GmailDisconnect 的顺序是「best-effort 撤销 → 删邮件 → 删连接行」:
// Best-effort revoke with Google before purging our copy.
if err := h.revokeGmailGrant(c.Context(), userID); err != nil {
log.Printf("gmail disconnect: revoke for user %d: %v", userID, err)
}
// Purge only this user's Gmail-sourced mail; a hosted mailbox's mail stays.
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
}
撤销失败只记日志不阻断——本地副本无论如何都要清掉。DeleteEmailsBySource 带上 source='gmail',确保用户托管邮箱里的邮件不受影响。撤销本身复用了 revokeGmailGrant,账号注销路径也调用它,因此「删号」同样会解除 Google 侧授权。
六、任务 5:Gmail API 客户端
6.1 接口先行
任务 5.1 的要求是「先定义接口 + 提供测试用 fake」,落地为 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 都被迫实现无关方法。
6.2 REST 实现与分页
gmailapi.go 直接走 REST,基址:
const gmailBaseURL = "https://gmail.googleapis.com/gmail/v1/users/me"
列表调用按 100 条一页循环,直到没有 nextPageToken:
u := fmt.Sprintf("%s/messages?maxResults=100&q=%s", gmailBaseURL, url.QueryEscape(q))
取全文用 format=full,线程兄弟用 format=minimal。解析是纯函数 parseMessage,它做三件事:
- 从
internalDate(毫秒字符串)得到ReceivedAt; - 用
net/mail.ParseAddress拆From为姓名 + 地址,解析失败时退化为整串当地址; - 递归遍历 MIME 树,取第一个
text/plain与第一个text/html,并用容忍缺失 padding 的 URL-safe base64 解码。
func decodeB64URL(s string) string {
if s == "" {
return ""
}
if pad := len(s) % 4; pad != 0 {
s += strings.Repeat("=", 4-pad)
}
b, err := base64.URLEncoding.DecodeString(s)
if err != nil {
return ""
}
return string(b)
}
此外还多了任务清单未提及的一项:遍历 MIME 树找 text/calendar 部件,读出会议 UID(CalendarUID)。注释说明容错原则——拿不到 UID 的邀请仍然是邀请,不能因此丢邮件。
6.3 Token 铸造被隔离在适配层
每个用户的 HTTP 客户端都由其 refresh token 现铸,绝不复用、绝不落库:
func (c *Connector) HTTPClient(ctx context.Context, refreshToken string) *http.Client {
gctx := safehttp.GuardedOAuth2Context(ctx, gmailHTTPTimeout)
return oauth2.NewClient(gctx, c.cfg.TokenSource(gctx, &oauth2.Token{RefreshToken: refreshToken}))
}
func (c *Connector) ReaderFactory() ReaderFactory {
return func(ctx context.Context, refreshToken string, learned []string) GmailReader {
return NewAPIReader(c.HTTPClient(ctx, refreshToken), learned)
}
}
ReaderFactory 这个函数类型正是 Worker 与真实 Google 之间的唯一缝:cron worker 与「手动同步」按钮共用它,测试则注入 fake。
七、任务 6:同步 Worker
7.1 Worker 结构
worker.go 的核心是一个显式接口 + 一个函数类型:
type Store interface {
ListConnected(ctx context.Context) ([]Connection, error)
RefreshToken(ctx context.Context, userID int64) (encToken string, err error)
UpsertEmail(ctx context.Context, e StoredEmail) error
SetSynced(ctx context.Context, userID, cursorUnix int64) error
SetNeedsReconsent(ctx context.Context, userID int64) error
}
type ReaderFactory func(ctx context.Context, refreshToken string, learned []string) GmailReader
Connection.Cursor 的类型是 int64,注释写明「Unix watermark of the newest synced message」。dbstore.go 负责把 sqlc 生成的方法适配到这个接口。
7.2 一次运行的三类结果
type Stats struct {
Connections int
Synced int
Failed int
Reconsent int
}
注释解释了为什么必须有这个结构:没有它时,「一个人都没同步到」和「全部成功」在返回值上完全一致,RunOnce 都返回 nil,命令都退出 0,监控指标一路显示成功。Reconsent 之所以单列,是因为它是这个 worker 的死信——标记之后没有任何后续运行会重试,只有浏览器重新授权才能清除。
7.3 单用户同步的关键语义
syncUser 的流程:读密文 token → 解密 → 加载自学习域名 → 构造 reader → 列 ID → 逐封取正文并入库 → 展开线程兄弟 → 推进水位。
其中有三段逻辑值得逐字阅读:
(1)失败必须回退水位,而不是「冻结」在某处。
newest := u.Cursor
...
if sawFailure {
newest = u.Cursor
}
if err := w.store.SetSynced(ctx, u.UserID, newest); err != nil { ... }
注释解释了原因:同一批里较新的邮件可能先成功并推进 newest,较旧的兄弟随后失败;若只是「保持不动」,水位已经跳过了失败的那封,它会永久漏同步。
(2)只有真正的撤销才翻转状态。
ids, err := reader.ListATSMessageIDs(ctx, u.Email, u.Cursor)
if err != nil {
if !RevokedGrant(err) {
log.Printf("gmail-sync: user %d: list: %v", u.UserID, err)
return outcomeFailed
}
return w.markRevoked(ctx, u.UserID)
}
RevokedGrant 在 grant.go 中实现,只承认两种证据:API 返回 401/403,或 token 端点返回 invalid_grant。注释明确列出被排除的情形——限流、500、连接重置、进程被杀,以及 invalid_client(那是部署方自己的凭证错了,不能因此断开所有用户)。
(3)跳过用户自己发出的邮件,但要展开线程。
if strings.EqualFold(msg.FromAddr, u.Email) {
return
}
同时收集 threadIDs,再对每个线程调 ListThreadMessageIDs,把「没有 ATS 标记的回复」(HR 个人邮箱、约面试的跟进)也一并拉进来。注释说明了这个取舍:recall 短语同样会命中用户自己的回复,取回来毫无价值,却要付出列表页额度与正文请求的代价。
7.4 命令入口与配置门禁
cmd/gmail-sync/main.go 是一个 run-once-and-exit 的 cron worker:
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
}
未配置时退出 0(不是错误);配置齐全时,用 worker.ExitCode(stats.Failed, stats.Reconsent) 让失败数与需重新授权数都体现在退出码上:
func ExitCode(failed, deadLettered int) int {
if failed > 0 || deadLettered > 0 {
return 1
}
return 0
}
运行结束会输出一行汇总,便于直接 grep 日志:
gmail-sync done: connections=N synced=N failed=N needs_reconsent=N
7.5 自学习域名缓存(超出原清单的增强)
learn.go 解决了硬编码白名单的过拟合问题:与其人工往 ATSDomains 里加每封偶然见到的 ATS 域名,不如让域名凭真实分类结果「挣得」位置。
PromoteThreshold = 3:至少 3 次被高置信度判为求职信,才进入同步查询;大于 1 是为了防止一次误分类把新闻订阅域名拉进来。MaxOtherShare = 1/3:被提升域名的邮件流会进入所有用户的查询,因此other占比超过三分之一就撤下。注释给了实测数据:google.com98%、linkedin.com87% 的other占比,而真实 ATS 低于 20%。freeMailDomains是永不可学习的名单(Gmail、Outlook、Yahoo、Proton 等),因为求职者的同龄人、独立招聘者和本人都在用它们发信。
Worker 通过 WithLearnedDomains(store) 接入读侧,并在加载失败时降级为只用硬编码核心而不是跳过该用户:
learned, err := w.domains.Promoted(ctx)
if err != nil {
log.Printf("gmail-sync: user %d: load learned domains: %v — using core only", u.UserID, err)
learned = nil
}
八、任务 7:Inbox API
8.1 接口一览(当前实现)
| 方法与路径 | 作用 | 鉴权 |
|---|---|---|
GET /me/inbox |
列出本人邮件,支持筛选与分页 | mw.key |
GET /me/emails/:id |
单封全文,并标记已读 | mw.key |
POST /me/inbox/read-all |
按当前筛选批量已读 | mw.key |
POST /me/emails/:id/delete / restore |
软删除 / 恢复 | mw.key |
POST /me/emails |
agent 自行推送邮件(source=external) |
mw.key |
POST /me/emails/:id/triage |
记录 agent 的分类判定 | mw.key |
POST /me/emails/:id/link / unlink / confirm / reject |
邮件 ↔ 申请关联 | mw.key |
任务 7.1–7.3 描述的「按 group key 取一组消息」接口(/me/inbox/groups/:key)在当前实现中已不存在,取而代之的是同一列表接口上的 q 过滤。
8.2 查询与分页约束
GetInbox 的分页上限分两档:
ceiling := maxLimit // 100
if q.WithBody {
ceiling = harnessPageMax // 50
}
limit, offset := pageParamsBounded(c, defaultLimit, ceiling)
常量定义在 handler.go:defaultLimit = 20、maxLimit = 100,harnessPageMax = 50 在 inbox.go。带正文的页被压到 50,是因为一次返回 100 封全文的响应体不可忽略。
?body=1 返回的正文并不是粗暴取 body_text:
if q.WithBody {
// The same body the classification worker reads, so what our LLM judges
// and what a caller's agent judges cannot drift. Many ATS senders mail
// HTML with no text/plain part, and body_text alone is then empty.
m.BodyText = maillink.ReadableBody(r.BodyText, r.BodyHtml)
}
这条注释解释了两个事实:ATS 发件方常常只发 HTML,body_text 会是空串;而分类器和 agent 必须读到同一份正文,否则判断会漂移。
8.3 「标记已读」为什么只在一个接口里
// Marking read is why this path is not part of internal/application/inbox and is not offered to
// the assistant: read_at means "a human saw this", and a reader sweeping a backlog
// through here would zero its owner's unread count. Sweeping goes through GetInbox.
因此 GET /me/emails/:id 会写 read_at(失败只记日志、不阻断阅读),而 GET /me/inbox?body=1 绝不写。agent 批量扫邮件时应当用后者。这条规则在 docs/API.md 中同样被强调。
8.4 调用者隔离
所有查询都带 user_id 条件。集成测试 gmail_integration_test.go 断言了最关键的越权场景:别的用户的消息不出现在列表里,GET /me/emails/{id} 对他人邮件返回 404 而不是 403(不泄露「这封邮件存在」)。同一测试还验证了断开连接后连接行与同步邮件被清空。
九、任务 8:SPA 页面
web/src/routes/my/inbox/+page.svelte 本身很薄,只负责标题与布局,主体是 InboxView.svelte。
未连接时的表现由状态接口的 available 决定(任务 11.2):
{:else if gmail?.available}
即「功能未配置」与「已配置但用户未连接」是两种不同的界面:前者不展示 Connect 按钮,避免点出一个 404。
阅读窗格对发件人 HTML 做了严格隔离(对应任务 8.2):
{#if msg.body_html}
<!-- Untrusted sender HTML isolated in a sandboxed iframe (no scripts/forms/navigation). -->
<iframe
title={s.messageBodyTitle}
sandbox=""
srcdoc={msg.body_html}
class="min-h-0 w-full flex-1 rounded-md border border-border bg-white"
></iframe>
{:else}
<pre ...>{msg.body_text}</pre>
{/if}
sandbox="" 表示全部能力关闭(不允许脚本、表单、导航),这是渲染不可信邮件正文的最低安全要求。没有 HTML 部件时退回纯文本 <pre>,保持换行。
十、任务 9:配置与 Google Cloud 侧准备
10.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 Cloud 启用 Gmail API。
- 在 OAuth 同意屏幕添加
https://www.googleapis.com/auth/gmail.readonly。 - 把
<FRONTEND_ORIGIN>/api/v1/me/gmail/callback注册为精确的 redirect URI。 - 应用未通过验证前,在 Test users 中添加需要试用的账号(上限 100)。
- 生成并填入
GMAIL_TOKEN_KEY(32 字节,base64)。 - 复用已有的
OAUTH_GOOGLE_CLIENT_ID/OAUTH_GOOGLE_CLIENT_SECRET。
10.2 未配置时的行为矩阵
| 缺失项 | 服务端行为 | 前端行为 |
|---|---|---|
Google client 或 GMAIL_TOKEN_KEY 不完整 |
buildGmail 返回 (nil, nil),OAuth 路由不注册 |
状态接口 available=false,不显示 Connect |
| 密钥长度为 32 但初始化失败 | 打印 gmail: token cipher init failed,功能禁用 |
同上 |
GMAIL_TOKEN_KEY 未设置 |
decodeKey 返回 nil,功能关闭 |
同上 |
10.3 迁移应用顺序
因为仓库尚无版本化迁移 runner,0014 的注释给出了明确前提:
-- 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).
后续的 0015(重命名列)与 0016(删除列)同样是部署窗口敏感的迁移,注释写明了应「先部署不再写该列的二进制,再执行 DROP」。
十一、任务 10:验证
任务 10.1 是清单中唯一未勾选的一项,要求跑通三条命令:
go build ./... && go vet ./...
go test ./...
go test -tags=integration ./internal/api/handler/
单元测试集中在 internal/application/gmailsync/,全部使用 fake,不触网:
worker_test.go:fakeStore+fakeReader覆盖正常同步、按 ID 去重、单封失败不中断、撤销标记、水位回退。senders_test.go:锁定 ATS 域名判定,含 relay 域名与子域场景。gmailapi_test.go:parseMessage的 MIME 解析与 base64url 容错。connector_test.go/grant_test.go:授权码换 token 的边界与RevokedGrant的状态码判定。learn_test.go:自学习域名的提升与撤回条件。
集成测试标记为 //go:build integration,需要真实 Postgres:
go test -tags=integration ./internal/api/handler/
gmail_callback_test.go 覆盖了四个 Google 回调的共同防护,其中包括两个易错点:无会话时返回重定向而非 JSON 401;用户拒绝时不能报「已连接」。
任务 10.2(端到端真机验证)已勾选,即用测试用户账号真实连一次 Gmail,确认同步抓到 ATS 邮件、页面按预期展示、正文可读。
十二、任务 11:实测期间追加的 UX
这三项都是「跑起来之后才发现需要」的补丁,很能说明产品化的真实成本。
11.1 分页与总数。收件箱改为 20 条一页(defaultLimit = 20),列表接口通过 listResponseWithHidden 一并返回总数与被隐藏条数,避免再发一次请求。
11.2 available 标志。GmailStatus 里始终返回 available: h.gmailReady(),前端据此隐藏 Connect。没有这个字段时,未配置实例上的按钮会点出 404。
11.3 手动同步按钮。POST /me/gmail/sync 立刻返回,真正的同步在后台 goroutine 里跑:
// Background context: the sync outlives this request.
go worker.SyncUser(context.Background(), gmailsync.Connection{
UserID: conn.UserID, Email: conn.Email, Cursor: conn.SyncCursor,
})
return c.JSON(fiber.Map{"data": fiber.Map{"started": true}})
用 context.Background() 是关键——请求上下文会在响应返回时被取消,全量回填必然被腰斩。前端通过轮询收件箱观察结果,而不是等这个请求返回计数(docs/API.md 也这样建议)。SyncUser 复用了 cron 的同一条 syncUser 路径,只是丢弃了 outcome。
十三、把这条链路串起来
| 阶段 | 关键代码 | 失败时会发生什么 |
|---|---|---|
| 发起授权 | Connector.AuthCodeURL + hire_gmail_state cookie |
state 不匹配 → 重定向 gmail_error=state |
| 回调落库 | runOAuthCallback → UpsertGmailConnection + RecordGrantScopes |
换取/加密/写入失败 → gmail_error=exchange |
| 定时同步 | cmd/gmail-sync → Worker.RunOnce |
单用户失败计入 Failed,撤销计入 Reconsent,退出码非 0 |
| 抓取范围 | BuildQueryFor(ATS 域 + 招聘站点地址 + 自学习域 + 召回短语) |
查询失败若为 401/403 → 标记 needs_reconsent |
| 幂等入库 | UpsertEmail,唯一键 (user_id, source, external_id) |
重跑不产生重复行 |
| 水位推进 | SetGmailSynced,Unix 秒 |
任一封失败则回退到旧水位,下轮重试 |
| 阅读 | GET /me/inbox、GET /me/emails/:id、sandbox="" iframe |
他人邮件 404;HTML 无脚本执行能力 |
| 断开 | revokeGmailGrant + DeleteEmailsBySource('gmail') + DeleteGmailConnection |
撤销失败仅记日志,本地数据仍清空 |
一句话总结这套实现的设计哲学:把「谁有权访问邮箱」和「哪些邮件值得保留」两件事都做窄,再让每一条错误路径都保留可恢复性。 Token 只存密文、只按需现铸;抓取范围由白名单 + 召回短语双重收窄;撤销与限流严格区分,避免一次 Google 抖动把所有用户踢下线;同步失败只回退水位、从不断言成功。
如需继续深入,建议的阅读顺序是:先看 design.md 理解取舍,再读 senders.go 搞清抓取边界,接着读 worker.go 的失败处理,最后用 API 文档 对照接口字段做联调。