oauth-draft
June 7, 2026 · View on GitHub
Small Python project that talks directly to the Gmail REST API on a user-provided OAuth refresh token. Three console scripts:
| Console script | Purpose |
|---|---|
oauth-draft-setup | One-time interactive OAuth consent flow that writes the credentials JSON. |
oauth-draft-create | Create a Gmail draft with threadId attachment. (As of the replyToMessageId parameter on the claude.ai Gmail MCP create_draft, the MCP can also produce thread-attached drafts — see ../draft-backends.md. This script remains useful when you have a threadId on hand and would rather skip the extra get_thread round-trip the MCP path requires, and is the only path that lets the skills delete drafts via the Gmail API afterwards.) |
oauth-draft-mark-read | Bulk-modify Gmail threads matching a search query (default: mark as read by removing the UNREAD label). No MCP equivalent today. |
The strongly preferred drafting backend is this oauth_curl tool:
the claude.ai Gmail MCP create_draft silently rewrites embedded URLs
into Google tracking redirects, so it must not be used for drafts that
contain links — see
../draft-backends.md.
This README covers local-setup, day-to-day invocation, and the
project's own test/lint workflow.
Run
From the framework's root (this repository when running standalone;
the .apache-magpie/ snapshot path inside an adopting tracker repo):
uv run --project tools/gmail/oauth-draft oauth-draft-create \
--thread-id <gmail-threadId> \
--to reporter@example.com \
--cc security@<project>.apache.org \
--subject "Re: <root subject>" \
--body-file /path/to/body.txt
Skill files and framework docs reference the same invocation via the
<framework> placeholder so the path resolves in either context:
uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-create ...
<framework> substitutes to .apache-magpie/apache-steward in
adopting projects and to . (the repository root) in framework
standalone — see the placeholder convention in
AGENTS.md.
The other two scripts follow the same shape:
# Bulk mark-as-read (dry-run by default; add --execute to actually modify)
uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-mark-read \
--query 'label:apache-security in:spam is:unread'
# Add --execute after reviewing the dry-run output
uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-mark-read \
--query 'label:apache-security in:spam is:unread' --execute
Per-flag help: oauth-draft-create --help,
oauth-draft-mark-read --help, oauth-draft-setup --help.
Setup — one-time
You need a Google OAuth client with the https://mail.google.com/
scope, and a refresh token issued against the Gmail account you use
for security@<project>.apache.org triage.
-
Create a Google Cloud project (if you don't already have one for this purpose). Enable the Gmail API.
-
Create an OAuth client of type Desktop app. Download the credentials JSON (call it
client_secrets.json). -
Run the consent flow with the downloaded
client_secrets.json.oauth-draft-setupopens a browser tab against Google's consent screen, captures the auth code on a local-bound port, exchanges it for a refresh token, and writes the credentials file in the shape the other two scripts expect:uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-setup \ /path/to/client_secrets.jsonOptional flags:
Flag Purpose --from-addressAddress baked into the credentials file as the outgoing From:. Defaults to$GMAIL_FROM, thengit config user.email.--outOutput path. Default: ~/.config/apache-magpie/gmail-oauth.json.--rm-client-secretsDelete the input client_secrets.jsonafter writing the credentials file.The script writes the credentials atomically with mode 600 and chmods the parent directory to 700. The refresh token it stores is the long-lived secret of the whole
oauth_curlbackend; treat the file like an SSH private key. -
Smoke-test by running a dry-run thread search:
uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-mark-read \ --query 'in:inbox is:unread' --max 3This exercises
Credentials.load → refresh_access_token → threads.listwithout modifying anything. A non-empty list of thread IDs (or "Found 0 matching thread(s)") means the credentials work.
How threading is guaranteed
When oauth-draft-create is invoked with --thread-id, the script
does three things, in order:
- Refreshes a short-lived access token from the stored refresh token.
- Reads the chronologically-last message in the thread and extracts
its
Message-IDheader (and the existingReferenceschain). - Builds an RFC822 MIME message with
In-Reply-To: <that-Message-ID>andReferences: <existing chain> <that-Message-ID>, plus setsthreadIdin the Gmail API call.
Gmail's server-side threader attaches by threadId; every other mail
client that receives the message threads by References /
In-Reply-To chain. Both paths agree, so the draft lands on the same
conversation for everyone.
Pass --no-reply-headers to skip step 2 (useful only for smoke
testing — production drafts always want the headers set).
Confidentiality
The refresh token grants full read/draft access to your Gmail. Treat it like an SSH key:
- The setup script writes the file with mode 600 and chmods its parent directory to 700; do not loosen those.
- Do not commit the credentials file. The path lives outside the
repo tree by default (
~/.config/apache-magpie/gmail-oauth.json). - Revoke the refresh token at https://myaccount.google.com/permissions if you suspect it has leaked.
Test
cd tools/gmail/oauth-draft
uv run --group dev pytest
Lint / type-check
cd tools/gmail/oauth-draft
uv run --group dev ruff check src tests
uv run --group dev ruff format --check src tests
uv run --group dev mypy
The prek hooks configured in .pre-commit-config.yaml at the
repository root run ruff check, ruff format --check, mypy,
and pytest on the project files automatically on every commit
that touches them.
Referenced by
../operations.md— two-backend overview.../threading.md— threading guarantees per backend.../draft-backends.md— the config knob.