Table of Contents generated with DocToc
- Gmail drafting backends
- Privacy warning — the claude.ai Gmail MCP rewrites embedded URLs into Google tracking redirects
- Why
oauth_curlis the preferred backend - How the skills pick a backend
- The Maildir backend
- Detecting drafts that already exist on a thread
- Limitations that apply to both backends
- Known issue — thread-attached drafts may not surface in the global Drafts folder when stacked
- Referenced by
The skills create Gmail drafts via one of two backends, selected by the
user in .apache-magpie-overrides/user.md under
tools.gmail.draft_backend:
| Backend | Value | Thread attach? | Setup |
|---|---|---|---|
OAuth + curl script |
oauth_curl (strongly preferred — use this) |
yes — via threadId (and explicit In-Reply-To / References headers) |
one-time Google OAuth client + refresh-token setup, automated via uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-setup — see oauth-draft/README.md |
| claude.ai Gmail MCP | claude_ai_mcp (discouraged — do not use; see privacy warning) |
yes — via replyToMessageId (a message ID resolved from the inbound thread) |
none — works as soon as the Gmail connector is authenticated on claude.ai, but silently rewrites embedded URLs into Google tracking redirects (see below) |
The oauth_curl backend is also exposed as an MCP server
(gmail-plaintext, tool create_draft) for agents that would rather
call a tool than shell out — same plain-text-only guarantee, same
credential file. See oauth-draft/README.md → MCP server.
Both backends create drafts — never send. The human review-and-send step is still required before any outbound message leaves the user's Gmail.
Use
oauth_curl. Do not use the claude.ai Gmail MCPcreate_draftfor any draft whose body contains URLs.
As of 2026-06-05, the claude.ai Gmail MCP create_draft tool
silently rewrites every bare URL in the draft body into a Google
tracking-redirect wrapper of the form:
https://www.google.com/url?q=<original-url>&source=gmail&ust=<timestamp>&sa=E
The rewrite is baked into the stored draft MIME — both the
text/plain and text/html parts — not merely a display artifact
(confirmed by reading the draft back via drafts.get?format=raw). So
when the message is sent, the recipient receives the Google redirect
instead of the link the skill wrote. This is unacceptable for the
project's correspondence:
- Privacy / tracking. Every link the recipient clicks is routed
first through
google.com/url, leaking click metadata (which recipient, which link, when) to a third party — on security-sensitive correspondence the project has no business funnelling through Google's redirector. - Reporter-facing and relay correctness. Many drafts carry a
reporter-voice paste-ready block (e.g. an ASF-security relay the
recipient pastes onto a GHSA advisory). The rewrite would paste a
google.com/url?q=...redirect onto a public advisory instead of the clean canonical URL. - Integrity of CVE / advisory links. Advisory URLs, CVE-record URLs, and PR links must reach recipients verbatim; a wrapped link is wrong on its face and erodes trust in the message.
The oauth_curl backend builds the message with a plain RFC822
EmailMessage, so URLs are preserved verbatim — no rewriting, no
third-party redirector. That is the decisive reason oauth_curl is the
preferred backend for all drafting, not just the threadId / bulk
cases.
If oauth_curl credentials are genuinely unavailable and a draft must
be created via the MCP, the body must not contain URLs — inline the
relevant text and tell the user to add the links by hand before
sending, or (better) set up oauth_curl first.
The oauth_curl script talks directly to the Gmail REST API on a
user-provided OAuth refresh token and builds its own MIME, which gives
it three advantages over the claude.ai Gmail MCP:
- Verbatim URLs (the decisive one) — it does not rewrite links into Google tracking redirects; see the privacy warning above.
threadId-keyed draft creation — attaches bythreadIddirectly; for a brand-new, non-reply message, omit--thread-idand pass--no-reply-headers.- Bulk read/modify + delete —
oauth-draft-mark-read(label-modify on a query result set) has no MCP equivalent, andoauth_curlis the only backend that can delete drafts via the Gmail API.
The one-time cost is a Google OAuth client + refresh-token setup
(automated via oauth-draft-setup; see
oauth-draft/README.md). Treat the
credentials file like an SSH key. It is worth it: oauth_curl is the
only backend that keeps the project's outbound links clean and
untracked.
Every skill step that says "create a Gmail draft via
mcp__claude_ai_Gmail__create_draft" is shorthand for "create a draft
via the project's configured drafting backend".
First, Gmail or Maildir. The drafting backend is set per project, and each person can override it for themselves. The first value found wins:
tools.mail_create.backendin the resolveduser.md— the person's own choice;mail_create.backendin<project-config>/project.md— the project's choice;gmail.
On maildir, follow The Maildir backend and skip the rest of this
section. The security skills make the whole choice in code: uv run --project <framework>/tools/security-management security-management issue-sync draft-backend takes
both backend values, the check_auth answer and whether the body has links, and prints the backend and where the choice came from. On gmail, pick the Gmail backend as below — which should be
oauth_curl.
Preferred — oauth_curl. Resolution:
The backend is reached through the gmail-plaintext MCP server
(oauth-draft-mcp, see oauth-draft/README.md → MCP server),
never through a shell command. Claude Code starts the server outside
the sandbox, so it can read the OAuth token and reach the Gmail API
while sandboxed Bash can do neither.
- Probe for
oauth_curlcredentials withmcp__gmail-plaintext__check_auth, passingcredentials_path=<tools.gmail.oauth_credentials_path>when.apache-magpie-overrides/user.mdsets it (otherwise the server uses$GMAIL_OAUTH_CREDENTIALS, then~/.config/apache-magpie/gmail-oauth.json).status: "ok"means the backend is usable; a tool error means it is not. Never probe the path with a shell command: the sandbox denies it, so the probe would always report the credentials missing. When themcp__gmail-plaintext__*tools are not registered at all, the backend is unavailable; point the user at the MCP server setup. - Create the draft with
mcp__gmail-plaintext__create_draft:toandccas lists,subject, the plain-textbodyinline, the samecredentials_pathas the probe when set, and eitherthread_id=<threadId>(reply on an existing thread — the tracker storesthreadIdper the security-thread body field convention) or, for a brand-new message, nothread_id. URLs in the body are preserved verbatim.
Last-resort fallback — claude_ai_mcp, only when oauth_curl
credentials are unavailable. Subject to the hard constraint in the
privacy warning
above: the body must not contain URLs. When used:
- Resolve the latest message ID on the inbound thread (for
threading): call
mcp__claude_ai_Gmail__get_thread(threadId=<inbound>, messageFormat='MINIMAL')and take theidof the chronologically-last message. - Create the draft with
mcp__claude_ai_Gmail__create_draft(..., replyToMessageId=<that message id>), or omitreplyToMessageIdto fall back to subject-matched threading (seethreading.md). - Warn the user that the MCP backend was used because
oauth_curlcredentials were missing, and that any links were omitted / must be added by hand. Do not silently swallow the configuration mismatch.
The skills surface which backend was used in the proposal / recap so the user can tell at a glance how the draft is threaded:
Draft created via
oauth_curl(threadId-attached on<thread-id-prefix>...)
or, only when credentials were missing:
Draft created via
claude_ai_mcp(URLs omitted per privacy policy —oauth_curlcredentials not found; threaded via<replyToMessageId / subject-matched fallback>)
tools/maildir/ files each draft into a local
Maildir that the user's mail client (Thunderbird, mutt, Evolution, …) shows
in its Drafts folder: no Gmail account, no credential, no network. It only
drafts. The skills still read the inbound thread through the project's mail
source (PonyMail, Gmail), and a human sends the reply.
Settings. Each resolves like the backend: tools.mail_create.<key> in
user.md first, then mail_create.maildir.<key> in project.md.
drafts_path— the Drafts Maildir, for example~/Mail/<project>/Drafts;from_address— theFrom:of every draft.
A setting that resolves nowhere is a question to the user, never a guess.
Threading. A Maildir draft threads by its headers alone:
--in-reply-to— theMessage-IDof the latest message on the inbound thread. PonyMail returns it frommcp__ponymail__get_threadandmcp__ponymail__get_email; on Gmail,oauth-draft-message-idresolves it (operations.md). When only the root is known, use the rootMessage-IDthe tracker records in its Security mailing list thread field.--references— the thread'sMessage-IDchain, root first.
With no Message-ID at all, omit both and keep Re: <root subject>: the
recipient's client then threads by subject, as with the Gmail subject
fallback (threading.md).
Existing drafts. Before filing a reply, look for one already waiting:
uv run --project <framework>/tools/maildir maildir-draft-find \
--maildir <drafts_path> \
--message-id <each Message-ID of the thread> \
--subject "<root subject>" --to <recipient>Any result means "a draft already exists; surface it to the user before drafting a new one", the same rule as the Gmail check below.
Create.
uv run --project <framework>/tools/maildir maildir-draft-create \
--from <from_address> --to <recipient> --cc <cc> \
--subject "Re: <root subject>" --body-file <file> \
--maildir <drafts_path> \
--in-reply-to <Message-ID> --references "<chain>"It prints the path of the draft file, which stands in for a Gmail
draftId. The body goes out as written: one text/plain part, links
verbatim.
Recap line:
Draft filed in
<drafts_path>(replying to<Message-ID>). Open it in your mail client to review and send. Not shown in Thunderbird? Repair the Drafts folder.
On the Maildir backend this check is maildir-draft-find
(The Maildir backend). The two paths below are the Gmail check.
Before drafting a reply on a thread, skills check whether a pending
draft already exists so they do not silently shadow it (the claude.ai
MCP cannot update or delete drafts; see
operations.md).
Run both detection paths and treat any hit as "a draft already
exists; surface it to the user before drafting a new one":
-
List drafts globally. Call
mcp__claude_ai_Gmail__list_drafts, optionally narrowed byquery: "<recipient-email>"or a distinctive subject substring. Bothclaude_ai_mcp-with-replyToMessageIddrafts andoauth_curldrafts surface here, as do legacy MCP drafts created withoutreplyToMessageId(which live as standalone server-side conversations). -
Read the thread directly. Call
mcp__claude_ai_Gmail__get_thread(threadId: "<inbound-thread-id>", messageFormat: MINIMAL)and scan the returned messages for any whose
labelIds(or the snippet's metadata) includeDRAFT. This catches thread-attached drafts that — under the pile-up condition described below — may not be navigable from the global Drafts folder.
list_drafts alone is not sufficient when thread-attached drafts
are involved (either backend); always do the per-thread check too.
- Plain text only — never HTML. Both backends produce plain-text
(
text/plain) drafts, and must keep doing so.oauth_curlbuilds a singletext/plainpart viaEmailMessage.set_content(its test suite asserts the message stays single-parttext/plainwith notext/htmlalternative). Forclaude_ai_mcp, this is guaranteed by populating only thebodyparameter and neverhtmlBody— passinghtmlBodywould add atext/htmlpart. The project's correspondence (security replies, relays, advisory text) is plain text by policy; no skill should ever emit an HTML draft. - No update, no delete on the claude.ai MCP side — see
operations.md— Hard limitation. Theoauth_curlscript could in principle update or delete drafts too (the Gmail API supports it), but the skills deliberately do not, to keep the drafts queue immutable and auditable. - Drafts are always drafts — both backends skip the
sendoperation. A human review step is non-negotiable. - Confidentiality — both leave drafts in the user's personal
Gmail account. The
oauth_curlbackend additionally requires the user to manage a refresh token on disk; treat it like an SSH key.
Caught live on 2026-04-25 during the <tracker>#346
fix-skill flow: when multiple thread-attached drafts pile up on
the same Gmail thread within a single skill flow (typical sequence:
security-cve-allocate drafts a CVE-allocated message → security-issue-sync
drafts a corrected version with updated state → security-issue-fix
drafts the final version after a state change), the drafts all carry
the DRAFT label in the Gmail API but only the most recent surfaces
in the user's global Drafts folder in Gmail's UI. The earlier ones
become reachable only by direct URL or by opening the conversation
view of the thread. The user's own report from that session:
"Can't see the draft — I see some old drafts on the list but they
are missing".
This is a Gmail UI behaviour where multiple thread-attached drafts on
a single conversation collapse / hide in the global Drafts list
rather than rendering as N separate entries. It applies to both
backends — claude_ai_mcp drafts created with replyToMessageId and
oauth_curl drafts attached by threadId — because both result in
true thread-attached drafts on the Gmail server. The drafts exist
(a Gmail API round-trip confirms DRAFT labels and full message
bodies); they are simply not navigable from the standard Drafts
folder when stacked.
The only path that avoids this is creating a draft without
attaching it to the inbound thread — the legacy MCP behaviour before
replyToMessageId was added. Each such draft becomes its own
top-level entry in the Drafts folder, at the cost of losing
sender-side threading.
When a skill is about to draft a reply on a thread that already
has a pending draft on it from an earlier skill pass in the same
session, omit the thread-attachment parameter for the new draft —
i.e. call mcp__claude_ai_Gmail__create_draft without
replyToMessageId (or mcp__gmail-plaintext__create_draft without
thread_id).
The trade-off:
- Visibility wins: the new draft is guaranteed to surface in the user's Gmail Drafts folder, so they can actually see and review it.
- Sender-side threading lost: the new draft will start a new
server-side thread on the user's own Gmail. The recipient's mail
client will still thread it onto the existing conversation via the
Re: <exact subject>match plus theIn-Reply-To/Referencesheaders Gmail synthesises, so the recipient experience is unaffected.
The pile-up case is the only situation where this trade-off applies. For the first draft on a thread, the default thread-attached path remains preferred — that draft is visible in both the conversation view and the Drafts folder.
- Delete the stale drafts.
oauth_curldrafts can be deleted via the Gmail API (DELETE https://gmail.googleapis.com/gmail/v1/users/me/drafts/<draft-id>with the OAuth bearer token from the sameoauth_curlcredentials file). Drafts created via the claude.ai MCP can only be discarded from the Gmail UI (the MCP is no-update / no-delete peroperations.md). - Recreate the consolidated message. Call
mcp__claude_ai_Gmail__create_draftwithreplyToMessageIdomitted and theRe: <exact subject>line so the recipient's client still threads it via subject match. - Surface the path change in the tracker's status rollup
so the audit trail shows why this draft is not thread-attached.
A future triager looking at the rollup should see "draft created
without
replyToMessageIdbecause the thread already carried a pending pile-up" rather than wondering why the threading suddenly degraded.
- The thread has no pending draft yet — keep the default
thread-attached path (
replyToMessageIdforclaude_ai_mcp,--thread-idforoauth_curl). The single-draft case does not trigger the visibility issue.
operations.md— per-backend call shape.threading.md— per-backend threading guarantees.tool.md— top-level Gmail tool overview.oauth-draft/README.md— theoauth_curlsetup walkthrough.