This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new c9f3bec feat(mail-source): backend-contract abstraction so adopters
can plug in any mail source (#277)
c9f3bec is described below
commit c9f3bec1fc358e5ea3d7f4670c7a89057b0b0656
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon May 25 13:54:41 2026 +0200
feat(mail-source): backend-contract abstraction so adopters can plug in any
mail source (#277)
* feat(security): skip obvious bot/AI accounts from CVE credits by default
Adds a bot/AI credit policy that fires at every site where the security
skill suite auto-extracts a candidate finder or remediation-developer
credit. When the candidate matches the policy (obvious bot or
automation), the skills now skip the credit by default, surface the
skip with the matched rule so the user can judge, and -- on email-
backed trackers -- propose a Gmail draft asking the reporter whether
the bot/AI handle is the intended attribution or whether a human
behind it should be credited instead.
- New `tools/vulnogram/bot-credits-policy.md`: single source of truth
for the detection heuristic and per-skill enforcement contract.
Detection covers GitHub `[bot]` suffix; known-bot / automation-name
list (dependabot, renovate, snyk, copilot, github-actions, mend,
mergify, automated, scanner, sast/dast, ...); suffix patterns
(`*-bot`, `*-ai`, `*-agent`, `*-gpt`, `*scanner*`, `*automat*`);
and noreply / service-relay email senders. Default behaviour: skip
silently in the data flow + surface the skip with which rule fired
+ honour explicit per-tracker overrides + propose a clarification
Gmail draft when an inbound reporter thread exists.
- `security-issue-import`: apply at *Reporter credited as*
(`From:`-header extraction) and at the ASF-relay `Credit:`-line
extraction; fold the clarification draft into the Step 7 receipt-
of-confirmation reply.
- `security-issue-import-from-pr`: apply at *Remediation developer*
(PR author resolution); no clarification draft (no inbound
reporter).
- `security-issue-import-from-md`: apply when the markdown carries a
finder-metadata line naming a specific handle.
- `security-issue-sync`: apply at the reporter-reply credit-mining
site (with clarification draft) and at the PR-author auto-append
for *Remediation developer*.
- `security-issue-deduplicate`: apply when consolidating credit
lines between two trackers; clarification draft on the drop
tracker's reporter thread if one exists.
- `generate-cve-json/SKILL.md`: add a "stays neutral; filter lives
upstream in the skills" note pointing at the policy doc, so the
upstream-vs-tool boundary is explicit and intentional human
overrides survive every JSON regeneration without a bypass flag.
Manual credits a human security-team member typed in are always
preserved verbatim -- the filter only fires on auto-extracted
candidates.
Generated-by: Claude Code (Opus 4.7)
* feat(mail-source): backend-contract abstraction so adopters can plug in
any mail source
Refactors the mail-source surface that `security-issue-import` and
`security-issue-sync` use so that Gmail, Ponymail, IMAP, mbox, and
any future backend are treated uniformly. Adopters declare their
backends in `<project-config>/project.md` with per-backend roles
(`primary` / `preferred for <op>` / `fallback` / `optional`) and a
`mandatory` flag; the skills apply a documented resolution rule per
operation at run time.
- New `tools/mail-source/contract.md`: single source of truth.
Defines the six abstract operations a backend may implement
(`list_recent_threads`, `read_thread`, `list_drafts`,
`list_sent_since`, `create_draft`, `thread_url`), the capability
matrix for the in-tree adapters, the adopter declaration shape,
role semantics, the mandatory flag, and the resolution rule for
which backend serves which op.
- New `tools/mail-source/imap/README.md` (stub): generic IMAP
adapter contract. Drafts gated on Drafts-mailbox write rights;
RFC-5322 Message-ID as the canonical thread identifier.
- New `tools/mail-source/mbox/README.md` (stub): read-only static
archive adapter contract. Forensics / late triage / air-gapped
use cases.
- `tools/gmail/tool.md`: opening reframed -- Gmail is now an
adapter implementing the mail-source contract with the full
capability set.
- `tools/ponymail/tool.md`: opening reframed -- read-only adapter;
adopters that name it primary must pair it with a draft-capable
backend via `preferred for create_draft`.
- `projects/_template/project.md`: replaced the *Gmail and PonyMail*
section with a *Mail sources* section carrying the backend-
declaration table and the per-backend config keys (Gmail, Ponymail,
IMAP, mbox). Updated the *Tools enabled* row for inbound mail to
reference the new contract.
- `security-issue-import` Prerequisites + Step 0: rewritten in
terms of the abstract operations and the resolution rule;
mandatory-backend stop / non-mandatory degrade is now explicit;
legacy Gmail-centric guidance kept as the reference-adopter
description so the existing operational detail still applies.
- `security-issue-sync` Prerequisites + Step 0: same reframing --
backends declared in project.md, contract-driven resolution.
The reference adopter's behaviour is unchanged (Gmail primary
mandatory + Ponymail fallback non-mandatory produces exactly the
existing flow); an adopter that needs IMAP, mbox, or some other
source now has a documented contract to plug into rather than
re-deriving the abstraction in a fork.
Generated-by: Claude Code (Opus 4.7)
---
.claude/skills/security-issue-import/SKILL.md | 101 +++++++++------
.claude/skills/security-issue-sync/SKILL.md | 34 +++--
projects/_template/project.md | 66 +++++++---
tools/gmail/tool.md | 19 ++-
tools/mail-source/contract.md | 174 ++++++++++++++++++++++++++
tools/mail-source/imap/README.md | 97 ++++++++++++++
tools/mail-source/mbox/README.md | 95 ++++++++++++++
tools/ponymail/tool.md | 16 ++-
8 files changed, 534 insertions(+), 68 deletions(-)
diff --git a/.claude/skills/security-issue-import/SKILL.md
b/.claude/skills/security-issue-import/SKILL.md
index 941bbec..a7b10f8 100644
--- a/.claude/skills/security-issue-import/SKILL.md
+++ b/.claude/skills/security-issue-import/SKILL.md
@@ -145,18 +145,33 @@ Drift severity:
Before running, the skill needs:
-- **Gmail MCP** connected to a Gmail account subscribed to
- `<security-list>`. The skill reads threads and
- creates drafts through this MCP; without it, there is no way
- to discover new reports.
+- **At least one configured mail-source backend** per
+ [`<project-config>/project.md → Mail
sources`](../../../<project-config>/project.md#mail-sources).
+ The skill treats every backend the same way — through the
+ abstract operations defined in
+ [`tools/mail-source/contract.md`](../../../tools/mail-source/contract.md)
+ (`list_recent_threads`, `read_thread`, `list_drafts`,
+ `list_sent_since`, `create_draft`, `thread_url`). Reference
+ adapters: [`gmail`](../../../tools/gmail/tool.md) (full
+ read+write), [`ponymail`](../../../tools/ponymail/tool.md)
+ (read-only ASF archive),
+ [`imap`](../../../tools/mail-source/imap/README.md) (stub),
+ [`mbox`](../../../tools/mail-source/mbox/README.md) (read-only
+ offline archive — stub). To **discover new reports** the
+ configured backends must collectively cover
+ `list_recent_threads` + `read_thread`; to **draft the
+ receipt-of-confirmation reply in Step 7** they must
+ additionally cover `create_draft`. If no available backend
+ covers `create_draft`, Step 7 surfaces a one-line *"no draft
+ backend available"* note and the user composes the reply by
+ hand.
- **`gh` CLI authenticated** (`gh auth status` returns OK) with
collaborator access to `<tracker>`. The skill calls
`gh issue create` and `gh search issues` directly.
See
[Prerequisites for running the agent
skills](../../../docs/prerequisites.md#prerequisites-for-running-the-agent-skills)
-in `docs/prerequisites.md` for the overall setup and the
-ponymail-mcp alternative on the horizon.
+in `docs/prerequisites.md` for the overall setup.
---
@@ -164,34 +179,45 @@ ponymail-mcp alternative on the horizon.
Before touching any candidate thread, verify:
-1. **Gmail MCP is reachable.** Run a trivial
- `mcp__claude_ai_Gmail__search_threads` with `pageSize: 1` and
- confirm it returns (not an auth error). If it fails, **stop
- immediately** and tell the user to configure Gmail MCP. Gmail
- is the load-bearing inbox + the only backend that can create
- the receipt-of-confirmation drafts this skill produces, so a
- Gmail failure is always a stop.
+1. **Mail-source backends from `<project-config>/project.md →
+ Mail sources` are available.** For each declared backend, run
+ the backend's trivial health probe (per its adapter doc —
+ Gmail: `mcp__claude_ai_Gmail__search_threads` with `pageSize:
+ 1`; Ponymail: `mcp__ponymail__auth_status()`; IMAP: a
+ `CAPABILITY` against the configured host; mbox: a `stat` on
+ the archive path) and record the result in the skill's
+ observed-state bag. Apply the
+ [contract's resolution
rule](../../../tools/mail-source/contract.md#resolution-rule--which-backend-runs-an-operation)
+ to figure out which backend serves which op for this run.
+
+ * **`mandatory: yes` backend unavailable** → **stop
+ immediately**. Surface *"mandatory mail-source backend
+ `<name>` unavailable: `<reason>`; run aborted"*. The user
+ fixes the auth / connection and re-invokes.
+ * **`mandatory: no` backend unavailable** → continue with the
+ remaining backends. If the resolution then leaves an
+ operation with no provider (e.g. no available backend
+ supports `create_draft`), the skill records *"no `<op>`
+ backend available"* in the observed-state bag and the
+ relevant downstream step omits that proposal with a clear
+ hand-back to the user.
+ * **Every declared backend healthy** → proceed; the
+ observed-state bag records one provider per op so every
+ dispatch later is unambiguous.
2. **`gh` is authenticated and has access.** Run
`gh api repos/<tracker> --jq .name`; if it errors
(401, 403, 404), stop and tell the user to log in with
`gh auth login` or get added to `<tracker>`.
-3. **PonyMail MCP status** (opt-in; primary read path when
- enabled) — read `.apache-steward-overrides/user.md` → `tools.ponymail`. If
- `enabled: true`, call `mcp__ponymail__auth_status()` once and
- record `ponymail_enabled` + `ponymail_authenticated` in the
- skill's observed-state bag. **When authenticated, downstream
- steps (1 candidate listing, 2b prior-rejection search) treat
- PonyMail MCP as the primary read path** and use Gmail as the
- fallback — except that Gmail remains the primary source for
- *just-arrived* inbound mail where inbox latency beats archive
- indexing delay (see per-step guidance below). Gmail always
- remains the draft-composition backend; PonyMail MCP has no
- write path. When PonyMail MCP is not enabled, not
- authenticated, or its tools are not available in the current
- session, proceed Gmail-only (no stop, one-line warning only
- when enabled but unauthenticated). See
- [`tools/ponymail/tool.md`](../../../tools/ponymail/tool.md)
- for the one-time setup instructions.
+3. **(Legacy guidance — kept for the reference adopter.)** The
+ reference adopter (`airflow-s`) lists `gmail` as primary
+ `mandatory: yes` and `ponymail` as fallback `mandatory: no`,
+ which produces the behaviour the rest of this skill describes:
+ Gmail handles reads of just-arrived inbound mail and all draft
+ creation, Ponymail handles archive lookups and degrades quietly
+ when unauthenticated. Adopters with different `Mail sources`
+ tables will see the resolution rule pick differently — the
+ step-by-step references to "Gmail" below should be read as
+ "the backend the resolution rule picked for the relevant op".
4. **Privacy-LLM contract.** This skill reads `<security-list>`
bodies that may contain third-party PII the reporter
discloses about other people. Run the gate-check first —
@@ -226,13 +252,14 @@ Before touching any candidate thread, verify:
when (and only when) the draft references a third-party
identifier.
-If the Gmail or `gh` check fails (PonyMail degrades quietly), do
-**not** proceed — the skill would fail mid-flow otherwise, leaving
-half-built state (a draft on the wrong thread, or a tracker with
-no receipt reply). Fail fast instead. A privacy-llm pre-flight
-failure is also a hard stop — the redactor's mapping store and
-the collaborator-source lookup are both load-bearing for every
-subsequent body read.
+If a `mandatory: yes` mail-source backend or the `gh` check fails,
+do **not** proceed — the skill would fail mid-flow otherwise,
+leaving half-built state (a draft on the wrong thread, or a tracker
+with no receipt reply). Fail fast instead. `mandatory: no` backends
+degrade quietly per the contract's resolution rule. A privacy-llm
+pre-flight failure is also a hard stop — the redactor's mapping
+store and the collaborator-source lookup are both load-bearing for
+every subsequent body read.
---
diff --git a/.claude/skills/security-issue-sync/SKILL.md
b/.claude/skills/security-issue-sync/SKILL.md
index dff2a15..3efed88 100644
--- a/.claude/skills/security-issue-sync/SKILL.md
+++ b/.claude/skills/security-issue-sync/SKILL.md
@@ -319,9 +319,19 @@ or an ambiguous credit line).
The skill needs:
-- **Gmail MCP** connected to an account subscribed to
- `<security-list>`. Required for reading the reporter
- thread and drafting status updates.
+- **At least one configured mail-source backend** per
+ [`<project-config>/project.md → Mail
sources`](../../../<project-config>/project.md#mail-sources),
+ collectively covering `read_thread` (for the reporter thread)
+ and — if status-update drafts will be proposed — `create_draft`.
+ The skill uses the abstract operations defined in
+ [`tools/mail-source/contract.md`](../../../tools/mail-source/contract.md)
+ and the contract's
+ [resolution
rule](../../../tools/mail-source/contract.md#resolution-rule--which-backend-runs-an-operation)
+ to pick a backend per op at run time. Reference adapters:
+ [`gmail`](../../../tools/gmail/tool.md),
+ [`ponymail`](../../../tools/ponymail/tool.md),
+ [`imap`](../../../tools/mail-source/imap/README.md),
+ [`mbox`](../../../tools/mail-source/mbox/README.md).
- **`gh` CLI authenticated** with collaborator access to
`<tracker>` (read + issue-write) and `<upstream>`
(read is enough — the sync only reads PR state on that repo).
@@ -339,12 +349,18 @@ in `docs/prerequisites.md` for the overall setup.
Before reading any tracker state, verify:
-1. **Gmail MCP is reachable** — trivial
- `mcp__claude_ai_Gmail__search_threads` with `pageSize: 1`; an
- auth error here means Gmail MCP is not configured, stop and
- say so. Gmail is the load-bearing backend for inbox reads and
- the only backend that can create drafts, so a Gmail failure is
- always a stop.
+1. **Mail-source backends per
+ `<project-config>/project.md → Mail sources` are available** —
+ for each declared backend run its trivial health probe (per its
+ adapter doc), record the result in the observed-state bag, and
+ apply the
+ [contract's resolution
rule](../../../tools/mail-source/contract.md#resolution-rule--which-backend-runs-an-operation)
+ to figure out which backend serves which op for this run. A
+ `mandatory: yes` backend that is unavailable is a **hard stop**;
+ `mandatory: no` backends degrade quietly and the affected ops
+ are skipped per the contract. The reference adopter
+ (`airflow-s`) has Gmail as `mandatory: yes` primary, so for the
+ reference flow a Gmail-MCP failure is always a stop.
2. **`gh` is authenticated** with access to `<tracker>` —
`gh api repos/<tracker> --jq .name` must return
`<tracker>`. A 401/403/404 means the user needs
diff --git a/projects/_template/project.md b/projects/_template/project.md
index 8420b23..87cbb3b 100644
--- a/projects/_template/project.md
+++ b/projects/_template/project.md
@@ -9,7 +9,9 @@
- [Tools enabled](#tools-enabled)
- [CVE tooling](#cve-tooling)
- [GitHub project board](#github-project-board)
- - [Gmail and PonyMail](#gmail-and-ponymail)
+ - [Mail sources](#mail-sources)
+ - [Backend declaration](#backend-declaration)
+ - [Per-backend config](#per-backend-config)
- [Issue-template fields](#issue-template-fields)
- [Pointers to sibling files](#pointers-to-sibling-files)
@@ -85,7 +87,7 @@ publicly archived lists may appear in CVE `references[]` as
| Capability | Tool | Adapter directory | Config knobs declared here |
|---|---|---|---|
| Issue tracking + source control + project board | `github` |
[`../../tools/github/`](../../tools/github/) | `tracker_repo`, `upstream_repo`,
`github_project_board_*`, `issue_template_fields` |
-| Inbound email / drafts | `gmail` |
[`../../tools/gmail/`](../../tools/gmail/) | `security_list` subscription;
PonyMail archive URL templates below |
+| Inbound email / drafts | `<one or more mail-source backends>` |
[`../../tools/mail-source/contract.md`](../../tools/mail-source/contract.md)
(abstract) + per-backend adapter dirs (`tools/gmail/`, `tools/ponymail/`,
`tools/mail-source/imap/`, `tools/mail-source/mbox/`, ...) | See [Mail
sources](#mail-sources) below — declare each backend's role (primary /
preferred-for-`<op>` / fallback / optional) and `mandatory` flag |
| CVE allocation + record mgmt | `vulnogram` |
[`../../tools/vulnogram/`](../../tools/vulnogram/) | see [CVE
tooling](#cve-tooling) below |
| Release voting / announce | TODO: ASF mailing lists — or replace with the
project's release-comms backend | — | via `dev_list` / `announce_list` /
`users_list` |
@@ -142,20 +144,56 @@ returns `not found`):
| `Fix released` | TODO |
| `Announced` | TODO |
-## Gmail and PonyMail
+## Mail sources
-Gmail-side mechanics (MCP call shapes, threading rule, search-query
-patterns, archive URL construction) live under
-[`../../tools/gmail/`](../../tools/gmail/); the concrete per-project
-values below are what the generic recipes substitute in.
+The skills treat every supported mail backend the same way —
+through the abstract operations defined in
+[`../../tools/mail-source/contract.md`](../../tools/mail-source/contract.md).
+The adopter declares which backends are configured, what *role*
+each plays, and whether any are *mandatory*. The skill's resolution
+rule (see the contract) then picks the right backend per operation
+at run time.
-| Key | Value |
-|---|---|
-| `security_list_domain` | TODO: e.g. `security.foo.apache.org` — Gmail
`list:` operator uses the domain form |
-| `ponymail_private_search_url_template` | TODO |
-| `ponymail_public_search_url_template` | TODO |
-| `ponymail_api_url_template` | TODO |
-| `ponymail_thread_url_template` |
`https://lists.apache.org/thread/<hash>?<list>` |
+### Backend declaration
+
+One row per configured backend. **Exactly one** row carries
+`role: primary`. Multiple rows may carry `preferred for <op>` to
+override the primary for specific operations. `fallback` rows are
+tried in order when no preferred / primary backend supports the op.
+`mandatory: yes` means the skill **refuses to run** when that
+backend is unavailable; `no` means the skill continues with the
+remaining backends (and skips ops that no available backend supports).
+
+| Backend | Role | Mandatory | Notes |
+|---|---|---|---|
+| TODO: `gmail` | TODO: e.g. `primary` | TODO: `yes` / `no` | TODO: e.g.
"Triager Gmail account subscribed to `<security-list>` and `<private-list>`" |
+| TODO: `ponymail` | TODO: e.g. `fallback` or `preferred for thread_url` |
TODO | TODO: e.g. "Read-only archive backstop; PMC LDAP session required for
private-list reads" |
+| TODO: *(add more rows as needed — `imap`, `mbox`, project-specific adapter)*
| | | |
+
+Reference adapter docs:
+[`tools/gmail/tool.md`](../../tools/gmail/tool.md) (full read+write),
+[`tools/ponymail/tool.md`](../../tools/ponymail/tool.md) (read-only ASF
archive),
+[`tools/mail-source/imap/README.md`](../../tools/mail-source/imap/README.md)
(stub),
+[`tools/mail-source/mbox/README.md`](../../tools/mail-source/mbox/README.md)
(read-only offline archive — stub).
+
+### Per-backend config
+
+Per-backend values the generic recipes substitute in. Only fill in
+the rows for backends declared above; leave the rest blank or
+remove the row.
+
+| Key | Backend | Value |
+|---|---|---|
+| `security_list_domain` | `gmail` | TODO: e.g. `security.foo.apache.org` —
Gmail `list:` operator uses the domain form |
+| `ponymail_private_search_url_template` | `ponymail` | TODO |
+| `ponymail_public_search_url_template` | `ponymail` | TODO |
+| `ponymail_api_url_template` | `ponymail` | TODO |
+| `ponymail_thread_url_template` | `ponymail` |
`https://lists.apache.org/thread/<hash>?<list>` |
+| `imap_host` | `imap` | TODO: e.g. `imap.example.org` |
+| `imap_account` | `imap` | TODO: e.g. `[email protected]` |
+| `imap_security_list_folder` | `imap` | TODO: e.g. `INBOX.security-list` |
+| `imap_drafts_folder` | `imap` | TODO: e.g. `Drafts` (or leave blank to
declare `create_draft` unsupported on this adapter) |
+| `mbox_archive_path` | `mbox` | TODO: e.g.
`/srv/audit/security-list-2024.mbox` |
## Issue-template fields
diff --git a/tools/gmail/tool.md b/tools/gmail/tool.md
index 0c16687..83aee62 100644
--- a/tools/gmail/tool.md
+++ b/tools/gmail/tool.md
@@ -17,11 +17,20 @@
This directory documents the **Gmail** tool adapter — the set of
capabilities the skills use when the adopting project declares Gmail
-as its inbound-email / draft-creation backend.
-
-A project opts into this tool by naming it in its manifest under
-*Tools enabled*. For the adopting project see
-[`../../<project-config>/project.md`](../../<project-config>/project.md#tools-enabled).
+as a backend in its
+[mail-source configuration](../mail-source/contract.md).
+
+A project opts into this tool by listing it in its manifest under
+*Mail sources*. For the adopting project see
+[`../../<project-config>/project.md`](../../<project-config>/project.md#mail-sources).
+
+Gmail's full capability set per the
+[backend contract](../mail-source/contract.md#capability-matrix):
+`list_recent_threads`, `read_thread`, `list_drafts`, `list_sent_since`,
+`create_draft`, `thread_url`. It is therefore the only in-tree
+adapter that supports drafting; an adopter that designates a
+read-only primary (e.g. Ponymail, mbox) typically also lists Gmail
+as `preferred for create_draft, list_drafts`.
## What this tool provides
diff --git a/tools/mail-source/contract.md b/tools/mail-source/contract.md
new file mode 100644
index 0000000..53e9533
--- /dev/null
+++ b/tools/mail-source/contract.md
@@ -0,0 +1,174 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents** *generated with
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Mail source — backend contract](#mail-source--backend-contract)
+ - [Abstract operations](#abstract-operations)
+ - [Capability matrix](#capability-matrix)
+ - [Adopter declaration in
`<project-config>/project.md`](#adopter-declaration-in-project-configprojectmd)
+ - [Role values](#role-values)
+ - [Mandatory flag](#mandatory-flag)
+ - [Resolution rule — which backend runs an
operation?](#resolution-rule--which-backend-runs-an-operation)
+ - [Backend implementation contract](#backend-implementation-contract)
+ - [What this contract does NOT cover](#what-this-contract-does-not-cover)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# Mail source — backend contract
+
+`security-issue-import` (and the mail-touching paths in
+`security-issue-sync`, `security-cve-allocate`, `security-issue-triage`)
+scan an inbound `<security-list>` for security reports, read threads,
+and draft replies. The skills treat every supported source — Gmail,
+PonyMail, IMAP, a static mbox snapshot, the next one we plug in —
+the **same way**: through the abstract operations defined here. The
+adopting project's `<project-config>/project.md → Mail sources`
+section declares *which* backends are configured, what *role* each
+plays, and which (if any) are *mandatory*.
+
+This file is the single source of truth for *what a backend is
+expected to do* and *how the skills choose between configured
+backends*. Backend-specific docs —
+[`tools/gmail/tool.md`](../gmail/tool.md),
+[`tools/ponymail/tool.md`](../ponymail/tool.md),
+[`tools/mail-source/imap/README.md`](imap/README.md), and
+[`tools/mail-source/mbox/README.md`](mbox/README.md) —
+implement this contract.
+
+## Abstract operations
+
+A mail-source backend exposes some subset of these operations.
+*Some subset* matters — Ponymail is read-only, an mbox snapshot is
+read-only-and-offline, and a corporate IMAP may or may not allow
+drafts depending on policy. The skills check capability before
+dispatch; backends that don't support an op are skipped in the
+resolution chain for that op.
+
+| Operation | What it does | Why the skills need it |
+|---|---|---|
+| `list_recent_threads(list, since)` | Return threads on `<list>` newer than
`since` (typically 14 / 30 / 90 days) | `security-issue-import` Step 1 — find
candidate reports that have arrived since the last sweep |
+| `read_thread(thread_id)` | Return full message history of a thread by stable
identifier | Steps 2–4 of `-import`; `-sync` reads tracker-linked threads for
credit / CVE-reviewer / status signals |
+| `list_drafts(thread_id)` | Return draft replies already attached to a thread
| Idempotency — never propose a fresh draft when one is already pending |
+| `list_sent_since(thread_id, since)` | Return outbound replies sent on a
thread within a window | Detect "we already replied; the ball is in the
reporter's court" |
+| `create_draft(thread_id, body, …)` | Compose an *un-sent* reply attached to
the thread (subject inherits, `In-Reply-To` set per the threading rule) | Every
reporter-facing reply the skills propose. **Drafts only — never sends** per the
framework rule |
+| `thread_url(thread_id)` | Return a human-clickable URL to the thread (for
tracker body fields, sync rollups, etc.) | The *Security mailing list thread*
tracker field |
+| `thread_id_kind` (attribute) | The shape of identifiers this backend emits
(e.g. Gmail UUID, PonyMail hash, RFC-5322 Message-ID) | Lets the skills tag
stored IDs with their source so a future sync from a different backend doesn't
dedupe across kinds |
+
+A backend's capability set is its supported subset of the operations
+above. The capability matrix below summarises the in-tree adapters.
+
+## Capability matrix
+
+| Backend | `list_recent_threads` | `read_thread` | `list_drafts` |
`list_sent_since` | `create_draft` | `thread_url` | Notes |
+|---|:---:|:---:|:---:|:---:|:---:|:---:|---|
+| [`gmail`](../gmail/tool.md) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Full read + write;
primary backend in the reference adopter |
+| [`ponymail`](../ponymail/tool.md) | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | Read-only
public/private archive viewer; auth via ASF LDAP |
+| [`imap`](imap/README.md) | ✓ | ✓ | depends | ✓ | depends | ✓ | Stub adapter;
`create_draft` / `list_drafts` depend on whether the IMAP server exposes the
Drafts folder writably to the agent |
+| [`mbox`](mbox/README.md) | ✓ (offline) | ✓ | ✗ | ✗ | ✗ | ✗ (or `file://`) |
Static archive snapshot; forensics / late triage only |
+
+Backends added by adopters extend the matrix in their own adapter
+README. The skill never assumes a backend has an op without
+consulting the matrix first.
+
+## Adopter declaration in `<project-config>/project.md`
+
+The adopter declares the configured backends under a *Mail sources*
+section, one entry per backend:
+
+```markdown
+## Mail sources
+
+| Backend | Role | Mandatory | Notes |
+|---|---|---|---|
+| `gmail` | primary | yes | Triager Gmail account is subscribed to
`<security-list>` and `<private-list>` |
+| `ponymail` | fallback | no | Read-only archive backstop when Gmail history
is incomplete |
+```
+
+### Role values
+
+* `primary` — the **default backend** for every operation it
+ supports. Exactly one backend may carry this role.
+* `preferred for <op>` — overrides `primary` for one specific
+ operation. Multiple backends may carry this role, each for a
+ different op. Example: a project that runs IMAP for inbound mail
+ but uses Gmail only for drafting would declare
+ `imap: preferred for read_thread, list_recent_threads` and
+ `gmail: preferred for create_draft, list_drafts`.
+* `fallback` — try after primary / preferred for any op the
+ primary doesn't support, in the order the backends are listed.
+* `optional` — available for ad-hoc use but never in the resolution
+ chain. Useful for adapter docs that are present but not wired in
+ yet.
+
+### Mandatory flag
+
+`yes` means the skill **refuses to run** when the backend is
+unavailable (auth missing, MCP server down, archive directory not
+mounted). The skill surfaces a clear *"mandatory backend `<name>`
+unavailable: `<reason>`; run aborted"* and exits without proposing
+anything.
+
+`no` means the skill **continues** with the remaining backends. If a
+specific op then has no backend to dispatch to, the skill skips that
+operation's proposal (e.g. *"no draft backend available — Step 7
+proposal omitted, please draft the receipt-of-confirmation reply by
+hand"*) and keeps going.
+
+## Resolution rule — which backend runs an operation?
+
+For each operation `<op>` the skill needs to dispatch:
+
+1. If a backend is marked `preferred for <op>` and the matrix shows
+ it supports `<op>` and it is available, use it. **Stop.**
+2. Else if the `primary` backend supports `<op>` and is available,
+ use it. **Stop.**
+3. Else walk the `fallback` backends in the declared order; use the
+ first one that supports `<op>` and is available. **Stop.**
+4. Else: if any **mandatory** backend was unavailable, abort the
+ run per the mandatory rule above. Otherwise skip this
+ operation's proposal with a one-line *"no backend available for
+ `<op>`"* note and continue.
+
+The resolution result is logged in every proposal recap so the user
+can see which backend served which op.
+
+## Backend implementation contract
+
+A new backend (`tools/mail-source/<name>/`) ships a `README.md`
+that:
+
+1. States which operations it supports (its column in the matrix
+ above).
+2. Describes how each supported op is invoked (CLI command, MCP
+ tool name, API call) and what identifier shape the backend
+ emits for `thread_id`.
+3. Documents auth + setup: what credentials / sessions /
+ subscriptions the operator needs in place before the skill can
+ dispatch.
+4. Notes any *fast-path* / *slow-path* differences (e.g. PonyMail's
+ private-list reads require an active ASF-LDAP session) so the
+ skill can route accordingly.
+
+The contract is intentionally minimal — backends are expected to
+gracefully decline ops they don't support (`NotSupported` /
+explicit error) rather than fake-implement them. Faking causes
+silent data loss (a draft that goes nowhere, a thread read that
+returns empty); declining lets the resolution rule fall through.
+
+## What this contract does NOT cover
+
+* **Send semantics.** The framework rule is *draft, never send*;
+ every backend that exposes `create_draft` is expected to honour
+ it. No backend exposes a `send` op through this contract.
+* **PII / privacy filtering.** Backends return raw thread content;
+ the privacy-LLM filtering layer (
+ [`tools/privacy-llm/`](../privacy-llm/)) sits between the backend
+ and any LLM consumer. The contract is at the wire-format level,
+ not the content-policy level.
+* **Tracker reconciliation.** Backends emit thread IDs and URLs;
+ the tracker's *Security mailing list thread* field is owned by
+ the tracker, not by any backend. Skills decide what to write
+ there based on the resolution result.
diff --git a/tools/mail-source/imap/README.md b/tools/mail-source/imap/README.md
new file mode 100644
index 0000000..d05dd98
--- /dev/null
+++ b/tools/mail-source/imap/README.md
@@ -0,0 +1,97 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents** *generated with
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Mail-source adapter — IMAP (stub)](#mail-source-adapter--imap-stub)
+ - [Capability claim](#capability-claim)
+ - [Auth + setup](#auth--setup)
+ - [Threading model](#threading-model)
+ - [What an adopter declares in
`project.md`](#what-an-adopter-declares-in-projectmd)
+ - [Why this is a stub](#why-this-is-a-stub)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# Mail-source adapter — IMAP (stub)
+
+Reference adapter for a generic IMAP mailbox as a backend for the
+`security-issue-import` family of skills. **Stub status** — this
+document describes the contract; the concrete CLI / MCP wiring is
+TBD and will land when an adopter actually wires IMAP in. The
+contract here lets that adopter know *what* they need to provide
+without re-deriving it from scratch.
+
+See [`../contract.md`](../contract.md) for the abstract
+mail-source-backend operations + capability matrix + adopter
+resolution rules this adapter conforms to.
+
+## Capability claim
+
+| Operation | Supported? | Notes |
+|---|:---:|---|
+| `list_recent_threads(list, since)` | ✓ | `IMAP SEARCH SINCE <date>` against
the inbox / `<security-list>` subfolder |
+| `read_thread(thread_id)` | ✓ | Fetch all messages whose `References:` /
`In-Reply-To:` chain reaches the thread root; thread ID = root Message-ID |
+| `list_drafts(thread_id)` | depends | Only if the IMAP server exposes the
`Drafts` mailbox writably to the agent's account. Many corporate IMAPs do; many
shared role mailboxes don't |
+| `list_sent_since(thread_id, since)` | ✓ | `IMAP SEARCH` against the `Sent`
mailbox filtering on the thread root's Message-ID in `In-Reply-To:` /
`References:` |
+| `create_draft(thread_id, body, …)` | depends | Same gating as `list_drafts`
— the agent's IMAP account needs `INSERT` rights on the `Drafts` mailbox.
Subject and threading headers are set per
[`../../gmail/threading.md`](../../gmail/threading.md) (the threading rule is
shared across backends) |
+| `thread_url(thread_id)` | ✓ (best-effort) | If the project has a public
mailing-list archive (PonyMail, Pipermail, hyperkitty), construct the deep-link
URL from the Message-ID per the archive's template. If there is no public
archive, return a `imap://<host>/<mailbox>;UID=<n>` URL that only works for
someone with the same IMAP credentials |
+| `thread_id_kind` | `rfc5322-message-id` | RFC-5322 `Message-ID` of the
thread root |
+
+## Auth + setup
+
+An adopter wiring IMAP needs to declare:
+
+1. **Server connection** — host, port, TLS preference, server-side
+ capabilities the agent will rely on (`IDLE`, `MOVE`, `UIDPLUS`).
+2. **Account** — the IMAP user the agent authenticates as. For a
+ shared role mailbox ([email protected]) prefer an
+ app-password / service-account credential so it can be rotated
+ without disrupting individual triagers.
+3. **Folder layout** — the inbox path for `<security-list>`, the
+ sent path, the drafts path (or `null` to declare the drafts ops
+ unsupported).
+4. **Credential storage** — where the agent reads credentials from.
+ The framework convention is the same shell-env / secret-manager
+ pattern other adapters use (see
+ [`../../gmail/oauth-draft/README.md`](../../gmail/oauth-draft/README.md)
+ as a reference for how a credential lifecycle is documented).
+
+## Threading model
+
+IMAP servers don't have a native "thread" concept — the client
+reconstructs threads from `References:` / `In-Reply-To:` chains.
+The adapter MUST canonicalise on the **root Message-ID** as the
+thread identifier (not the most recent message, not a server-side
+folder UID, which can change). This matches the contract's
+`thread_id_kind: rfc5322-message-id` so the tracker can store a
+stable identifier across reconnects, folder moves, and server
+migrations.
+
+## What an adopter declares in `project.md`
+
+```markdown
+## Mail sources
+
+| Backend | Role | Mandatory | Notes |
+|---|---|---|---|
+| `imap` | primary | yes | Subscribed to `[email protected]` via the team
mailbox; drafts via Sent-as |
+```
+
+…plus an `imap_*` section under *Mail sources* documenting the
+host / account / folders / credential path. Use the same shape the
+other backends in `<project-config>/project.md` use for their
+per-tool config (see the *Gmail and PonyMail* section in the
+template for the pattern).
+
+## Why this is a stub
+
+The reference adopter (`airflow-s` / `apache-airflow`) does not
+currently use IMAP — Gmail covers the primary path and PonyMail
+covers the archive backstop. The stub exists so an adopter that
+*does* live on a corporate IMAP (or a self-hosted Postfix +
+Dovecot setup) can declare it as the primary backend without
+authoring the contract from scratch. When the first adopter wires
+it in, the concrete CLI / MCP wiring lands in this directory
+alongside this README.
diff --git a/tools/mail-source/mbox/README.md b/tools/mail-source/mbox/README.md
new file mode 100644
index 0000000..af1a153
--- /dev/null
+++ b/tools/mail-source/mbox/README.md
@@ -0,0 +1,95 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents** *generated with
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Mail-source adapter — mbox / local archive
(stub)](#mail-source-adapter--mbox--local-archive-stub)
+ - [When this adapter makes sense](#when-this-adapter-makes-sense)
+ - [Capability claim](#capability-claim)
+ - [Auth + setup](#auth--setup)
+ - [What an adopter declares in
`project.md`](#what-an-adopter-declares-in-projectmd)
+ - [Why this is a stub](#why-this-is-a-stub)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# Mail-source adapter — mbox / local archive (stub)
+
+Reference adapter for a static `mbox` (or `Maildir`, or a directory
+of `.eml` files) as a read-only backend for the
+`security-issue-import` family of skills. **Stub status** — this
+document describes the contract; the concrete CLI wiring lands when
+an adopter actually wires a local archive in.
+
+See [`../contract.md`](../contract.md) for the abstract
+mail-source-backend operations + capability matrix + adopter
+resolution rules this adapter conforms to.
+
+## When this adapter makes sense
+
+* **Forensics / late triage of a historical security thread.** The
+ inbox is gone or the role mailbox is no longer credentialed, but
+ the messages were archived to an mbox snapshot at the time. The
+ skill can still classify, extract template fields, and reconcile
+ against trackers — it just can't draft a reply.
+* **Air-gapped triage environments.** The agent runs offline against
+ a thumbdrive snapshot of `<security-list>` for the time window
+ being audited.
+* **Compliance / discovery exports.** A regulator request produces
+ an mbox export of all `<security-list>` mail for a time range; the
+ skill imports it the same way it would import live mail, into a
+ scratch tracker repo, without touching any live mail credentials.
+
+## Capability claim
+
+| Operation | Supported? | Notes |
+|---|:---:|---|
+| `list_recent_threads(list, since)` | ✓ (offline) | Parse the mbox, group
messages by `References:` / `In-Reply-To:` chain, filter by message `Date:`
newer than `since`. The `list` parameter is *advisory* — the mbox is whatever
the operator pointed the adapter at, no remote subscription is checked |
+| `read_thread(thread_id)` | ✓ | Same parser; return all messages in the
thread |
+| `list_drafts(thread_id)` | ✗ | A static archive has no concept of pending
drafts |
+| `list_sent_since(thread_id, since)` | ✗ (or limited) | Only if the archive
includes the team's outbound mail (some exports do; many don't). The adapter
declares this op as `unsupported` by default; an adopter with an
outbound-included archive may upgrade the claim per-deployment |
+| `create_draft(thread_id, body, …)` | ✗ | Read-only by construction |
+| `thread_url(thread_id)` | depends | If the project has a public archive
(PonyMail, Pipermail), construct the URL from the thread root's Message-ID.
Otherwise return `file://<archive-path>#<message-id>` which only works in the
operator's local environment |
+| `thread_id_kind` | `rfc5322-message-id` | Same as IMAP — root Message-ID is
the stable identifier |
+
+## Auth + setup
+
+There is no auth — the archive file is a file. The adopter
+declares:
+
+1. **Archive path** — absolute path to the mbox file, or to the
+ `Maildir/` root, or to a directory of `.eml` files. The adapter
+ sniffs format from the path shape.
+2. **Time window cap** — optional safety knob to refuse parsing
+ archives larger than `N` MB unless explicitly approved. Prevents
+ accidental long-running parses of multi-year archives.
+3. **Read-only enforcement** — the adapter MUST NOT write to the
+ archive path under any circumstance. The framework's
+ privacy-LLM gate (see [`../../privacy-llm/`](../../privacy-llm/))
+ should also be considered before sending archive content to
+ any LLM consumer.
+
+## What an adopter declares in `project.md`
+
+```markdown
+## Mail sources
+
+| Backend | Role | Mandatory | Notes |
+|---|---|---|---|
+| `mbox` | primary | yes | Forensics-only deployment; archive at
`/srv/audit/security-list-2024.mbox` |
+```
+
+…or as a fallback for an otherwise-live deployment:
+
+```markdown
+| `gmail` | primary | yes | Triager Gmail subscribed to `<security-list>` |
+| `mbox` | fallback | no | 2024-Q4 snapshot at
`/srv/snapshots/security-list-2024-Q4.mbox`; used when Gmail history is
incomplete |
+```
+
+## Why this is a stub
+
+No adopter is currently using it. The stub documents the contract
+shape so a forensics / compliance team can wire the adapter
+without re-designing the read interface; concrete parsing code
+lands alongside this README when the first such adopter materialises.
diff --git a/tools/ponymail/tool.md b/tools/ponymail/tool.md
index 1015476..2ea0f41 100644
--- a/tools/ponymail/tool.md
+++ b/tools/ponymail/tool.md
@@ -26,9 +26,19 @@ capabilities the skills use to read ASF mailing-list archives
directly via an MCP server, without going through a personal Gmail
subscription.
-A project opts into this tool by naming it in its manifest under
-*Tools enabled*. The adopting project manifest lives at
-[`../../<project-config>/project.md`](../../<project-config>/project.md#tools-enabled).
+A project opts into this tool by listing it in its manifest under
+*Mail sources*. The adopting project manifest lives at
+[`../../<project-config>/project.md`](../../<project-config>/project.md#mail-sources).
+
+Ponymail's capability set per the
+[backend contract](../mail-source/contract.md#capability-matrix) is
+**read-only**: `list_recent_threads`, `read_thread`, `thread_url`.
+It does **not** support `create_draft` / `list_drafts` /
+`list_sent_since` — an adopter that names Ponymail as `primary` must
+also list a draft-capable backend (typically Gmail or an IMAP
+adapter with a writable Drafts mailbox) as
+`preferred for create_draft, list_drafts` so the skills' reply-draft
+operations have a place to land.
The backing MCP server is
[`rbowen/ponymail-mcp`](https://github.com/rbowen/ponymail-mcp)
(Python) which wraps the public PonyMail HTTP API at