Architecture
Written for someone about to change something and deciding where the change belongs.
The shape
provider seam sink seam
│ │
┌──────────┐ │ ┌───────┐ ┌────────┐ │ ┌──────────┐
│ Gmail │─────────┼───▶│ parse │───▶│ filter │────────┼──▶│ .eml │
│ (IMAP) │ │ │ │ │ engine │ │ │ markdown │
└──────────┘ │ └───────┘ └────────┘ │ └──────────┘
▲ │ ▲ │
│ │ │ ▼
┌──────────┐ │ ┌───────────┐ files on disk
│ state │◀────────┴────────────────│domains.txt│
│ store │ cursor saved │(owned by │
└──────────┘ after a cycle │ another │
│ program) │
└───────────┘
Data flows left to right and never back. Nothing downstream of the provider seam knows what a provider is; nothing upstream of the sink seam knows what a file is.
Packages
| Package | Owns | Depends on |
|---|---|---|
cmd/mail-muncher |
Cobra command tree, flags, exit codes, logger setup. No behavior. | internal/config, internal/filter, internal/provider/gmail, internal/pipeline |
internal/config |
YAML schema, defaults, path expansion, validation. | gopkg.in/yaml.v3 only |
internal/model |
The canonical Message and RFC822 parsing. |
enmime |
internal/provider |
The Provider interface, RawMessage, SyncState, retry/backoff, a Fake. |
nothing internal |
internal/provider/gmail |
OAuth, token cache, list/history/RAW fetch, label resolution. | internal/provider, internal/config |
internal/provider/imap |
password_cmd, TLS dial and login, per-mailbox UID cursors, BODY.PEEK[] fetch. |
internal/provider, internal/config, go-imap/v2 |
internal/filter |
Match-tree compilation, the Engine, the domain-file cache. |
internal/config, internal/model |
internal/sink |
On-disk layout, .eml and markdown writers, atomic writes. |
internal/config, internal/model |
internal/state |
Per-account sync state files and the flock-based lockfile. |
internal/provider |
internal/pipeline |
One cycle: fetch → parse → evaluate → sink → save state. The manifest, the exit-code grading, quarantine. | everything above |
internal/mcpserver |
The MCP tools, the archive index over the dest trees, and the path jail. |
internal/config, internal/model, internal/sink, internal/filter, internal/pipeline |
The dependency graph is acyclic and shallow on purpose. internal/config at
the bottom knows about no other internal package; internal/pipeline and
internal/mcpserver at the top are the only places that know about all of them.
internal/mcpserver sits beside the pipeline rather than above it: it reads
the archive on disk directly, and reaches into the pipeline only through
Runner.Cycle for the sync tool. It never touches a provider.
The two seams
The provider seam — pluggable input
internal/provider.Provider is the architectural invariant:
type Provider interface {
Name() string
Fetch(ctx context.Context, state SyncState, fn func(RawMessage) error) (SyncState, error)
}
A provider streams RawMessage values — a provider-scoped stable id, complete
RFC822 bytes, an internal date, and label names — and returns an updated
SyncState. Everything downstream sees those two types and never a provider
SDK type. That is what makes the choice of Gmail-over-IMAP a reversible
decision rather than a one-way door.
The test of the seam: adding a provider must require zero changes to
internal/pipeline, internal/filter, or internal/sink. If your new
provider needs a pipeline change, the seam is wrong and the fix belongs in the
interface, not in a special case downstream.
Three details the interface deliberately pins down:
IDmust be stable across runs. It is half the input to the sinks’ idempotency digest, so an unstable id means re-downloading and re-writing the same message under a new name forever. Gmail uses the message id; IMAP usesaccount:mailbox:uidvalidity:uid.Rawmust be byte-faithful. The.emlsink writes it verbatim andinternal/modelparses from it. Re-encoding anywhere upstream breaks DKIM verification for every archived message.SyncStateis carried through, not owned. A provider updates the fields it understands (HistoryIDfor Gmail,Extrakeys for others) and leaves the rest alone.SeenIDsbelongs to the pipeline.
Fetch streams rather than returning a slice so a large first scan does not
have to fit in memory, and so the pipeline can store as it goes.
The sink seam — pluggable output
internal/sink.Sink is the other end:
type Sink interface {
Format() config.Format
Path(msg *model.Message, rule *config.Rule) string
Plan(msg *model.Message, rule *config.Rule) (path string, skipped bool, err error)
Store(msg *model.Message, rule *config.Rule) (path string, skipped bool, err error)
}
Path is pure and does no I/O. Plan adds exactly one Stat. Store writes.
The split is what makes --dry-run honest: it reports the precise path a real
run would produce without touching the destination tree.
Sinks hold no per-message state and are safe to reuse across messages and
rules. sink.ForRule returns one sink per format on a rule, in the rule’s
order, collapsing duplicates — that is the fan-out point.
A new output format is a new Sink implementation plus a config.Format
constant plus a case in sink.New. Nothing else changes.
Reading back: the archive
internal/mcpserver is the only code that reads the archive rather than writing
it, and it treats the on-disk layout as the schema: it parses the [:16] digest
out of a basename as the message id, reads the .md frontmatter for metadata,
and falls back to parsing the .eml when there is no markdown rendering. The
index is lazy and keyed by size and mtime, so an unchanged file is never
re-parsed.
Its one security-relevant invariant is the path jail. Every rule dest is
resolved once at construction; a caller-supplied path is then checked lexically
against those roots, resolved with filepath.EvalSymlinks, and checked again
against the resolved roots. Two checks, because either alone is defeatable:
lexical alone follows a planted symlink, and post-resolution alone can be raced.
Only .eml and .md are readable, the walk never follows symlinks, and a
refusal is deliberately indistinguishable from “no such file” so the jail cannot
be used to probe the filesystem.
The invariants
Break any of these and something subtle breaks downstream.
Identity determines path. sink.Basename is
<unix-seconds>-<sha256(account + ":" + id)[:16]>-<slug(subject)>, where the
width comes from the exported sink.HashLen. Only the digest carries identity;
the timestamp and slug are for humans. This is why:
- Re-running is safe. The file being there means an earlier cycle wrote it.
- The slug is ASCII-only. Non-ASCII filenames are subject to filesystem Unicode normalization (HFS+ stores NFD), which can make the name written differ from the name the next cycle stats for — and that existence check is the entire idempotency story.
- Renaming an account or changing the digest input rewrites every future filename and orphans everything already archived.
- The fragment is public layout, not an implementation detail:
internal/mcpserverparses it back out of a filename as the message id it reports to agents. ChangingHashLenchanges an API.
The seen set is belt and braces, not the idempotency key.
provider.SyncState.SeenIDs is capped at 2000, FIFO, so any scan larger than
that evicts its earliest ids. This is harmless and must stay that way: the real
idempotency key is the deterministic filename plus skip-on-exists. A re-run
after eviction re-downloads the message and then skips it at the write, costing
bandwidth and nothing else. Do not “fix” this by unbounding the set — an
unbounded set grows without limit in a file that is rewritten every cycle, to
buy a guarantee the sinks already provide.
Writes claim a name, they do not replace one. A message file goes to a temp
file in its destination directory, is fsynced and chmodded, and is then
hard-linked into place with os.Link. link(2) fails with EEXIST rather
than clobbering — unlike rename(2), which cannot express “only if absent” — so
the kernel decides whether the name is free at the instant it is claimed. That
is the idempotency check, not a step after one: there is no window between
deciding and acting. A filesystem without hard links falls back to
O_CREAT|O_EXCL, trading the no-partial-file guarantee for the same
no-clobber one.
Attachments and state files still use temp-and-rename, deliberately: their names are not idempotency markers, so replacing is correct for them.
Symlinks in the layout are refused, never followed. Nothing a sink writes is
legitimately a link, so one means something else is placing them there.
ErrSymlink covers a link at a message’s final path and at any directory
component the sink itself creates (<YYYY>, <MM>, <basename>.attachments).
The rule’s own dest: is exempt — the operator chose that path. Existence
checks use Lstat, not Stat, so a link planted at a destination is an error
rather than a silent “already stored”.
Message-level errors never abort a cycle by default. A message that will not
parse, or one whose renderings failed to write, is handled by
on_message_failure: quarantine (the default) parks the raw bytes under the
quarantine directory, counts it, and lets the cursor advance; abort returns a
*MessageError and the account’s state is not saved. Either way the message is
never both undelivered and forgotten. Provider and auth errors always abort the
account — they will not get better by trying the next message — but the other
accounts still run.
Domain files are read once per cycle, and unreadable is a reportable event.
filter.Engine.BeginCycle clears the cache and Preload re-reads every
referenced list up front, so Engine.Degraded() is authoritative before the
first message is fetched. That ordering is what makes on_degraded_filter: fail
implementable at all: it can only promise to store nothing if it knows first. A
missing file is still not an evaluation error — the predicate matches nothing —
but it is reported, and by default (hold) it stops the cursor from advancing.
State advances past unmatched messages — unless the filter was degraded. A
message evaluated once is normally never evaluated again, whether or not a rule
claimed it. The exception is deliberate: under hold or abort the cursor is
held, precisely so mail evaluated against an unreadable list is evaluated again.
Changing rules still does not retroactively archive old mail; deleting a state
file and re-scanning does.
There are two locks, and they answer different questions.
<state_dir>/mail-muncher.lock—flock-based, held for the duration of each cycle byrun, by every daemon tick, and by the MCPsynctool. It answers “is a cycle running right now?” and is what stops a cron invocation racing a daemon on the same cursors.<state_dir>/instance/mail-muncher.lock— held by a daemon for its whole process lifetime. It answers “is something else already polling this state directory?”, which the cycle lock cannot: that one is released between ticks, so a second daemon starting while the first sleeps would pass it and then poll forever alongside it. It lives in a sub-directory so it cannot be confused with the cycle lock.
Both return an error matching state.ErrLocked, which pipeline.ExitCode grades
as exit 3.
There is one manifest shape. pipeline.Manifest is what run --json writes,
what daemon --json streams one line at a time, and what the MCP sync tool
returns. Its JSON tags are API surface, not debug output — see
manifest.md. A new field must be additive and omitempty unless
it is genuinely always present.
Where does my change go?
| Change | Where |
|---|---|
| A new config key | internal/config/config.go (struct + default), validate.go (rule + severity), and docs/configuration.md. Add it to a test in config_test.go. |
| A new match predicate | internal/filter/compile.go (a compileX function, a case in compileNode, an entry in predicates), the package doc in doc.go, and docs/filters.md. If it needs message data that does not exist yet, add an accessor to internal/model/message.go first. |
| A new predicate whose value is a file path | Also add the key to filePathPredicates in internal/config/expand.go, or its ~/$VAR will not expand and validate will not check it. |
| A new output format | A new Sink in internal/sink, a config.Format constant, a case in sink.New, and validateFormats prose. |
| A new provider | A package under internal/provider/, implementing Provider. A config block, a case in validateAccounts, and a case in pipeline.DefaultProviderFactory — that switch is the one line outside internal/provider that may change. Nothing else in pipeline, and nothing in filter or sink. |
| Anything about what a message exposes | internal/model. Parsing lives in parse.go, accessors in message.go. |
| A new CLI flag | cmd/mail-muncher. Keep behavior in an internal package and the command thin. |
| A new manifest field | internal/pipeline/manifest.go, and docs/manifest.md. Additive and omitempty; the JSON tags are API surface. |
| A new MCP tool, or an argument on one | internal/mcpserver/tools.go (the input/output structs — schemas are inferred from them, so they cannot drift), server.go (registerTools, ToolNames), and docs/mcp.md. Anything that reads a file must go through Archive.Resolve. |
| Retry or backoff behavior | internal/provider/retry.go, which is shared. Gmail’s judgment of which errors are retryable lives in gmail.go (retryable, isRateLimited) because it needs the error body. |
Design decisions worth knowing
Gmail API first, IMAP second — and the seam held. The Gmail API gives OAuth,
format=RAW (so storage and parsing stay provider-neutral anyway), labels as
data, and users.history for cheap incremental sync, which made it the cheaper
first implementation. IMAP was the test of the claim above, and it passed:
adding it took a package under internal/provider/, a config block, and one
case in DefaultProviderFactory. internal/pipeline, internal/filter and
internal/sink were not touched.
The two things IMAP needed that Gmail did not, it got from the interface as it
already stood. Per-mailbox UID bookkeeping lives in SyncState.Extra, which is
free-form per-provider state. And an empty RawMessage.ThreadID is not a gap:
IMAP has no conversation id, and model.Parse already synthesized one from the
References chain for exactly this case.
A UIDVALIDITY change is a resync, not a hint. When an IMAP server’s
UIDVALIDITY differs from the stored one, the mailbox’s UID cursor is discarded
and the fetch falls back to initial_lookback. That is the protocol saying
every UID you remember now names a different message, and honouring it
half-heartedly loses or duplicates mail silently. The UIDVALIDITY is also part
of the message id, so the resync re-archives its window under fresh filenames
rather than colliding with what the mailbox’s previous incarnation left behind:
duplicated mail is recoverable, skipped mail is not.
The history cursor is best-effort. Gmail keeps roughly a week of history. A
404 from users.history.list is not an error: the cursor is cleared and the
same cycle falls through to a full scan. The full-scan path is not a legacy
path — it is the permanent fallback, and it must keep working.
The mailbox cursor is read before listing, not after. In fullScan,
users.getProfile is called first. Mail arriving during a scan then still shows
up in the next incremental cycle, and the seen-set absorbs the overlap. Taking
the cursor afterwards would silently skip those messages.
gmail.query is an optimization, not a filter, and it applies to the
first-ever scan only. The gate is firstEver := state.HistoryID == 0 &&
state.LastSyncTime.IsZero(), computed from the incoming state before the
expired-cursor branch clears anything — so a recovery scan is not a first run
even though it ends up on the same code path, and it deliberately sends no
query. History results are not query-filtered either, and the query is never
re-applied locally, because the filter engine is the single authority on what is
kept. Two places that both decide what is archived would eventually disagree.
Narrowing the query is therefore only ever a way to make one first run cheaper. It cannot bound steady-state behavior, and a query that excludes something your rules would keep makes the two scan modes inconsistent for no benefit.
Spam and Trash are excluded by default, on both paths.
gmail.include_spam_trash defaults to false, and the default is a security
decision: delivered mail ends up in an LLM’s context window, and Spam is the
highest-density source of hostile, attacker-authored text a mailbox contains.
The interesting part is not the default but the invariant underneath it. The
history path has no includeSpamTrash parameter and reports everything added
anywhere in the mailbox, so whichever way the setting points, the two paths must
be made to point the same way — a full scan that enumerated a different
population than an incremental cycle over the same window would lose mail
silently, because the expired-cursor fallback installs a fresh cursor on the way
out and never revisits what it skipped. So:
- The full scan passes the setting straight to
IncludeSpamTrash, on first-ever and recovery scans alike. - The incremental path cannot ask the server, so it downloads and then drops
messages carrying
SPAMorTRASHbefore they reach the pipeline — onemessages.getspent to learn what a message is and then discard it. The same predicate also runs on the full-scan path, where it is normally a no-op; one predicate on both routes is what makes the agreement structural.
A dropped message is not an error and not a parse failure. It is not marked seen, either: the seen-set records deliveries, and leaving excluded ids out of it is what keeps the two paths’ persisted state identical. Nothing loops as a result — the history cursor advances past a message whatever we decide about it, and a later full scan excludes it server-side.
Once fetched, rules remain the only authority on what is kept: with the key on,
not: {label: SPAM} is how you take one folder and not the other.
Recovery scans overlap the watermark by 24 hours. RecoveryOverlap is
subtracted from the stored LastSyncTime before it becomes the after: bound,
so mail that arrived while the previous cycle was listing is not stepped over.
The overlap re-fetches a day of mail and then skips all of it at the write —
cheap insurance bought with the idempotency the sinks already guarantee.
Parsing never panics. model.Parse recovers panics from the MIME parser and
returns them as errors. Mail is hostile input from strangers; one malformed
message must not kill a cycle.
Markdown is a rendering, not a copy. It is lossy by design — most visibly,
inline cid: images stay unresolved links. The .eml is the fidelity copy, and
any feature request of the form “make markdown byte-accurate” is really a
request to read the .eml.
Read-only is structural on both providers, by different means. Gmail
requests only gmail.readonly: there is no write path to remove, because there
is no write path. IMAP has no scopes to lean on, so it is enforced twice in the
client — folders are opened with EXAMINE rather than SELECT, and bodies are
fetched with BODY.PEEK[] rather than BODY[]. Either alone suffices on a
compliant server; both are used because a bare BODY[] sets \Seen on mail a
human is also reading, and nothing behind that one field would stop it. It has
a test whose negative control proves the test can fail.
Testing the seams
internal/provider.Fake replays canned RawMessage values and can simulate a
fetch dying partway through with partial state. It is how the pipeline gets
tested end to end with no network:
p := provider.NewFake("gmail", msgs...)
The Gmail provider itself is tested against an httptest server holding canned
JSON, via gmail.NewWithHTTPClient(ctx, account, srv.Client(), srv.URL, opts).
The IMAP provider is tested against go-imap’s in-memory server on a loopback
socket — a real server speaking the real protocol, because the behaviours that
matter there (UIDVALIDITY, the n:* range quirk, \Seen on a non-PEEK fetch)
are the server’s behaviours, and a hand-written fake would only assert what we
already believed. Options.TLSConfig lets a test mint a throwaway certificate
so the default tls: true path is exercised rather than assumed; there is no
config key for it, and there should not be one.
No test in this repo touches the network. See CONTRIBUTING.md.