blob: 4f228432d9d0b0b5221d2afc77554515545a11e7 [file] [view]
<!-- SPDX-License-Identifier: Apache-2.0
https://www.apache.org/licenses/LICENSE-2.0 -->
<!-- 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)*
- [Gmail — drafts stay on the inbound thread](#gmail--drafts-stay-on-the-inbound-thread)
- [The rule](#the-rule)
- [Selecting the inbound thread when multiple are recorded](#selecting-the-inbound-thread-when-multiple-are-recorded)
- [Fallback — subject-matched draft when `replyToMessageId` is unavailable](#fallback--subject-matched-draft-when-replytomessageid-is-unavailable)
- [Special case — ASF-security relay](#special-case--asf-security-relay)
<!-- 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 -->
# Gmail — drafts stay on the inbound thread
Every drafted email that relates to a tracking issue **should**
attach to the original inbound Gmail thread — the thread whose
`threadId` was recorded when the tracker was imported. Gmail's
server-side threader attaches by message-ID-of-parent (passed as
`replyToMessageId` on the MCP, or by `threadId` on the OAuth path);
**other mail clients** (Thunderbird, Outlook, Apple Mail, the
recipient's own client) thread by the MIME `In-Reply-To` /
`References` headers and the subject line, which Gmail synthesises
from the parent message in either case.
Both supported drafting backends now provide thread-attachment — see
[`draft-backends.md`](draft-backends.md):
| Backend | Thread attach | Mechanism |
|---|---|---|
| `oauth_curl` (**preferred**) | **yes** | `threadId` plus explicit `In-Reply-To` / `References` headers |
| `claude_ai_mcp` (discouraged — see [`draft-backends.md`](draft-backends.md#privacy-warning--the-claudeai-gmail-mcp-rewrites-embedded-urls-into-google-tracking-redirects)) | **yes** | `replyToMessageId` — the message ID of the chronologically-last message on the inbound thread |
The two threading paths available to the skills, in preferred order:
1. **Thread-attached draft** — the first-choice path. Both backends
support this. Resolve the inbound thread's latest message ID (via
`get_thread`) and pass it to `replyToMessageId`, or pass the
`threadId` to `oauth-draft-create --thread-id`.
2. **Subject-matched draft** — the pragmatic fallback. Use when the
thread cannot be resolved (archived, deleted, stale `threadId`)
or the inbound subject is unsafe to match on. Both backends fall
back to this by simply omitting the thread-attachment parameter.
The call shape (signature, kwargs, no-send rule) per backend lives in
[`operations.md` — Drafting backends](operations.md#drafting-backends);
the rules on **which** thread to use and what the other fields look
like live here.
## The rule
- **Same thread every time, by `threadId` when known.** Whatever
the recipient change — a reporter reply, an ASF-security relay
request, a PMC credit question, a follow-up asking for a PoC —
the draft should attach to the inbound tracker's `threadId`. A
triager reading the Gmail conversation view should see every
exchange on a single tracker in one place; if threading breaks,
that history scatters across two conversations.
- **Subject is always `Re: <root subject>`**, never a fabricated
new one. Gmail's own threading survives without matching
subjects when `threadId` is set, but other clients commonly fall
back to subject-based threading. A drifted subject looks like a
broken conversation on half the world's mail readers. The root
subject is the subject of the **first message** on the inbound
thread — not the last reply's subject line, which Gmail may
have displayed with a prefix trim.
- **`To:` may differ from the original correspondents.** It is
fine to address a draft to a specific person (an ASF
security-team member who relayed the report, a named PMC member,
an individual reporter) even if the original inbound root was
addressed to a list. Threading does not require recipient
overlap; it requires `threadId` or (as the fallback) a matching
subject plus the right `In-Reply-To` / `References` headers.
## Selecting the inbound thread when multiple are recorded
A tracker's *Security mailing list thread* body field can hold
more than one thread when:
- a second reporter independently filed the same root-cause bug
through a different channel and
[`security-issue-deduplicate`](../../skills/security-issue-deduplicate/SKILL.md)
merged the two trackers (one line per reporter, per the dedupe
skill's body-field shape); or
- an external reporter's report reached the project's security
list via a separate forwarder thread — e.g. a huntr.com bounty
relayed by `@apache/security`, a GHSA forward, a HackerOne
forward — *after* the original direct report had already been
imported, so the secondary thread was appended to record the
duplicate-of provenance.
**The rule: default drafts go to the primary reporter's thread,
never to a forwarder/relay thread.** The forwarder/relay thread
is kept on the tracker for record-keeping and for back-channel
relay questions only (e.g. *"please ask the external reporter to
confirm a credit form"*) — see [`asf-relay.md`](asf-relay.md) for
the relay-shape body language.
The primary reporter is the one whose name appears in
*Reporter credited as* without a relay annotation, whose direct
email started the security-list thread chronologically first, and
whose line in *Security mailing list thread* does **not** carry
any of the forwarder signals below.
**Forwarder/relay signals — match case-insensitively in the line's
annotation text** (everything around the `threadId` reference):
- `via huntr.com`, `via GHSA`, `via HackerOne`, `via bugcrowd`,
`via <any bounty platform>`
- `ASF-relayed`, `ASF-security relay`, `ASF-security-relay`,
`relayed by @apache/security`, `relayed by`
- `forwarder`, `forwarded by`, `relay`, `relayed`
- `huntr.com bounty <id>-class duplicate`,
`<provider>-class duplicate`
If a line has any of these signals it is **secondary**; the line
without any of these signals (or — for legacy trackers that
predate the convention — the chronologically-first thread
mentioned) is **primary**.
Worked example. The body field on a real tracker reads:
```text
No public archive URL — tracked privately on Gmail thread `19dc8d4675dfc1f1`.
Aymane Maguiti (huntr.com bounty `abdbcf11-…`-class duplicate, ASF-relayed by @apache/security on 2026-05-04T09:22:25Z): Gmail thread `19def0954b27ac31`.
```
- Line 1 → primary (no relay signal). Use `19dc8d4675dfc1f1` for
every default reply: receipt-of-confirmation, credit-question,
CVE-allocated status update, advisory-shipped follow-up.
- Line 2 → secondary (matches `via huntr.com`-class, `ASF-relayed`).
Use `19def0954b27ac31` only when the project needs to relay a
question back through huntr.com to the external reporter and the
primary thread cannot deliver it.
**Edge cases:**
- **Only one thread recorded, with relay signals.** Classic
ASF-security-relay case. Follow [`asf-relay.md`](asf-relay.md);
there is no primary thread to fall back to.
- **Only one thread recorded, no relay signals.** Standard
single-reporter case; the thread is the primary by default.
- **Both lines carry relay signals.** Rare — typically a
third-party reporter relayed by two different channels.
Surface to the user before drafting; do not pick a "least
forwarded" line silently.
- **Neither line carries a `threadId`** (PonyMail URL only, no
Gmail identifier). The tracker pre-dates the Gmail-threadId
convention; fall back to the rollup-comment `threadId` lookup
per the per-skill recipes.
Surface the primary/secondary selection in the skill's proposal
so the user sees which thread the draft attaches to (*"Drafting
on primary reporter thread `19dc8d4675dfc1f1` (Vincent55); the
secondary huntr.com-relay thread `19def0954b27ac31` was excluded
from default reply targets."*). The user can override per draft
if a specific message genuinely needs to go through the relay
channel instead.
## Fallback — subject-matched draft when `replyToMessageId` is unavailable
Thread attachment is the first-choice path, but the skills must also
work in cases where the inbound thread cannot be resolved:
- The tracker's *security-thread* body field was never filled in
(see
[`../github/issue-template.md`](../github/issue-template.md#field-roles-the-skills-use)
for the field role).
- The `threadId` in that field is stale (the thread was deleted
or archived out of the user's Gmail search index).
- `get_thread` returns no messages (the thread exists but has been
emptied), so there is no `replyToMessageId` to point at.
- The draft is a brand-new outbound ask on a topic the inbound
thread did not cover (e.g. a relay request to a PMC member who
was not on the original thread), where re-threading on the
original inbound is actually confusing.
- The Gmail backend returns an error when attaching the supplied
message / thread (rare, but possible if the user has moved the
thread between accounts).
- A draft already exists on the same thread and the pile-up
workaround in [`draft-backends.md`](draft-backends.md#known-issue--thread-attached-drafts-may-not-surface-in-the-global-drafts-folder-when-stacked)
applies — drop thread attachment for the new draft so it surfaces
in the global Drafts folder.
In these cases, **create the draft with `replyToMessageId` omitted
(or `--thread-id` omitted on the OAuth path) but with the matching
subject line from the inbound message**. Gmail will start a new
conversation on the sender's side, but most other clients (and
Gmail's own subject-fallback behaviour on the recipient's side) will
still thread the reply by subject. This is not as good as a
thread-attached draft, but it is substantially better than either
fabricating a new subject or not drafting at all.
**Surface the degraded threading in the skill's proposal** so the
user knows which path the draft took:
- *"Draft attached by `replyToMessageId` to message `<msg-id-prefix>...`
on thread `<thread-id-prefix>...`."* — the good case (default
`claude_ai_mcp` backend).
- *"Draft attached by `threadId` (via `oauth_curl` backend)."* —
the good case for the OAuth-opt-in user.
- *"Draft created by subject fallback (`<reason>`). Gmail shows it
as a new conversation server-side; the recipient's client will
thread it via the `Re: <subject>` match."*
When the fallback kicks in, record the reason on the tracker's
status comment so the next sync run can see why a new Gmail thread
appeared. Do not silently drop to fallback — it changes the shape
of the conversation the reporter sees.
**When fallback is not appropriate.** Some cases genuinely warrant
stopping rather than drafting on a mismatched subject. Examples:
- You have neither a thread to attach to **nor** a matching subject
to use (typically when the tracker has never been linked to any
inbound thread at all — a bug, usually a missed import step).
Stop and surface it; drafting with no thread context at all is
worse than no draft.
- The inbound subject itself is the reason you cannot thread (the
reporter sent the report with an empty subject, a generic
*"Security"*, or a subject that collides with dozens of unrelated
threads in the user's inbox). A same-subject draft would attach
to the wrong conversation on the recipient's side. Stop and ask
the user to confirm a good subject manually.
## Special case — ASF-security relay
When the inbound report arrives via an ASF forwarder rather than
directly from the external reporter, the drafting shape changes
slightly (different `To:` / `Cc:`, relay-specific body language) but
the threading rules above are **unchanged**: resolve and attach to
the inbound thread (`replyToMessageId` on the default backend,
`threadId` on `oauth_curl`); fall back to the inbound subject when
the thread cannot be resolved. See [`asf-relay.md`](asf-relay.md).