关于 gmail-access
gmail-access 是通过 Claude Code 以命令行方式读取和搜索 Gmail 的技能,适用于需要在项目上下文中处理邮件的用户。它包含账户核验、OAuth 配置指引及邮件内容归属判断流程。
主要功能
- 01列出和搜索 Gmail 邮件
- 02执行账户与令牌预检
- 03引导设置 Gmail OAuth
- 04列出和更新草稿
- 05分析回复邮件中的发言归属
适用场景
- 01按发件人或查询条件查找邮件
- 02确认项目应使用的 Gmail 账户
- 03在编辑草稿后重新核对草稿标识
- 04总结回复邮件时区分双方发言
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ terrylica/ cc-skills --skill gmail-access安装后在 Claude Code 中输入/gmail-access确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/attribution-parsing.mdenv-setup.mdevolution-log.mdgmail-api-setup.md详细介绍
Gmail 访问
通过 Claude Code 以编程方式读取和搜索 Gmail。
自我演进技能:此技能会在使用过程中不断改进。如果说明有误、参数已变更,或需要采用变通方案,请立即修复此文件,不要拖延。仅在遇到真实且可复现的问题时更新。
强制预检(执行任何 Gmail 操作前)
重要:运行任何 Gmail 命令前,必须完成此预检清单。不得跳过任何步骤。
步骤 1:检查 CLI 二进制文件是否存在
ls -la "$HOME/.claude/plugins/marketplaces/cc-skills/plugins/gmail-commander/scripts/gmail-cli/gmail" 2>/dev/null || echo "BINARY_NOT_FOUND"
如果 BINARY_NOT_FOUND:先构建它:
cd ~/.claude/plugins/marketplaces/cc-skills/plugins/gmail-commander/scripts/gmail-cli && bun install --frozen-lockfile && bun run build
步骤 2:检查 GMAIL_OP_UUID 环境变量
echo "GMAIL_OP_UUID: ${GMAIL_OP_UUID:-NOT_SET}"
如果 NOT_SET:必须运行下方的设置流程。不得继续运行 Gmail 命令。
步骤 2.5:验证账户上下文(重要)
务必确认你访问的是当前项目对应的电子邮件账户。
# Show current project context
echo "=== Gmail Account Context ==="
echo "Working directory: $(pwd)"
echo "GMAIL_OP_UUID: ${GMAIL_OP_UUID}"
# Check where GMAIL_OP_UUID is defined (plain env: shell startup files, this shell, or inline)
echo ""
echo "=== GMAIL_OP_UUID Source ==="
grep -l "GMAIL_OP_UUID" ~/.zshenv ~/.zshrc 2>/dev/null || echo "Not in shell startup files (set in this shell or passed inline)"
# Quick connectivity test — shows the account email from a real email
echo ""
echo "=== Account Verification ==="
$GMAIL_CLI list -n 1 2>&1 | head -5
继续操作前,暂停并向用户确认:
list -n 1的输出会显示账户的收件箱——请确认这与项目预期使用的电子邮件账户一致- 如果显示的是错误账户,请在命令中传入正确的 UUID 重新运行(
GMAIL_OP_UUID=<uuid> $GMAIL_CLI ...);工作目录不会决定选择哪个账户 - 如果不匹配,请告知用户,不要继续操作
多账户消歧(当 GMAIL_OP_UUID 为 NOT_SET,但存在令牌时)。 没有 whoami 子命令;请通过探测将每个缓存的令牌 UUID 映射到其邮箱,然后选择与项目匹配的账户:
🔴 不要使用 list -n 1 --json | jq '.[0].to' 来识别邮箱。 这读取的是最新邮件的收件人,而不是邮箱所有者。当最新项目是已发出的草稿或已发送邮件时,它会报告你写信给的对象。2026-08-18 的实测结果:以 <token-uuid-prefix> 开头的令牌探测结果是外部联系人的地址,而邮箱实际上是 [email protected]——这会在唯一目的就是防止你操作错误账户的步骤中,给出一个看似可信却错误的答案。
请直接询问 Gmail。users/me/profile 是权威信息:
# Which accounts are cached, which mailbox does each ACTUALLY own, and which alias is its DEFAULT?
bun "$HOME/.claude/plugins/marketplaces/cc-skills/plugins/gmail-commander/scripts/gmail-accounts.ts"
# one account, machine-readable:
bun "$HOME/.claude/plugins/marketplaces/cc-skills/plugins/gmail-commander/scripts/gmail-accounts.ts" --account <uuid> --json
它会使用每个缓存的令牌查询 users/me/profile 和 settings/sendAs,且绝不会打印令牌。不要自行读取令牌文件(jq .access_token、cat、python3):gmail-send-guard 会拒绝此操作,因为这些是可访问整个邮箱的 Bearer 凭据。使用任何 --from 之前都需要获取发件人列表:verificationStatus 必须为 accepted;如果标记为 DEFAULT 的别名不是你想要使用的别名,则必须指定 --from,不能省略。若邮件到达 Gmail 时没有 From 行,将使用 DEFAULT 别名发出。
如果探测返回 invalid_grant,表示该账户的刷新令牌已失效(参见“诊断 invalid_grant”)。选择邮箱与项目匹配且可正常使用的 UUID,并在每条命令中以内联方式传入 GMAIL_OP_UUID=<uuid>。子项目通常需要与父项目不同的账户——务必验证,不要假定父项目的 UUID 适用。
步骤 3:验证令牌状态
# Check cached token exists and is not expired
TOKEN_FILE="$HOME/.claude/tools/gmail-tokens/${GMAIL_OP_UUID}.json"
APP_CREDS="$HOME/.claude/tools/gmail-tokens/${GMAIL_OP_UUID}.app-credentials.json"
echo "Token file: $([ -f "$TOKEN_FILE" ] && echo "EXISTS" || echo "MISSING")"
echo "App credentials: $([ -f "$APP_CREDS" ] && echo "CACHED" || echo "MISSING — will need 1Password on first run")"
如果令牌文件缺失:首次运行时会打开浏览器进行 OAuth 授权。这是预期行为。如果应用凭据缺失:将调用一次 1Password,以缓存 client_id/client_secret,之后不会再调用。
设置流程(当 GMAIL_OP_UUID 为 NOT_SET 时)
请按顺序执行以下步骤。在需要做决定时使用 AskUserQuestion。
设置步骤 1:检查 1Password CLI
command -v op && echo "OP_CLI_INSTALLED" || echo "OP_CLI_MISSING"
如果 OP_CLI_MISSING:停止并告知用户:
必须安装 1Password CLI。使用以下命令安装:
brew install 1password-cli
设置步骤 2:在 1Password 中查找 Gmail OAuth 项目
# Try common vaults — "Claude Automation" for service accounts, "Employee" for interactive
for VAULT in "Claude Automation" "Employee" "Personal"; do
ITEMS=$(op item list --vault "$VAULT" --format json 2>/dev/null | jq -r '.[] | select(.title | test("gmail|oauth|google"; "i")) | "\(.id)\t\(.title)"')
[ -n "$ITEMS" ] && echo "=== Vault: $VAULT ===" && echo "$ITEMS"
done
解析输出,并根据结果继续操作:
设置步骤 3:用户选择 OAuth 凭据
如果找到项目,使用 AskUserQuestion 并提供找到的项目:
AskUserQuestion({
questions: [{
question: "Which 1Password item contains your Gmail OAuth credentials?",
header: "Gmail OAuth",
options: [
// POPULATE FROM op item list RESULTS - example:
{ label: "Gmail API - project-f (56peh...)", description: "OAuth client in Employee vault" },
{ label: "Gmail API - personal (abc12...)", description: "Personal OAuth client" },
],
multiSelect: false
}]
})
如果没有找到项目,使用 AskUserQuestion 引导设置:
AskUserQuestion({
questions: [{
question: "No Gmail OAuth credentials found in 1Password. How would you like to proceed?",
header: "Setup",
options: [
{ label: "Create new OAuth credentials (Recommended)", description: "I'll guide you through Google Cloud Console setup" },
{ label: "I have credentials elsewhere", description: "Help me add them to 1Password" },
{ label: "Skip for now", description: "I'll set this up later" }
],
multiSelect: false
}]
})
- 如果选择“创建新的 OAuth 凭据”:阅读并展示 references/gmail-api-setup.md
- 如果选择“我的凭据在其他位置”:引导用户将其添加到 1Password,并填写必填字段
- 如果选择“暂时跳过”:告知用户配置完成前,此技能无法使用
设置步骤 4:确认 UUID 的使用范围
用户选择一个项目(包含 UUID)后,使用 AskUserQuestion:
AskUserQuestion({
questions: [{
question: "Where should GMAIL_OP_UUID be set?",
header: "Configure",
options: [
{ label: "This session only (Recommended)", description: "export GMAIL_OP_UUID in the current shell; nothing written to disk" },
{ label: "Per command", description: "Pass GMAIL_OP_UUID=<uuid> inline on every gmail call; safest when projects use different mailboxes" }
],
multiSelect: false
}]
})
如果选择“仅限本次会话”:运行 export GMAIL_OP_UUID=<selected-uuid>。
如果选择“每条命令”:在每次调用前添加前缀,例如 GMAIL_OP_UUID=<selected-uuid> $GMAIL_CLI list -n 1。
切勿将其写入本机后台机器人或摘要任务使用的环境文件:这些任务运行在拥有独立密钥存储的私有部署中,取代它们的笔记本 launchd 作业已于 2026-09-24 停用。
设置步骤 5:验证
echo "GMAIL_OP_UUID: ${GMAIL_OP_UUID:-NOT_SET}"
设置步骤 6:测试连接
GMAIL_OP_UUID="${GMAIL_OP_UUID}" $HOME/.claude/plugins/marketplaces/cc-skills/plugins/gmail-commander/scripts/gmail-cli/gmail list -n 1
如果出现 OAuth 提示:这是首次运行时的预期情况。浏览器将打开,请求 Google 授权。
Gmail 命令(仅在预检通过后使用)
GMAIL_CLI="$HOME/.claude/plugins/marketplaces/cc-skills/plugins/gmail-commander/scripts/gmail-cli/gmail"
# List recent emails
$GMAIL_CLI list -n 10
# Search emails
$GMAIL_CLI search "from:[email protected]" -n 20
# Search with date range
$GMAIL_CLI search "from:alice after:2026/01/27" -n 10
# Read specific email with full body
$GMAIL_CLI read <message_id>
# Read and download inline images (copy-pasted screenshots in compose)
$GMAIL_CLI read <message_id> --save-images
# Download inline images to a specific directory
$GMAIL_CLI read <message_id> --save-images --image-dir ./attachments/my-folder/
# Shorthand: --image-dir implies --save-images
$GMAIL_CLI read <message_id> --image-dir ./attachments/my-folder/
# JSON output with image metadata and saved paths
$GMAIL_CLI read <message_id> --save-images --json
# Download REAL file attachments (PDF, docx, csv, …) — distinct from inline images
$GMAIL_CLI read <message_id> --save-attachments
# Download attachments to a specific directory (implies --save-attachments)
$GMAIL_CLI read <message_id> --attachment-dir ./files/case-17402939/
# Export search results to JSON (full body + inlineImages + attachments metadata per message)
$GMAIL_CLI export -q "label:inbox" -o emails.json -n 100
# JSON output (for parsing)
$GMAIL_CLI list -n 10 --json
# Create a draft email
$GMAIL_CLI draft --to "[email protected]" --subject "Hello" --body "Message body"
# Create a draft reply (threads into existing conversation)
$GMAIL_CLI draft --to "[email protected]" --subject "Re: Hello" --body "Reply text" --reply-to <message_id>
# Draft with body loaded from a file (multi-paragraph bodies are awkward via --body)
$GMAIL_CLI draft --to "[email protected]" --subject "Report" --body-file ./email-body.txt
# Draft with file attachments (--attach is repeatable; MIME type guessed from extension)
$GMAIL_CLI draft --to "[email protected]" --subject "Q1 Report" \
--body-file ./email-body.txt \
--attach ./report.pdf \
--attach ./screenshot.png
# Draft reply with both body-file and multiple attachments
$GMAIL_CLI draft --to "[email protected]" --subject "Re: Project" \
--reply-to <message_id> \
--body-file ./reply.txt \
--attach ./diagram.pdf
# List drafts with their draft IDs
$GMAIL_CLI drafts -n 10
# Replace a draft in place (delete + recreate — the draft ID CHANGES)
$GMAIL_CLI draft-update <draft_id> --to "[email protected]" --from "[email protected]" \
--subject "Same subject" --body-file ./revised-body.txt
drafts --json 字段名称
标识符字段是 draftId,而不是 id。.id 选择器会对每一行静默返回 null,而不会报错,因此基于它构建的 jq 管道看起来像是正常运行了,实际却什么也没提供:
# WRONG — .id does not exist; prints "null" per draft and fails silently
$GMAIL_CLI drafts -n 10 --json | jq -r '.[] | "\(.id)\t\(.subject)"'
# RIGHT
$GMAIL_CLI drafts -n 10 --json | jq -r '.[] | "\(.draftId)\t\(.subject)"'
完整的行结构:date、draftId、from、messageId、snippet、subject、threadId、to。请注意这两个不同的标识符:draft-update 和 draft-delete 接受 draftId;read 接受 messageId。混用会失败或返回错误的记录。
draft-update 会返回一个新的 draftId(它会删除并重新创建草稿)。更新后,你之前记下的任何 ID 都会失效;第二次更新前请重新列出草稿,切勿在编辑期间缓存草稿 ID。
🔴 归属判定准则——在你写下“他们说了 X”之前,先执行此流程
读懂邮件和确认每句话是谁写的,是两个不同的问题。 后者更难,答案默认不包含在你收到的文本中;而且出错时不会抛出错误——只会生成一段流畅、自信的摘要,把你自己的句子归到通信对象名下,或者悄无声息地漏掉对方写的一半内容。本节记录的每次故障都是这种情况:查询错误返回空结果,却被当作世界一片空白。
默认适用此规则。 如果邮件是回复——也就是包含引用的历史内容——而你打算引用、总结、归属、判断或根据发件人所说的内容采取行动,就必须执行以下流程。列出、搜索、分流或计数时不适用。
模型:作者身份 ⊃ 颜色 ⊃ 标记
有三种信号,层层嵌套,可信度依次递减。2026-08-25 事件的两类问题,都是因为将错误的信号当作首要依据而发生的。
| 信号 | 覆盖范围 | 所在位置 | 可信度 |
|---|---|---|---|
| 引用深度 | 完整——捕捉正文内容和行内插入内容 | text/plain:未以 > 开头的行 | 首要依据。但请注意例外情况——某些客户端会说谎 |
| 颜色 | 真子集——仅捕捉引用区域内的插入内容 | 仅限 text/html | 用于区分引用深度不可靠的确切位置 |
手动输入的标记(>>>PT、@@@、[JS]) | 颜色的真子集——标出区块的起始位置 | 任一部分 | 最弱。单独使用永远不够 |
协议
bun scripts/attribution-parse.ts --id <messageId> --token-cmd '<cmd printing an access token>'
它会打印出实际存在的信号、它将这些信号归因于谁以及原因,还有——最重要的是——它无法归因的内容。当信号相互矛盾时,它会报告 CONFLICT;当某一行处于引用层级,却既没有颜色也没有标记时,它会报告 UNKNOWN,因为这种情况下,这一行确实可能属于任何一方。**UNKNOWN 是正确答案。**总是做出判断的工具,有时也会自信地判断错误。
适用于任何客户端的四条规则
- **逐条消息核实约定。切勿从另一条消息推断。**同一位联系人在六天内用了三种不同的约定:8 月 20 日使用 css 颜色,8 月 21 日使用
>>>PT标记且完全没有颜色,8 月 25 日使用旧式<font color>。一条消息可能_写着_“看我用蓝色写的评论”,实际却完全没有颜色——这种情况已经测出过两次。 - **检测器结果为空,意味着检测器可能有问题。**这是本文件中代价最高的习惯。颜色跨度为零、标记为零、匹配行数为零——在断定发送方没有这样做之前,先证明你的查询在目标确实存在时能找到它。这与本技能其他位置的
.[0].to邮箱探测和.id草稿字段问题如出一辙。 - 读完整个段落。对 22 个撰写片段的审计发现,剩余的遗漏并非由解析造成——每一个都位于已经提取出的片段内,却因只读了第一句话而被漏掉。她的结构一贯如此,而且与摘要截然相反:决定在第一句,条件在第二句,而约束你的正是这个条件。“我们暂时就这么办吧”……并希望他们以后能提高额度。“我准备好退出了”……如果我们能找到本地替代方案,而且还有时间的话。
- **宁可说“我无法判断”,也不要自信地归因。**你提出询问,人们会原谅你。但在对方会读到的文件里,把话强加给别人,没人会原谅你。
六天内,同一位联系人发来的三个实测案例
| 日期 | 她所说的惯例 | 来源实际包含的内容 | 出现的问题 |
|---|---|---|---|
| 8 月 20 日 | “下方的评论用蓝色标注” | 只有 一个 css rgb(0,0,255) span;其余部分用 >>>> / >>> 标记 | 颜色几乎毫无用处;其中一条评论是截断的片段(>>> 应该是),需要追问才能弄清楚 |
| 8 月 21 日 | “请看下方的 >>>PT” | 完全没有颜色。 只有标记 | 优先识别颜色的解析器会什么也找不到,并报告她没有写任何内容 |
| 8 月 25 日 | “请看下方的蓝色部分 >>>PT” | 25 个旧式 <font color="#0000ff"> 标签,0 个 css color:。 22 段作者内容,13 个标记 | 仅检测 css 的检测器返回 0,并被理解为“没有使用颜色”。随后过度纠正,认为“颜色就是完整信号”,就会丢弃她的顶部内容;那部分是黑色文字,却包含邮件中唯一且最重要的决定 |
第三行的教训最值得牢记。 找到 bug 不等于修正模型。第一次解读相信标记,漏掉了 22 段中的 9 段;修正后的解读相信颜色,则会漏掉迁移日期。只有嵌套模型能同时应对这两种情况。
危险信号——停下来手动核实
- 发件人描述的惯例无法被解析器识别(说是“蓝色”,却检测不到颜色)。
- 彩色文字段数与标记数不一致——差异部分就是未标记的续写文字,而条件往往就在续写内容里。
- 颜色出现在
text/html中,但对应文字不在text/plain中,反之亦然。解析两部分并取并集;绝不要只选其一。 客户端会分别生成它们,因此一部分中的偏移量无法对应到另一部分。 - 双人对话中出现两个以上表面上的作者——通常是同一作者使用了不同的颜色写法(
blue/#00F/rgb(0,0,255)),因此在进行任何相等性测试前,必须先将颜色规范化为标准三元组。 - 出现任何 CJK 全角标点(
>>、:、【PT】)。^>无法匹配>,因此最后回复者引用的对话会被解析为 100% 的新文本。
在归档之前,而不是之后
颜色只存在于 text/html 中。如果归档只保存 text/plain,之后就无法回答“谁说了什么”——证据根本不在其中,你只好再次调用 API,去回答本应由自己的记录解决的问题。两部分都要保存。read --json 只返回纯文本;要获取 html,请使用 format=full:
curl -s --noproxy '*' -H "Authorization: Bearer $TOK" \
"https://gmail.googleapis.com/gmail/v1/users/me/messages/<id>?format=full" \
| jq -r '[ .. | objects | select(.mimeType? == "text/html") | .body.data ]
| map(select(. != null)) | .[0] // empty' \
| tr '_-' '/+' | base64 -d > msg.html
发件人以内嵌方式回复时,绝不要删除引用历史——他们的回答就在引用内容_里面_,删除引用内容会删掉回复,却保留签名。
完整目录
每条规则之前都必须执行的规范化处理(quoted-printable、format=flowed、CRLF、颜色规范化、实体解码、NFKC、Word 条件注释)、各客户端的先验信息、类型化标记正则表达式,以及伪装成作者身份信号的客户端产物:references/attribution-parsing.md。
内嵌图片提取
电子邮件中常包含复制粘贴的屏幕截图(嵌入 HTML 正文中的内嵌图片,而非文件附件)。这些图片在纯文本中显示为 [image: image.png] 占位符,但通过 Gmail API 可以访问其真实图片数据。
主要行为
| 标志 | 效果 |
|---|---|
--save-images | 将所有内嵌图片下载到磁盘(默认目录:~/.claude/tools/gmail-images/<message_id>/) |
--image-dir <path> | 自定义输出目录(同时启用 --save-images) |
| 无标志 | 显示图片元数据(数量、文件名、大小),但不会下载 |
图片存在时的输出区块
--- Inline Images (3) ---
image.png image/png 245.3 KB
image.png image/png 512.1 KB
photo.jpg image/jpeg 89.7 KB
--- Saved to Disk ---
./attachments/01_image.png (251,234 B)
./attachments/02_image.png (524,001 B)
./attachments/03_photo.jpg (91,852 B)
--- Markdown References ---



重要说明:内嵌图片与文件附件
它们是彼此独立的两个渠道,分别显示和下载:
| 渠道 | MIME 部分 | 元数据字段 | 下载标志 |
|---|---|---|---|
| 内嵌图片 | 带有 attachmentId 的 image/*(复制粘贴的屏幕截图) | inlineImages[] | --save-images / --image-dir |
| 文件附件 | 带有 filename 和 attachmentId 的非图片部分(PDF、docx、csv 等) | attachments[] | --save-attachments / --attachment-dir |
普通的 gmail read <id>(不带标志)会将两者都显示为元数据区块(文件名、MIME 类型、大小),但不会下载,因此你可以在下载前确认 PDF 是否存在。export 和 read --json 的 JSON 中都包含 attachments[](以及 inlineImages[])。
has:attachment 可以匹配真实文件附件,但无法匹配内嵌图片。 Gmail 搜索没有用于搜索内嵌图片的运算符。要查找包含内嵌图片的电子邮件,必须读取邮件并检查 MIME 树。
查找包含内嵌图片的电子邮件的策略:
# Search by sender/date, then read each to check for images
$GMAIL_CLI search "from:[email protected] after:2026/02/01" -n 10 --json | \
jq -r '.[].id' | while read id; do
COUNT=$($GMAIL_CLI read "$id" --json | jq '.inlineImages | length')
[ "$COUNT" -gt 0 ] && echo "$id has $COUNT inline image(s)"
done
Gmail 会话与图片去重
从会话(包含多封回复邮件)下载图片时,后续回复会包含此前所有内嵌图片。会话中的最后一封邮件通常包含最完整的图片集。
建议:对于会话邮件,只下载最新回复中的图片,以避免重复。如无法确定,可按文件大小进行比较。
文件名冲突处理
复制粘贴的屏幕截图通常都使用通用文件名 image.png。CLI 会在文件名前加上补零索引:01_image.png、02_image.png 等。应将这些由机器生成的文件名改为描述性名称,以便归档通信记录。
下载后:注释转录协议
当内嵌图片包含手写注释(在截图上叠加的圆圈、箭头、文字)时,请系统地进行两层分析:
- 场景描述:截图显示了什么?(例如:“显示职位列表的求职网站主页”)
- 注释清单:详尽记录每个非原始标记元素:
- 手绘形状:圆圈、椭圆、箭头、下划线、叉号——注明其圈定或指向的内容
- 手写文字:逐字转录并加引号,注明可辨识程度及其在图片中的位置
- 输入的测试内容:截图中可见的表单字段所输入的文字
- 高亮或颜色标记:注明颜色及高亮内容
将注释格式化为 Markdown 中每张图片下方的引用说明:

> **Annotation transcription**: [Detailed description of visual markup.]
> Handwritten text reads: _"exact transcription here"_
> [Interpretation of what the annotator is requesting.]
**不要将注释转录留到第二轮处理。**首次检查图片时就记录所有注释,避免重复读取。
文件附件提取
实际文件附件(PDF、docx、csv 等)会显示在 attachments[] 中,并通过 --save-attachments / --attachment-dir 下载。获取路径相同,元数据字段不同。
# See what a message carries (no download) — both metadata blocks print
$GMAIL_CLI read <id> # → "--- Attachments (1) --- foo.pdf application/pdf 192.5 KB"
# Download to a chosen dir; files are index-prefixed + sanitized
$GMAIL_CLI read <id> --attachment-dir ./files/
# Discover which messages in a corpus actually carry attachments
$GMAIL_CLI search "from:[email protected] has:attachment" -n 20 --json | \
jq -r '.[].id' | while IFS= read -r id; do
N=$($GMAIL_CLI read "$id" --json | jq '.attachments | length')
[ "$N" -gt 0 ] && echo "$id → $N attachment(s)"
done
这对存档很重要的原因:在客户、法律或运营邮件中,附件 PDF(协议、供应商表格、已签署的同意书)往往是最重要的内容。仅导出正文会悄然丢失附件。归档邮件往来时,务必检查 attachments[]。
批量检索与邮件线程归档
归档整段邮件往来的标准做法(已在包含 27 条消息、15 个线程的客户语料库中验证):
- 使用高信号查询限定范围,而非泛泛的关键词。 单独使用关键词(
"Acme")会返回大量无关的新闻邮件。优先使用:- 域名:
vendor-domain.com(匹配该组织的发件人、收件人和抄送人) - 参与者:
from:[email protected] OR to:… - 项目代码:发件人使用的任何内部标签(例如
1233V)
- 域名:
- 从
search --json(仅含摘要)收集消息 ID,并从结果中筛选出相关消息。 - 使用
read --json循环获取完整正文(每条消息一个文件)。 - 在客户端按
threadId分组——Gmail 的列表/搜索 API 返回的是单条消息,_而非_线程;需要自行重建线程。 - 按解析后的
Date在每个线程内排序,并删除引用的历史内容(去掉以>开头的行,以及On … wrote:之后的所有内容),以便显示每条消息的新内容。 - 对
attachments[]非空的消息提取附件。
# Robust batch fetch. NOTE: in zsh `for id in $VAR` does NOT word-split —
# always loop with `while IFS= read -r` over newline-delimited IDs.
printf '%s\n' $IDS | while IFS= read -r id; do
[ -n "$id" ] && $GMAIL_CLI read "$id" --json > "raw/$id.json"
done
export 是一键快捷方式(已修复)
gmail export -q "<query>" -o out.json -n N 会写入一个 JSON 数组,其中每条消息都包含完整正文、inlineImages[] 和 attachments[]——当单个查询涵盖所需消息时,可用于批量获取。(历史说明:修复前,export 会打印 "Exported N emails to <path>",但不会写入文件——outputPath 参数未被使用。如果遇到这种情况,说明二进制文件版本过旧;请重新构建。)export 不会下载附件内容——它只包含元数据;要获取文件,请对每条消息使用 read --save-attachments。
创建电子邮件草稿
draft 命令会在 Gmail 的“草稿”文件夹中创建电子邮件,供发送前审核。
必需选项:
--to- 收件人电子邮件地址--subject- 电子邮件主题--body或--body-file- 电子邮件正文文本(两者选其一)
可选项:
--body-file- 从文件读取正文,而不是使用--body。适用于多段正文,因为这类内容在命令行中传入不太方便。不能与--body同时使用;如果两者都传入,则以--body为准,并向 stderr 输出警告。--attach- 要附加的文件路径。多个附件时可重复使用。根据扩展名推测 MIME 类型(PDF、PNG、JPEG、DOCX、XLSX、ZIP、MD、JSON 等 → 映射;未知类型 →application/octet-stream)。邮件总大小 ≤ 25 MB(Gmail 限制;超过此限制时,CLI 会返回 413,并提供有用的提示)。--from- 发件人电子邮件别名(回复时会自动检测,见下文“发件人匹配”)--reply-to- 要回复的邮件 ID(创建带有正确标头的线程回复)--json- 以 JSON 格式输出草稿详情
发件人必须匹配(不可妥协)
用户在 Gmail 中配置了多个“以此身份发送”别名。必须正确匹配发件人地址,否则收件人会看到来自错误身份的回复。
规则 1 - 回复(已设置 --reply-to):无论如何都要传入 --from。
CLI 会尝试读取原始邮件的 To/Cc/Delivered-To 标头,并与用户的“以此身份发送”别名匹配,以自动检测发件人,然后打印选择结果:
From: [email protected] (auto-detected from original email)
不要相信这一行所显示的身份就是你想要的身份。 即使原始邮件发往某个别名,自动检测也会解析为_底层账户_,而不是该别名。实测于 2026-08-25:一封发往 Jane Doe <[email protected]> 的邮件,生成的发件人是 [email protected],没有任何提示,而 [email protected] 正是该账户中状态为 accepted 的“以此身份发送”别名。草稿看起来创建成功了。
这一点很重要,因为别名本身就是身份,而不是装饰性标签。如果通信政策规定应以哪个姓名署名,那么账户和别名代表的是不同的签署人;选错身份,就会让整封邮件以错误的身份发出。还要注意,此账户的_默认_别名是 [email protected],而政策禁止将其用于非工作邮件——因此,省略或误检测 --from,可能会使用一个不仅错误、而且明令禁止的身份。
因此:新邮件和回复都必须显式传入 --from,并在信任确认信息前仔细核对。 如果草稿已在未指定 --from 的情况下创建,可使用 draft-update <draftId> --from … 重新正确创建;它不会原地修改草稿,因此还必须重新提供所有其他标志。
没有 --cc 标志。 因此,回复草稿只会包含 To,即使所回复的线程抄送了其他人也是如此。线程关联会保留(会设置 In-Reply-To/References),但抄送对象不会保留。在一个抄送了客户和多家供应商的线程中,代表客户发送回复时,所有这些对象都会在不提示的情况下被移除。发送前,请在 Gmail 撰写窗口中恢复抄送对象;或者在交接说明中明确指出需要恢复抄送名单——绝不能默认草稿会以“回复全部”的方式发出。
规则 2 - 新邮件(未设置 --reply-to): 起草全新邮件(不是回复)时,必须先使用 AskUserQuestion 确认要使用哪个发件人别名,然后才能创建草稿。绝不要默认使用默认别名。
AskUserQuestion({
questions: [{
question: "Which email address should this be sent from?",
header: "Send As",
options: [
// Populate from known aliases or let user specify
{ label: "[email protected]", description: "Personal Gmail" },
{ label: "[email protected]", description: "Work email" },
],
multiSelect: false
}]
})
然后通过 --from 传入选定的地址:
$GMAIL_CLI draft --to "[email protected]" --from "[email protected]" --subject "Hello" --body "Message"
规则 3 - 始终核对输出: 创建草稿后,确认输出中显示了发件人地址。如果缺失或错误,请删除草稿并重新创建。
必须执行的草稿后续步骤(不可妥协)
每次创建草稿后,你都必须向用户提供可直接打开 Gmail 的链接,以便查看草稿。这一点至关重要,因为发送前应始终直观确认草稿内容。
创建草稿后,始终输出以下内容:
Draft created! Review it here:
https://mail.google.com/mail/u/0/#drafts
From: <sender_address>
绝不能跳过此步骤。 用户必须能够点击链接进入 Gmail,并在发送前直观核实草稿内容、发件人、收件人和邮件会话。
示例:回复邮件(自动检测发件人)
# 1. Find the message to reply to
$GMAIL_CLI search "from:[email protected] subject:meeting" -n 5 --json
# 2. Create draft reply - From is auto-detected from original email's To header
$GMAIL_CLI draft \
--to "[email protected]" \
--subject "Re: Meeting tomorrow" \
--body "Thanks for the update. I'll be there at 2pm." \
--reply-to "19c1e6a97124aed8"
# 3. ALWAYS present the review link + From address to user
示例:新邮件(必须询问用户发件人)
# 1. Ask user which alias to send from (AskUserQuestion)
# 2. Create draft with explicit --from
$GMAIL_CLI draft \
--to "[email protected]" \
--from "[email protected]" \
--subject "Hello" \
--body "Message body"
# 3. ALWAYS present the review link + From address to user
注意: 创建草稿后,如果用户之前只有读取权限,则需要重新进行身份验证。CLI 会提示用户授予 OAuth 同意,以添加 gmail.compose 范围。
Gmail 搜索语法
| 查询 | 描述 |
|---|---|
from:[email protected] | 来自特定发件人 |
to:[email protected] | 发给特定收件人 |
subject:keyword | 主题包含关键词 |
after:2026/01/01 | 指定日期之后 |
before:2026/02/01 | 指定日期之前 |
label:inbox | 带有标签 |
is:unread | 未读邮件 |
has:attachment | 有文件附件(不匹配内嵌图片 — 参见“内嵌图片提取”) |
参考:https://support.google.com/mail/answer/7190
环境变量
| 变量 | 必需 | 描述 |
|---|---|---|
GMAIL_OP_UUID | 是 | 用于 OAuth 凭据的 1Password 项目 UUID |
GMAIL_OP_VAULT | 否 | 1Password 保管库(默认值:Employee) |
HTTPS_PROXY | 否 | 由底层 gaxios 库使用,但 CLI 启动时会自动将 *.googleapis.com 注入 NO_PROXY,以免企业代理干扰 Gmail 流量。请参见下文“代理自动绕过”。 |
Google API 主机的代理自动绕过
如果环境中设置了 HTTPS_PROXY(或 HTTP_PROXY),CLI 会在模块加载时自动将以下主机注入 NO_PROXY — 在进行任何身份验证或 API 调用之前:
.googleapis.com(涵盖gmail.googleapis.com、oauth2.googleapis.com等).google.comaccounts.google.comoauth2.googleapis.com
原因:许多企业网络(Cloudflare WARP、mitmproxy 本地拦截器、类似 ITP 的本地代理)无法通过 CONNECT 隧道连接到 Google API 主机。代理故障时,响应会返回 HTTP 502,且错误正文为空 — googleapis 库会抛出 .message 为空的 gaxios 错误,而之前这会在 stderr 中显示为毫无用处的空 Error:。
强制绕过 Google 主机的代理后,使用企业代理的终端用户无需手动设置 NO_PROXY 或针对每条命令取消设置 HTTPS_PROXY,即可运行 CLI。
幂等:注入操作只会添加 NO_PROXY 中尚不存在的条目。如果你已手动配置 NO_PROXY=.googleapis.com,CLI 会保持原样。
诊断其他代理问题(少见):如果仍看到 HTTP 5xx 错误,CLI 的新版错误格式化器会打印完整 URL、响应正文和提示。请检查是否在 CLI 启动前设置了 HTTPS_PROXY(环境变量只会在模块加载时检测一次)。
错误消息
CLI 的顶层错误处理器会将未知错误格式化为结构化消息,其中包含 HTTP 状态、请求 URL、响应正文片段和特定类别的提示。以下是针对无效草稿 ID 返回 404 的示例:
Error: HTTP 404 Not Found DELETE https://gmail.googleapis.com/gmail/v1/users/me/drafts/r-doesnotexist123
body: {"error":{"code":404,"message":"Requested entity was not found.",...}}
hint: message / draft ID not found. List existing drafts with `gmail drafts` first.
提示类别:
| 状态 | 提示 |
|---|---|
| 401 | 令牌已过期或被撤销。删除 ~/.claude/tools/gmail-tokens/$GMAIL_OP_UUID.json 以强制重新进行身份验证。 |
| 403 | OAuth 范围不足(草稿/附件需要 gmail.compose),或未配置“以此身份发送”别名。 |
| 404 | 未找到邮件/草稿 ID。请先使用 gmail drafts 列出现有草稿。 |
| 413 | 附件超出 Gmail 每封邮件 25 MB 的限制。请拆分附件或使用 Drive 链接。 |
| 502/503/504 | 网关错误 — 通常是代理无法通过隧道连接到 Google。自动绕过机制应可避免此问题;请检查是否在 CLI 启动前设置了 HTTPS_PROXY。 |
网络层错误(ECONNREFUSED、ENOTFOUND 等)会显示其错误代码,不再像之前的实现那样显示空的 Error:。
令牌架构
存储布局
~/.claude/tools/gmail-tokens/
├── <uuid>.json # OAuth token (access + refresh), refreshed hourly
└── <uuid>.app-credentials.json # client_id + client_secret (static, cached from 1Password)
- 集中存储(不在插件中,也不在项目中)
- 按 1Password UUID 组织(支持多个账户)
- 创建时使用 chmod 600
身份验证流程(1Password 仅使用一次)
- 首次运行:调用 1Password 获取
client_id/client_secret→ 缓存到<uuid>.app-credentials.json - 首次运行:打开浏览器进行 Google OAuth 授权 → 令牌保存到
<uuid>.json - 之后每次运行:仅读取缓存文件 — 不调用 1Password,也不打开浏览器
- 每小时刷新器(launchd):通过使用缓存的 refresh_token 调用 Google 的令牌端点,保持 access_token 有效
如需强制重新查询 1Password(例如轮换 OAuth 应用凭据后):
rm ~/.claude/tools/gmail-tokens/<uuid>.app-credentials.json
诊断 invalid_grant
Google OAuth 应用的**发布状态为“Testing”时,其刷新令牌会在7 天后过期——就是这么久。**每小时运行的刷新程序会更新 access 令牌,但不会延长 refresh 令牌的 7 天计时,因此处于 Testing 模式的账号大约每周就会失效一次,只能通过浏览器重新授权来恢复。(状态为 “In production” 的应用会签发长期有效的刷新令牌,不受这个计时限制。)
恢复(重新授权):删除过期的令牌文件,然后通过浏览器重新授权:
# 1. Back up and remove the expired token
mv ~/.claude/tools/gmail-tokens/<uuid>.json ~/.claude/tools/gmail-tokens/<uuid>.json.expired
# 2. Run any gmail command — browser will open for OAuth consent
# (sign in with the SPECIFIC account that <uuid> maps to — see accounts.json labels)
$GMAIL_CLI list -n 1
# 3. Verify the hourly refresher picks up the new token
~/.claude/automation/gmail-token-refresher/gmail-oauth-token-refresher 2>&1
# 4. Clean up backup
rm ~/.claude/tools/gmail-tokens/<uuid>.json.expired
长期修复(避免每周失效——“让一切都保持重新授权状态”):将 OAuth 应用发布到 Production,让刷新令牌不再按 7 天计时过期。
- 如果一个账号能一直正常使用,而另一个每周都会失效,说明它们使用了不同的 OAuth 应用(检查
accounts.json中每个 uuid 对应的vault)。只有失效的那个账号受困于 Testing 状态。 - Google Cloud Console → 拥有该 OAuth 客户端的项目(
client_id前缀就是项目编号;重新授权期间,CLI 会在授权同意 URL 中打印完整的client_id)。 - APIs & Services → OAuth consent screen → Publishing status → Publish app → 确认状态为 “In production”。(使用 Gmail scopes 的 External + Production 应用可能会向_新_用户显示“未经验证”警告,但已授权的账号会获得长期有效的刷新令牌;只有面向公众或用户数超过 100 的应用才需要完成 Google 全面验证。)
- 发布后再重新授权一次;此后每小时运行的刷新程序会让 access 令牌始终保持有效,不再需要每周重新授权。
多账号令牌状态
# Check all accounts at once: mailbox, access-token expiry, scopes, send-as aliases
bun "$HOME/.claude/plugins/marketplaces/cc-skills/plugins/gmail-commander/scripts/gmail-accounts.ts"
参考资料
- env-setup.md -
GMAIL_OP_UUID的来源、各使用方的配置方式以及多账号用法 - gmail-api-setup.md - Google Cloud OAuth 设置指南
变更后检查清单
- YAML frontmatter 有效(描述中不含冒号)
- 触发关键词为最新版本
- 路径模式使用 $HOME,而非硬编码路径
- 参考资料均存在且已添加链接
演进日志
-
2026-10-08 — 一封邮件以账号的默认身份直接发出;现在令牌读取统一通过一个脚本进行。
- 触发原因:某个 Agent 编写了一个脚本,读取缓存的令牌文件,并在没有 From 行的情况下向 Gmail 发送端点发出 POST 请求。Gmail 使用了每个账号的默认发件人别名,但两个账号使用的身份都不正确。邮件最终只发到了操作员自己的测试地址。
- 修复:
hooks/gmail-send-guard.ts会拒绝直接发送邮件,以及直接读取~/.claude/tools/gmail-tokens/(参见docs/gmail-send-guard.md)。原本直接从令牌文件读取.access_token和expiry_date的身份探测和多账号状态检查,现已改用scripts/gmail-accounts.ts。该脚本会报告邮箱、有效期和所有发件人别名,并标记默认别名,且绝不会输出令牌。
-
2026-09-26 — 设置流程仍可能配置已弃用的笔记本机器人和摘要任务。
- 触发原因:Telegram 机器人和定时摘要任务已迁移到私有 Restate 部署;它们取代的笔记本 launchd 任务已于 2026-09-24 停用。但设置步骤 4 仍提供“Launchd 守护进程”作为写入
GMAIL_OP_UUID的位置,步骤 2.5 的预检仍会在该守护进程的环境文件中搜索该变量。 - 修复:步骤 4 现在提供“仅此会话”和“每条命令”两种选项,并说明切勿将 UUID 写入这台机器上后台任务的环境文件。预检只会搜索 shell 启动文件。构建步骤会针对新提交的
scripts/gmail-cli/bun.lock运行bun install --frozen-lockfile。
- 触发原因:Telegram 机器人和定时摘要任务已迁移到私有 Restate 部署;它们取代的笔记本 launchd 任务已于 2026-09-24 停用。但设置步骤 4 仍提供“Launchd 守护进程”作为写入
-
2026-09-25 — 邮箱探测需要一个无人定义的辅助脚本,因此无法在全新会话中运行。
- 触发原因:某个代码库的邮件获取器需要
GMAIL_OP_UUID,但没人记录过它对应的是哪个缓存令牌,导致第 2.5 步的探测在${GMAIL_TOKEN_SCRIPT:?…}处停止。没有任何 shell 文件、插件或项目设置这个变量。该获取器归档的邮件线程因此三周未更新,错过了其中一封较新的邮件。 - 修复:如果未设置辅助脚本,探测现在会直接从缓存的
<uuid>.json读取.access_token。每小时刷新程序会保持该值最新,因此无需创建令牌。实测:一次操作将两个缓存令牌解析到各自的邮箱,剩余有效期均约为 59 分钟。 - 给使用者的经验:如果某个代码库依赖特定邮箱,应在该代码库中记录邮箱地址,使用
users/me/profile返回的地址,而不是只记录 UUID。UUID 前缀只是方便识别;个人资料地址才是凭据。
- 触发原因:某个代码库的邮件获取器需要
-
2026-08-25 — 回复自动检测识别的是 ACCOUNT,而非别名,却报告成功。
- 触发原因:代表客户在供应商邮件线程中起草回复。原邮件发给了
Jane Doe <[email protected]>;CLI 打印了From: [email protected] (auto-detected from original email)并创建了草稿。该别名在同一账户中的状态为verificationStatus=accepted,因此检测并未失败——它确实检测到了,只是选择了底层账户。 - 险些被忽略的原因:此文件此前在回复相关说明中写道“无需手动干预”,并告知只有“自动检测失败”时才使用
--from。实际上没有任何失败。成功路径选错了署名人,而打印的确认行又让人误以为结果已验证。 - 这比表头样式问题更重要的原因:如果邮件往来政策规定了应使用哪个名字署名,那么账户和别名就是不同的人。此账户的_默认_别名是工作身份,政策禁止在发给该客户的邮件中使用。因此,省略
--from并非只是选了一个出乎意料的发件人身份,而是可能选到被禁止的身份。下方 2026-08-18 条目已记录了新邮件中的这一风险;回复路径却被当作安全例外,而事实并非如此。 - 修复:规则 1 现在要求回复时也显式传入
--from,并仔细核对确认行,而不是只看它是否打印出来。draft-update <draftId>可以修复现有草稿,但它会删除并重建草稿——必须重新提供所有标志。 - 同一会话中记录的其他事项:没有
--cc标志。回复草稿只包含To,所以回复一个抄送了客户和多家供应商的邮件线程时,所有抄送对象都会被悄悄丢弃。线程标头会保留,因此看起来像是“回复所有人”,但实际上并非如此。发送前应在 Gmail 中恢复这些抄送对象,或在交接说明中明确指出。 - 证据:草稿
r2992600088433000620使用--reply-to且未指定--from创建 →[email protected];随后使用显式--from重建为r6764046697684220651→[email protected],属于同一线程19f8ccd3370cdf57。
- 触发原因:代表客户在供应商邮件线程中起草回复。原邮件发给了
-
2026-08-25 — 我们无法判断哪些行是谁写的,而且问题出在检测器上,不在邮件里。
- 触发原因:一位操作人员询问,客户端邮件中以不同颜色标注的行内回复是否确实已被解析。实际上并没有。扫描 css
color:返回了 0,而这个 0 被解读为“发件人没有使用颜色”。邮件中包含 25 个旧式<font color="#0000ff">标签。这与上面的.[0].to探测和.id字段是同一种自信地得出“没有”的情况:查询有误,空结果却被当成世界一片空白。 - 缺陷 2,修复缺陷 1 时发现:修正也有误。发现颜色后,模型就变成了“颜色就是完整信号”——这会丢弃发件人的开头内容,因为它是黑色的。那封邮件的开头内容包含迁移日期、迁移原因以及请求电话沟通的内容。按既定规则,这封邮件中最重要的内容会被丢弃,而分析过程却悄无声息地违反了自己要保留这些内容的规则。
- 缺陷 3,结构性问题:归档中只存储了
text/plain。颜色是 html 属性,因此,区分发件人文字与我们文字的证据从未存入仓库——要回答这个问题就必须重新调用 API,而归档存在的目的正是消除此依赖。 - 修复方案:在上方新增 ATTRIBUTION DOCTRINE(归属原则)章节(嵌套模型:作者身份 ⊃ 颜色 ⊃ 标记,引用层级优先),新增可复用的
scripts/attribution-parse.ts,在不猜测的情况下报告CONFLICT/UNKNOWN;新增scripts/attribution-parse.test.ts,锁定每种失败模式;并新增references/attribution-parsing.md,其中收录了对颜色、标记、引用结构和客户端特征的调研目录,共 108 种惯例。 - 构建工具时测得,也是最深刻的教训:对全部 22 个撰写片段进行审计后发现,剩余漏检并非由标记与颜色的解析方式导致。每个漏检都位于已提取的片段内,原因是只读第一句话——决定在第一句,条件在第二句,而真正约束你的是条件。
- 同时测得:真实邮件使用 CRLF,而结尾的
\r对正则表达式引擎来说是行终止符,因此(.*)$模式会在每一行都匹配失败,导致整封邮件的文本为空,却仍报告“quote depth: yes”。只有将解析器用于一封真实且答案已知的邮件时,才发现了这个问题。 - 证据:同一位联系人在 6 天内发送的 3 封邮件使用了 3 种不同的惯例——一封使用 css span,接着一封使用无颜色的标记,最后一封使用旧式 font 标签。最后一封中有 22 个带颜色的片段,对应 13 个标记。20/20 项单元测试通过。
- 触发原因:一位操作人员询问,客户端邮件中以不同颜色标注的行内回复是否确实已被解析。实际上并没有。扫描 css
-
2026-08-18 — 账户验证探测报告了错误的邮箱地址,而且
GMAIL_OP_UUID有两种不同含义。- 触发场景:起草必须使用特定发件别名发送的客户邮件。第 2.5 步的目的正是避免你使用错误账户操作,但它给出了一个信心十足的错误答案。
- 缺陷 1:消歧代码片段读取的是
list -n 1 --json | jq '.[0].to'——也就是最新邮件的收件人,而不是邮箱所有者。最新邮件是一封已发出的草稿,因此在邮箱地址为[email protected]时,<token-id>令牌报告了外部通信对象的地址。任何相信这个结果的人都会以为自己是以对方身份通过身份验证的。 - 修复 1:改用
users/me/profile→.emailAddress,这是权威数据;另外还探测了settings/sendAs,以便在选择--from之前确认别名和 DEFAULT 别名。这一点很重要:此账户的默认地址是[email protected],而客户邮件政策禁止使用该地址——因此,省略--from会以禁止使用的身份发送邮件。 - 缺陷 2,尚未修复:
GMAIL_OP_UUID的含义过载。项目环境可能会将它设为 1Password 项目的标题(例如"<account>-gmail"),而gmail-access-token.sh和令牌缓存使用的是项目的 UUID(<token-id>.json)。传入标题会导致no token file。尚未测试编译后的gmailCLI 是否会通过 1Password 解析标题,因为错误猜测会触发新的 OAuth 浏览器授权。修复任一端之前请先验证。 - 证据:
users/me/profile→[email protected];settings/sendAs→[email protected]verificationStatus=accepted、[email protected]isDefault=true。草稿r6501695713107519416使用显式--from创建,读取后确认别名正确,且标头中没有真实姓名令牌。
-
2026-08-18 — 正文防护并未覆盖
scripts/gmail-draft.ts,Markdown 通过这个漏洞进入了一封真实草稿。- 触发场景:一次临时的 Gmail drafts API 写入被
gmail-draft-guard.sh正确拦截,并转而使用scripts/gmail-draft.ts。该脚本接受 Markdown--body文件,且不受pretooluse-gmail-body-guard.ts覆盖,因为后者匹配gmail draft/draft-update。有人通过该脚本撰写了一封客户草稿,内容含 Markdown,并成功通过。 - 症状:存储的
text/plain中有 11 处原样的**bold**,以及 2 个原样的##标题;text/html部分不含<b>/<strong>——CLI 会对正文进行 HTML 转义,而不会渲染 Markdown。收件人会看到原样的星号和井号。只有操作人员在 Gmail 中打开草稿并检查后才发现了这个问题。 - 修复:通过
draft-update改写为纯文本。撰写规则适用于写入草稿的每一种方式,而不仅是受防护的方式:每段占一行且不换行,不使用 Markdown,只有列表项和落款可以换行。 - 待处理:防护逻辑应覆盖
gmail-draft.ts,或者该脚本应自行拒绝 Markdown。在此之前,防护逻辑没有告警并不代表安全——请验证存储的正文:read <id> --json | jq -r '.body' | grep -cE '\*\*|^#{1,6} '应返回 0。 - 证据:草稿
r4023096633097304797(通过gmail-draft.ts写入,含 Markdown)与替代草稿r6980772236558441151(通过draft-update写入,内容干净);同一会话中的提案草稿r6501695713107519416使用纯文本撰写,检测结果为 0/0。
- 触发场景:一次临时的 Gmail drafts API 写入被
-
2026-07-22 — PreToolUse 正文防护现在会强制段落保持单行,并使用纯文本。
- 触发原因:尽管已在 2026-07-10 修复,供应商联络邮件草稿仍是从 按约 100 列硬换行 且包含原始 Markdown 的 Markdown 文件中撰写的。由于
toHtmlBody()会将每个撰写时的换行符转换为<br>,每个硬换行都会变成实际的换行 → 收件人看到的是一列短行,且句子从中间断开;由于 CLI 会对正文进行 HTML 转义而不会渲染 Markdown,**bold**/反引号/[text](url)/标题/表格也会原样显示。 - 修复:PreToolUse 防护(
itp-hooks→pretooluse-gmail-body-guard.ts,检测器的 SSoT 为hooks/lib/gmail-body-detector.ts)会拒绝gmail draft/draft-update命令中内联--body或--body-file内容包含句中硬换行(HARD-WRAP)或高信号原始 Markdown(RAW MARKDOWN)的请求。例外开关:GMAIL-BODY-OK。说明文档:plugins/itp-hooks/docs/gmail-body-guard.md。 - 撰写规则(未变,现已强制执行):每个段落都写成一整行,不要换行——长行会在读者的窗口中自动换行;只将预期的换行(列表项、
Best,/姓名落款)单独放在一行。不要将正文预先按 72/80/100 列换行;并且要发送纯文本——不要粘贴原始 Markdown 并期待它被渲染。
- 触发原因:尽管已在 2026-07-10 修复,供应商联络邮件草稿仍是从 按约 100 列硬换行 且包含原始 Markdown 的 Markdown 文件中撰写的。由于
-
2026-07-10 — 收件人看到的草稿有硬换行 → 切换为 HTML(multipart/alternative)。
- 触发原因:已发送的草稿(某个客户端的供应商邮件往来)在 Gmail 中显示为短行、固定宽度,/看起来像被截断。最初的原因是使用了没有换行指引的
text/plain,导致客户端对长段落进行硬换行。 - 首次尝试(不适用于 Gmail)——
format=flowed:按 RFC 3676 输出format=flowed,并进行 72 列软换行。Gmail 无法可靠地重新排版 format=flowed,而且 72 列换行会在 Gmail 的撰写/草稿视图中原样显示——所以草稿看起来仍有换行,/像是被“截断”了。发往 Gmail 的邮件不要使用 format=flowed。 - 正确修复——HTML
multipart/alternative:buildRawMessage现在会发送包含 text/plain 部分(正文不换行——使用长行,不按 72 列截断)和 text/html 部分(toHtmlBody()+escapeHtml())的multipart/alternative。HTML 会将正文包在<div>中,并且只将作者输入的换行符转换为<br>——连续文本中不会插入换行,因此浏览器会根据读者的窗口宽度重新排版长段落,不会显示固定的短行。列表项和Best,\n姓名落款仍会通过<br>保留换行。有附件时,替代正文会嵌套在multipart/mixed中。 - 证据:重新创建的草稿(msg 19f4e01…)在
read --json中的纯文本部分现在包含一行 329 字符的段落(未换行——证明新路径已生效);HTML 部分会在 Gmail 中重新排版。已重新构建二进制文件(bun run build),tsgo 检查通过。注意:gmail二进制文件是编译生成的——修改 TS 后需要运行bun run build;修复前创建的草稿必须重新创建。
- 触发原因:已发送的草稿(某个客户端的供应商邮件往来)在 Gmail 中显示为短行、固定宽度,/看起来像被截断。最初的原因是使用了没有换行指引的
-
2026-05-31 — 导出静默失败,且无法检索附件(客户端归档任务)。
- 触发原因:归档 27 封供应商往来邮件时,
gmail export -o <path>打印了"Exported N emails to <path>",但没有写入任何内容(exportEmails返回了数组,却从未写入outputPath)。此外,CLI 不会显示文件附件(只有inlineImages),因此 11 封邮件附带的 PDF(供应商认证表、协议)被静默遗漏。 - 修复:(1)
exportEmails现在会调用writeFile写入 JSON。(2) 新增extractAttachments和formatMessage中的attachments[]元数据、gmail-images.ts 中的saveAttachments()、--save-attachments/--attachment-dir标志,以及printEmails中的附件元数据区块。文档说明了内嵌图片与附件的区别、批量邮件线程归档流水线、多账户 UUID→邮箱探测,以及 zshwhile read批处理循环的注意事项。 - 证据:
export -q vendor-domain.com -o /tmp/x.json现在会写入 3 封带完整正文的邮件;read <id> --attachment-dir提取了供应商认证表 PDF(197,168 B,有效 PDF 1.7,3 页)。已重新构建二进制文件,tsc --noEmit检查通过。
- 触发原因:归档 27 封供应商往来邮件时,
执行后反思
此技能完成后,请在结束任务前进行反思:
- 确认位置。——编辑前,找到此 SKILL.md 的规范路径。
- 哪里失败了?——修复导致问题的指令。
- 哪些做得比预期更好?——将其列为推荐做法。
- 哪里偏离了?——修复任何与实际情况不符的脚本、参考资料或依赖项。
- 记录下来。——在演进日志中记录触发原因、修复内容和证据。
不要推迟。下一次调用会继承你留下的一切。