Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/allocate-cve/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ user to confirm. Numbered items:
preferred drafting backend per the precedence rule in
[`tools/gmail/draft-backends.md`](../../../tools/gmail/draft-backends.md#how-the-skills-pick-a-backend):
**probe for `oauth_curl` credentials first** (default path
`~/.config/airflow-s/gmail-oauth.json`); use `oauth_curl` when
`~/.config/apache-steward/gmail-oauth.json`); use `oauth_curl` when
present so the draft is `threadId`-attached, only fall back to
`claude_ai_mcp` (subject-matched) when oauth credentials are not
on disk. The `tools.gmail.draft_backend` config field acts as an
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/import-security-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -935,7 +935,7 @@ For each confirmed `Report` / `ASF-security relay`:
precedence rule in
[`tools/gmail/draft-backends.md`](../../../tools/gmail/draft-backends.md#how-the-skills-pick-a-backend):
**probe for `oauth_curl` credentials first** (default path
`~/.config/airflow-s/gmail-oauth.json`); use `oauth_curl` when
`~/.config/apache-steward/gmail-oauth.json`); use `oauth_curl` when
present so the draft is `threadId`-attached, only fall back to
`claude_ai_mcp` (subject-matched) when oauth credentials are not
on disk. The `tools.gmail.draft_backend` config field acts as an
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/invalidate-security-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ For `security@`-imported trackers:
discuss further"* — close the loop.
4. **Backend selection:** probe for `oauth_curl` credentials
first (default path
`~/.config/airflow-s/gmail-oauth.json`) per
`~/.config/apache-steward/gmail-oauth.json`) per
[`tools/gmail/draft-backends.md`](../../../tools/gmail/draft-backends.md#how-the-skills-pick-a-backend);
fall back to `claude_ai_mcp` (subject-matched) when
credentials are not on disk.
Expand Down
5 changes: 3 additions & 2 deletions .claude/skills/sync-security-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -1615,13 +1615,14 @@ before moving on to the next item. Use:
[`tools/gmail/draft-backends.md`](../../../tools/gmail/draft-backends.md#how-the-skills-pick-a-backend).
**`oauth_curl` is preferred whenever its credentials are on disk**
(probe order: `tools.gmail.oauth_credentials_path` →
`$GMAIL_OAUTH_CREDENTIALS` → default `~/.config/airflow-s/gmail-oauth.json`),
`$GMAIL_OAUTH_CREDENTIALS` → default `~/.config/apache-steward/gmail-oauth.json`),
regardless of what `tools.gmail.draft_backend` is set to. The
config field acts as an explicit override only when set to
`claude_ai_mcp_force`. Per-backend call shape:

- **`oauth_curl`** (preferred) — invoke
[`tools/gmail/oauth-draft/create_draft.py`](../../../tools/gmail/oauth-draft/create_draft.py)
`uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-create`
(see [`tools/gmail/oauth-draft/README.md`](../../../tools/gmail/oauth-draft/README.md))
with `--thread-id` from Step 1c, the standard `--to` / `--cc`,
`--subject "Re: <root subject>"`, and a `--body-file`. Threads
on every client (including the sender's own Gmail view).
Expand Down
30 changes: 30 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,33 @@ repos:
entry: uv run --directory tools/vulnogram/generate-cve-json pytest
files: ^tools/vulnogram/generate-cve-json/(src|tests|pyproject\.toml)
pass_filenames: false
# Project-local checks for the `oauth-draft` Python project at
# `tools/gmail/oauth-draft/`. Same shape as the generate-cve-json
# hooks above — `uv run --directory` so each tool picks up config
# from its own pyproject.toml; `files:` scopes by path.
- repo: local
hooks:
- id: oauth-draft-ruff-check
name: ruff check (oauth-draft)
language: system
entry: uv run --directory tools/gmail/oauth-draft ruff check
files: ^tools/gmail/oauth-draft/(src|tests|pyproject\.toml)
pass_filenames: false
- id: oauth-draft-ruff-format
name: ruff format (oauth-draft)
language: system
entry: uv run --directory tools/gmail/oauth-draft ruff format --check
files: ^tools/gmail/oauth-draft/(src|tests|pyproject\.toml)
pass_filenames: false
- id: oauth-draft-mypy
name: mypy (oauth-draft)
language: system
entry: uv run --directory tools/gmail/oauth-draft mypy
files: ^tools/gmail/oauth-draft/(src|tests|pyproject\.toml)
pass_filenames: false
- id: oauth-draft-pytest
name: pytest (oauth-draft)
language: system
entry: uv run --directory tools/gmail/oauth-draft pytest
files: ^tools/gmail/oauth-draft/(src|tests|pyproject\.toml)
pass_filenames: false
14 changes: 8 additions & 6 deletions tools/gmail/draft-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ user in [`config/user.md`](../../config/user.md) under
| Backend | Value | `threadId` attach? | Setup |
|---|---|---|---|
| claude.ai Gmail MCP | `claude_ai_mcp` (default) | **no** (subject-matched fallback only) | none — works as soon as the Gmail connector is authenticated on claude.ai |
| OAuth + `curl` script | `oauth_curl` | **yes** | one-time Google OAuth client + refresh-token setup, automated via `uv run tools/gmail/oauth-draft/setup_credentials.py` — see [`oauth-draft/README.md`](oauth-draft/README.md) |
| OAuth + `curl` script | `oauth_curl` | **yes** | 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) |

Both backends create **drafts** — never send. The human review-and-send
step is still required before any outbound message leaves the user's
Expand Down Expand Up @@ -72,7 +72,7 @@ on disk, the skills should always use them. Resolution:
- `tools.gmail.oauth_credentials_path` from
[`config/user.md`](../../config/user.md) when set;
- the `$GMAIL_OAUTH_CREDENTIALS` environment variable;
- the default path `~/.config/airflow-s/gmail-oauth.json`.
- the default path `~/.config/apache-steward/gmail-oauth.json`.

The probe is a single `test -f <path>` — actually parsing the file
or doing a token-refresh probe at this stage would burn HTTP
Expand All @@ -81,8 +81,10 @@ on disk, the skills should always use them. Resolution:
with a clear error.
2. **If credentials are found → use `oauth_curl`** unconditionally.
Invoke
[`tools/gmail/oauth-draft/create_draft.py`](oauth-draft/create_draft.py)
with `--thread-id`, `--to`, `--cc`, `--subject`, `--body-file`.
`uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-create`
with `--thread-id`, `--to`, `--cc`, `--subject`, `--body-file` —
see [`oauth-draft/README.md`](oauth-draft/README.md) for the full
shape.
`threadId` attachment is guaranteed; the draft surfaces in both the
conversation view (recipient-side threading) and — when no pile-up
blocks it — the global Drafts folder. The user's
Expand Down Expand Up @@ -113,8 +115,8 @@ or

> *Draft created via `claude_ai_mcp` (subject-matched fallback —
> `oauth_curl` credentials not found at default path; install via
> `tools/gmail/oauth-draft/setup_credentials.py` to get
> threadId attachment)*
> `uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-setup`
> to get threadId attachment)*

The fallback line is intentionally noisy when oauth credentials are
absent — a user who would benefit from threadId attachment should
Expand Down
6 changes: 6 additions & 0 deletions tools/gmail/oauth-draft/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
198 changes: 198 additions & 0 deletions tools/gmail/oauth-draft/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<!-- 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)*

- [oauth-draft](#oauth-draft)
- [Run](#run)
- [Setup — one-time](#setup--one-time)
- [How threading is guaranteed](#how-threading-is-guaranteed)
- [Confidentiality](#confidentiality)
- [Test](#test)
- [Lint / type-check](#lint--type-check)
- [Referenced by](#referenced-by)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

<!-- Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License. -->

# oauth-draft

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** (the claude.ai Gmail MCP cannot do this). |
| `oauth-draft-mark-read` | Bulk-modify Gmail threads matching a search query (default: mark as read by removing the `UNREAD` label). |

The behavioural contract for the `oauth_curl` drafting backend and
the surrounding policy live in
[`../draft-backends.md`](../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-steward/apache-steward/` submodule path inside an
adopting tracker repo):

```bash
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:

```bash
uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-create ...
```

`<framework>` substitutes to `.apache-steward/apache-steward` in
adopting projects and to `.` (the repository root) in framework
standalone — see the placeholder convention in
[`AGENTS.md`](../../../AGENTS.md#placeholder-convention-used-in-skill-files).

The other two scripts follow the same shape:

```bash
# 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.

1. **Create a Google Cloud project** (if you don't already have one
for this purpose). Enable the Gmail API.

2. **Create an OAuth client** of type *Desktop app*. Download the
credentials JSON (call it `client_secrets.json`).

3. **Run the consent flow** with the downloaded `client_secrets.json`.
`oauth-draft-setup` opens 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:

```bash
uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-setup \
/path/to/client_secrets.json
```

Optional flags:

| Flag | Purpose |
|---|---|
| `--from-address` | Address baked into the credentials file as the outgoing `From:`. Defaults to `$GMAIL_FROM`, then `git config user.email`. |
| `--out` | Output path. Default: `~/.config/apache-steward/gmail-oauth.json`. |
| `--rm-client-secrets` | Delete the input `client_secrets.json` after 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_curl` backend; treat
the file like an SSH private key.

4. **Smoke-test** by running a dry-run thread search:

```bash
uv run --project <framework>/tools/gmail/oauth-draft oauth-draft-mark-read \
--query 'in:inbox is:unread' --max 3
```

This exercises `Credentials.load → refresh_access_token →
threads.list` without 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:

1. Refreshes a short-lived access token from the stored refresh token.
2. Reads the chronologically-last message in the thread and extracts
its `Message-ID` header (and the existing `References` chain).
3. Builds an RFC822 MIME message with `In-Reply-To: <that-Message-ID>`
and `References: <existing chain> <that-Message-ID>`, plus sets
`threadId` in 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-steward/gmail-oauth.json`).
- Revoke the refresh token at
<https://myaccount.google.com/permissions> if you suspect it has
leaked.

## Test

```bash
cd tools/gmail/oauth-draft
uv run --group dev pytest
```

## Lint / type-check

```bash
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`](../operations.md#drafting-backends) — two-backend overview.
- [`../threading.md`](../threading.md) — threading guarantees per backend.
- [`../draft-backends.md`](../draft-backends.md) — the config knob.
Loading