内容
# mail-muncher
[](https://github.com/craigjmidwinter/mail-muncher/actions/workflows/ci.yml)
[](https://pkg.go.dev/github.com/craigjmidwinter/mail-muncher)
[](go.mod)
[](https://github.com/craigjmidwinter/mail-muncher/releases/latest)
[](LICENSE)
给程序一个专属的、只读的邮箱,过滤后直接以文件形式存放在磁盘上。
mail-muncher 从邮件提供商拉取邮件,根据有序规则评估每封邮件,并将匹配结果写入指定目录 - 生成原始 `.eml` 文件和可选的 Markdown 渲染,附带 YAML 前置元数据和提取的附件。规则可以从一个纯文本文件中获取过滤输入,这个文件由其他程序维护,mail-muncher会在每次循环开始时重新读取。当其他程序更改文件内容时,下一循环即可获得不同邮件,无需修改配置、无需重启或重新部署。
它可以作为 cron 一次性运行,也可以作为轮询守护进程或 stdio MCP 服务器运行,代理可以直接查询。每次运行都会生成相同的机器可读信息记录。它请求且仅请求 `gmail.readonly` 权限:无法发送、删除、标记或修改邮箱中的任何内容。
## 问题
自动化过程需要一些邮件。职位搜索跟踪器需要来自已申请公司的回复。支持机器人需要来自特定供应商域名的消息。研究代理需要来自三个出版商的每个新闻通讯,以可读取的文本形式。
通常的解决方案都不好。给过程提供邮箱凭证,它可以读取(和发送、删除)所有内容。提供邮件 API 集成,你现在需要在每个需要邮件的进程中维护 OAuth 流、同步游标、MIME 解析和重复数据删除逻辑。或者在配置文件中硬编码过滤器,每个更改都需要更新配置并重新部署。
mail-muncher 将凭证、增量同步、解析和重复数据删除逻辑分开。使用者程序负责维护一个文本文件,列出所需内容和读取结果的目录 - 如果需要,还可以通过同一目录提供少量 MCP 工具。
## 代理工作流
支持两种形态,可以组合使用。根据代理是否主动运行或等待调用,选择合适形态。
- **文件投递** - mail-muncher 按计划运行,写入文件;代理读取目录。两者之间没有直接调用。
- **工具调用** - 代理通过 MCP 调用 `mail-muncher mcp`,直接询问问题:有什么订阅?有什么新邮件?线程内容是什么?立即获取。
两种方式读取相同的存档,同时运行两种方式是正常的:守护进程填充目录,同时 MCP 服务器回答问题。
### 形态 1: 文件投递
循环完全解耦:mail-muncher 从不调用代理,代理也从不调用 mail-muncher。两者共享磁盘上的两个路径。
**1. 代理声明所需内容。** 追加到它拥有的文件中:
```bash
mkdir -p ~/.local/share/agent
cat >> ~/.local/share/agent/domains.txt <<'EOF'
# 当前代理感兴趣的域名
acme.com
globex.io
EOF
```
**2. mail-muncher 订阅此声明。** 一条规则,指向该文件:
```yaml
rules:
- name: agent-inbox
match:
from_domains_file: ~/.local/share/agent/domains.txt
dest: ~/mail/agent-inbox
formats: [eml, markdown]
```
**3. 每次循环重新读取文件。** 从 cron 运行,或让守护进程运行:
```bash
mail-muncher run # 一次循环 - cron 入口
mail-muncher daemon --interval 5m # 无限循环
```
**4. 匹配邮件以文件形式存放在 `dest` 目录下。**
```
~/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` 是可消费的渲染 - 解析前置元数据,将正文提供给模型,从附件目录打开附件:
```markdown
---
subject: 'Re: Your application for Senior Engineer'
from: Jane Doe <jane@acme.com>
to: [me@example.com]
date: 2026-07-28T09:15:00Z
message_id: <abc123@acme.com>
thread_id: 18fe9c0d1a2b3c4d
thread_id_source: provider
in_reply_to: <application-000@example.com>
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 输出每个日志行:
```bash
mail-muncher run --json 2>/dev/null | jq -r '.stored[].path'
```
完整合约:[docs/manifest.md](docs/manifest.md)。
三个属性使其能够安全地放入自治循环:
- **仅读构造。** 请求的唯一 OAuth 权限范围是 `gmail.readonly`。无论输出的消费者或 bug 如何,都无法发送、删除或修改邮件。
- **幂等交付。** 消息的文件名嵌入了 `account + ":" + message id` 的摘要,因此其目标路径是其身份的纯函数。如果文件已经存在,意味着“早期循环已存储”,接收者写入无内容。重新运行、状态丢失后重放、循环中崩溃或重叠两次 cron 调用:树收敛,不会处理任何文件两次。
- **确定性路由。** 规则有序,优先匹配,因此每条消息仅由一个规则写入。给每个消费者一个规则和一个 `dest`,每个消费者都有一个私有邮箱,不会被其他内容写入。
交付是磁盘上的文件,此处不监听网络。合约是目录,可选地提供机器可读的更改记录。
### 形态 2: 工具调用
`mail-muncher mcp` 是已归档邮件的 stdio MCP 服务器。代理询问;不会安排任何内容。
```json
{
"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` 根目录,因此配置文件、OAuth 令牌和状态目录均不可达且未命名。
完整引用、客户端连接和每个参数和返回字段的文档:[docs/mcp.md](docs/mcp.md)。
`list_rules` 是闭环的关键。代理写入域名到其自身文件,然后调用 `list_rules` 并看到其订阅的反射 - 下一个循环将匹配的相同列表。
## 替代品
在采用前请先阅读。本工具已很好地实现了 fetch-filter-deliver 流程,但有些工具可能更适合。
| 工具 | 使用场景 |
| --- | --- |
| [getmail6](https://github.com/getmail6/getmail6) | 您需要一个成熟、广泛打包的 fetcher。它支持 IMAP 和 Gmail OAuth2,交付到 Maildir/MDAs,并通过外部程序过滤。如果人类(或 mutt,或 notmuch)是消费者,这是一个更强的工具。 |
| [fdm](https://github.com/nicm/fdm) | 您需要每个规则的 Maildir 目的地,并带有紧凑、经过充分测试的配置 - 正是此工具的形状,不包括外部过滤源。Gmail 访问是应用密码 IMAP。 |
| [lieer](https://github.com/gauteh/lieer) | 您希望将整个 Gmail 邮箱双向同步到本地 Maildir 中,用于 notmuch,而不是从中提取过滤后的子集。 |
| `gmail-archive` | 它几乎就是这样 - Gmail 查询到 Maildir,增量 - 如果仍然维护,将是明显的答案。自 2018 年以来未维护。 |
| `gmail-exporter` | 您需要一次性、基于标签的电子表格导出,而不是增量同步。 |
| `mbsync` / `offlineimap` | 您需要完整的邮箱复制,并在之后进行过滤。 |
这些工具都没有实现的功能:从另一个程序拥有的文件中获取过滤器输入,并在每次循环中重新读取,并为程序消耗而非显示给邮件客户端而生成的渲染。
## 安装
需要 Go 1.25 或更高版本。
```bash
git clone https://github.com/craigjmidwinter/mail-muncher
cd mail-muncher
make build # -> ./mail-muncher
```
或者直接安装到 `$GOBIN`:
```bash
go install github.com/craigjmidwinter/mail-muncher/cmd/mail-muncher@latest
```
`make build` 从 `git describe` 中记录版本;`go install` 和普通 `go build` 留下的是 `dev`。
### 作为 Claude Code 技能
仓库中附带一个技能和插件包,位于 [`skills/`](skills/) 下,它将 mail-muncher 安装为代理可以设置和驱动的内容 - 编写配置,运行 `auth`,并将 MCP 服务器连接到您的客户端。如果您想采用这种方式,请从此处开始,而不是快速入门。
## 快速入门
十分钟即可完成。步骤 2 和 3 是唯一需要浏览器的步骤。
**1. 构建并编写配置文件。**
```bash
make build
mkdir -p ~/.config/mail-muncher
cp examples/minimal.yml ~/.config/mail-muncher/config.yml
```
`~/.config/mail-muncher/config.yml` 是默认路径;`--config` 覆盖它。
**2. 创建 Google OAuth 客户端。** mail-muncher 不提供 OAuth 客户端,而是创建自己的客户端,位于您自己的 Google Cloud 项目中,它归您所有。请参阅 [docs/gmail-setup.md](docs/gmail-setup.md):创建项目、启用 Gmail API、配置同意屏幕、创建 **Desktop app** OAuth 客户端,并下载其 JSON 到 `~/.config/mail-muncher/credentials.json`。
**3. 授权。**
```bash
./mail-muncher auth --account personal
```
这会打印一个同意 URL(并尝试打开浏览器),监听回环端口以获取重定向,并将令牌写入账户的 `token_file`,权限为 0600。
**4. 检查配置。**
```bash
./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
```
`validate` 解析配置,编译每个规则的匹配树,并检查它引用的文件。另一个程序拥有的缺失文件 - OAuth 凭证、令牌、`from_domains_file` - 是警告,而不是错误:
```
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. 查看实际运行的效果。**
```bash
./mail-muncher run --dry-run
```
干跑会像真实运行一样抓取和评估邮件,并报告匹配结果 *将写入* 的路径。它不会写入任何文件,也不会保存同步状态,因此您可以随意运行多次。
**6. 运行它。**
```bash
./mail-muncher run
```
然后再次运行 - 已经在磁盘上的内容会被跳过,增量游标意味着第二次运行几乎不会与 Gmail 通信。
一旦生效,请将其安排在计划上(参见[计划](#scheduling)),并阅读 [docs/configuration.md](docs/configuration.md) 和 [docs/filters.md](docs/filters.md) 以编写实际规则。
## 外部管理的过滤文件
这是该工具的核心功能,因此请准确说明其语义。
`from_domains_file` 命名一个文件,mail-muncher 不拥有、不创建且从不写入:
```yaml
match:
from_domains_file: ~/.local/share/jobsearch/domains.txt
```
```
# ~/.local/share/jobsearch/domains.txt
# 由 job-search 跟踪器编写
acme.com
globex.io # 行内注释可以
@initech.com # 前导 @ 被剥离
MAIL.Umbrella.COM # 不区分大小写
```
- **每次循环读取一次,仅在首次使用时读取。** 不是每个进程一次,也不是每条消息一次。`run` 重新读取它;每次守护进程 tick 都会重新读取它。被多个规则引用的文件只读一次并共享。
- **丢失或不可读的文件不会致命。** 谓词简单地不匹配,并为该文件记录一个警告。拥有程序可能尚未创建它,mail-muncher 必须不会因此失败。
- **宽松解析。** 每行一个条目;`#` 开始注释;空白行被跳过;周围的空白被修剪;前导 `@` 和尾随 `.` 被剥离;所有内容都转换为小写;重复项折叠。条目如果没有 `.` 则被保留并记录为可疑,因为文件属于其他人,不应该错误地丢弃条目。
- **相等性或子域名。** `acme.com` 匹配 `acme.com` 和 `careers.acme.com`,但不匹配 `notacme.com`。
内联 `from_domains:` 谓词适用相同的匹配规则;唯一的区别是谁拥有列表。
## 配置
完整参考:[docs/configuration.md](docs/configuration.md)。
```yaml
state_dir: ~/.local/state/mail-muncher
on_message_failure: quarantine # 或:abort
on_degraded_filter: hold # 或: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]
```
| 键 | 类型 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `state_dir` | 路径 | `~/.local/state/mail-muncher` | 同步游标(每个帐户一个 JSON 文件)、循环锁、实例锁和隔离区目录。 |
| `on_message_failure` | `quarantine`、`abort` | `quarantine` | 处理无法解析的消息或接收器失败的消息的方法。参见下文。 |
| `on_degraded_filter` | `hold`、`fail`、`proceed` | `hold` | 处理 `from_domains_file` 无法读取时的策略。参见下文。 |
| `quarantine_dir` | 路径 | `<state_dir>/quarantine` | 隔离消息的存储位置。 |
| `accounts` | 列表 | — | 要拉取的邮箱。至少需要一个。 |
| `accounts[].name` | 字符串 | — | 必需,唯一。命名状态文件,`rules[].account` 引用。 |
| `accounts[].provider` | `gmail` | `gmail` | 目前唯一识别的值。 |
| `accounts[].gmail` | 映射 | — | 当提供者是 `gmail` 时必需。 |
| `accounts[].gmail.credentials_file` | 路径 | — | 从 Google Cloud 下载的 OAuth **客户端** JSON。 |
| `accounts[].gmail.token_file` | 路径 | — | `auth` 缓存 OAuth token 的位置,模式 0600。 |
| `accounts[].gmail.query` | 字符串 | 无 | 可选的 Gmail 搜索表达式。仅在 **首次** 扫描时进行成本优化 - 参见下文。 |
| `accounts[].gmail.initial_lookback` | Go 时长 | `720h` | 首次扫描时回溯的时长。必须为正数。参见 [Backfill](#backfill-the-first-run)。 |
| `rules` | 列表 | — | 按照顺序针对每个消息进行评估;匹配第一个获胜。 |
| `rules[].name` | 字符串 | — | 必需,唯一。出现在日志和 markdown 前置元数据中。 |
| `rules[].account` | 字符串 | 所有帐户 | 将规则限制在某个帐户上。 |
| `rules[].match` | 匹配节点 | — | 必需。参见 [Filters](#filters)。 |
| `rules[].dest` | 路径 | — | 必需。目标目录;按需创建。 |
| `rules[].formats` | `eml`、`markdown` 列表 | `[eml]` | 要写入的渲染。 |
需要注意的事项:
- **未知键是硬错误**。键入错误会导致加载失败,而不是被忽略,因此 `validate` 在运行前会捕获 `initial_lookbak`。
- **`~` 和 `$VAR` 在每个路径值字段中扩展**,包括 `from_domains_file` 值内的匹配树。`~user` 形式不受支持。未定义的变量扩展为空字符串,就像在 shell 中一样。
- **`gmail.query` 不过滤保留的内容,仅适用于您认为的范围**。它仅在帐户的 **首次** 扫描时发送到 Gmail,而不适用于增量循环和历史记录光标过期后的恢复扫描。它永远不会在本地重新应用。您的规则是存储内容的唯一权威。请保持查询的广泛性,否则省略它。
- **完整扫描包括垃圾邮件和回收站**。每个 `users.messages.list` 调用都会设置 `includeSpamTrash`,因此垃圾邮件和回收站中的消息会到达筛选引擎,如果规则认领,则会存档。增量历史记录路径始终表现为此;完整扫描现在与之匹配。 使用 **规则** 而不是查询来排除它们 - 参见 [Keeping Spam and Trash out](#keeping-spam-and-trash-out)。
### 两个可能出错事项的策略
这两个键都位于顶级,旁边是 `state_dir`。默认值为安全选项;如果您决定选择哪种故障,请更改它们。
**`on_message_failure`** - 一个无法解析的消息,或其规则要求的所有渲染都失败的消息。
| 值 | 行为 |
| --- | --- |
| `quarantine`(默认) | 将原始字节写入 `<quarantine_dir>/<account>/<id>.eml`,并附带一个 `.json` 侧车,记录失败原因,然后让光标超过该消息。没有任何内容丢失,一个有毒消息无法阻塞管道。在摘要和清单中计为 `quarantined`;运行仍以 0 退出。 |
| `abort` | 返回失败,因此光标 **不会** 前进,消息将在下一个循环中重新获取。权衡是显式的:一个无法解析的消息会阻塞帐户,直到人为处理。 |
隔离写入如果本身失败,则回退到 `abort` 语义 - 拒绝前进是可以恢复的,而丢失消息则不可恢复。
**`on_degraded_filter`** - 规则的 `from_domains_file` 丢失、无法读取或部分截断。
| 值 | 行为 |
| --- | --- |
| `hold`(默认) | 运行循环并存储所有匹配的内容,在错误级别记录降级,但 **不** 保存高级光标 - 因此如果文件返回,则会重新评估相同的邮件。清单报告 `degraded` 和 `state_held`。退出 0。 |
| `fail` | 在获取任何内容之前结束循环。无存储,无高级,非零退出。 |
| `proceed` | 将不可读的列表视为一个空列表并继续。旧行为,也是接受沉默邮件丢失的唯一选项 - `validate` 会发出警告。 |
已经存储在 `hold` 下的文件保持存储:接收器是幂等的,因此重新运行会跳过它们。
## 过滤器
完整参考和 cookbook:[docs/filters.md](docs/filters.md)。
`match:` 值是一个映射,**只有一个键** - 一个组合器或谓词。映射中的两个键是一个编译错误,它会告诉您使用 `all:` 或 `any:` 组合它们。正则表达式和持续时间在配置加载时编译,因此错误的模式是一个 `validate` 失败,而不是在凌晨 3 点出现意外。
### 组合器
| 键 | 值 | 匹配条件 |
| --- | --- | --- |
| `all` | 节点列表 | 每个子节点都匹配(至少需要一个子节点) |
| `any` | 节点列表 | 至少一个子节点匹配(至少需要一个子节点) |
| `not` | 单个节点 | 子节点不匹配 |
```yaml
match:
all:
- any:
- from_domains: [acme.com]
- from_domains_file: ~/.local/share/agent/domains.txt
- not:
subject_regex: "(?i)^\\[newsletter\\]"
```
### 谓词
| 键 | 值 | 匹配条件 |
| --- | --- | --- |
| `from_domains` | 域名列表 | 任何 `From` 地址的域名等于或为列出的域名之一 |
| `from_domains_file` | 路径 | 相同,列表从外部文件读取,每次循环读取 |
| `from_regex` | RE2 模式 | 模式与任何 `From` 地址规范(无显示名称)匹配 |
| `to_regex` | RE2 模式 | 模式与任何 `To` 或 `Cc` 地址规范匹配 |
| `subject_regex` | RE2 模式 | 模式与解码的 `Subject` 匹配 |
| `header` | `{name: X-Foo, regex: ...}` | 模式与该标头的任何值匹配 |
| `has_attachment` | `true` / `false` | 消息是否带有实际附件 |
| `label` | 标签名称 | 消息带有该提供者标签,比较完全相同 |
| `older_than` | Go 时长 | 消息 `Date` 早于该时长 |
| `newer_than` | Go 时长 | 消息 `Date` 比该时长更近 |
每个工作示例:
```yaml
# 来自公司或其任何子域名的邮件。
- from_domains: [acme.com, globex.io]
# 同一个列表,由另一个程序拥有和更新。
- from_domains_file: ~/.local/share/jobsearch/domains.txt
# 特定发件人,不区分大小写。
- from_regex: "(?i)^no-?reply@acme\\.com$"
# 发送到您分发的 + 别名。
- to_regex: "(?i)^me\\+vendors@example\\.com$"
# 应用程序确认,不区分大小写。
- subject_regex: "(?i)(your application|application received)"
# 邮件列表为每个邮件列表标签的内容。
- header: {name: List-Id, regex: "golang-nuts"}
# 仅带有实际文件的消息。
- has_attachment: true
# Gmail 标签,与 UI 中显示的完全相同。嵌套标签使用“父/子”;
# 系统标签为大写(INBOX、 SENT、UNREAD、STARRED)。
- label: INBOX
# 消息日期早于 90 天 / 比 1 天新。
- older_than: 2160h
- newer_than: 24h
```
详细信息:
- `from_regex` 和 `to_regex` 测试裸地址(`jane@acme.com`),永远不是显示名称。使用 `header: {name: From, regex: ...}` 测试原始标头,包括显示名称。
- `has_attachment` 计算 `Content-Disposition: attachment` 标记的部分。内联图像由 `cid:` 引用,不被视为附件。
- `label` 区分大小写且完全相同 - `label: inbox` 与 `INBOX` 不匹配。
- `older_than` / `newer_than` 与消息的 `Date` 头进行比较,如果头缺失或无法解析,则回退到提供者的内部日期。没有可用日期的消息均不匹配。
- 模式为 Go [RE2](https://github.com/google/re2/wiki/Syntax):无反向引用和无前瞻。 使用 `(?i)` 前缀进行不区分大小写的匹配。
- 在 YAML 中使用双引号并转义反斜杠(`"\\."`),或使用单引号,其中不需要转义。
### 保留垃圾邮件和回收站
获取的内容包括垃圾邮件和回收站。这些让人们感到惊讶,因此请明确说明:**您的垃圾邮件和回收站中的每个消息都会根据您的规则进行评估**,如果有规则认领,则会存档。
`gmail.query` 无法解决这个问题。它仅在 **首次** 扫描时发送到 Gmail,因此 `-in:spam` 在稍后循环中没有任何作用。请使用规则过滤 - 过滤引擎是看到每个消息的唯一东西:
```yaml
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](docs/output-format.md) 是合同 - 每个前置元数据键、为什么前置元数据需要真正的 YAML 解析器,以及安全枚举交付树以确保正确读取它。请在编写使用者之前阅读它,并在 [examples/read_delivered.py](examples/read_delivered.py) 中查看一个简短的正确示例。
mail-muncher 按照消息的 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 字符**。如果主题完全使用非拉丁语脚本编写,或者完全是表情符号,则会缩略为 `no-subject`。非 ASCII 文件名会受到文件系统 Unicode 规范化(HFS+ 存储 NFD)的影响,这可能会使写入的名称与下一个循环检查的名称不同 - 而此检查是整个幂等故事。摘要仍然会将此类消息分开。
- **它是美观的**。只有摘要才具有身份。两个具有相同主题的消息永远不会冲突。
### 文件的写入方式
消息文件写入目标目录的临时文件,fsynced,然后 **硬链接**到位,使用 `link(2)`。有三个后果值得依赖:
- **永远不会发布部分文件**。临时文件在名称存在之前是完整的。
- **永远不会覆盖现有文件**。 `link(2)` 失败并出现 `EEXIST` 错误,而不是覆盖,就像 `rename(2)` 一样。这种失败 *是* 幂等检查 - 内核在名称被声明时决定名称是否空闲,因此存在窗口期另一个写入者可以偷偷地插入文件并被静默替换。“已经存在” 被报告为 `skipped`。
- **拒绝符号链接**。消息的最终路径上的符号链接或 `dest:` 处的符号链接是一个错误,该消息被计数并记录。布局中没有合法的符号链接,因此意味着某些东西被放置在那里。规则的 `dest:` 免受此限制 - 将其指向另一个卷是普通的。
不支持文件系统不支持硬链接(FAT,一些网络挂载)的文件系统会回退到 `O_CREAT|O_EXCL` 写入到位:仍然是原子无损并防止符号链接,但会失去部分文件保证。
附件是例外:它们使用临时文件和 `rename(2)` 编写,因为它们的名字不是幂等标记 - `.md` 上方的 `.md` 决定了这一点。
**目录创建 0700,文件 0600**。存档邮件是私有对应和解码附件,因此它与同步游标和 OAuth 令牌具有相同的待遇:这里没有任何内容对其他本地用户可见。
一个工具以你运行的用户身份运行不受影响。
### `.eml` 文件
`model.Message.Raw`,字节为字节,与提供者交付的内容完全相同。没有任何内容被重新编码、重新包装或规范化,因此它会通过任何邮件工具并仍然验证 DKIM 签名。这是保真副本。
### `.md` 文件
YAML 前置元数据,然后是正文,然后是附件链接。
- **正文选择**:如果有 `text/plain` 部分,则使用它;否则使用 `text/html` 部分转换为 markdown;否则使用字面 `*(no body)*`。换行符标准化为 LF,每行尾部空格被剥离,并且会修剪前置和尾部空白行。
- **前置元数据** 始终携带 `subject`、`from`、`to`、``date`、`message_id`、`thread_id`、`thread_id_source`、`account`、`rule`。`cc`、`in_reply_to`、`labels` 和 `attachments` 在为空时省略。 它使用 YAML 编码器生成,而不是字符串格式化,因此充满引号和冒号的主题不会破坏解析 - 这也意味着 **您需要一个真正的 YAML 解析器来读取它**。带有换行符的主题会以 `|-` 块标量形式出现。带有 `\U0001F389` 转义序列的表情符号主题会以双引号出现。 `key: value` 分割器会搞砸这两者。有关详细信息,请参阅 [docs/output-format.md](docs/output-format.md#the-frontmatter-is-real-yaml)。
- **线程** 是三个字段,而不是四个。`thread_id` 是连接键,**永远不会为空**:提供者自己的会话 ID,如果有的话,否则从消息的 `References` 链合成。`thread_id_source` 表示信任程度 - `provider`、`references`、`in_reply_to` 或 `self` - 因为重建是尽力而为的,而邮件发送者破坏链会使线程分离。`in_reply_to` 命名父级。完整的 `References` 链被故意省略:它无边界,而 `.eml` 文件旁边的文件具有相同的功能。
- **附件** 被写入 `<basename>.attachments/` 目录,与 `.md` 文件相同,文件名经过净化(无目录组件,无路径遍历),`.md` 或 `.eml` 扩展名被中和为 `.md.attachment` / `.eml.attachment`,冲突的文件名被编码为 `name-2.pdf`、`name-3.pdf`。它们在 `.md` 写入后写入,因此文档永远不会链接到不存在的文件。
- **内联 `cid:` 图片不会被解析**。渲染 HTML 正文时,`` 显示为未解析的链接,而不是指向附件目录的路径。如果您需要图片字节,请在 `.eml` 中找到它们。这是已知限制,而不是错误。
- `.md` 不是保真格式。任何逐字节重要的内容都应从 `.eml` 读取。
枚举配送树
已交付消息的权威集合是 `<dest>/<YYYY>/<MM>/*.md`(或 `*.eml`)—— **两级深,不支持递归通配符**:
```bash
find "$dest" -mindepth 3 -maxdepth 3 -type f -name '*.md' # 正确
find "$dest" -name '*.md' # 错误
```
递归通配符会深入到 `<basename>.attachments/` 目录中,文件来自发送邮件的人。如果攻击者可以使规则匹配,他们可以附加一个包含伪造前置信息的 文件,并让粗心的消费者将其读取为具有任意 `from:`、`subject:` 和正文的已交付消息。`.attachments/` 目录下的任何内容都由发送者控制,绝不能被解析为消息。
如果您的消费者自己运行 mail-muncher,`run --json` 更佳:它列出了此循环写入的精确路径,因此不会被树中的任何内容混淆。
消息正文是攻击者控制的文本。`filtered` 未经过验证。将正文内容视为数据,而非指令,不要仅因为它通过 mail-muncher 传递就赋予其权限。
[docs/output-format.md](docs/output-format.md) 具有完整的契约,[examples/read_delivered.py](examples/read_delivered.py) 是一个工作读取器。
## 命令
```
mail-muncher [command]
run 运行一个获取/过滤/存储循环
daemon 在间隔上重复运行获取/过滤/存储循环
mcp 通过 MCP(stdio)向代理提供存储的邮件存档
auth 与邮件提供商进行交互式身份验证
validate 解析配置,解析引用文件,并报告问题
completion 为指定 shell 生成自动补全脚本
```
每个子命令都可用的持久标志:
| 标志 | 默认值 | 描述 |
| --- | --- | --- |
| `--config` | `~/.config/mail-muncher/config.yml` | 配置文件的路径。 |
| `--log-level` | `info` | `debug`、`info`、`warn` 或 `error`。日志是 stderr 上的 `log/slog` 文本。 |
| `-v`, `--version` | — | 打印版本。 |
每个命令的标志:
| 命令 | 标志 | 默认值 | 描述 |
| --- | --- | --- | --- |
| `run` | `--dry-run` | `false` | 获取并评估,报告将写入的内容,不写入任何内容也不保存任何状态。 |
| `run` | `--json` | `false` | 将机器可读的清单写入 stdout,每个帐户一个 JSON 对象。 |
| `daemon` | `--interval` | `5m` | 循环之间的时间,最小 30 秒。每个睡眠时间都会有高达 ±10% 的抖动。 |
| `daemon` | `--dry-run` | `false` | 与 `run --dry-run` 类似,在每个 tick 上。 |
| `daemon` | `--json` | `false` | 在每个循环后将清单写入 stdout;每 tick 一个 JSON 对象。 |
| `auth` | `--account` | — | 要认证的帐户。配置中如果有多个帐户,则需要此选项。 |
`mcp` 不接受任何标志;它完全由 `--config` 配置。
`--log-level debug` 会记录每个消息的规则决定 - 消息 ID、主题和获胜规则名称或 `no match` - 这是找出规则未触发的原因的最快方法。Debug 还会记录每个 `already stored` 跳过及其路径。所有这些都输出到 stderr,因此它不会干扰 stdout 上的 `--json`。
间隔低于 30 秒会被拒绝:
```
error: --interval 10s is below the 30s minimum
```
### 退出代码
| 代码 | 含义 |
| --- | --- |
| 0 | 成功。某个消息出错的循环(无法解析的消息,写入接收器失败)仍会退出 0 — 这些会在摘要中计算,不会升级。 |
| 1 | 配置或验证错误:文件无法解析,规则无法编译,缺少必填密钥。未获取任何内容。 |
| 2 | 提供者或身份验证失败:无缓存令牌,刷新 Google 被拒绝,API 在重试后无法访问。 |
| 3 | 另一个实例持有循环锁。退出代码 3 是重叠的 cron 调用报告“上一个仍在进行” 的方式。 |
隔离的消息 **不会** 更改退出状态:这是消息级失败,已处理,计为 `quarantined`,并在清单中报告。在计数器上触发警报,而不是在退出代码上。
`daemon` 绝不会退出 2。失败的循环会记录连续失败计数,并在下一个 tick 重新尝试 — 守护进程在最不合适的时刻退出时需要人类干预。其状态为 0(由 SIGINT 或 SIGTERM 停止,在完成飞行中的循环并保存状态后),1(配置或验证错误,包括低于最小值的 `--interval`),以及 3(另一个实例已在启动时持有实例或循环锁)。
### 运行摘要
每个循环为每个帐户记录并打印一行摘要:
```
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` | 消息 | 由于无法投递到隔离目录下的消息。 |
字段列表从 `fetched=` 到 `duration=` 连续运行,因此循环中任何不寻常的内容都在帐户标签上标记:
```
personal (dry-run): fetched=42 ...
personal (degraded, state held): fetched=42 ...
personal (stopped): fetched=12 ...
```
稳定状态的 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 流。
```bash
mail-muncher run --json 2>/dev/null | jq -r '.stored[] | "\(.rule)\t\(.path)"'
```
完整字段契约:[docs/manifest.md](docs/manifest.md)。
## 状态和锁定
```
~/.local/state/mail-muncher/
├── personal.json # 每个帐户一个,模式 0600
├── mail-muncher.lock # 循环锁,run、daemon 和 mcp sync 共享
├── instance/
│ └── mail-muncher.lock # 守护进程生命周期锁 — 每个状态目录一个守护进程
└── quarantine/
└── personal/
├── 18f2a1b2c3.eml # 未投递消息的原始字节
└── 18f2a1b2c3.json # 说明它在这里的原因
```
每个帐户的状态文件都是 JSON:
```json
{
"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 小时,以便在循环列出时到达的邮件不会被跳过。重叠是无害的:磁盘上已有的所有内容都在写入时跳过。
**删除帐户状态文件强制完整重新扫描** 以 `initial_lookback` 为界限。这是安全的 — 接入器跳过磁盘上已有的所有内容 — 并且是解决损坏的光标的受支持方式。
### 两个锁
- **循环锁** `<state_dir>/mail-muncher.lock` 是基于 `flock` 的,由 `run`、`daemon` 和 MCP `sync` 工具在每个循环期间持有。它阻止了 cron 调用与正在运行的守护进程竞争同一个光标。找到它被持有意味着“现在不行”,而不是“损坏”:`run` 退出 3,守护进程记录并跳过。
- **实例锁** `<state_dir>/instance/mail-muncher.lock` 由守护进程在其整个进程生命周期中持有。循环锁无法完成此任务 — 它在 tick 之间释放,因此第二个守护进程启动时,第一个守护进程睡眠时,会越过它并无限期轮询同一个光标,从而使 API 流量加倍。第二个守护进程立即退出 3:
```
另一个 mail-muncher 守护进程已经在运行此状态目录;不启动
```
如果进程崩溃,两个锁都会被 OS 释放。
### 隔离
在默认的 `on_message_failure: quarantine` 下,无法投递的消息被写入 `<quarantine_dir>/<account>/<id>.eml` — 原始字节,原样 — 带有 `.json` 边车,命名规则、失败的阶段(`parse` 或 `sink`)、错误和时间。边车是自成体系的;扫描目录不需要运行日志。
没有任何内容自动重新投递。修复原因并手动将 `.eml` 重新送入。目录是 0700,文件是 0600,与状态目录的其他部分一样 — 隔离的消息是整个电子邮件,位于其目标树之外。
## 调度
**cron** — 每十分钟一个循环。`run` 如果上一次调用仍在进行,则退出 3,因此重叠是无害的:
```cron
*/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`](contrib/crontab.sample)。
**launchd (macOS)** — 在 `launchd` 下运行守护进程,以便它在注销后存活并在失败时重新启动。 一个可编辑的 plist 位于 [`contrib/launchd/`](contrib/launchd/);将其复制到 `~/Library/LaunchAgents/`,编辑二进制文件和配置文件的路径,然后:
```bash
launchctl load ~/Library/LaunchAgents/<the-plist-you-copied>
```
**systemd** — 暂无单元文件;`mail-muncher daemon --interval 5m` 是一个直接的 `Type=simple` 服务,或将 `mail-muncher run` 与计时器配对。
## 回填:第一次运行
广泛的第一次运行是一种 **预期模式** ,而非滥用。默认的 `initial_lookback` 为 `720h`(30 天),这意味着您最想要的第一天事情 — 整个历史记录,在磁盘上一次性收集 — 默认情况下不会发生。
在第一次运行时设置一个广泛的值:
```yaml
accounts:
- name: personal
gmail:
credentials_file: ~/.config/mail-muncher/credentials.json
token_file: ~/.config/mail-muncher/token.json
initial_lookback: 13140h # ~18 个月 — 仅限第一次运行
```
然后运行一次,并 **在之后降低该值**:
```yaml
initial_lookback: 720h
```
降低它是安全的,因为 `initial_lookback` 仅限于帐户的 **首次** 扫描。之后的每个循环都从存储的光标恢复,并忽略该键。仅当删除帐户的状态文件时,它才会被重新启用。
预期情况:
- 这是一个 **大型完整扫描**,限速并重试,可能需要一段时间。下载同时运行四个,页面大小为 500 条消息;两者都不可配置。
- **查看后再提交。** `mail-muncher run --dry-run` 像真正的运行一样获取和评估,并报告它 *将* 编写的每个路径,而不触及目标树。添加 `--json` 来计算它们:
```bash
mail-muncher run --dry-run --json 2>/dev/null | jq '.summary'
```
- 之后的运行应报告所有内容均为 `skipped`。这是确认回填实际到位的检查。
持续时间年单位 — 乘以小时。18 个月是 `13140h`,一年是0h` 天是 `216。
2000 seen 集** 限制参见 [.md](docs.md#the原因如下:确定性文件名和存在时跳真正的键,而 seen 集。
##范围
Pre-。当前 [v0.1.0](https://github.com/craigjmidwinter/mail-muncher/releases/latest);没有` 的构建版本报告其版本为 `。配置模式已编写,但在此将其视为可能 1.0。
区域 || --- | --- 提供者(OAuth、完整扫描、历史同步、 | 已构建 配置加载和验证构建 |
|(上述所有谓词) | 已构建 |
| `.eml` 和 markdown 接入器 | 已构建 |
| `run`、`daemon`、循环锁、实例锁 | 已构建 |
| `--json` 运行清单 | 已构建 |
| `mcp` — stdio MCP 服务器,五个工具构建 |
| 隔离和两个故障策略 | 已构建 |
| IMAP 提供者 | **计划中,尚未构建。** 没有 `provider: imap` 块;配置拒绝除 `gmail` 之者。 |
故意不在范围内写入您的邮箱读范围是一种设计约束,而非阶段标签,无删除草稿。
邮件客户端。,无索引_messages`文件的大小写敏感,而非无词干提取,无查询语言。线程按每个消息上 分组重新组装为。
- **网络 API。** 无监听套接字。` 通过 stdio启动为的客户端通信 — 无端口,无 HTTP 端点,无网络表面。身份验证在过程中打开。
- **非 Gmail 提供** 提供者接口IMAP 是实现,但尚未### 已知- 内联 `cid:` 图片在 markdown为未。
- 主题仅限 ASCII;非拉丁语主题 `no-sub- `gmail.query` 仅适用于 **首次** 适用于增量循环,也不限于过期后的恢复扫描 完整扫描邮件和回收站。使用 `SPAM` 和ASH` 标签的它们。
-下载并发性(4)和页面大小(配置。
- 没有谓词看到消息;匹配基于标签和日期 文档
可在://craigjmidwinter.github.io/mail 或此:
-/gmail-setupdocs/gmail-setup.md) — Google Cloud练和 OAuth排除。
- [.md](docs/configuration.md) — 每个、其验证故障模式。
- [docs/filters.md](docs/filters.md) — 完整的匹配树语言和真实规则的食谱。
- [docs/output-format.md](docs/output-format.md) — 磁盘契约:布局、文件名、每个前置键和树的 安全规则之前阅读它。
- [docs.md](anifest.mdjson` 清单契约,字段。
-/mcp.md](cp.md) 服务器:客户端和每个工具的参数和返回形状。
-itecture.md](docs/architecture.md)流水线、隙以及您的更改属于的位置。
- [CONTRIBUTING.md](CONTRIBUTING.md) —测试和坚持的约定。
## 许可证
MIT。参见 [LICENSE](LICENSE)。
MCP 配置
以下是此 MCP Server 的配置信息,可直接复制到 Cursor 或其他 MCP 客户端中使用。
mcp.json
连接信息
猜你喜欢
暂无推荐服务器