This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new dc98175 fix(gmail): guarantee plain-text drafts on both backends
(#471)
dc98175 is described below
commit dc98175cdd4029a1ac28688cd1c08df8145557e1
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Jun 8 09:45:07 2026 +0200
fix(gmail): guarantee plain-text drafts on both backends (#471)
Make plain-text-only an enforced invariant for Gmail drafts:
- oauth_curl: document the intent on build_mime (set_content yields a
single text/plain part; never add an HTML alternative) and add a
regression test asserting the built MIME stays single-part text/plain
with no text/html anywhere.
- claude_ai_mcp: document the hard rule that drafts must pass only
`body` and never `htmlBody` (which would add a text/html part) in
operations.md and draft-backends.md.
Generated-by: Claude Code (Opus 4.8)
---
tools/gmail/draft-backends.md | 9 +++++++
.../oauth-draft/src/oauth_draft/create_draft.py | 6 +++++
tools/gmail/oauth-draft/tests/test_create_draft.py | 28 ++++++++++++++++++++++
tools/gmail/operations.md | 18 ++++++++++++--
4 files changed, 59 insertions(+), 2 deletions(-)
diff --git a/tools/gmail/draft-backends.md b/tools/gmail/draft-backends.md
index 3f3b9e3..b8005a4 100644
--- a/tools/gmail/draft-backends.md
+++ b/tools/gmail/draft-backends.md
@@ -188,6 +188,15 @@ 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
diff --git a/tools/gmail/oauth-draft/src/oauth_draft/create_draft.py
b/tools/gmail/oauth-draft/src/oauth_draft/create_draft.py
index 014aee5..6a0181d 100644
--- a/tools/gmail/oauth-draft/src/oauth_draft/create_draft.py
+++ b/tools/gmail/oauth-draft/src/oauth_draft/create_draft.py
@@ -136,6 +136,12 @@ def build_mime(
msg["In-Reply-To"] = in_reply_to
if references:
msg["References"] = references
+ # Plain text only — by design. ``set_content(str)`` produces a
+ # single ``text/plain; charset="utf-8"`` part with no ``text/html``
+ # alternative. The project's outbound mail is always plain text:
+ # never call ``add_alternative`` / ``set_content(..., subtype="html")``
+ # here, and never attach an HTML body. (The test suite asserts the
+ # built message stays single-part text/plain.)
msg.set_content(body)
return bytes(msg)
diff --git a/tools/gmail/oauth-draft/tests/test_create_draft.py
b/tools/gmail/oauth-draft/tests/test_create_draft.py
index a50056e..b49cc7c 100644
--- a/tools/gmail/oauth-draft/tests/test_create_draft.py
+++ b/tools/gmail/oauth-draft/tests/test_create_draft.py
@@ -68,6 +68,34 @@ def test_build_mime_sets_basic_headers():
assert msg.get_content().rstrip() == "Body content."
+def test_build_mime_is_plain_text_not_html():
+ """The built message must be single-part ``text/plain`` — never HTML.
+
+ The project sends plain-text mail only. ``build_mime`` uses
+ ``EmailMessage.set_content(str)``, which yields a single
+ ``text/plain`` part with no ``text/html`` alternative. This guards
+ against a regression that would introduce an HTML body (e.g. a
+ stray ``add_alternative`` / ``set_content(subtype="html")``).
+ """
+ raw = build_mime(
+ from_addr="[email protected]",
+ to=["[email protected]"],
+ cc=[],
+ bcc=[],
+ subject="Plain",
+ body="Just text, no markup.",
+ in_reply_to=None,
+ references=None,
+ )
+ msg = parse_built_message(raw)
+ assert msg.get_content_type() == "text/plain"
+ assert not msg.is_multipart()
+ # No HTML part anywhere in the tree.
+ assert all(part.get_content_type() != "text/html" for part in msg.walk())
+ # The raw RFC822 bytes carry no HTML content-type header either.
+ assert b"text/html" not in raw
+
+
def test_build_mime_joins_multiple_recipients():
raw = build_mime(
from_addr="[email protected]",
diff --git a/tools/gmail/operations.md b/tools/gmail/operations.md
index 12e81fc..27a278a 100644
--- a/tools/gmail/operations.md
+++ b/tools/gmail/operations.md
@@ -194,16 +194,25 @@ mcp__claude_ai_Gmail__get_thread(
)
# → take messages[-1].id as <reply-to-message-id>
-# 2. Create the draft with replyToMessageId set:
+# 2. Create the draft with replyToMessageId set.
+# Pass `body` (plain text) ONLY — never `htmlBody`. The tool sends
+# a plain-text message iff `htmlBody` is omitted; supplying it adds
+# a text/html alternative, which the project never wants.
mcp__claude_ai_Gmail__create_draft(
subject='Re: <root subject of the inbound message>',
to=['<primary>'],
cc=['<security-list>', ...],
- body='<body>',
+ body='<body>', # plain text only
replyToMessageId='<reply-to-message-id>',
+ # htmlBody=... # DO NOT SET — would make the draft HTML
)
```
+- **Plain text only — never set `htmlBody`.** The `create_draft` tool
+ produces a plain-text message when only `body` is supplied; passing
+ `htmlBody` (or `body` + `htmlBody`) creates a `text/html` part. The
+ project's outbound mail is always plain text, so only ever populate
+ `body`.
- **`replyToMessageId` is the message ID of the latest message on the
inbound thread.** Resolve it from `get_thread` rather than guessing
— Gmail does not accept a `threadId` here.
@@ -249,6 +258,11 @@ backend too — drafts only, never send; subject is always
### Hard rules that apply to both backends
- **Never send.**
+- **Plain text only — never HTML.** Every draft is a plain-text
+ (`text/plain`) message. For `claude_ai_mcp`, populate `body` and
+ never `htmlBody`. For `oauth_curl`, the script builds a single
+ `text/plain` part via `EmailMessage.set_content` (asserted by its
+ test suite). Do not introduce an HTML body in either backend.
- **Subject is always `Re: <root subject>`**, never fabricated.
- **Run `pii-reveal` before passing the body to the create-draft
call.** If the draft body carries any third-party identifiers