freehire Gmail ATS 收件箱实现全解:从 OAuth 增量授权到 Gmail 同步 Worker 的任务拆解

原创2026-10-24 23:46:251,816 阅读
文章标签:后端前端搜索引擎网页爬虫数据工程AI 应用可观测性

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 后续迁移对模型的改写(重要)

任务清单写于功能落地时,而仓库后续的演进改变了两个关键点,阅读时必须对齐:

  1. 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 在不同来源下会互相覆盖。

  2. 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 application vs your 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,它做三件事:

  1. 从 internalDate(毫秒字符串)得到 ReceivedAt;
  2. 用 net/mail.ParseAddress 拆 From 为姓名 + 地址,解析失败时退化为整串当地址;
  3. 递归遍历 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.com 98%、linkedin.com 87% 的 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=

归纳成可执行清单:

  1. 在 Google Cloud 启用 Gmail API。
  2. 在 OAuth 同意屏幕添加 https://www.googleapis.com/auth/gmail.readonly。
  3. 把 <FRONTEND_ORIGIN>/api/v1/me/gmail/callback 注册为精确的 redirect URI。
  4. 应用未通过验证前,在 Test users 中添加需要试用的账号(上限 100)。
  5. 生成并填入 GMAIL_TOKEN_KEY(32 字节,base64)。
  6. 复用已有的 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 文档 对照接口字段做联调。

登录后查看全文
freehire