关于 mail-muncher
mail-muncher 将 IMAP 或 Gmail 邮件筛选后归档到磁盘,供自动化程序和 Agent 使用。它支持定时或轮询投递,也可通过 MCP 查询同一归档。
主要功能
- 01只读获取 IMAP 和 Gmail 邮件
- 02有序规则与实时文件筛选
- 03归档为 EML 和 Markdown
- 04增量且去重的投递
- 05支持定时、守护进程和 MCP 模式
适用场景
- 01按发件域名归档求职回复
- 02为客服机器人收集供应商邮件
- 03按会话或日期查询归档邮件
MCP 相关
可作为 stdio MCP 服务运行,提供规则和邮件查看、搜索、会话读取及同步工具。访问范围限制在配置的归档目录;同步只会添加文件。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
docker run -i --rm ghcr.io/craigjmidwinter/mail-muncher:0.4.0本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。
ghcr.io/craigjmidwinter/mail-muncher:0.4.0stdio工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
mail-muncher
为程序提供专属的只读邮箱,精准筛选出它请求的邮件,并将邮件作为文件写入磁盘。
mail-muncher 从邮件服务商获取邮件,按顺序应用规则进行筛选,并将匹配的邮件写入目录——保留字节原样的 .eml 文件,以及可选的 Markdown 格式文件,其中邮件头以 YAML 前置元数据呈现,正文以文本形式呈现,附件则提取到同一目录中。规则可以从一个由其他程序管理的纯文本文件中读取筛选条件,而 mail-muncher 会在每个周期开始时重新读取该文件。该程序只需更改文件中的一行,紧接着的下一个周期就会投递不同的邮件——无需编辑配置、重启或重新部署。
它可以从任何 IMAP 邮箱读取邮件——Gmail、Fastmail、iCloud、Proton Bridge、工作邮箱或你自己的服务端——也可以通过 Gmail API 和只读 OAuth 作用域读取邮件。它既可以单次运行以配合 cron,也可以作为轮询守护进程运行,还可以作为 stdio MCP 服务运行,让 Agent 直接查询。所有运行模式都会生成相同的机器可读操作清单,并且都不会向你的邮箱写入任何内容。
连接邮箱的两种方式
安装任何内容之前,先选一种。两种方式都受支持,后续的一切——规则、格式、文件名、归档布局、MCP 工具——都完全相同。
provider: imap | provider: gmail | |
|---|---|---|
| 设置时间 | 约 2 分钟 | 约 10 分钟,需在 Google Cloud Console 中操作 |
| 注册内容 | 无 | 你自己的 Google Cloud 项目和桌面应用 OAuth 客户端 |
| 凭据 | 从邮箱服务商自己的设置页面获取的应用密码 | OAuth 令牌,作用域为 gmail.readonly |
| 凭据的权限范围 | 完整的邮箱凭据。 应用密码可以发送和删除邮件 | 只读,仅此而已 |
| 谁来强制只读 | mail-muncher 自身的代码 | |
| 有效期 | 无 | 测试模式的 OAuth 授权屏幕下每 7 天过期;必须每周重新运行 mail-muncher auth |
| 密钥存放位置 | 密码管理器原本存放密码的位置:运行 password_cmd,其标准输出即为密码。这里刻意不提供 password 键 | token.json,权限模式为 0600,由 mail-muncher auth 写入 |
| 支持的邮箱 | mailboxes: 中列出的文件夹;默认是 [INBOX] | 整个 Gmail 账号,除非主动启用垃圾邮件和废纸篓,否则不包含这两者 |
| 适用服务 | Gmail、Fastmail、iCloud、Proton Bridge、工作账号、可自托管服务 | 仅 Gmail |
| 额外步骤 | 无。这种方式不需要 auth 命令 | 按照 docs/gmail-setup.md 操作后运行 mail-muncher auth |
上文的约 2 分钟、约 10 分钟和 7 天,与 mail-muncher init 以及未配置时显示的指导信息中的数字相同,因为这些数字正是决定因素。
两种方式都能真正保证只读,但保证的方式并不相同,混为一谈是不诚实的。
- Gmail:由 Google 强制执行。 唯一请求的作用域是
gmail.readonly。返回的令牌无法发送、删除、添加标签或修改邮件——这不是因为 mail-muncher 不愿执行,而是因为 Google 会拒绝调用。这个程序中的漏洞也无法触及你的邮箱。 - IMAP:由 mail-muncher 强制执行。 IMAP 没有可申请的只读凭据。应用密码是完整的邮箱凭据;该协议会允许持有者删除文件夹。mail-muncher 的做法则是拒绝这样做:每个文件夹都使用
EXAMINE打开,从不使用SELECT;每封邮件都使用BODY.PEEK[]获取,从不使用BODY[](因此邮件不会被标记为已读);并且此服务提供方的代码路径中,任何地方都不会发出STORE、APPEND或EXPUNGE。之所以双重防护,是因为服务端没有义务保护客户端免受自身操作的影响。这是强有力且可审计的保证——只是由本程序提供的保证,而非邮箱服务商提供的保证。
如果没有特别需要使用 Gmail API 的理由,请从 IMAP 开始。它也适用于 Gmail 账号,而且快速入门使用的就是这种方式。
问题
自动化流程需要读取一些邮件。求职跟踪器想获取你申请过的公司的回复。客服机器人想获取来自某个供应商域名的邮件。研究 Agent 想把三家出版商的所有新闻邮件都以可实际阅读的文本形式获取。
常见的解决办法都不理想。把邮箱凭据交给流程,它就能读取(也能发送和删除)所有邮件。为它接入邮件 API,你就得在每个需要邮件的流程中维护 OAuth 流程、同步游标、MIME 解析和去重机制。或者把筛选条件硬编码到配置文件中,这样每次更改它要获取的内容,都得修改配置并重新部署。
mail-muncher 将这些工作一分为二。它负责凭据、增量同步、解析和去重。使用它的程序只需维护一个列出所需内容的文本文件,以及一个读取结果的目录——如果更喜欢主动查询而非持续监听,还可以使用一组针对同一目录的 MCP 工具。
Agent 工作流
有两种受支持的模式,并且可以组合使用。根据你的 Agent 是自行循环运行,还是等待被调用来选择。
- 文件投递 — mail-muncher 按计划运行并写入文件;Agent 读取目录。两者之间没有调用关系。下文介绍这种模式。
- 工具调用 — Agent 通过 MCP 与
mail-muncher mcp通信,直接提问:我订阅了什么、收到了什么、这个会话讲了什么、现在获取。参见模式 2:工具调用。
两种模式读取同一份归档,同时运行也很常见:守护进程填充目录,而 MCP 服务回答相关问题。
模式 1:文件投递
这个循环完全解耦:mail-muncher 从不调用 Agent,Agent 也不必调用 mail-muncher。两者共享磁盘上的两个路径。
1. Agent 声明它需要什么。 追加到它拥有的文件中:
mkdir -p ~/.local/share/agent
cat >> ~/.local/share/agent/domains.txt <<'EOF'
# domains this agent is currently interested in
acme.com
globex.io
EOF
2. mail-muncher 订阅该声明。 添加一条指向该文件的规则:
rules:
- name: agent-inbox
match:
from_domains_file: ~/.local/share/agent/domains.txt
dest: ~/mail/agent-inbox
formats: [eml, markdown]
3. 每个周期都会重新读取该文件。 通过 cron 运行,或让守护进程持续运行:
mail-muncher run # one cycle — the cron entrypoint
mail-muncher daemon --interval 5m # poll forever
4. 匹配的邮件会以文件形式写入 dest,供 Agent 读取。
~/mail/agent-inbox/
└── 2026/
└── 07/
├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.eml
├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.md
└── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.attachments/
└── offer.pdf
.md 是可供使用的呈现形式——解析 frontmatter,将正文提供给模型,并从同级目录打开附件:
---
subject: 'Re: Your application for Senior Engineer'
from: Jane Doe <[email protected]>
from_address: [email protected]
from_addresses: [[email protected]]
to: [[email protected]]
to_addresses: [[email protected]]
date: 2026-07-28T09:15:00Z
message_id: <[email protected]>
thread_id: 18fe9c0d1a2b3c4d
thread_id_source: provider
in_reply_to: <[email protected]>
account: personal
rule: job-search
labels: [INBOX]
attachments: [offer.pdf]
---
Hi there,
Thanks for applying.
## Attachments
- [offer.pdf](1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.attachments/offer.pdf)
每条消息都有 thread_id,且它绝不会为空,因此只需按一个字段 sort,就能将目录整理为各个对话——无需重新组装引用链。
5. 也可以选择使用清单,而不是遍历目录树。 --json 会将本周期的机器可读记录写入 stdout,每个账户一个对象;每条日志行则写入 stderr:
mail-muncher run --json 2>/dev/null | jq -r '.stored[].path'
完整约定:参见 docs/manifest.md。
以下三项特性使其适合安全地用于自主循环:
- 从设计上只读。 mail-muncher 中没有任何操作会写入邮箱。对于 Gmail,这是由 Google 强制执行
gmail.readonly范围;对于 IMAP,则使用EXAMINE和BODY.PEEK[],完全没有写入路径。无论哪种情况,使用输出的程序——以及其中可能存在的任何错误——都无法发送、删除或修改邮件。关于你会获得这两种保证中的哪一种,请参见上文的比较。 - 幂等投递。 消息文件名包含
account + ":" + message id的摘要,因此其目标路径完全由消息身份决定。文件已存在表示“较早的周期已存储此文件”,而接收端不会写入任何内容。重新运行、丢失状态后重放、在周期中途崩溃,或两个 cron 调用重叠运行:目录树最终都会收敛,且不会重复处理任何内容。 - 确定性路由。 规则按顺序排列,并采用首条匹配规则,因此每条消息只会由一条规则写入。为每个使用方配置自己的规则和
dest,每个使用方都会拥有一个不会被其他程序写入的独立邮箱。
投递内容是磁盘上的文件,这里没有任何服务监听网络。约定以目录为准,清单则是对变更内容的可选机器可读记录。
形态 2:工具调用
mail-muncher mcp 是一个基于已归档邮件运行的 stdio MCP 服务。Agent 发起请求;不会安排任何任务。
{
"mcpServers": {
"mail-muncher": {
"command": "/usr/local/bin/mail-muncher",
"args": ["mcp", "--config", "/Users/you/.config/mail-muncher/config.yml"]
}
}
}
五个工具:
| 工具 | 解答的问题 |
|---|---|
list_rules | 我在收集什么?目前订阅了哪些发件人?每次调用时都会重新读取各个 from_domains_file。 |
list_messages | 收到了什么?可按规则、账户、线程或日期筛选;也可以选择按对话分组。 |
search_messages | 提及 X 的邮件在哪里?在主题、发件人、收件人、标签、附件名称和正文中进行子串搜索。 |
read_message | 获取一封邮件的完整内容——元数据、正文、附件名称和大小;还可以按顺序获取其整个线程。 |
sync | 获取一次新邮件,并返回与 run --json 写入的相同清单。 |
它对邮件只有只读权限:没有工具会发送、删除或修改任何内容;sync 是唯一会进行任何更改的工具,但它只能添加文件。
文件系统访问仅限于配置的规则 dest 根目录,因此无法访问或获知配置、已存储的凭据和状态目录。
即使未经配置,mcp 服务也会启动,这是有意为之。 如果客户端在尚无配置时启动 mail-muncher mcp,服务不会退出——它会完成握手、注册同样的五个工具名称,并将设置指引作为工具错误返回给每次调用,让 Agent 有内容可以转述,而不是只收到“服务启动失败”。如果你正在为操作人员接入此服务,这是预期行为,不是需要提交的错误。启动时也会将指引写入 stderr,客户端会在此处转存服务日志。
完整参考、客户端接入方式以及每个参数和返回字段: docs/mcp.md。
list_rules 是闭环的关键。Agent 将某个域名写入自己的文件,然后调用 list_rules,就能看到自己的订阅已反映在结果中——下一轮周期也会匹配同一份列表。
替代方案
采用前请先阅读。许多工具都能很好地完成获取、筛选和投递的工作,其中一些比这个工具更适合你的需求。
| 工具 | 适合改用它的情况 |
|---|---|
| getmail6 | 你需要成熟且广泛打包的邮件获取工具。它支持 IMAP 和 Gmail OAuth2,可投递到 Maildir/MDAs,并通过外部程序筛选。如果使用者是人(或 mutt、notmuch),它会是更强的工具。 |
| fdm | 你需要按规则投递到 Maildir 目录,且配置简洁、经过充分测试——这正是本工具的形态,只是没有外部筛选源。Gmail 通过应用专用密码 IMAP 接入。 |
| lieer | 你想将整个 Gmail 邮箱双向同步到本地 Maildir,供 notmuch 使用,而不是从中筛选并提取部分邮件。 |
gmail-archive | 它几乎就是这个工具——通过 Gmail 查询增量写入 Maildir——如果仍在维护,本来会是显而易见的选择。但它自 2018 年以来就没有维护过。 |
gmail-exporter | 你需要一次性的、基于标签、适合电子表格的导出,而不是增量同步。 |
mbsync / offlineimap | 你需要复制整个邮箱,之后再在本地筛选。 |
这些工具都做不到,而本工具正是为此而存在:从其他程序拥有的文件中读取筛选条件,并在每个周期重新读取;输出适合程序消费,而不是供邮件客户端显示的内容。如果你不需要这两项功能,上面某个工具会更适合你,而且经过了更多年的实践检验。
安装
前两种方式都不需要 Go 工具链。
Homebrew
brew install craigjmidwinter/tap/mail-muncher
此操作会添加 craigjmidwinter/homebrew-tap tap,并安装预编译二进制文件。运行 brew upgrade mail-muncher 即可跟进新版本。
下载二进制文件
每个发行版
都提供适用于 macOS 和 Linux、支持 amd64 和 arm64 的压缩包,以及
checksums.txt 和对应的签名。
# Latest release, without the leading v. Set this by hand to pin a version.
VERSION=$(curl -fsSL https://api.github.com/repos/craigjmidwinter/mail-muncher/releases/latest \
| sed -n 's/.*"tag_name": *"v\{0,1\}\([^"]*\)".*/\1/p')
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # darwin | linux
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/mail-muncher_${VERSION}_${OS}_${ARCH}.tar.gz"
tar xzf "mail-muncher_${VERSION}_${OS}_${ARCH}.tar.gz" mail-muncher
sudo install -m 0755 mail-muncher /usr/local/bin/mail-muncher
如果二进制文件随后完全无法运行 —
bash: mail-muncher: cannot execute binary file: Exec format error
— 说明你下载的压缩包架构不匹配。这条消息来自内核,与 mail-muncher 无关,因此值得了解它的样子。将 uname -m 的结果与下载文件名中的 _amd64 / _arm64 进行比较;上面的 ARCH= 行会为你计算正确的架构,因此只有手动设置名称时才会遇到这个问题。
没有 root 权限? /usr/local/bin 需要 root 权限;~/.local/bin 不需要。去掉
sudo,改为安装到那里即可——mail-muncher 并不需要安装在系统级目录中:
install -d ~/.local/bin
install -m 0755 mail-muncher ~/.local/bin/mail-muncher
如果随后提示 mail-muncher“找不到命令”,说明 ~/.local/bin 不在你的
PATH 中;请在 shell 配置文件中添加它。
如果不更改目标目录就跳过 sudo,install 本身会报错 Permission denied——在 macOS 上,报错中提到的是临时文件而不是
mail-muncher,第一次看到时可能会让人困惑:
install: /usr/local/bin/INS@LPh1Hz: Permission denied # macOS
install: cannot create regular file '/usr/local/bin/mail-muncher': Permission denied # GNU
两种提示都表示同一件事:选择上面的 ~/.local/bin 方式,或者加回
sudo。
在 macOS 上,你自行下载的二进制文件会被 Gatekeeper 隔离。运行
xattr -d com.apple.quarantine /usr/local/bin/mail-muncher 清除隔离标记,或者使用上面的
Homebrew 安装方式,它会自动完成这一步。
验证下载的文件
这个工具会读取你的邮件。请确认压缩包是由发行工作流构建的。先验证校验和:
curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt"
# Linux
sha256sum --check --ignore-missing checksums.txt
# macOS
shasum -a 256 --check --ignore-missing checksums.txt
然后验证 checksums.txt 上的签名。发行版使用
cosign 进行无密钥签名——
无需获取公钥,也不需要任何人保管私钥。签名证书由发行工作流自己的 GitHub OIDC
身份颁发,并记录在公开的 Rekor 透明日志中,因此你验证的是“此文件由此仓库中的
release.yml 从某个标签构建”:
cosign 并非任何平台的默认安装项,也不在常见的发行版软件仓库中,因此此处出现
cosign: command not found 表示“尚未安装”,而不是“验证失败”。先安装它——运行
brew install cosign,或
go install github.com/sigstore/cosign/v2/cmd/cosign@latest,或从安装
文档获取发行版二进制文件。
curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt.sig"
curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt.pem"
cosign verify-blob \
--certificate checksums.txt.pem \
--signature checksums.txt.sig \
--certificate-identity-regexp '^https://github\.com/craigjmidwinter/mail-muncher/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
checksums.txt
Verified OK 表示校验和文件真实可信;接着,sha256sum 步骤会验证你的压缩包与之匹配。cosign 3 会针对
--certificate 和 --signature 输出弃用提示——验证仍会继续执行,而且这些分离文件也能被 cosign 2 识别。
go install
如果你已经安装 Go 1.25 或更高版本,这是合适的方式:
go install github.com/craigjmidwinter/mail-muncher/cmd/mail-muncher@latest
请注意,使用 go install 构建的程序在 --version 中会报告 dev,因为版本信息是在链接时写入的,而 go 工具不会执行这一步。发行版二进制文件和 make build 会报告实际标签。如果你针对 go install 构建版本提交 bug,请说明安装的是哪个提交。
从源代码构建
git clone https://github.com/craigjmidwinter/mail-muncher
cd mail-muncher
make build # -> ./mail-muncher, version stamped from git describe
如果你想检查发行版会包含哪些内容,可以运行 make snapshot 在本地构建完整的发行版压缩包(需要
goreleaser)。
下文提到的示例配置位于 examples/ 中——
imap.yml、minimal.yml 和
job-search.yml。它们也随每个发行版压缩包一起打包,因此下载二进制文件时也会获得这些配置。不过,入门并不需要它们:mail-muncher init 会从头创建配置。
容器镜像
docker pull ghcr.io/craigjmidwinter/mail-muncher:latest
支持 linux/amd64 和 linux/arm64,由与发布归档相同的二进制文件构建。镜像的默认命令是 mcp,因为通过 stdio 提供归档服务是容器适合的用法:客户端启动它、与它通信,然后停止它。run 和 daemon 也能运行——覆盖默认命令即可——但在主机上,它们对应的是 cron 条目和 launchd/systemd 单元,更适合直接在主机上使用。
需要挂载两个目录,缺一不可:
# -e IMAP_PASSWORD forwards the variable, it does not invent it: export it
# first, from wherever you actually keep the secret.
export IMAP_PASSWORD="$(security find-generic-password -s mail-muncher -w)"
docker run -i --rm \
-e IMAP_PASSWORD \
-v ~/.config/mail-muncher:/home/muncher/.config/mail-muncher:ro \
-v ~/.local/share/mail-muncher:/home/muncher/archive \
ghcr.io/craigjmidwinter/mail-muncher:latest mcp
配置中的 password_cmd: printenv IMAP_PASSWORD 与这条 export 配对使用——关于主机密码管理器为何无法从容器内部访问,请参阅下方说明。
config.yml 中的每个路径都必须是容器可见的路径。 dest: 设置为 ~/Mail/receipts 时,会相对于容器的主目录解析,而不是你的主目录,因此邮件会写入容器退出后就会消失的层。将 dest: 指向挂载目录——对于上面的挂载,即 /home/muncher/archive/receipts——否则你就会把归档写入虚空,而清单还会欣然告诉你操作成功。
password_cmd 在容器内部运行,使用的是 /bin/sh,也就是说,容器里没有你的主机密码管理器。pass show mail/fastmail 无法运行。请使用容器实际能访问的机密信息:
password_cmd: printenv IMAP_PASSWORD # -e IMAP_PASSWORD
password_cmd: cat /run/secrets/imap-password # docker secret or a mounted file
这是容器方案确实不如主机安装的一点:它会将凭据从密码管理器移到容器的环境中。如果你觉得这种取舍不值得,就安装二进制文件——password_cmd 是为主机环境设计的,而容器中的做法只是妥协,并非初衷。
MCP Registry 中的条目也由此镜像提供支持;server.json 就是该条目,其中的 name 必须与镜像中内置的 io.modelcontextprotocol.server.name 标签一致。
该条目的发布过程是自动的。为发布打标签时,系统会构建并推送镜像,然后由第二个任务根据 git 标签改写 server.json 中的 version 和镜像标签,并使用工作流自身的 OIDC 身份(而非存储的令牌)进行身份验证,最后发布到注册表。
因此,提交在 server.json 中的 version 是上次发布的版本,并且会有意落后一个标签。 标签才是唯一可信来源;该文件是由 CI 填写的模板。手动更新它毫无意义。
作为 Claude Code 技能
仓库在 skills/ 下提供了技能和插件包,可将 mail-muncher 安装为由 Agent 为你设置和操作的工具——编写配置、运行 auth,并将 MCP 服务接入客户端。如果你想以这种方式使用它,请从这里开始,而不要看下面的快速入门。
该技能以 provider: imap 为起点,并会运行 mail-muncher init,因此它和本 README 一样,只需两分钟即可完成,不会把你引向 Google Cloud Console。
Windows
没有适用于 Windows 的构建版本,上述选项也都无法在后台绕过这一问题。Homebrew 无法在 Windows 上运行。发布归档仅包含 darwin 和 linux,上面的下载代码段是基于 uname 的 POSIX shell 脚本,PowerShell 和 cmd 都完全无法运行。
go install 是唯一能生成可执行文件的途径,而这正是需要直言说明的问题。Go 可以干净地交叉编译此模块——没有 cgo,测试文件之外也没有平台构建标签——因此你会得到一个可以启动的 mail-muncher.exe,mail-muncher init 也会毫无异议地写入配置。但运行到第一个 run 就会停止。IMAP 提供方是本 README 首推的路径(约 2 分钟),它会将 imap.password_cmd 交给 /bin/sh -c 执行(internal/provider/imap/password.go),而标准 Windows 机器上没有 /bin/sh。init 足够谨慎,不会在 Windows 配置中预置 macOS 或 Linux 的密钥工具,但它预置的命令仍会交给一个不存在的 shell,因此问题会到很晚才出现,而且报错指向错误的原因。
Gmail 提供方没有这种依赖——它的 OAuth 流程已会在 Windows 上选择 rundll32——因此可能可以端到端运行。但它未经 Windows 测试,也不受支持。
在 Windows 上可行的做法:在 Docker Desktop 下运行容器镜像,或者在 WSL2 中使用任一安装方式,因为那里同时具备 Linux 二进制文件和 /bin/sh。
升级
brew upgrade mail-muncher # Homebrew
go install github.com/craigjmidwinter/mail-muncher/cmd/mail-muncher@latest
如果使用下载的二进制文件,请重复上述下载步骤——install 步骤会就地覆盖文件。其他内容都不需要更改:在 0.x 版本中,配置架构和磁盘布局保持稳定,state_dir 中的同步游标也能被任何较新版本读取。CHANGELOG.md 会记录任何可能导致上述情况不再成立的变更,目前还没有此类变更。
使用 mail-muncher --version 查看安装的版本。如果构建时没有使用 make,则会显示 dev——这是 go install 的版本标记注意事项,并不代表安装损坏。
卸载
移除二进制文件不会影响其他内容,因此以下步骤按优先移除最敏感材料的顺序排列。这里没有任何操作会自动替你完成:
# 1. Stop it, if you scheduled it.
launchctl unload ~/Library/LaunchAgents/com.craigjmidwinter.mail-muncher.plist
rm ~/Library/LaunchAgents/com.craigjmidwinter.mail-muncher.plist
rm -f ~/Library/Logs/mail-muncher.out.log ~/Library/Logs/mail-muncher.err.log
# or, if you used cron: crontab -e and delete the line.
# 2. The credential. This is the part nothing else will clean up.
security delete-generic-password -s mail-muncher # macOS Keychain, IMAP
# Gmail instead: revoke the app at https://myaccount.google.com/permissions
# 3. Config, credentials and the OAuth token.
rm -rf ~/.config/mail-muncher
# 4. Sync cursors, both lockfiles, and the quarantine directory.
rm -rf ~/.local/state/mail-muncher
# 5. The binary.
brew uninstall mail-muncher # Homebrew
rm -f /usr/local/bin/mail-muncher # downloaded binary
rm -f "$(go env GOPATH)/bin/mail-muncher" # go install
你归档的邮件有意不在此列表中。 它们存放在规则中指定的 dest 位置——如果使用了 init 的默认值,则为 ~/Mail/mail-muncher——这些普通文件会在工具卸载后继续存在,这正是该格式的设计目的。如果想查看路径,请在执行第 3 步前运行 grep -n 'dest:' ~/.config/mail-muncher/config.yml;如果想删除邮件,请自行删除这些文件。
Homebrew cask 没有 zap 段落,因此 brew uninstall 只会移除二进制文件,不会删除主目录中的任何内容。这是有意为之——此工具已写入的邮件属于你,而卸载程序不是发现这一点的好地方。
快速入门
大约 5 分钟,无需浏览器,也无需克隆仓库。以下是 IMAP 路径;如果要使用 Gmail API,请在开始前阅读下方的 快速入门:Gmail,因为这需要在 Google Cloud Console 中操作约 10 分钟,而且令牌每周都会过期。
0. 检查安装。 安装后立即执行,尚未创建任何配置时:
mail-muncher run
这实际上是很有用的冒烟测试,并非错误。它会以状态码 1 退出,并准确告诉你它查找的位置、接下来要运行什么,以及各提供方的开销:
mail-muncher is not configured.
missing config file: /Users/you/.config/mail-muncher/config.yml
next command: mail-muncher init
then: mail-muncher validate && mail-muncher run --dry-run
init asks which provider to use. Both are supported; the costs differ.
provider: imap ~2 min. Gmail, Fastmail, Proton Bridge, work accounts,
self-hosted. Needs an app password, which is a broader credential than a
read-only OAuth token; mail-muncher only ever issues BODY.PEEK.
provider: gmail ~10 min in the Google Cloud Console: gmail.readonly is a
Google restricted scope, so mail-muncher ships no OAuth client and you
register your own. Google enforces read-only, but on a Testing-mode
consent screen the refresh token expires every 7 days, so
"mail-muncher auth" must be re-run weekly. Read docs/gmail-setup.md.
Docs: https://craigjmidwinter.github.io/mail-muncher/
所有需要配置的命令都会显示这条信息,因此安装损坏和尚未配置不会混淆。
1. 获取应用专用密码。 前往邮件服务提供方自己的设置页面——Gmail、Fastmail、iCloud、Proton 或你的工作账户。该密码仅用于这一用途,你可以撤销它,而不影响其他内容。将它存放在你平时保存密钥的地方:
# macOS Keychain
security add-generic-password -s mail-muncher -a "$USER" -w
# or pass, 1Password, secret-tool, gpg — anything that prints it on stdout
mail-muncher 从不存储此密码。它会运行你指定的命令,并从该命令的标准输出中读取密码,因此密钥会留在你的密码管理器中。配置架构中有意不设 password: 键。
2. 编写配置。
mail-muncher init --provider imap
Account name [personal]:
Write matched mail to [~/Mail/mail-muncher]:
IMAP host: imap.fastmail.com
IMAP username: [email protected]
Password command [security find-generic-password -s mail-muncher -w]:
Wrote /Users/you/.config/mail-muncher/config.yml
Next, for provider imap:
1. Run that password_cmd in a shell and check it prints the password and
nothing else, for example:
security find-generic-password -s mail-muncher -w | cat -A
Anything else on stdout - a prompt, a warning, a trailing blank line -
becomes part of the password and the login fails. If it is not there
yet, create an app password with your mail provider first and store it
where password_cmd can read it.
2. mail-muncher validate
3. mail-muncher run --dry-run then mail-muncher run
Matched mail lands in ~/Mail/mail-muncher
Docs: https://craigjmidwinter.github.io/mail-muncher/
无需编辑步骤。 init 会询问 IMAP 流程所需的所有信息,并写入一份首次即可通过验证的配置。密码命令会预先填入你平台的默认值——macOS 上为 Keychain,Linux 上为 secret-tool,其他平台上为 pass——因此一路按 Enter 就能给出真实答案,而不是占位符。
每个问题都有对应的标志,方便提前回答或编写脚本完成整个流程:
mail-muncher init --provider imap --yes \
--host imap.fastmail.com --username [email protected]
--yes 会为所有确实有默认值的问题采用默认值,因此仍需要你提供 --provider、--host 和 --username。这三项都没有值得猜测的默认值,程序会明确说明,而不是写入占位符:
error: --host and --username required with --yes --provider imap; host and
username have no honest default to take. Run `mail-muncher init --provider
imap` without --yes to be prompted instead
添加 --account NAME、--dest DIR 和 --password-cmd CMD,即可回答其余问题。除非使用 --force,否则绝不会覆盖现有配置。
~/.config/mail-muncher/config.yml 是默认路径;--config 可在所有地方覆盖此路径,包括 init。
3. 检查密码命令是否只输出密码,不包含其他内容。
security find-generic-password -s mail-muncher -w | cat -A
| cat -A 会让多余的提示、警告或尾随空行清晰可见。stdout 上的任何额外内容都会成为密码的一部分,导致登录失败——这是首次运行无法通过身份验证的最常见原因。
以下是 init 写入的内容,供参考;文件中有详细注释,完整示例请参阅 examples/imap.yml:
accounts:
- name: personal
provider: imap
imap:
host: imap.fastmail.com
username: [email protected]
password_cmd: security find-generic-password -s mail-muncher -w
mailboxes: [INBOX]
4. 检查配置。
mail-muncher validate
config: /Users/you/.config/mail-muncher/config.yml
1 account(s), 1 rule(s), state_dir /Users/you/.local/state/mail-muncher
OK
IMAP 账户可通过干净的验证:无需查找凭据文件,也无需预先写入令牌,磁盘上什么都不需要有。预期结果是没有警告的 OK。validate 会解析配置、编译每条规则的匹配树,并检查规则引用的文件。由其他程序管理的缺失文件——例如 from_domains_file,或 Gmail 流程中的 OAuth 凭据和令牌——会产生警告,而非错误:
warning: rules[0].match.any[0].from_domains_file: file does not exist yet: /Users/you/.local/share/jobsearch/domains.txt (it is maintained by another program; the rule matches nothing until it appears)
OK with 1 warning
5. 查看实际运行会执行什么操作。
mail-muncher run --dry-run
试运行会像真实运行一样连接、获取并评估邮件,同时报告每个匹配项将会写入的路径。它不会写入文件,也不会保存同步状态,因此你可以随意多次运行。这也能准确指出主机、用户名或 password_cmd 错误之处:
error: account "personal": imap: password_cmd "security find-generic-password -s mail-muncher -w" failed: exit status 44: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
6. 运行。
mail-muncher run
init 写入的配置包含一条匹配所有 72h 内新邮件的入门规则,因此首次运行一定会存储内容——如果运行后什么都没存储,就无法分辨是没有匹配项还是安装出了问题。然后再运行一次:磁盘上已有的内容都会标记为 skipped,增量游标也意味着第二次运行几乎不会与服务通信。
成功后,将入门规则缩小到你实际需要的范围(docs/filters.md),然后设置定时运行(参见定时运行)。docs/configuration.md 列出了所有配置项。
快速入门:Gmail
如果你特别需要 Gmail API,并希望由 Google 而非此程序强制保证只读,请选择此路径。开始之前,请了解两项成本,因为它们都是结构性的,且都无法消除:
- 需要预先花约十分钟在 Google Cloud Console 中进行设置。
gmail.readonly是 Google 的受限范围,因此 mail-muncher 不会提供 OAuth 客户端,将来也不会——你需要注册自己的项目和桌面应用客户端,并下载其 JSON 文件。 - 令牌每 7 天过期一次。 Google 会对所有仍处于测试模式的同意屏幕应用此规则,而你的应用就会处于这种模式。你必须每周重新运行
mail-muncher auth。没有任何设置可以取消此限制; docs/gmail-setup.md 解释了原因以及替代方案的代价。
如果你认为这两项成本都不值得,那么上面的 IMAP 路径也适用于 Gmail 帐户。
mail-muncher init --provider gmail # prints the cost warning, then writes the config
# → follow docs/gmail-setup.md: project, Gmail API, consent screen,
# Desktop app OAuth client, save its JSON as
# ~/.config/mail-muncher/credentials.json
mail-muncher auth --account personal # browser consent; writes token.json 0600
mail-muncher validate
mail-muncher run --dry-run
mail-muncher run
auth 会打印同意 URL(并尝试打开浏览器),监听回环端口上的重定向,并以 0600 权限将令牌写入帐户的
token_file。此命令仅适用于 Gmail 帐户——如果是 IMAP 帐户,它会拒绝执行,因为无需授权。上文 IMAP 快速入门中的第 4 至第 6 步仍然适用;在凭据文件和令牌文件存在之前,validate 会报告两条警告。
外部管理的筛选文件
这是此工具的核心功能,因此有必要准确说明其语义。
from_domains_file 指定的文件归 mail-muncher 所有;mail-muncher 不会创建或写入此文件:
match:
from_domains_file: ~/.local/share/jobsearch/domains.txt
# ~/.local/share/jobsearch/domains.txt
# written by the job-search tracker
acme.com
globex.io # inline comments are fine
@initech.com # a leading @ is stripped
MAIL.Umbrella.COM # case is irrelevant
- 每个周期读取一次,在首次使用时读取。 不是每个进程读取一次,也不是每条邮件读取一次。
run会重新读取该文件;守护进程的每次定时运行也会重新读取。多个规则引用同一文件时,该文件只读取一次,且内容会共享。 - 文件缺失或无法读取不会导致致命错误。 此谓词只会匹配不到任何内容,并针对该文件在该周期记录一条警告。所属程序可能尚未创建该文件,mail-muncher 不应因此失败。
- 宽松解析。 每行一个条目;
#表示注释;跳过空行;去除首尾空格;去掉开头的@和末尾的.;全部转换为小写;重复项会合并。没有点号的条目会保留,并记录为可疑条目,而不会被丢弃,因为该文件属于其他程序,错误猜测而静默丢弃条目并不可取。 - 匹配域名或其子域名。
acme.com匹配acme.com和careers.acme.com,但不匹配notacme.com。
相同的匹配规则也适用于内联的 from_domains: 谓词;唯一的区别在于列表的所有者。
域名列表无法表达的情况
有些发件人无法提前枚举。某家公司的邮件可能来自
wagepoint.teamtailor.com、mail.wagepoint.com 和
[email protected] ——域名列表只能列出你已经知道的主机。from_regex_file 则可以用模式实现同样的功能:
match:
from_regex_file: ~/.local/share/jobsearch/companies.txt
# one RE2 pattern per line, unanchored
wagepoint
(?i)^careers@acme\.io$
teamtailor\.com$
生命周期完全相同——每个周期读取一次,文件缺失绝不会导致致命错误,
on_degraded_filter 决定游标的处理方式。与域名格式有两处刻意的差异:
- 不会将任何内容转换为小写,因为正则表达式本身区分大小写。
需要忽略大小写时,请写入
(?i)。 - 只有行首的
#才表示注释。 在行中途遇到#就截断模式, 会悄无声息地改变匹配结果。
两种格式的故障模式恰好相反,因此防护方式也不同。 域名列表中的拼写错误会导致什么也匹配不到——代价是静默失效。模式列表中的拼写错误则可能导致匹配所有内容:.* 或一个多余的空行就会匹配整个邮箱。因此,空模式或任何能匹配空字符串的模式都会被直接拒绝;无法编译的模式会被单独拒绝,其余文件内容仍然生效;每个周期都会记录已加载的模式数量,因此,如果文件中的模式从十二条降到一条兜底模式,你在运行输出中就能看到这个数字,而不必等到整个磁盘都被占满才发现。
配置
完整参考:docs/configuration.md。可运行文件:
examples/imap.yml、
examples/minimal.yml、
examples/job-search.yml。
只有账户配置块会因提供商而异。IMAP:
accounts:
- name: personal
provider: imap
imap:
host: imap.fastmail.com
port: 993 # default
tls: true # default
username: [email protected]
password_cmd: pass show mail/fastmail # stdout is the password
mailboxes: [INBOX, Archive] # default [INBOX]
initial_lookback: 720h # default
Gmail:
state_dir: ~/.local/state/mail-muncher
on_message_failure: quarantine # or: abort
on_degraded_filter: hold # or: fail, proceed
accounts:
- name: personal
provider: gmail
gmail:
credentials_file: ~/.config/mail-muncher/credentials.json
token_file: ~/.config/mail-muncher/token.json
query: "-in:chats"
initial_lookback: 2160h
rules:
- name: job-search
account: personal
match:
any:
- from_domains_file: ~/.local/share/jobsearch/domains.txt
- subject_regex: "(?i)your application"
dest: ~/Mail/job-search
formats: [eml, markdown]
| Key | 类型 | 默认值 | 描述 |
|---|---|---|---|
state_dir | 路径 | ~/.local/state/mail-muncher | 同步游标(每个账户一个 JSON 文件)、周期锁、实例锁和隔离目录。 |
on_message_failure | quarantine、abort | quarantine | 遇到无法解析的邮件或导致接收端失败的邮件时该怎么处理。见下文。 |
on_degraded_filter | hold、fail、proceed | hold | 规则的 from_domains_file 或 from_regex_file 无法读取时该怎么处理。见下文。 |
quarantine_dir | 路径 | <state_dir>/quarantine | 存放隔离邮件的位置。 |
accounts | 列表 | — | 要拉取邮件的邮箱。至少需要一个。 |
accounts[].name | 字符串 | — | 必填,且必须唯一。用作状态文件名,也是 rules[].account 引用的名称。 |
accounts[].provider | imap、gmail | — | 必填;没有默认值。指定用于获取邮件的后端。见连接邮箱的两种方式。 |
accounts[].imap | 映射 | — | 仅当 provider 为 imap 时必填,且仅允许在此时设置。 |
accounts[].imap.host | 字符串 | — | 必填。imap.fastmail.com、imap.gmail.com;Proton Bridge 使用 127.0.0.1。 |
accounts[].imap.port | 整数 | 993 | 993 表示隐式 TLS(IMAPS),与默认值 tls: true 配套使用。 |
accounts[].imap.username | 字符串 | — | 必填。通常是完整地址;有些提供商要求填写不带域名的本地部分。 |
accounts[].imap.password_cmd | shell 命令 | — | 必填。在 /bin/sh -c 下运行;其标准输出即为密码。特意不提供 password 键——密钥保存在你的密码管理器中。 |
accounts[].imap.mailboxes | 字符串列表 | [INBOX] | 要获取的文件夹,每个文件夹都有自己的游标。文件夹名称也会作为 label 谓词值。服务器上不存在的文件夹会报错,而不会被视为空文件夹。 |
accounts[].imap.tls | 布尔值 | true | 连接时使用隐式 TLS。设为 false 会以明文发送密码和所有邮件正文;validate 会发出警告。仅适用于回环地址或位于 stunnel 后方的情况。 |
accounts[].imap.initial_lookback | Go 时长 | 720h | 每个邮箱首次同步时回溯的时间范围;UIDVALIDITY 发生变化后也会按此范围回溯。必须为正数。 |
accounts[].gmail | 映射 | — | 仅当 provider 为 gmail 时必填,且仅允许在此时设置。 |
accounts[].gmail.credentials_file | 路径 | — | 必填。从 Google Cloud 下载的 OAuth 客户端 JSON 文件。 |
accounts[].gmail.token_file | 路径 | — | 必填。auth 缓存 OAuth 令牌的位置,文件权限为 0600。 |
accounts[].gmail.query | 字符串 | none | 可选的 Gmail 搜索表达式。仅用于优化首次扫描的成本——见下文。 |
accounts[].gmail.initial_lookback | Go 时长 | 720h | 首次扫描时回溯的时间范围。必须为正数。见回填。 |
accounts[].gmail.include_spam_trash | 布尔值 | false | 获取垃圾邮件和垃圾箱中的邮件。两个 Gmail 同步路径对此的处理方式相同。设为 true 时,validate 会发出警告。见垃圾邮件和垃圾箱(Gmail)。 |
rules | 列表 | — | 按顺序对每封邮件进行评估;第一个匹配项生效。 |
rules[].name | 字符串 | — | 必填,且必须唯一。显示在日志和 Markdown frontmatter 中。 |
rules[].account | 字符串 | 所有账户 | 将规则限制为一个账户。 |
rules[].match | 匹配节点 | — | 必填。见筛选器。 |
rules[].dest | 路径 | — | 必填。目标目录;需要时会自动创建。 |
rules[].formats | eml、markdown 列表 | [eml] | 要写入的格式。 |
容易踩坑的注意事项:
- 未知键会直接导致错误。 拼写错误会导致加载失败,而不是被忽略,因此
validate能在运行前捕获initial_lookbak。 - 所有路径字段中的
~和$VAR都会展开,包括匹配树中from_domains_file和from_regex_file的值。不支持~user形式。未定义的变量会展开为空字符串,与 shell 中的行为相同。 gmail.query不会筛选最终保留的内容,而且作用范围比你想象的更窄。 它只会在某个账户首次扫描时发送给 Gmail,除此之外不会在其他情况下使用——增量周期不会使用,历史游标过期后的恢复扫描也不会使用。它绝不会在本地重新应用。决定存储哪些内容的唯一依据是你的规则。请保持查询条件宽泛,或省略它。- 默认不会获取垃圾邮件和已删除邮件(Gmail)。 两种 Gmail 同步路径对此一致:完整扫描会传入
includeSpamTrash=false,增量路径则会在消息进入流水线之前丢弃标记为SPAM或TRASH的邮件。设置gmail.include_spam_trash: true即可获取这些邮件——请参阅垃圾邮件和已删除邮件。IMAP 没有对应的键:你只会获取mailboxes:中列出的文件夹,因此不列出垃圾邮件文件夹就是全部做法。 gmail:和imap:块不能同时使用。 如果设置的块与provider:不匹配,就会直接报错,而不是被静默忽略。因此,在 Gmail 账户下设置imap:块,不会让你误以为正在通过 IMAP 获取邮件。
两种可能故障的处理策略
这两个键都位于顶层,与 state_dir 同级。默认值是安全选项;只有在确定更愿意遇到哪种故障时,才需要更改它们。
on_message_failure — 消息无法解析,或者规则要求的所有渲染结果都写入失败时的处理方式。
| 值 | 行为 |
|---|---|
quarantine(默认) | 将原始字节写入 <quarantine_dir>/<account>/<id>.eml,并附带一个 .json 文件说明失败原因,然后让游标越过该消息。不会丢失内容,单条有问题的消息也无法卡住流水线。摘要和清单会将其计为 quarantined;运行仍以状态码 0 退出。 |
abort | 返回失败,因此游标不会前移,该消息会在下一个周期重新获取。取舍很明确:永久无法解析的消息会卡住该账户,直到有人处理为止。 |
如果隔离写入本身失败,则该消息会回退到 abort 语义——拒绝前移游标可以恢复,丢失消息则不行。
on_degraded_filter — 规则的 from_domains_file 或 from_regex_file 缺失、不可读或被截断时的处理方式。此类文件不会匹配任何内容,因此如果没有处理策略,该周期中的每条消息都会针对空列表进行评估,被判定为不匹配,然后被处理掉。
| 值 | 行为 |
|---|---|
hold(默认) | 运行周期并存储所有匹配的内容,以错误级别记录降级情况,但不保存前移后的游标——因此文件恢复后会重新评估同一批邮件。清单会报告 degraded 和 state_held。以状态码 0 退出。 |
fail | 在获取任何内容之前结束周期。不会存储内容,也不会前移游标,并以非零状态码退出。 |
proceed | 将不可读的列表视为空列表并继续前移游标。这是旧行为,也是唯一会接受静默丢失所需邮件的选项——validate 会对此发出警告。 |
在 hold 模式下已存储的文件会继续保留:目标位置具有幂等性,因此重新运行时会跳过这些文件。
筛选器
完整参考和使用指南:docs/filters.md。
match: 的值是一个映射,且必须恰好包含一个键——组合器或谓词。同一个映射中出现两个键会导致编译错误,并提示你使用 all: 或 any: 将它们组合起来。正则表达式和持续时间会在配置加载时编译,因此错误的模式会导致 validate 失败,而不会在凌晨 3 点带来意外。
组合器
| Key | Value | 匹配条件 |
|---|---|---|
all | 节点列表 | 每个子节点都匹配(至少需要一个子节点) |
any | 节点列表 | 至少一个子节点匹配(至少需要一个子节点) |
not | 单个节点 | 子节点不匹配 |
match:
all:
- any:
- from_domains: [acme.com]
- from_domains_file: ~/.local/share/agent/domains.txt
- not:
subject_regex: "(?i)^\\[newsletter\\]"
谓词
| Key | Value | 匹配条件 |
|---|---|---|
from_domains | 域名列表 | 任意 From 地址的域名与所列域名相同,或是其子域名 |
from_domains_file | 路径 | 同上,但每个周期都会从外部管理的文件读取列表 |
from_regex | RE2 模式 | 模式匹配任意 From addr-spec(不含显示名称) |
from_regex_file | 路径 | 同上,但每个周期都会从外部管理的文件读取模式 |
to_regex | RE2 模式 | 模式匹配任意 To 或 Cc addr-spec |
subject_regex | RE2 模式 | 模式匹配解码后的 Subject |
header | {name: X-Foo, regex: ...} | 模式匹配该标头的任意值 |
has_attachment | true / false | 邮件是否包含真实附件 |
label | 标签名称 | 邮件包含指定的提供方标签,按完全匹配比较。在 Gmail 上,这是 Gmail 标签;在 IMAP 上,这是邮件所在的邮箱名称 |
older_than | Go 时长 | 邮件的 Date 时间早于当前时间,且超过该时长 |
newer_than | Go 时长 | 邮件的 Date 时间晚于当前时间,且在该时长范围内 |
各举一个完整示例:
# Mail from a company or any of its subdomains.
- from_domains: [acme.com, globex.io]
# The same list, owned and updated by another program.
- from_domains_file: ~/.local/share/jobsearch/domains.txt
# A specific sender, however they capitalize it.
- from_regex: "(?i)^no-?reply@acme\\.com$"
# Patterns owned and updated by another program, for senders whose host cannot
# be enumerated in advance (wagepoint.teamtailor.com, mail.wagepoint.com).
- from_regex_file: ~/.local/share/jobsearch/companies.txt
# Anything addressed to a plus-alias you hand out to vendors.
- to_regex: "(?i)^me\\+vendors@example\\.com$"
# Application acknowledgements, case-insensitively.
- subject_regex: "(?i)(your application|application received)"
# Everything a mailing list tags for you.
- header: {name: List-Id, regex: "golang-nuts"}
# Only messages that actually carry a file.
- has_attachment: true
# On Gmail: labels exactly as shown in the UI. Nested labels use "Parent/Child";
# system labels are upper case (INBOX, SENT, UNREAD, STARRED).
# On IMAP: the mailbox the message came from, verbatim as the server names it,
# including its hierarchy separator ("Lists/golang", "Lists.golang").
- label: INBOX
# Message Date older than 90 days / newer than a day.
- older_than: 2160h
- newer_than: 24h
值得了解的细节:
from_regex和to_regex匹配裸地址([email protected]),不会匹配显示名称。若要匹配包含显示名称的原始标头,请使用header: {name: From, regex: ...}。has_attachment会统计标记为Content-Disposition: attachment的部分。通过cid:引用的内嵌图片不算附件。label区分大小写且必须完全匹配——label: inbox不会匹配INBOX。在 IMAP 账户上,可用值是你在imap.mailboxes下列出的邮箱名称,因此邮件只会带有提取它时所在邮箱对应的标签。older_than/newer_than会根据邮件的Date标头进行比较;如果标头缺失或无法解析,则回退到提供方的内部日期。没有可用日期的邮件不会匹配这两项中的任何一项。- 模式使用 Go RE2:不支持反向引用和环视。添加前缀
(?i)可忽略大小写。在 YAML 中,建议使用双引号并转义反斜杠("\\."),或者在无需转义时使用单引号。 has_attachment请使用true/false。YAML 1.2 会将yes和no视为字符串,mail-muncher 会拒绝它们。
垃圾邮件和已删除邮件(Gmail)
**默认情况下不会获取垃圾邮件和已删除邮件。**这些文件夹中的内容不会进入规则,也不会写入磁盘。垃圾邮件最有可能包含由攻击者撰写的恶意文本,并进入最终会出现在模型上下文窗口中的处理流程,因此默认做法是将其留在 Gmail 中。
本节全部针对 Gmail 提供方。IMAP 没有对应的键,因为它不需要:IMAP 账户只会获取 imap.mailboxes 中指定的文件夹,因此只有在你按名称请求垃圾邮件时,它们才会被获取。
如果你确实需要获取它们——例如,有一封合法邮件被误判为垃圾邮件,或者你需要一个真正完整的归档——请为每个账户设置此键:
accounts:
- name: personal
gmail:
include_spam_trash: true # validate warns; that is deliberate
这两项设置的作用不同,你可能两者都需要:
| 决定 | |
|---|---|
gmail.include_spam_trash | 是否获取这些邮件 |
针对 SPAM / TRASH 标签的规则 | 获取后如何处理这些邮件 |
gmail.query 无法完成这两项工作。它只会在首次扫描时发送,因此其中的 -in:spam 对之后的任何周期都不起作用。设置 include_spam_trash: true 后,请使用规则进行筛选——规则引擎是唯一能看到每封已获取邮件的地方:
rules:
- name: job-search
match:
all:
- from_domains_file: ~/.local/share/jobsearch/domains.txt
- not:
any:
- label: SPAM
- label: TRASH
dest: ~/Mail/job-search
Gmail 的系统标签区分大小写,且必须使用大写。如果你希望所有规则都排除垃圾邮件和已删除邮件,就必须在每条规则中加入 not:——这是有意设计的,没有全局排除机制:规则是决定存储内容的唯一依据。
磁盘上的布局
**mail-muncher 写入的文件就是它的公共 API。**本节将带你了解其布局;docs/output-format.md 是相关约定,涵盖每个 frontmatter 键、为何 frontmatter 需要真正的 YAML 解析器,以及安全枚举传递目录树的规则。编写使用这些文件的程序前,请先阅读该文档;正确示例请参阅 examples/read_delivered.py。
每个 sink 都会按邮件日期(UTC),将邮件写入规则的 dest 目录下:
~/Mail/job-search/
└── 2026/
└── 07/
├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.eml
├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.md
└── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.attachments/
├── offer.pdf
└── R-sum-2026.docx
所有格式共用同一个基本文件名,因此同一封邮件的不同呈现形式会排列在一起:
<unix-seconds>-<sha256(account + ":" + message-id)[:16]>-<subject-slug>
摘要片段为 16 个十六进制字符,即 64 位。在邮箱可能产生的任何规模下,两封邮件都具有相同摘要片段的情况都不可能发生;归档读取程序会将其解析为邮件 ID,因此请将其位数视为布局的一部分。
- 时间戳可使目录按时间顺序排列。
- 摘要是幂等性键。它仅取决于账户名称和提供方邮件 ID,因此路径是邮件身份的纯函数。
- slug 是转换为小写的主题,其中所有不属于
[a-z0-9]的字符都会合并为单个-,然后去除首尾的-,并截断至 40 个字符。
关于 slug 有两点需要注意,且都是有意如此:
- **它仅使用 ASCII 字符。**完全由非拉丁文字或表情符号组成的主题,其 slug 会变成
no-subject。非 ASCII 文件名会受到文件系统 Unicode 规范化的影响(HFS+ 使用 NFD),这可能导致写入的名称与下一周期检查的名称不同——而该存在性检查就是整个幂等性机制。摘要仍能区分这些邮件。 - **它仅用于外观。**只有摘要携带身份信息。主题相同的两封邮件绝不会因此冲突。
文件的写入方式
消息文件会先写入目标目录中的临时文件并执行 fsync,然后通过 link(2) 硬链接到目标位置。有三点值得注意:
- 不会发布不完整的文件。 文件写完后,文件名才会出现。
- 不会覆盖已有文件。 与
rename(2)不同,link(2)会因EEXIST而失败,不会覆盖文件。这种失败本身就是幂等性检查——内核会在占用文件名的瞬间判断该名称是否空闲,因此不存在其他写入者插入文件并被静默替换的时间窗口。已存在的文件会报告为skipped。 - 拒绝符号链接,不会跟随它们。 如果消息最终路径上存在符号链接,或
dest下作为<YYYY>或<MM>目录的符号链接,系统会将该消息计数并记录为错误。目录结构中的任何内容都不应是链接,因此出现链接就说明有其他东西将它们放在那里。规则自身的dest:除外——将其指向另一个卷是常见做法。
在不支持硬链接的文件系统上(FAT、某些网络挂载),会回退为就地使用 O_CREAT|O_EXCL 写入:仍可原子地避免覆盖,也能防止符号链接,但不再保证文件不会部分写入。
附件是唯一的例外:它们通过临时文件和 rename(2) 写入,因为附件名称并非幂等性标记——上方的 .md 文件才是。
目录权限为 0700,文件权限为 0600。 归档邮件属于私人通信,解码后的附件也同样如此,因此它们会受到与同步游标和 OAuth 令牌相同的保护:其他本地用户无法读取。以你本人身份运行的工具不受影响。
.eml 文件
model.Message.Raw,逐字节保留,与服务提供方传送的内容完全一致。不进行重新编码、重新换行或规范化,因此可以通过任何邮件工具往返处理,并且仍能通过 DKIM 签名验证。这是用于保真保存的副本。
.md 文件
YAML frontmatter,然后是正文,最后是指向所有附件的链接。
- 正文选择:如果有
text/plain部分,就使用它;否则将text/html部分转换为 Markdown;否则使用字面内容*(no body)*。 行尾会统一为 LF,每行末尾的空白会被去除,首尾空行会被裁掉。 - Frontmatter 始终包含
subject、from、from_address、from_addresses、to、to_addresses、date、message_id、thread_id、thread_id_source、account、rule。cc、cc_addresses、in_reply_to、labels和attachments为空时会省略。解析地址时要使用*_address/*_addresses字段,绝不要从from/to/cc解析——这些是 显示字符串;发件人选择的显示名称中若包含<、>或,,就会产生歧义。 机器可读字段包含不带显示名称的 addr-spec,无法通过这种方式伪造。内容由 YAML 编码器生成,而非字符串格式化,因此即使主题中满是引号和冒号,也不会破坏解析; 这也意味着读取时需要使用真正的 YAML 解析器。 带表情符号的主题会以双引号括起,并包含\U0001F389转义;包含换行符的主题则会以|-块标量形式出现。按key: value拆分会把这两种情况都处理错。请参阅 docs/output-format.md。 - 线程信息由三个字段组成,而非四个。
thread_id是连接键,永不为空:若有 提供方自己的对话 ID,就使用该 ID;否则根据消息的References链生成一个。 可直接按它对目录分组,无需特殊处理。thread_id_source说明应在多大程度上信任 该分组——取值为provider、references、in_reply_to或self——因为重建只能尽力而为, 如果邮件客户端破坏了链,就会拆分线程。in_reply_to指明父消息。完整的References链会有意省略:它长度不受限制,而同目录下的.eml文件中保留了 原始内容。 - 附件会写入
.md旁边的<basename>.attachments/,文件名会经过清理(不含目录组件, 不允许路径遍历);.md或.eml扩展名会分别替换为.md.attachment/.eml.attachment;发生重名时会去重为name-2.pdf、name-3.pdf。附件会先于.md文件写入,因此文档不会链接到不存在的文件。 附件内容由发件人选择——请参阅下文的枚举规则。 - **行内
cid:图片不会解析。**通过内容 ID 嵌入图片的 HTML 正文会渲染为——这是未解析的链接,并非指向附件目录中的路径。如果需要图片数据, 可在.eml中找到。这是已知限制,并非错误。 .md并非用于保真保存的格式。任何需要逐字节精确保留的内容,都应从.eml中读取。
枚举投递目录树
已投递邮件的权威集合恰好是
<dest>/<YYYY>/<MM>/*.md(或 *.eml>)——仅两层深,绝不使用递归
glob:
find "$dest" -mindepth 3 -maxdepth 3 -type f -name '*.md' # correct
find "$dest" -name '*.md' # WRONG
递归 glob 会深入 <basename>.attachments/,其中的文件来自邮件发送者。如果攻击者能让某条规则匹配,就可以附加一个包含伪造 frontmatter 的文件,让粗心的使用者将其读作已投递邮件,并接受任意 from:、subject: 和正文。.attachments/ 目录下的任何内容都由发送者控制,绝不能将其解析为邮件。
如果你的使用者本身运行 mail-muncher,那么 run --json 更好:它会准确列出本轮写入的路径,因此不会把下面这类内容误认为树中的邮件。
dest=~/Mail/job-search # 规则的 dest
邮件正文是由攻击者控制的文本。经过筛选不代表经过审查。将正文内容视为数据,绝不要将其视为指令,也不要仅仅因为它是通过 mail-muncher 收到的就赋予它权威性。
docs/output-format.md 说明了完整约定,examples/read_delivered.py 是一个可用的读取器。
命令
mail-muncher [command]
init Write a starter config and print the next command to run
run Run one fetch/filter/store cycle
daemon Run fetch/filter/store cycles repeatedly on an interval
mcp Serve the stored mail archive to agents over MCP (stdio)
auth Authenticate interactively against a mail provider (Gmail only)
validate Parse the config, resolve referenced files, and report problems
completion Generate the autocompletion script for the specified shell
任何需要配置文件但找不到配置文件的命令,都会打印 快速入门第 0 步 中所示的设置指南——包括它查找的路径、下一条命令,以及各提供商的费用——而不是显示 open: no such file 错误。
所有子命令都可用的持久标志:
| 标志 | 默认值 | 描述 |
|---|---|---|
--config | ~/.config/mail-muncher/config.yml | 配置文件的路径。 |
--log-level | info | debug、info、warn 或 error。日志以 log/slog 文本格式输出到 stderr。 |
-v、--version | — | 打印版本。 |
各命令的标志:
| 命令 | 标志 | 默认值 | 描述 |
|---|---|---|---|
init | --provider | — | imap 或 gmail。省略时会提示选择;与 --yes 一起使用时必须指定,因为两种方式的费用不同,无法给出合理的默认值。 |
init | --account | personal | 配置中创建的账户名称。 |
init | --dest | ~/Mail/mail-muncher | 存放匹配邮件的规则文件的路径。 |
init | --host | — | IMAP 服务主机名,例如 imap.fastmail.com。省略时会提示输入;使用 IMAP 方式时与 --yes 一起使用则必须指定。 |
init | --username | — | IMAP 用户名,通常为完整邮箱地址。省略时会提示输入;使用 IMAP 方式时与 --yes 一起使用则必须指定。 |
init | --password-cmd | 平台默认值 | 用于将应用密码打印到 stdout 的 Shell 命令。macOS 默认使用 Keychain,Linux 默认使用 secret-tool,其他平台默认使用 pass。 |
init | --yes | false | 不提示;对有合理默认值的问题均采用默认值。仍必须指定 --provider;使用 IMAP 方式时还必须指定 --host 和 --username。 |
init | --force | false | 覆盖现有配置。若不指定,init 会拒绝执行并以状态码 1 退出,以免覆盖他人的规则和凭据路径。 |
run | --dry-run | false | 获取并评估邮件,报告将写入的内容,但不写入任何内容,也不保存状态。 |
run | --json | false | 将机器可读的清单写入 stdout,每个账户对应一个 JSON 对象。 |
daemon | --interval | 5m | 循环之间的间隔,最短为 30s。每次休眠时长会随机调整,幅度最高为 ±10%。 |
daemon | --dry-run | false | 每次轮询都按 run --dry-run 的方式运行。 |
daemon | --json | false | 每个循环结束后将清单写入 stdout:以换行符分隔的 JSON,每个账户在每次轮询中对应一个对象。 |
auth | --account | — | 要进行身份验证的账户。配置中有多个账户时必须指定。仅适用于 Gmail 账户——对 IMAP 账户运行 auth 会被拒绝,因为无需授权。 |
mcp 不接受任何自身的标志;其配置完全由 --config 指定。请注意,init 会写入 --config 指定的位置,而不是从该位置读取,并会按需创建目录(0700)和文件(0600)。
--log-level debug 会记录每封邮件的规则判定——邮件 ID、主题,以及胜出的规则名称或 no match——这是查明规则为何未触发的最快方式。Debug 级别还会记录每条 already stored 跳过记录及其路径。所有日志都输出到 stderr,因此不会干扰 stdout 上的 --json 输出。
小于 30s 的间隔会在启动任何操作前被拒绝:
error: --interval 10s is below the 30s minimum
退出代码
| 代码 | 含义 |
|---|---|
| 0 | 成功。即使某个周期遇到单条消息错误(消息无法解析,或一次目标写入失败),也仍会以 0 退出——这些错误会计入摘要,而不会升级处理。 |
| 1 | 配置或验证错误:文件无法解析、规则无法编译、缺少必需的键,或者根本没有配置文件。未获取任何内容。如果配置已存在且未提供 --force,init 也会返回此代码。 |
| 2 | 提供方或身份验证失败:Gmail 令牌从未写入,或被 Google 拒绝;IMAP password_cmd 执行失败,或服务器拒绝了登录;重试后仍无法连接服务器。 |
| 3 | 另一个实例持有周期锁。退出代码 3 表示重叠的 cron 调用报告“上一次调用仍在运行”。 |
隔离的消息不会改变退出状态:这是已处理并计为 quarantined、且已记录在清单中的消息级故障。请根据计数器而非退出代码触发告警。
daemon 永远不会以 2 退出。失败的周期会记录日志和连续失败次数,并在下一次计时触发时重试——如果 daemon 因令牌过期而立即退出,就需要有人恰好在最不方便的时候介入。它的状态码为 0(收到 SIGINT 或 SIGTERM 后停止,等待正在执行的周期完成并保存状态)、1(配置或验证错误,包括 --interval 低于最小值)和 3(启动时另一个实例已持有实例锁或周期锁)。如果后续计时触发时锁已被持有——例如 cron run 与 daemon 周期重叠——则会记录日志并跳过,不会视为致命错误。
运行摘要
每个周期都会为每个账户记录并打印一行摘要:
personal: fetched=128 matched=6 stored=6 skipped=0 parse_errors=0 sink_errors=0 quarantined=0 duration=4.1s
| 字段 | 计数单位 | 含义 |
|---|---|---|
fetched | 消息 | 提供方在此周期交付的消息。 |
matched | 消息 | 被某条规则认领的消息。 |
stored | 渲染结果 | 实际写入的渲染结果。 |
skipped | 渲染结果 | 因目标位置已存在而未写入的渲染结果。 |
parse_errors | 消息 | 无法解析的消息;记录日志并跳过。 |
sink_errors | 渲染结果 | 写入失败;记录日志并计数,周期继续执行。 |
quarantined | 消息 | 因无法交付而存放在隔离目录中的消息。 |
vanished | 消息 | 提供方曾列出、但之后发现已不存在的消息——即在列出和下载之间被删除的消息。此类消息会被跳过,游标也会越过它们。仅在非零时打印。 |
字段列表从 fetched= 到 duration= 连续排列,因此周期中的异常情况会标记在账户标签上:
personal (dry-run): fetched=42 ...
personal (degraded, state held): fetched=42 ...
personal (stopped): fetched=12 ...
vanished=N 是唯一会有条件追加的计数器。只有在某条消息于周期中途消失时,它才会显示在 quarantined= 之后;其他情况下都会省略,因此其他所有运行的已发布字段列表保持不变。使用 --json 时,对应字段为 summary.vanished,同样在值为零时省略——如果要据此触发告警,请将其解析为 .summary.vanished // 0。
稳定状态下的 cron 运行会显示 fetched=0。对同一时间窗口重新运行则会显示 matched=N stored=0 skipped=N——这表示幂等性正在正常工作。
JSON 清单
run --json 和 daemon --json 会将该行替换为每个账户一个机器可读对象,其中包含 stored[]、skipped[]、quarantined[]、各项计数器,以及游标是否前移。所有日志行仍会输出到 stderr,因此 run --json 2>/dev/null 输出纯 JSON,而 daemon --json 输出 NDJSON 流。
mail-muncher run --json 2>/dev/null | jq -r '.stored[] | "\(.rule)\t\(.path)"'
完整的逐字段契约请参阅:docs/manifest.md。
状态与锁定
~/.local/state/mail-muncher/
├── personal.json # one per account, mode 0600
├── mail-muncher.lock # cycle lock, shared by run, daemon and mcp sync
├── instance/
│ └── mail-muncher.lock # daemon lifetime lock — one daemon per state dir
└── quarantine/
└── personal/
├── 18f2a1b2c3.eml # raw bytes of an undeliverable message
└── 18f2a1b2c3.json # why it is here
每个账户的状态文件都是 JSON。其中的游标属于对应的提供方,因此结构各不相同。Gmail:
{
"history_id": 918273,
"last_sync_time": "2026-07-28T09:15:00Z",
"seen_ids": ["18f2a...", "18f2b..."]
}
history_id是 Gmail 的增量游标。设置后,每个周期都会向 Gmail 查询自该游标以来的变更。Gmail 保留历史记录的时间大约为一周;游标过期后,API 会返回 404,mail-muncher 会记录一条警告、清除游标,并在同一个周期内回退到完整扫描。last_sync_time用于限定完整扫描中after:条件的范围。恢复扫描会比已存储的水位线再往前覆盖 24 小时,因此不会漏掉上个周期列出邮件期间到达的邮件。重复扫描无妨:写入时会跳过磁盘上已有的所有邮件。seen_ids是包含最近 2000 个已投递邮件 ID 的先进先出集合,与各目标位置使用的幂等文件名构成双重保障。
IMAP 会在 extra 下,以 UIDVALIDITY/UID 对的形式独立跟踪每个邮箱:
{
"last_sync_time": "2026-07-28T09:15:00Z",
"extra": {
"imap.INBOX.uidvalidity": "1650000000",
"imap.INBOX.last_uid": "48213"
},
"seen_ids": ["personal:INBOX:1650000000:48213"]
}
如果存储的 UIDVALIDITY 仍与服务器上的值匹配,周期任务会请求 UID FETCH 48214:*——仅获取此后到达的邮件。如果发现 UIDVALIDITY 已更改,周期任务就会丢弃已存储的 UID,并从 initial_lookback 开始重新同步,因为 UIDVALIDITY 的变更意味着协议在声明:mail-muncher 记住的每个 UID 现在都指向另一封邮件。重新同步会使用新的文件名,将覆盖范围内的邮件重新归档,因为此路径上的邮件标识为 <account>:<mailbox>:<uidvalidity>:<uid>。这是有意为之的取舍:重复邮件可以删除,而悄无声息地漏掉的邮件则无法找回。详细说明:docs/configuration.md。
状态目录会以 0700 权限创建,状态文件则为 0600:哪些账户存在以及已查看过哪些邮件 ID,都不应公开。
删除账户的状态文件会强制执行完整重新扫描,扫描范围由 initial_lookback 限定。这是安全的——目标位置会跳过磁盘上已有的所有内容——也是从损坏的游标中恢复的受支持方式。
两种锁
-
周期锁
<state_dir>/mail-muncher.lock基于flock,由run、每次守护进程轮询以及 MCPsync工具在整个周期内持有。它可以防止 cron 调用与正在运行的守护进程使用相同游标时发生竞争。如果发现锁已被持有,表示“现在不行”,而不是“出错了”:run会以 3 退出,守护进程则会记录日志并跳过本次轮询。 -
实例锁
<state_dir>/instance/mail-muncher.lock由守护进程在其整个进程生命周期内持有。周期锁无法承担这个作用——它会在轮询间隔期间释放,因此第二个守护进程若在第一个休眠时启动,就能绕过该锁,随后与第一个守护进程并行地无限轮询,使它们使用相同游标并将 API 流量加倍。第二个守护进程会立即以 3 退出:another mail-muncher daemon is already running against this state directory; not starting
如果进程崩溃,操作系统会释放这两种锁。
隔离区
在默认设置 on_message_failure: quarantine 下,无法投递的邮件会写入 <quarantine_dir>/<account>/<id>.eml——原始字节原样保存——旁边还会附带一个 .json 文件,其中记录规则、失败阶段(parse 或 sink)、错误和时间。这个附带文件会包含所有必要信息,以便检查该目录时无需查阅本次运行的日志。
邮件不会自动重新投递。修复原因后,手动将 .eml 文件重新输入。该目录的权限为 0700,文件为 0600,与状态目录中的其他内容一致——隔离邮件是一封完整的电子邮件,存放在其目标目录树之外。
调度
cron — 每十分钟运行一次。若上一次调用仍在运行,run 会以状态码 3 退出,因此重叠运行无害:
*/10 * * * * /usr/local/bin/mail-muncher run --config /home/you/.config/mail-muncher/config.yml >> /home/you/.local/state/mail-muncher/cron.log 2>&1
请使用二进制文件和配置文件的绝对路径:cron 的环境与 shell 不同,而且配置中的 ~ 展开取决于是否设置了 $HOME。
更完整的示例(包含 cron 不会提供给你的环境变量)见
contrib/crontab.sample。
launchd(macOS) — 在 launchd 下运行守护进程,使其在注销后继续运行,并在故障时重启。可直接编辑的 plist 文件位于
contrib/launchd/。将其复制到目标位置,编辑副本中的四个绝对路径——launchd 不会展开 ~ 或 $HOME——然后加载:
cp contrib/launchd/com.craigjmidwinter.mail-muncher.plist ~/Library/LaunchAgents/
# edit the four paths in the copy, then:
launchctl load ~/Library/LaunchAgents/com.craigjmidwinter.mail-muncher.plist
systemd — 目前尚未提供 unit 文件;mail-muncher daemon --interval 5m 可直接作为 Type=simple 服务运行,也可以将 mail-muncher run 与计时器配合使用。
回填:首次运行
首次大范围运行是预期的使用方式,并非滥用。默认的
initial_lookback 为 720h(30 天),这意味着你最希望在第一天获得的内容——你要收集的全部历史记录,一次性保存在磁盘上——默认情况下恰恰不会发生。
首次运行时请将其设为较大的值。两个提供方都有此配置项,放在你的账户所使用的配置块下:
要停止它,请对同一路径运行 launchctl unload。若要彻底移除,还需删除该文件——参见卸载。
accounts:
- name: personal
gmail:
credentials_file: ~/.config/mail-muncher/credentials.json
token_file: ~/.config/mail-muncher/token.json
initial_lookback: 13140h # ~18 months — first run only
accounts:
- name: personal
provider: imap
imap:
host: imap.fastmail.com
username: [email protected]
password_cmd: pass show mail/fastmail
initial_lookback: 13140h # ~18 months — first run only, per mailbox
然后运行一次,并在之后将该值调回原值:
initial_lookback: 720h
将其调回原值是安全的,因为 initial_lookback 只会限制首次扫描范围——Gmail 账户的首次扫描,或 IMAP 下每个邮箱的首次扫描。之后的每个周期都会从已存储的游标继续运行,并完全忽略该配置项。只有删除账户的状态文件,或 IMAP 服务器更改 UIDVALIDITY 时,才会重新启用该配置项。
预期情况:
-
这是一次大型全量扫描,会进行速率限制和重试,可能需要一些时间。在 Gmail 上,下载会同时进行,每次四封邮件,每页 500 封;这两项都不可配置。在 IMAP 上,它会转为对每个邮箱执行
SINCEUID 搜索。 -
提交前先检查。
mail-muncher run --dry-run会像真实运行一样获取并评估内容,并报告所有将要写入的路径,但不会触碰目标目录树。添加--json可统计数量:mail-muncher run --dry-run --json 2>/dev/null | jq '.summary' -
之后的运行应报告所有内容都已
skipped。这是确认回填确实完成的检查方法。
Go 的时长不支持天或年的单位——请换算成小时。18 个月是 13140h,一年是 8760h,90 天是 2160h。
2000 条目的已见集合上限不会限制回填。原因见 docs/architecture.md:确定性文件名加上存在即跳过才是真正的幂等性标识,而已见集合只是双重保险。
状态与范围
尚未达到 1.0。目前的版本是
v0.4.0,每个版本的变更记录见 CHANGELOG.md;不使用 make 构建时,版本会显示为 dev。配置架构已稳定到可以据此编写配置,但在 1.0 之前仍可能发生变化。
| 范围 | 状态 |
|---|---|
IMAP 提供方(password_cmd、每个邮箱的 UID 游标、EXAMINE + BODY.PEEK[]) | 已实现 |
| Gmail 提供方(OAuth、全量扫描、增量历史同步、RAW 下载) | 已实现 |
init — 为任一提供方写入经过验证的配置,支持交互式或脚本化操作 | 已实现 |
| 针对所有未配置或配置不完整的命令提供设置指南 | 已实现 |
未配置时仍可按协议规范提供服务的 mcp | 已实现 |
| 配置加载与验证 | 已实现 |
| 筛选引擎(上述所有组合器和谓词) | 已实现 |
.eml 和 Markdown 输出目标 | 已实现 |
run、daemon、周期锁、实例锁 | 已实现 |
--json 运行清单 | 已实现 |
mcp — stdio MCP 服务,提供五种工具 | 已实现 |
| 隔离区和两种故障策略 | 已实现 |
明确不在范围内
- 写入邮箱。 只读是设计约束,不是阶段目标。 不会添加标签、删除、发送或创建草稿;对于 IMAP,甚至不会将 邮件标记为已读。
- 充当邮件客户端。 没有界面,也没有索引。
search_messages会对存储文件 进行不区分大小写的子字符串扫描,并非搜索引擎: 不做词干提取、不排序,也不支持查询语言。邮件线程根据每封邮件携带的 ID 分组, 不会重新组装成对话模型。 - 网络 API。 没有任何进程监听套接字。
mcp通过 stdio 协议与将其作为子进程 启动的客户端通信——没有端口、没有 HTTP 端点,也没有网络接口。唯一会绑定端口的 是在 Gmail 流程中,auth为 OAuth 重定向打开几秒钟的本地环回端口。 - 捆绑 OAuth 客户端。
gmail.readonly是 Google 的受限范围,因此 Gmail 流程始终需要自行注册——这正是 IMAP 作为低门槛接入方式存在的原因。
已知限制
- Markdown 输出中,内联
cid:图片会保留为无法解析的链接。 - 主题 slug 仅支持 ASCII;非拉丁字符主题会归档为
no-subject。 - 没有任何谓词会查看邮件正文;匹配依据是邮件头、标签和日期。
Gmail:
- 在测试模式的授权屏幕下,Gmail 令牌每 7 天过期一次,因此
mail-muncher auth每周都要运行一次。这是 Google 的政策,不是可配置项。 gmail.query只应用于账户首次扫描,不适用于增量周期,也不适用于游标过期后的恢复扫描。- 除非设置
gmail.include_spam_trash: true,否则根本不会抓取垃圾邮件和回收站邮件。 抓取后,由针对SPAM和TRASH标签的规则决定如何处理。 - Gmail 的下载并发数(4)和页面大小(500)无法配置。
IMAP:
- 应用专用密码是一种完整的邮件凭据。只读限制由 mail-muncher 的代码强制执行,而非由你的服务提供商强制执行——请参阅 连接邮箱的两种方式。
- 服务器更改 UIDVALIDITY 后,会使用新文件名重新归档
initial_lookback时间范围内的邮件。 这是有意为之:重复邮件可以恢复,漏掉的邮件则无法恢复。 - IMAP 没有服务端对话 ID,因此
thread_id始终根据References/In-Reply-To链合成, 且thread_id_source永远不会是provider。 - 服务器上不存在的邮箱会报错,而不是视为空文件夹——因此
mailboxes:中的拼写错误会导致首次运行失败, 而不是看起来像没有新邮件。 - 只会抓取
mailboxes:中列出的文件夹;没有“全部”选项,也不支持服务端搜索。
文档
可在 https://craigjmidwinter.github.io/mail-muncher/ 浏览,也可在此代码库中查看:
- docs/configuration.md — 两种提供方的每个配置项、验证规则及失败情况。
accounts[].imap(见 docs/configuration.md#accountsimap)部分是 IMAP 参考;没有单独的设置页面,因为除了应用专用密码外无需进行其他设置。 - docs/gmail-setup.md — 仅介绍 Gmail:Google Cloud 操作指南、七天有效期,以及每条 OAuth 错误消息及其解决方法。本文档内容不适用于 IMAP 账户。
- docs/filters.md — 完整的匹配树语言,以及包含真实规则的实用指南。
- docs/output-format.md — 磁盘上的契约:目录布局、文件名、每个 frontmatter 字段,以及枚举投递目录树时的安全规则。编写使用方前请先阅读。
- docs/manifest.md —
--json清单契约,逐字段说明。 - docs/mcp.md — MCP 服务:客户端接入方式,以及每个工具的参数和返回格式。
- docs/architecture.md — 流水线、各个衔接点,以及应将更改放在哪里。
- CONTRIBUTING.md — 构建、测试,以及代码库遵循的约定。
可运行的配置:examples/imap.yml(IMAP 配置,含大量注释)、examples/minimal.yml(最精简且实用的 Gmail 配置)、examples/job-search.yml(实际使用的外部管理筛选器文件),以及 examples/read_delivered.py(正确的使用方示例)。这三个配置都能通过 mail-muncher validate --config <file> 验证。
致谢
字标采用 Jason Kottke 设计的 Silkscreen 字体,依据 SIL Open Font License 1.1 使用。品牌资源、配色方案和使用规则见 branding/BRAND.md。
许可证
MIT。请参阅 LICENSE。
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。