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

Reply via email to