mcpField
G
gmail-access

gmail-access

github.com/terrylica生产力1136 次安装今天更新
代码仓库

关于 gmail-access

gmail-access 是通过 Claude Code 以命令行方式读取和搜索 Gmail 的技能,适用于需要在项目上下文中处理邮件的用户。它包含账户核验、OAuth 配置指引及邮件内容归属判断流程。

主要功能

  1. 01列出和搜索 Gmail 邮件
  2. 02执行账户与令牌预检
  3. 03引导设置 Gmail OAuth
  4. 04列出和更新草稿
  5. 05分析回复邮件中的发言归属

适用场景

  1. 01按发件人或查询条件查找邮件
  2. 02确认项目应使用的 Gmail 账户
  3. 03在编辑草稿后重新核对草稿标识
  4. 04总结回复邮件时区分双方发言

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/terrylica/cc-skills --skill gmail-access

安装后在 Claude Code 中输入/gmail-access确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
attribution-parsing.md
env-setup.md
evolution-log.md
gmail-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 是正确答案。**总是做出判断的工具,有时也会自信地判断错误。

适用于任何客户端的四条规则
  1. **逐条消息核实约定。切勿从另一条消息推断。**同一位联系人在六天内用了三种不同的约定:8 月 20 日使用 css 颜色,8 月 21 日使用 >>>PT 标记且完全没有颜色,8 月 25 日使用旧式 <font color>。一条消息可能_写着_“看我用蓝色写的评论”,实际却完全没有颜色——这种情况已经测出过两次。
  2. **检测器结果为空,意味着检测器可能有问题。**这是本文件中代价最高的习惯。颜色跨度为零、标记为零、匹配行数为零——在断定发送方没有这样做之前,先证明你的查询在目标确实存在时能找到它。这与本技能其他位置的 .[0].to 邮箱探测和 .id 草稿字段问题如出一辙。
  3. 读完整个段落。对 22 个撰写片段的审计发现,剩余的遗漏并非由解析造成——每一个都位于已经提取出的片段内,却因只读了第一句话而被漏掉。她的结构一贯如此,而且与摘要截然相反:决定在第一句,条件在第二句,而约束你的正是这个条件。“我们暂时就这么办吧”……并希望他们以后能提高额度。“我准备好退出了”……如果我们能找到本地替代方案,而且还有时间的话。
  4. **宁可说“我无法判断”,也不要自信地归因。**你提出询问,人们会原谅你。但在对方会读到的文件里,把话强加给别人,没人会原谅你。
六天内,同一位联系人发来的三个实测案例
日期她所说的惯例来源实际包含的内容出现的问题
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 ---
![01_image.png](./attachments/01_image.png)
![02_image.png](./attachments/02_image.png)
![03_photo.jpg](./attachments/03_photo.jpg)
重要说明:内嵌图片与文件附件

它们是彼此独立的两个渠道,分别显示和下载:

渠道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 等。应将这些由机器生成的文件名改为描述性名称,以便归档通信记录。

下载后:注释转录协议

当内嵌图片包含手写注释(在截图上叠加的圆圈、箭头、文字)时,请系统地进行两层分析:

  1. 场景描述:截图显示了什么?(例如:“显示职位列表的求职网站主页”)
  2. 注释清单:详尽记录每个非原始标记元素:
    • 手绘形状:圆圈、椭圆、箭头、下划线、叉号——注明其圈定或指向的内容
    • 手写文字:逐字转录并加引号,注明可辨识程度及其在图片中的位置
    • 输入的测试内容:截图中可见的表单字段所输入的文字
    • 高亮或颜色标记:注明颜色及高亮内容

将注释格式化为 Markdown 中每张图片下方的引用说明:

![Scene description — annotation summary](path/to/image.png)

> **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 个线程的客户语料库中验证):

  1. 使用高信号查询限定范围,而非泛泛的关键词。 单独使用关键词("Acme")会返回大量无关的新闻邮件。优先使用:
    • 域名:vendor-domain.com(匹配该组织的发件人、收件人和抄送人)
    • 参与者:from:[email protected] OR to:…
    • 项目代码:发件人使用的任何内部标签(例如 1233V)
  2. 从 search --json(仅含摘要)收集消息 ID,并从结果中筛选出相关消息。
  3. 使用 read --json 循环获取完整正文(每条消息一个文件)。
  4. 在客户端按 threadId 分组——Gmail 的列表/搜索 API 返回的是单条消息,_而非_线程;需要自行重建线程。
  5. 按解析后的 Date 在每个线程内排序,并删除引用的历史内容(去掉以 > 开头的行,以及 On … wrote: 之后的所有内容),以便显示每条消息的新内容。
  6. 对 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.com
  • accounts.google.com
  • oauth2.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 以强制重新进行身份验证。
403OAuth 范围不足(草稿/附件需要 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 仅使用一次)
  1. 首次运行:调用 1Password 获取 client_id/client_secret → 缓存到 <uuid>.app-credentials.json
  2. 首次运行:打开浏览器进行 Google OAuth 授权 → 令牌保存到 <uuid>.json
  3. 之后每次运行:仅读取缓存文件 — 不调用 1Password,也不打开浏览器
  4. 每小时刷新器(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 天计时过期。

  1. 如果一个账号能一直正常使用,而另一个每周都会失效,说明它们使用了不同的 OAuth 应用(检查 accounts.json 中每个 uuid 对应的 vault)。只有失效的那个账号受困于 Testing 状态。
  2. Google Cloud Console → 拥有该 OAuth 客户端的项目(client_id 前缀就是项目编号;重新授权期间,CLI 会在授权同意 URL 中打印完整的 client_id)。
  3. APIs & Services → OAuth consent screen → Publishing status → Publish app → 确认状态为 “In production”。(使用 Gmail scopes 的 External + Production 应用可能会向_新_用户显示“未经验证”警告,但已授权的账号会获得长期有效的刷新令牌;只有面向公众或用户数超过 100 的应用才需要完成 Google 全面验证。)
  4. 发布后再重新授权一次;此后每小时运行的刷新程序会让 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"

参考资料

变更后检查清单

  • 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。
  • 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 项单元测试通过。
  • 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。尚未测试编译后的 gmail CLI 是否会通过 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。
  • 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 — 收件人看到的草稿有硬换行 → 切换为 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;修复前创建的草稿必须重新创建。
  • 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→邮箱探测,以及 zsh while 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 检查通过。

执行后反思

此技能完成后,请在结束任务前进行反思:

  1. 确认位置。——编辑前,找到此 SKILL.md 的规范路径。
  2. 哪里失败了?——修复导致问题的指令。
  3. 哪些做得比预期更好?——将其列为推荐做法。
  4. 哪里偏离了?——修复任何与实际情况不符的脚本、参考资料或依赖项。
  5. 记录下来。——在演进日志中记录触发原因、修复内容和证据。

不要推迟。下一次调用会继承你留下的一切。

评论

支持 Markdown