| <!-- 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 drafting backends](#gmail-drafting-backends) |
| - [Privacy warning — the claude.ai Gmail MCP rewrites embedded URLs into Google tracking redirects](#privacy-warning--the-claudeai-gmail-mcp-rewrites-embedded-urls-into-google-tracking-redirects) |
| - [Why `oauth_curl` is the preferred backend](#why-oauth_curl-is-the-preferred-backend) |
| - [How the skills pick a backend](#how-the-skills-pick-a-backend) |
| - [Detecting drafts that already exist on a thread](#detecting-drafts-that-already-exist-on-a-thread) |
| - [Limitations that apply to both backends](#limitations-that-apply-to-both-backends) |
| - [Known issue — thread-attached drafts may not surface in the global Drafts folder when stacked](#known-issue--thread-attached-drafts-may-not-surface-in-the-global-drafts-folder-when-stacked) |
| - [Recommended workflow when re-drafting on a thread that already carries a pending draft](#recommended-workflow-when-re-drafting-on-a-thread-that-already-carries-a-pending-draft) |
| - [Concrete steps when the pile-up has already happened](#concrete-steps-when-the-pile-up-has-already-happened) |
| - [When this rule does not apply](#when-this-rule-does-not-apply) |
| - [Referenced by](#referenced-by) |
| |
| <!-- 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 drafting backends |
| |
| 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`](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](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. |
| |
| ## Privacy warning — the claude.ai Gmail MCP rewrites embedded URLs into Google tracking redirects |
| |
| > **Use `oauth_curl`. Do not use the claude.ai Gmail MCP `create_draft` |
| > for 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: |
| |
| ```text |
| 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. |
| |
| ## Why `oauth_curl` is the preferred backend |
| |
| 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 by `threadId` |
| directly; for a brand-new, non-reply message, omit `--thread-id` and |
| pass `--no-reply-headers`. |
| - **Bulk read/modify + delete** — `oauth-draft-mark-read` |
| (label-modify on a query result set) has no MCP equivalent, and |
| `oauth_curl` is 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`](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. |
| |
| ## How the skills pick a backend |
| |
| 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"* — which should be |
| `oauth_curl`. |
| |
| **Preferred — `oauth_curl`.** Resolution: |
| |
| 1. **Probe for `oauth_curl` credentials** in this order: |
| - `tools.gmail.oauth_credentials_path` from |
| `.apache-magpie-overrides/user.md` when set; |
| - the `$GMAIL_OAUTH_CREDENTIALS` environment variable; |
| - the default path `~/.config/apache-magpie/gmail-oauth.json`. |
| |
| The probe is a single `test -f <path>`. |
| 2. **Create the draft.** Invoke |
| `uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-create` |
| with `--to`, `--cc`, `--subject`, `--body-file`, and either |
| `--thread-id <threadId>` (reply on an existing thread — the tracker |
| stores `threadId` per the *security-thread* body field convention) |
| or, for a brand-new message, `--no-reply-headers` with no |
| `--thread-id`. See [`oauth-draft/README.md`](oauth-draft/README.md) |
| for the full shape. 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](#privacy-warning--the-claudeai-gmail-mcp-rewrites-embedded-urls-into-google-tracking-redirects) |
| above: **the body must not contain URLs.** When used: |
| |
| 1. **Resolve the latest message ID on the inbound thread** (for |
| threading): call `mcp__claude_ai_Gmail__get_thread(threadId=<inbound>, |
| messageFormat='MINIMAL')` and take the `id` of the |
| chronologically-last message. |
| 2. **Create the draft** with |
| `mcp__claude_ai_Gmail__create_draft(..., replyToMessageId=<that |
| message id>)`, or omit `replyToMessageId` to fall back to |
| subject-matched threading (see |
| [`threading.md`](threading.md#fallback--subject-matched-draft-when-replytomessageid-is-unavailable)). |
| 3. **Warn the user** that the MCP backend was used because `oauth_curl` |
| credentials 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_curl` credentials not found; threaded via |
| > `<replyToMessageId / subject-matched fallback>`)* |
| |
| ## Detecting drafts that already exist on a thread |
| |
| 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`](operations.md#hard-limitation--no-update-no-delete)). |
| 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 by `query: "<recipient-email>"` or a |
| distinctive subject substring. Both `claude_ai_mcp`-with- |
| `replyToMessageId` drafts and `oauth_curl` drafts surface here, as |
| do legacy MCP drafts created without `replyToMessageId` (which live |
| as standalone server-side conversations). |
| - **Read the thread directly.** Call |
| |
| ```text |
| 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) include `DRAFT`. 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. |
| |
| ## Limitations that apply to both backends |
| |
| - **Plain text only — never HTML.** Both backends produce plain-text |
| (`text/plain`) drafts, and must keep doing so. `oauth_curl` builds a |
| single `text/plain` part via `EmailMessage.set_content` (its test |
| suite asserts the message stays single-part `text/plain` with no |
| `text/html` alternative). For `claude_ai_mcp`, this is guaranteed by |
| populating only the `body` parameter and **never** `htmlBody` — |
| passing `htmlBody` would add a `text/html` part. 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](operations.md#hard-limitation--no-update-no-delete). |
| The `oauth_curl` script 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 `send` |
| operation. A human review step is non-negotiable. |
| - **Confidentiality** — both leave drafts in the user's personal |
| Gmail account. The `oauth_curl` backend additionally requires the |
| user to manage a refresh token on disk; treat it like an SSH key. |
| |
| ## Known issue — thread-attached drafts may not surface in the global Drafts folder when stacked |
| |
| Caught live on 2026-04-25 during the [`<tracker>#346`](https://github.com/<tracker>/issues/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. |
| |
| ### Recommended workflow when re-drafting on a thread that already carries a pending draft |
| |
| 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 `oauth-draft-create` *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 the `In-Reply-To` / `References` |
| headers 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. |
| |
| ### Concrete steps when the pile-up has already happened |
| |
| 1. **Delete the stale drafts.** `oauth_curl` drafts 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 same `oauth_curl` credentials |
| file). Drafts created via the claude.ai MCP can only be discarded |
| from the Gmail UI (the MCP is no-update / no-delete per |
| [`operations.md`](operations.md#hard-limitation--no-update-no-delete)). |
| 2. **Recreate the consolidated message.** Call |
| `mcp__claude_ai_Gmail__create_draft` with `replyToMessageId` |
| *omitted* and the `Re: <exact subject>` line so the recipient's |
| client still threads it via subject match. |
| 3. **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 `replyToMessageId` because the thread already carried a |
| pending pile-up"* rather than wondering why the threading |
| suddenly degraded. |
| |
| ### When this rule does not apply |
| |
| - **The thread has no pending draft yet** — keep the default |
| thread-attached path (`replyToMessageId` for `claude_ai_mcp`, |
| `--thread-id` for `oauth_curl`). The single-draft case does not |
| trigger the visibility issue. |
| |
| ## Referenced by |
| |
| - [`operations.md`](operations.md#drafting-backends) — per-backend call |
| shape. |
| - [`threading.md`](threading.md) — per-backend threading guarantees. |
| - [`tool.md`](tool.md) — top-level Gmail tool overview. |
| - [`oauth-draft/README.md`](oauth-draft/README.md) — the `oauth_curl` |
| setup walkthrough. |