用 Go 与 Gmail API 构建 ATS 求职邮箱:freehire Gmail 收件箱方案剖析

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

用 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/inbox SPA 页面(未连接时显示 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 事故:

  1. 取当前用户,失败则重定向带 ?gmail_error=auth;
  2. 读取并立即清除 state cookie,比对 state 查询参数,不匹配则 ?gmail_error=state;
  3. 先检查 error 查询参数(用户点了“拒绝”),有值则 ?gmail_error=denied——注释特别强调:不能把“缺少 code”简单当作无事发生,否则用户刚拒绝授权就会被提示“已连接”;
  4. 缺失 code 则 ?gmail_error=exchange;
  5. 交换 code,取出 refresh token 与授权 scope;
  6. 用 tokencrypt.Cipher.Encrypt 加密后持久化;
  7. 成功后重定向到 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 判定。它包含两类条目:

  1. 平台主域: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 等;
  2. 与招聘板域不同的邮件中继域: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,其中包含四层保护:

  1. 内存去重:seen[id] 防止同一批与 thread 展开重复处理;
  2. 按 gmail message id 幂等落库:UpsertEmail 对应唯一约束 (user_id, external_id)(原 gmail_msg_id,见后文迁移演进),重同步不产生重复行;
  3. 跳过自己发出的邮件:strings.EqualFold(msg.FromAddr, u.Email) 命中即返回——收件箱只存入站邮件;
  4. 水位线只进不退且失败回滚: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).

上线前的最小清单:

  1. 在 Google Cloud 创建/配置 OAuth 客户端,启用 Gmail API,添加 gmail.readonly scope,并把使用者加为测试用户(上限 100 人);
  2. 生成 32 字节随机密钥并 base64 编码,写入 GMAIL_TOKEN_KEY;
  3. 在目标库手工执行 gmail_connections 与 emails 的建表 DDL;
  4. 部署二进制,确认 /api/v1/me/gmail 返回 available: true;
  5. 配置 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 的关键场景:

端到端验证在 tasks.md 中已勾选:连接一个测试用户 Gmail,同步拉取到 ATS 邮件,/my/inbox 能展示并按可读正文打开。唯一未勾选的是 10.1(go build ./... && go vet ./...、go test ./...、web check 全量通过),说明归档时该验证项未被标记完成,阅读源码时应以实际 CI 结果为准。

十、可复用的工程结论

从这份提案到落地代码,能提炼出五条与项目无关、可迁移到任何“第三方邮箱接入”场景的经验:

  1. 把敏感 scope 从登录中剥离,用增量授权按需索取,并让“登录不请求邮件权限”成为一条可测规格;
  2. 加密只保令牌、不保业务数据是不够的,撤销路径(revoke + purge + 按来源精确删除)才是隐私承诺的实际兑现方式;
  3. 幂等键选对方能救命:以 Gmail message id 唯一约束吸收重试与游标重叠,比精巧的增量算法更耐用;
  4. 把失败分类:区分“用户撤销授权”(需重新授权,停止重试)与“服务端抖动”(可重试),否则一次外部故障会连带废掉共享同一状态位的其他功能;
  5. 未配置要优雅降级,并在 API 上暴露 available 让前端隐藏入口——这比让用户点出一个 404 体面得多。

原始设计文档见 proposal.md、design.md、tasks.md 以及三份能力规格 gmail-connection、gmail-ats-sync、email-inbox。

登录后查看全文
freehire