Skip to content

fix(gmail): match Gmail's forwarded-message header block - #1236

Open
malob wants to merge 3 commits into
openclaw:mainfrom
malob:fix/gmail-forward-header
Open

malob wants to merge 3 commits into
openclaw:mainfrom
malob:fix/gmail-forward-header

Conversation

@malob

@malob malob commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

What

People use gog in place of Gmail’s own interface, so by default what gog writes should match what Gmail writes. A forward’s header block, the one that introduces the forwarded message, didn’t:

  • In HTML the whole block was grey, the labels were bold, and margins spaced it out. No address was linked, and To and Cc were the raw header text, quotes included ("Carol, Quoted" <carol@example.org>).
  • In both parts a bare address stayed bare, and a single blank line (in HTML, a margin) separated the headers from the original body.
  • The bold labels also reached plain-text readers. When a forward with an HTML part is sent through the API, Gmail rebuilds its text part from the HTML and turns bold into asterisks, so the delivered text read *From:* … *Date:* ….

Both parts now follow the layout and address formatting of Gmail Web’s own forwards, compared against forwards saved as drafts in Gmail Web. In text/plain:

---------- Forwarded message ---------
From: Sender, Example <sender@example.com>
Date: Sat, Aug 15, 2026 at 3:55 PM
Subject: Project update
To: Alice Example <alice@example.com>, <bob@example.com>, Carol, Quoted <carol@example.org>
Cc: Dave, Encoded <dave@example.com>, <erin@example.com>


Original message body.

In HTML the same text, one header per <br>, with each address linked and the sender’s name in bold the way Gmail marks it:

From: <strong class="gmail_sendername">Sender, Example</strong> &lt;<a href="mailto:sender@example.com">sender@example.com</a>&gt;<br>

This covers gmail forward and gmail drafts forward, which share the formatter.

Changes

  1. refactor(gmail): move the quote mailbox helpers into their own file. The helpers fix(gmail): match Gmail's reply attribution line #1213 added for the reply attribution line (parse a mailbox, render it as text, render it as linked HTML) move from gmail_reply.go to gmail_quote_mailbox.go with their tests, renamed for mailboxes rather than senders (formatQuoteMailbox, formatQuoteMailboxHTML), since the forwarded headers now use them for recipients too. No behavior change.
  2. fix(gmail): match Gmail's forwarded-message header block.
    • Layout. The HTML block no longer has grey styling or bold labels: the separator line runs straight into From:, and each header is one <br> line. Two blank lines separate the headers from a non-empty body, in both parts. The gmail_quote wrapper is unchanged. Two things Gmail does are left out on purpose: its plain-text hard wrap, which splits addresses mid-way (Carol, Quoted < on one line, carol@example.org> on the next), and a doubled HTML space before a nameless address, which doesn’t render.

    • Addresses. From, To and Cc use the attribution line’s rule: Name <email>, or <email> without a name, with display names unquoted. To and Cc entries are joined by , . In HTML each address is a mailto: link, percent-encoded as in fix(gmail): match Gmail's reply attribution line #1213.

    • The sender’s name is bold with Gmail’s own markup, <strong class="gmail_sendername">. A plain <strong> would bring the asterisks back: the rebuilt text part would read From: *Sender, Example* <…>. Gmail leaves a gmail_sendername name as it is. I checked this by sending test messages to a second account and reading the delivered copies; the text part of one whose HTML held both forms arrived as:

      From: *Plain Strong* <a@example.com>
      From: Classed Strong <b@example.com>
      
    • Groups. Like Gmail Web, a group lists its members without its name. An empty group inside a list (undisclosed-recipients:;, …) drops out, and a To of only undisclosed-recipients:; keeps an empty To: line.

    • Values that don’t parse. A To or Cc that isn’t a parseable address list is shown trimmed but otherwise as it came, escaped in HTML, without links. A From that isn’t a single mailbox keeps fix(gmail): match Gmail's reply attribution line #1213’s fallback, the same treatment. Gmail Web instead splits such a list at its commas and links what it can (Evil, Name <evil@example.com> became <Evil>, Name <evil@example.com>). gog doesn’t guess where a name ends, so the line loses only its links.

    • The header lines are built once and passed to both formatters, whose argument lists shrink from nine to four, and Gmail’s separator line is one constant both parts share.

    • docs/gmail-workflows.md describes the block.

Display names with a comma need nothing special. In probes, the Gmail API returned encoded names containing a comma quoted (=?UTF-8?Q?Sender=2C_Example?= arrives as "Sender, Example" <…>), and they parse. Only a CRLF inside an encoded name came back unquoted (=?UTF-8?Q?Evil=0D=0AName?= arrives as Evil, Name <…>); that isn’t a valid address, so it takes the fallback above.

Not in this PR

  • Gmail’s other markup: the dir attributes and the gmail_quote_container and gmail_attr classes. Gmail puts them on replies’ quoted text as well as forwards, so they belong in one separate change that covers both.
  • A forward of an HTML-only original still has no body text in its plain part. That comes from deriving a plain version of the original, separate from the header block, and is better fixed on its own.

Behavior proof

Live against a personal Gmail account (personal is an account alias); nothing is sent. proof_forward_header.py (below; Python 3 standard library plus gog) inserts five synthetic source messages with gmail.users.messages.insert, saves a drafts forward of each, reads the draft back with gog gmail raw, and compares the header block in both MIME parts with the expected block. The fixtures cover a named sender with several recipients, a nameless sender, RFC 5322 comments and groups, a To naming no mailbox, and an unparseable display name. The first four expect the formatting Gmail Web produced for the same kinds of headers; the fifth expects the fallback. The script prints HTML one <br> per line, and moves everything it created to Trash; its last check confirms each move succeeded.

Run against a build of current main without this change (gog-main: ten header-block checks fail, exit 1), then a build of this branch (gog-branch: all twelve checks pass, exit 0):

$ GOG_ACCOUNT=personal GOG=./gog-main python3 -I proof_forward_header.py
# v0.45.0-6-g5579acdf (5579acdf0c21 2026-10-19T17:47:36Z)
not ok 1 - named sender, several recipients: text/plain header block
#   got:
#     ---------- Forwarded message ---------
#     From: "Sender, Example" <sender@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (named sender, several recipients)
#     To: Alice Example <alice@example.com>, bob@example.com, "Carol, Quoted" <carol@example.org>
#     Cc: "Dave, Encoded" <dave@example.com>, <erin@example.com>
#     (blank line)
#     Original message body.
#   want:
#     ---------- Forwarded message ---------
#     From: Sender, Example <sender@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (named sender, several recipients)
#     To: Alice Example <alice@example.com>, <bob@example.com>, Carol, Quoted <carol@example.org>
#     Cc: Dave, Encoded <dave@example.com>, <erin@example.com>
#     (blank line)
#     (blank line)
#     Original message body.
not ok 2 - named sender, several recipients: text/html header block
#   got:
#     <div class="gmail_quote"><div style="margin:0 0 10px 0;color:#777">---------- Forwarded message ---------</div><div style="margin:0 0 10px 0;color:#777"><b>From:</b> Sender, Example &lt;sender@example.com&gt;<br>
#     <b>Date:</b> Sat, Aug 15, 2026 at 3:55 PM<br>
#     <b>Subject:</b> gog forward header proof (named sender, several recipients)<br>
#     <b>To:</b> Alice Example &lt;alice@example.com&gt;, bob@example.com, &#34;Carol, Quoted&#34; &lt;carol@example.org&gt;<br>
#     <b>Cc:</b> &#34;Dave, Encoded&#34; &lt;dave@example.com&gt;, &lt;erin@example.com&gt;<br>
#     </div><div style="margin:10px 0 0 0"><div>Original message body.</div>
#   want:
#     <div class="gmail_quote"><div>---------- Forwarded message ---------<br>
#     From: <strong class="gmail_sendername">Sender, Example</strong> &lt;<a href="mailto:sender@example.com">sender@example.com</a>&gt;<br>
#     Date: Sat, Aug 15, 2026 at 3:55 PM<br>
#     Subject: gog forward header proof (named sender, several recipients)<br>
#     To: Alice Example &lt;<a href="mailto:alice@example.com">alice@example.com</a>&gt;, &lt;<a href="mailto:bob@example.com">bob@example.com</a>&gt;, Carol, Quoted &lt;<a href="mailto:carol@example.org">carol@example.org</a>&gt;<br>
#     Cc: Dave, Encoded &lt;<a href="mailto:dave@example.com">dave@example.com</a>&gt;, &lt;<a href="mailto:erin@example.com">erin@example.com</a>&gt;<br>
#     </div><br>
#     <br>
#     <div>Original message body.</div>
not ok 3 - nameless sender: text/plain header block
#   got:
#     ---------- Forwarded message ---------
#     From: sender@example.com
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (nameless sender)
#     To: recipient@example.com
#     (blank line)
#     Original message body.
#   want:
#     ---------- Forwarded message ---------
#     From: <sender@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (nameless sender)
#     To: <recipient@example.com>
#     (blank line)
#     (blank line)
#     Original message body.
not ok 4 - nameless sender: text/html header block
#   got:
#     <div class="gmail_quote"><div style="margin:0 0 10px 0;color:#777">---------- Forwarded message ---------</div><div style="margin:0 0 10px 0;color:#777"><b>From:</b> sender@example.com<br>
#     <b>Date:</b> Sat, Aug 15, 2026 at 3:55 PM<br>
#     <b>Subject:</b> gog forward header proof (nameless sender)<br>
#     <b>To:</b> recipient@example.com<br>
#     </div><div style="margin:10px 0 0 0"><div>Original message body.</div>
#   want:
#     <div class="gmail_quote"><div>---------- Forwarded message ---------<br>
#     From: &lt;<a href="mailto:sender@example.com">sender@example.com</a>&gt;<br>
#     Date: Sat, Aug 15, 2026 at 3:55 PM<br>
#     Subject: gog forward header proof (nameless sender)<br>
#     To: &lt;<a href="mailto:recipient@example.com">recipient@example.com</a>&gt;<br>
#     </div><br>
#     <br>
#     <div>Original message body.</div>
not ok 5 - groups and comments: text/plain header block
#   got:
#     ---------- Forwarded message ---------
#     From: John <john@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (groups and comments)
#     To: Team:a@example.com, b@example.com;, c@example.com
#     Cc: undisclosed-recipients:;, John Smith <john2@example.com>, "Acme (Support)" <support@example.com>
#     (blank line)
#     Original message body.
#   want:
#     ---------- Forwarded message ---------
#     From: John <john@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (groups and comments)
#     To: <a@example.com>, <b@example.com>, <c@example.com>
#     Cc: John Smith <john2@example.com>, Acme (Support) <support@example.com>
#     (blank line)
#     (blank line)
#     Original message body.
not ok 6 - groups and comments: text/html header block
#   got:
#     <div class="gmail_quote"><div style="margin:0 0 10px 0;color:#777">---------- Forwarded message ---------</div><div style="margin:0 0 10px 0;color:#777"><b>From:</b> John &lt;john@example.com&gt;<br>
#     <b>Date:</b> Sat, Aug 15, 2026 at 3:55 PM<br>
#     <b>Subject:</b> gog forward header proof (groups and comments)<br>
#     <b>To:</b> Team:a@example.com, b@example.com;, c@example.com<br>
#     <b>Cc:</b> undisclosed-recipients:;, John Smith &lt;john2@example.com&gt;, &#34;Acme (Support)&#34; &lt;support@example.com&gt;<br>
#     </div><div style="margin:10px 0 0 0"><div>Original message body.</div>
#   want:
#     <div class="gmail_quote"><div>---------- Forwarded message ---------<br>
#     From: <strong class="gmail_sendername">John</strong> &lt;<a href="mailto:john@example.com">john@example.com</a>&gt;<br>
#     Date: Sat, Aug 15, 2026 at 3:55 PM<br>
#     Subject: gog forward header proof (groups and comments)<br>
#     To: &lt;<a href="mailto:a@example.com">a@example.com</a>&gt;, &lt;<a href="mailto:b@example.com">b@example.com</a>&gt;, &lt;<a href="mailto:c@example.com">c@example.com</a>&gt;<br>
#     Cc: John Smith &lt;<a href="mailto:john2@example.com">john2@example.com</a>&gt;, Acme (Support) &lt;<a href="mailto:support@example.com">support@example.com</a>&gt;<br>
#     </div><br>
#     <br>
#     <div>Original message body.</div>
not ok 7 - a To naming no mailbox: text/plain header block
#   got:
#     ---------- Forwarded message ---------
#     From: Example Sender <sender@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (a To naming no mailbox)
#     To: undisclosed-recipients:;
#     (blank line)
#     Original message body.
#   want:
#     ---------- Forwarded message ---------
#     From: Example Sender <sender@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (a To naming no mailbox)
#     To: 
#     (blank line)
#     (blank line)
#     Original message body.
not ok 8 - a To naming no mailbox: text/html header block
#   got:
#     <div class="gmail_quote"><div style="margin:0 0 10px 0;color:#777">---------- Forwarded message ---------</div><div style="margin:0 0 10px 0;color:#777"><b>From:</b> Example Sender &lt;sender@example.com&gt;<br>
#     <b>Date:</b> Sat, Aug 15, 2026 at 3:55 PM<br>
#     <b>Subject:</b> gog forward header proof (a To naming no mailbox)<br>
#     <b>To:</b> undisclosed-recipients:;<br>
#     </div><div style="margin:10px 0 0 0"><div>Original message body.</div>
#   want:
#     <div class="gmail_quote"><div>---------- Forwarded message ---------<br>
#     From: <strong class="gmail_sendername">Example Sender</strong> &lt;<a href="mailto:sender@example.com">sender@example.com</a>&gt;<br>
#     Date: Sat, Aug 15, 2026 at 3:55 PM<br>
#     Subject: gog forward header proof (a To naming no mailbox)<br>
#     To: <br>
#     </div><br>
#     <br>
#     <div>Original message body.</div>
not ok 9 - line break in an encoded name: text/plain header block
#   got:
#     ---------- Forwarded message ---------
#     From: Evil, Name <evil@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (line break in an encoded name)
#     To: recipient@example.com
#     (blank line)
#     Original message body.
#   want:
#     ---------- Forwarded message ---------
#     From: Evil, Name <evil@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (line break in an encoded name)
#     To: <recipient@example.com>
#     (blank line)
#     (blank line)
#     Original message body.
not ok 10 - line break in an encoded name: text/html header block
#   got:
#     <div class="gmail_quote"><div style="margin:0 0 10px 0;color:#777">---------- Forwarded message ---------</div><div style="margin:0 0 10px 0;color:#777"><b>From:</b> Evil, Name &lt;evil@example.com&gt;<br>
#     <b>Date:</b> Sat, Aug 15, 2026 at 3:55 PM<br>
#     <b>Subject:</b> gog forward header proof (line break in an encoded name)<br>
#     <b>To:</b> recipient@example.com<br>
#     </div><div style="margin:10px 0 0 0"><div>Original message body.</div>
#   want:
#     <div class="gmail_quote"><div>---------- Forwarded message ---------<br>
#     From: Evil, Name &lt;evil@example.com&gt;<br>
#     Date: Sat, Aug 15, 2026 at 3:55 PM<br>
#     Subject: gog forward header proof (line break in an encoded name)<br>
#     To: &lt;<a href="mailto:recipient@example.com">recipient@example.com</a>&gt;<br>
#     </div><br>
#     <br>
#     <div>Original message body.</div>
ok 11 - every forward is an unsent draft
#   got:
#     ['DRAFT']
ok 12 - every recorded draft and source message moved to Trash
#   got:
#     10 of 10
1..12
$ GOG_ACCOUNT=personal GOG=./gog-branch python3 -I proof_forward_header.py
# v0.45.0-8-gd07b3c96 (d07b3c96e649 2026-10-19T19:47:51Z)
ok 1 - named sender, several recipients: text/plain header block
#   got:
#     ---------- Forwarded message ---------
#     From: Sender, Example <sender@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (named sender, several recipients)
#     To: Alice Example <alice@example.com>, <bob@example.com>, Carol, Quoted <carol@example.org>
#     Cc: Dave, Encoded <dave@example.com>, <erin@example.com>
#     (blank line)
#     (blank line)
#     Original message body.
ok 2 - named sender, several recipients: text/html header block
#   got:
#     <div class="gmail_quote"><div>---------- Forwarded message ---------<br>
#     From: <strong class="gmail_sendername">Sender, Example</strong> &lt;<a href="mailto:sender@example.com">sender@example.com</a>&gt;<br>
#     Date: Sat, Aug 15, 2026 at 3:55 PM<br>
#     Subject: gog forward header proof (named sender, several recipients)<br>
#     To: Alice Example &lt;<a href="mailto:alice@example.com">alice@example.com</a>&gt;, &lt;<a href="mailto:bob@example.com">bob@example.com</a>&gt;, Carol, Quoted &lt;<a href="mailto:carol@example.org">carol@example.org</a>&gt;<br>
#     Cc: Dave, Encoded &lt;<a href="mailto:dave@example.com">dave@example.com</a>&gt;, &lt;<a href="mailto:erin@example.com">erin@example.com</a>&gt;<br>
#     </div><br>
#     <br>
#     <div>Original message body.</div>
ok 3 - nameless sender: text/plain header block
#   got:
#     ---------- Forwarded message ---------
#     From: <sender@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (nameless sender)
#     To: <recipient@example.com>
#     (blank line)
#     (blank line)
#     Original message body.
ok 4 - nameless sender: text/html header block
#   got:
#     <div class="gmail_quote"><div>---------- Forwarded message ---------<br>
#     From: &lt;<a href="mailto:sender@example.com">sender@example.com</a>&gt;<br>
#     Date: Sat, Aug 15, 2026 at 3:55 PM<br>
#     Subject: gog forward header proof (nameless sender)<br>
#     To: &lt;<a href="mailto:recipient@example.com">recipient@example.com</a>&gt;<br>
#     </div><br>
#     <br>
#     <div>Original message body.</div>
ok 5 - groups and comments: text/plain header block
#   got:
#     ---------- Forwarded message ---------
#     From: John <john@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (groups and comments)
#     To: <a@example.com>, <b@example.com>, <c@example.com>
#     Cc: John Smith <john2@example.com>, Acme (Support) <support@example.com>
#     (blank line)
#     (blank line)
#     Original message body.
ok 6 - groups and comments: text/html header block
#   got:
#     <div class="gmail_quote"><div>---------- Forwarded message ---------<br>
#     From: <strong class="gmail_sendername">John</strong> &lt;<a href="mailto:john@example.com">john@example.com</a>&gt;<br>
#     Date: Sat, Aug 15, 2026 at 3:55 PM<br>
#     Subject: gog forward header proof (groups and comments)<br>
#     To: &lt;<a href="mailto:a@example.com">a@example.com</a>&gt;, &lt;<a href="mailto:b@example.com">b@example.com</a>&gt;, &lt;<a href="mailto:c@example.com">c@example.com</a>&gt;<br>
#     Cc: John Smith &lt;<a href="mailto:john2@example.com">john2@example.com</a>&gt;, Acme (Support) &lt;<a href="mailto:support@example.com">support@example.com</a>&gt;<br>
#     </div><br>
#     <br>
#     <div>Original message body.</div>
ok 7 - a To naming no mailbox: text/plain header block
#   got:
#     ---------- Forwarded message ---------
#     From: Example Sender <sender@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (a To naming no mailbox)
#     To: 
#     (blank line)
#     (blank line)
#     Original message body.
ok 8 - a To naming no mailbox: text/html header block
#   got:
#     <div class="gmail_quote"><div>---------- Forwarded message ---------<br>
#     From: <strong class="gmail_sendername">Example Sender</strong> &lt;<a href="mailto:sender@example.com">sender@example.com</a>&gt;<br>
#     Date: Sat, Aug 15, 2026 at 3:55 PM<br>
#     Subject: gog forward header proof (a To naming no mailbox)<br>
#     To: <br>
#     </div><br>
#     <br>
#     <div>Original message body.</div>
ok 9 - line break in an encoded name: text/plain header block
#   got:
#     ---------- Forwarded message ---------
#     From: Evil, Name <evil@example.com>
#     Date: Sat, Aug 15, 2026 at 3:55 PM
#     Subject: gog forward header proof (line break in an encoded name)
#     To: <recipient@example.com>
#     (blank line)
#     (blank line)
#     Original message body.
ok 10 - line break in an encoded name: text/html header block
#   got:
#     <div class="gmail_quote"><div>---------- Forwarded message ---------<br>
#     From: Evil, Name &lt;evil@example.com&gt;<br>
#     Date: Sat, Aug 15, 2026 at 3:55 PM<br>
#     Subject: gog forward header proof (line break in an encoded name)<br>
#     To: &lt;<a href="mailto:recipient@example.com">recipient@example.com</a>&gt;<br>
#     </div><br>
#     <br>
#     <div>Original message body.</div>
ok 11 - every forward is an unsent draft
#   got:
#     ['DRAFT']
ok 12 - every recorded draft and source message moved to Trash
#   got:
#     10 of 10
1..12
proof_forward_header.py
#!/usr/bin/env python3
"""Live proof: a forward's header block reads the way Gmail Web writes it.

Inserts five synthetic text+HTML source messages with
gmail.users.messages.insert (nothing is sent; every address is under
example.com or example.org): a named sender with several recipients, a
nameless sender, addresses with RFC 5322 comments and groups, a To naming no
mailbox, and a display name with a line break in it. Saves a
`gog gmail drafts forward` draft of each, reads it back with `gog gmail raw`,
and compares the forwarded-message header block in both MIME parts with the
expected block.

For the first four, the expected From, To and Cc lines follow the formatting
seen in Gmail Web forwards of such messages saved as drafts. Gmail's markup
also carries dir attributes, its own wrapper classes and a doubled space
before a nameless address, and its plain text is hard-wrapped. The fifth
checks gog's fallback for a value that isn't a valid address: the line as it
came, unlinked.

At the end it tries to move every draft and source message it recorded to
Trash, even after an error or Ctrl-C; its last check counts the `gmail trash`
commands that succeeded. A run cut off while a command is in flight can leave
that command's draft or message unrecorded; search Gmail for
subject:"gog forward header proof" to find it.

Usage: GOG_ACCOUNT=you@example.com [GOG=./bin/gog] python3 proof_forward_header.py
Needs gog and Python 3.8+ only. Prints TAP; exit 0 means every check passed.
"""

import base64
import json
import os
import subprocess
import sys
import time

GOG = os.environ.get("GOG", "gog")
ACCOUNT = os.environ["GOG_ACCOUNT"]
ENV = {**os.environ, "GOG_TIMEZONE": "America/Los_Angeles"}  # deterministic dates
TIMEOUT = 120  # seconds per gog call
NNBSP = " "  # Gmail's narrow no-break space before AM/PM
SEPARATOR = "---------- Forwarded message ---------"
QUOTE = '<div class="gmail_quote">'  # gog's wrapper around the forwarded message
BOLD = '<strong class="gmail_sendername">'  # Gmail's markup for the sender's name
DATE = f"Sat, Aug 15, 2026 at 3:55{NNBSP}PM"
BODY = "Original message body."


def link(address, href=None):
    return f'&lt;<a href="mailto:{href or address}">{address}</a>&gt;'


# (fixture, From, To, Cc as inserted; expected From, To, Cc lines as text, then as HTML)
FIXTURES = [
    ("named sender, several recipients",
     "=?UTF-8?Q?Sender=2C_Example?= <sender@example.com>",
     'Alice Example <alice@example.com>, bob@example.com, "Carol, Quoted" <carol@example.org>',
     "=?UTF-8?Q?Dave=2C_Encoded?= <dave@example.com>, <erin@example.com>",
     ["From: Sender, Example <sender@example.com>",
      "To: Alice Example <alice@example.com>, <bob@example.com>, Carol, Quoted <carol@example.org>",
      "Cc: Dave, Encoded <dave@example.com>, <erin@example.com>"],
     [f"From: {BOLD}Sender, Example</strong> {link('sender@example.com')}",
      f"To: Alice Example {link('alice@example.com')}, {link('bob@example.com')}, Carol, Quoted {link('carol@example.org')}",
      f"Cc: Dave, Encoded {link('dave@example.com')}, {link('erin@example.com')}"]),
    ("nameless sender",
     "sender@example.com", "recipient@example.com", None,
     ["From: <sender@example.com>", "To: <recipient@example.com>"],
     [f"From: {link('sender@example.com')}", f"To: {link('recipient@example.com')}"]),
    ("groups and comments",
     "John (Work) <john@example.com>",
     "Team: a@example.com, b@example.com;, c@example.com",
     'undisclosed-recipients:;, john2@example.com (John Smith), "Acme (Support)" <support@example.com>',
     ["From: John <john@example.com>",
      "To: <a@example.com>, <b@example.com>, <c@example.com>",
      "Cc: John Smith <john2@example.com>, Acme (Support) <support@example.com>"],
     [f"From: {BOLD}John</strong> {link('john@example.com')}",
      f"To: {link('a@example.com')}, {link('b@example.com')}, {link('c@example.com')}",
      f"Cc: John Smith {link('john2@example.com')}, Acme (Support) {link('support@example.com')}"]),
    # Like Gmail Web, a group with no members leaves the line empty.
    ("a To naming no mailbox",
     "Example Sender <sender@example.com>", "undisclosed-recipients:;", None,
     ["From: Example Sender <sender@example.com>", "To: "],
     [f"From: {BOLD}Example Sender</strong> {link('sender@example.com')}", "To: "]),
    # Gmail returns this name as "Evil, Name", unquoted, which is not a valid
    # address; the line is kept as it came, unlinked.
    ("line break in an encoded name",
     "=?UTF-8?Q?Evil=0D=0AName?= <evil@example.com>", "recipient@example.com", None,
     ["From: Evil, Name <evil@example.com>", "To: <recipient@example.com>"],
     ["From: Evil, Name &lt;evil@example.com&gt;", f"To: {link('recipient@example.com')}"]),
]

checks = failures = 0
created = []  # message ids to move to Trash at the end


class GogError(RuntimeError):
    pass


def say(text):
    print(text.replace(ACCOUNT, "$GOG_ACCOUNT"), flush=True)


def gog(*args, stdin=None):
    """Run gog for ACCOUNT and parse its JSON output; stdin is closed unless given."""
    # Errors name the subcommand only: gog's errors can carry ids and addresses.
    command = " ".join(a for a in args[:3] if a.isalpha())
    try:
        proc = subprocess.run([GOG, "-a", ACCOUNT, "--no-input", *args, "--json"], input=stdin,
                              capture_output=True, text=True, env=ENV, timeout=TIMEOUT,
                              stdin=subprocess.DEVNULL if stdin is None else None)
    except subprocess.TimeoutExpired:
        raise GogError(f"gog {command} timed out after {TIMEOUT}s") from None
    if proc.returncode != 0:
        raise GogError(f"gog {command} exited {proc.returncode}")
    return json.loads(proc.stdout)


def insert(tag, sender, to, cc):
    headers = f"From: {sender}\r\nTo: {to}\r\n" + (f"Cc: {cc}\r\n" if cc else "")
    mime = (headers + "Date: Sat, 15 Aug 2026 15:55:00 -0700\r\n"
            f"Subject: gog forward header proof ({tag})\r\n"
            f"Message-ID: <gog-forward-header-{time.time_ns()}@example.com>\r\n"
            'MIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary="b"\r\n\r\n'
            f"--b\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n{BODY}\r\n"
            f"--b\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n<div>{BODY}</div>\r\n"
            "--b--\r\n")
    raw = base64.urlsafe_b64encode(mime.encode()).decode()
    msg_id = gog("api", "call", "gmail", "v1", "gmail.users.messages.insert",
                 "--params", '{"userId":"me"}', "--body", "@/dev/stdin",
                 "--allow-write", "--force", stdin=json.dumps({"raw": raw}))["id"]
    created.append(msg_id)
    return msg_id


def text_parts(msg_id):
    """The stored draft's labels and decoded text/plain and text/html bodies,
    with carriage returns removed."""
    message, parts = gog("gmail", "raw", msg_id), {}

    def walk(part):
        if part.get("mimeType") in ("text/plain", "text/html"):
            data = part.get("body", {}).get("data") or ""
            text = base64.urlsafe_b64decode(data + "=" * (-len(data) % 4)).decode()
            parts.setdefault(part["mimeType"], text.replace("\r", ""))
        for child in part.get("parts", []):
            walk(child)

    walk(message["payload"])
    return message.get("labelIds", []), parts


def block(text, start, end):
    """The header block: from start through the first end after it."""
    head, found, rest = text.partition(start)
    if not found or end not in rest:
        return None
    return start + rest[:rest.index(end) + len(end)]


def check(description, ok, got, want, lines):
    """One TAP check; got and want are shown a line at a time, split by lines()."""
    global checks, failures
    checks += 1
    failures += not ok
    say(f"{'ok' if ok else 'not ok'} {checks} - {description}")
    for label, value in (("got", got),) + ((("want", want),) if not ok else ()):
        say(f"#   {label}:")
        for line in lines(value) if value is not None else ["(no header block)"]:
            say(f"#     {line}")


def plain_lines(text):
    return [line if line else "(blank line)" for line in text.split("\n")]


def html_lines(text):
    # One <br>-separated piece per line, so each header shows on its own.
    return [piece + "<br>" for piece in text.split("<br>")[:-1]] + [text.split("<br>")[-1]]


def run():
    say("# " + subprocess.run([GOG, "version"], capture_output=True, text=True).stdout.strip())
    labels = []
    for tag, sender, to, cc, want_plain, want_html in FIXTURES:
        source = insert(tag, sender, to, cc)
        draft = gog("gmail", "drafts", "forward", source, "--to", "recipient@example.com")
        created.append(draft["message"]["id"])
        draft_labels, parts = text_parts(draft["message"]["id"])
        labels.append(draft_labels)

        subject = f"Subject: gog forward header proof ({tag})"
        plain_want = "\n".join([SEPARATOR, want_plain[0], f"Date: {DATE}", subject, *want_plain[1:], "", "", BODY])
        plain_got = block(parts.get("text/plain", ""), SEPARATOR, BODY)
        check(f"{tag}: text/plain header block", plain_got == plain_want, plain_got, plain_want, plain_lines)

        html_want = (f"{QUOTE}<div>{SEPARATOR}<br>"
                     + "<br>".join([want_html[0], f"Date: {DATE}", subject, *want_html[1:]])
                     + f"<br></div><br><br><div>{BODY}</div>")
        html_got = block(parts.get("text/html", ""), QUOTE, f"<div>{BODY}</div>")
        check(f"{tag}: text/html header block", html_got == html_want, html_got, html_want, html_lines)
    unsent = bool(labels) and all("DRAFT" in ls and "SENT" not in ls for ls in labels)
    check("every forward is an unsent draft", unsent, str(sorted({l for ls in labels for l in ls})), None, lambda v: [v])


def cleanup():
    trashed = 0
    for msg_id in created:
        try:
            done = subprocess.run([GOG, "-a", ACCOUNT, "--no-input", "gmail", "trash", msg_id], capture_output=True,
                                  stdin=subprocess.DEVNULL, env=ENV, timeout=TIMEOUT).returncode == 0
        except subprocess.TimeoutExpired:
            done = False
        trashed += done
    check("every recorded draft and source message moved to Trash", trashed == len(created),
          f"{trashed} of {len(created)}", None, lambda v: [v])


def main():
    completed = False
    try:
        run()
        completed = True
    except Exception as err:  # infrastructure failure: never counts as a pass
        say(f"Bail out! {err}")
    finally:
        cleanup()  # also runs on Ctrl-C
    say(f"1..{checks}")
    return 0 if completed and not failures else 1


if __name__ == "__main__":
    sys.exit(main())

Testing

  • Header lines: TestForwardedHeaderLines checks whole lines in both parts for:
    • a named sender with several recipients and quoted comma names;
    • a nameless sender, with no Cc line;
    • named and empty groups, and a To of only an empty group;
    • the unparseable CRLF name, in From and in To;
    • a blank display name, which counts as none;
    • escaping and link encoding of a name, an unparseable date, the subject, a quoted local part and a ? in an address.
  • Layout: TestForwardedHeaderLayout checks that:
    • the separator runs into From:;
    • the generated block has no bold labels or styling, and only the sender’s name is bold, with gmail_sendername;
    • two blank lines come before a body, and none when there is no body.
  • Commands: for both gmail forward and gmail drafts forward, TestGmailForward_HeaderBlockLikeGmail checks that the original’s headers reach both decoded MIME parts (the plain block exactly, the HTML lines in order), and TestGmailForward_SubjectNoteAndNoThreading checks the Fwd: subject, the note, the original’s body and the absence of thread and reply headers.
  • Older tests: the substring tests of the two formatters, which the tests above now cover strictly, are removed. The send-only and drafts-only tests of a forward’s subject, note and threading are merged into the one test above.
  • Replies: fix(gmail): match Gmail's reply attribution line #1213’s attribution tests pass unchanged on the moved helpers.
  • Mutation-checked: reverting each behavior above (layout and separator text, bold name and its class, address formatting, links, escaping, the group and fallback rules, blank lines) fails at least one test.
  • make ci green.

🤖 Generated with Claude Code

malob and others added 2 commits October 19, 2026 10:43
The helpers that render an original message's address the way Gmail shows
it in quoted text (parse, plain, HTML with a mailto: link) are about to
render a forward's From, To and Cc as well as a reply's sender. Move them
out of gmail_reply.go into gmail_quote_mailbox.go, alongside their tests,
and name them for mailboxes rather than senders: parseQuoteMailbox,
formatQuoteMailbox, formatQuoteMailboxHTML. No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lay the forwarded headers out the way Gmail Web does: no grey styling or
bold labels, the separator running straight into the From line, and two
blank lines before the original body in both parts. From, To and Cc
follow the reply attribution's rule ("Name <email>", or "<email>"
without a name, names unquoted), with each address linked in HTML. The
sender's name is bold with Gmail's gmail_sendername class, which also
keeps it free of *asterisks* when Gmail rebuilds the text part of a
message sent through the API. Like Gmail, a group shows only its
members, so a To of only "undisclosed-recipients:;" leaves the line
empty; a To or Cc that isn't a parseable address list is shown as it
came, unlinked.

The header lines are built once and passed to both formatters, and both
parts share one separator constant, which the tests use to find the
block. The new tests replace the older substring tests of the
formatters, the send-only and drafts-only tests of a forward's subject,
note and threading become one test that runs both commands, and the
forward test helpers shared across files move to gmail_testutil_test.go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@clawsweeper

clawsweeper Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

ClawSweeper review complete

ClawSweeper finished reviewing this revision. The review result is being finalized.

View the workflow run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@clawsweeper clawsweeper Bot added P3 Low-risk cleanup, docs, polish, ergonomics, or speculative feature. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Oct 19, 2026
@clawsweeper

clawsweeper Bot commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge.

What this changes

This PR aligns forwarded-message headers with Gmail’s layout and address formatting in plain text and HTML.

Example: Forward a message whose To header is bob@example.com.

  • Before: The quoted header shows To: bob@example.com, without an HTML link.
  • After: The quoted header shows To: <bob@example.com>, with the address linked in HTML.

Review scores

Measure Result What it means
Overall readiness 🦞 diamond lobster (5/6) Focused shared implementation, live Gmail readback, meaningful edge coverage, and successful reviewed-head checks support merging.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): Supplied terminal output exercises the changed shared composer through real gmail drafts forward and Gmail raw readback in America/Los_Angeles, covering named and nameless senders, multiple recipients, groups, empty groups, and malformed-name fallback in both MIME parts; production commit d07b3c9 passes all twelve checks, and the final commit changes only the changelog.
Patch quality 🦞 diamond lobster (5/6) No actionable review findings were identified.

Product

Kind: Bug fix · Worth it: Yes · Fix scope: Complete
User problem: Forwarded headers differ between MIME parts and Gmail’s own output, and bold labels become unwanted asterisks in delivered plain text.
Reason: This completes the established Gmail-style composition direction using the existing shared owner, with provider-backed evidence of the resulting output.

Merge readiness

✅ Ready for maintainer review

Keep this PR open: current main retains the formatting mismatch, and the focused shared fix has relevant live Gmail proof with no actionable code findings.

Priority: P3
Reviewed head: 7bfed57131996143cdfbe8ece6dc1feebc828071

Before merge

None.

Findings

None.

Agent review details

How this fits together

Gmail send and draft commands share a composition builder that reads the original message and produces MIME bodies, headers, and attachments for the Gmail API.

flowchart LR
A[Original Gmail message] --> B[Shared forward composer]
C[Note and signature] --> B
B --> D[Mailbox and header renderers]
D --> E[Plain and HTML MIME bodies]
E --> F[Gmail send or draft API]
Loading

Technical review

Best possible solution:

Land the shared forward-header fix with its existing mailbox escaping and focused command coverage.

Do we have a high-confidence way to reproduce the issue?

Current-main source confirms the mismatched header formatting, and contributor terminal output demonstrates baseline failures through live Gmail draft readback; this read-only review ran no target code.

Is this the best way to solve the issue?

The existing shared forward composer and mailbox renderer are the appropriate owners, and the patch preserves reply output, recipient selection, signatures, attachments, and threading.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning medium; reviewed against d8917466326d.

Provenance checked

  • Forward-header layout and address display changes intended behavior with a stated reason (13a1aae: feat(gmail): add forward command #482 introduced complete forwarded messages with original headers, bodies, and attachments; it states no requirement for grey styling or bold labels.)
  • Shared reply mailbox formatting and safe mailto links keeps the original intent (e8fb041: fix(gmail): match Gmail's reply attribution line #1213 aligned reply MIME attributions and introduced safely encoded links intended for later forward-header reuse.)
  • Quoted dates, notes, signatures, and draft composition keeps the original intent (e35aad9: Gmail-style timezone-aware dates and subsequent shared draft, signature, and note composition remain in their existing owners; only header presentation changes.)

Testing

Proof path: shipped entry point.

Security

None.

Evidence

What I checked:

  • Repository policy: Read the complete root AGENTS.md; no nested AGENTS.md, maintainer-notes directory, or pr-simplify skill was present. The PR includes an Unreleased changelog entry with its reference and contributor thanks. (AGENTS.md:45, 7bfed5713199)
  • Introduced production change: The pinned introduction diff reuses the reply mailbox renderer, adds recipient-list rendering, and updates the shared forward formatter; reply rendering retains its previous behavior. (internal/cmd/gmail_forward.go:208, 7bfed5713199)
  • Current main still needs the change: Current main renders raw To/Cc values and bold HTML labels inside grey styled blocks, without linked addresses. (internal/cmd/gmail_forward.go:350, d8917466326d)
  • Supplied live behavior proof: Read the complete REST body, including the supplied script without executing it; SHA-256 matches db8ad5a052d8f4cfd9ec090639b7e911145db3c502c4b230fdd5cccbf40293a1. Terminal output shows ten header assertions failing on the baseline and passing on production commit d07b3c9 through real drafts forward and Gmail MIME readback. The remaining head commit changes only the changelog. (d07b3c96e649)
  • Existing composition direction: Merged fix(gmail): match Gmail's reply attribution line #1213 established the shared mailbox rendering and explicitly left forward-header reuse as a separate change; fix(gmail): render quoted and forwarded dates in Gmail's human style #873 established Gmail-style quoted dates. (internal/cmd/gmail_reply.go:339, e8fb041148e3)
  • Feature history: History identifies spencer-c-reed’s original forward command, steipete’s composition refactors and first-class replies, and malob’s date, attribution, draft, and forward-note work. One original-commit inspection hit an unavailable parent object; the supplied provenance and REST rationale for feat(gmail): add forward command #482 establish the original forward behavior. (internal/cmd/gmail_forward.go:199, d8917466326d)

Likely related people:

  • Malo Bourgon: Raw commit e35aad9 adds internal/cmd/gmail_forward.go:85 relative to its recorded parents. This identifies author metadata, not feature responsibility or a PR merger. (role: source-line author; confidence: high; commits: e35aad920a97; files: internal/cmd/gmail_forward.go)
  • Peter Steinberger: Raw commit 896cccc adds internal/cmd/gmail_reply.go:302 relative to its recorded parents. This identifies author metadata, not feature responsibility or a PR merger. (role: source-line author; confidence: high; commits: 896cccc3dca5; files: internal/cmd/gmail_reply.go)
  • spencer-c-reed: Raw commit 13a1aae adds internal/cmd/gmail_forward.go:88 relative to its recorded parents. This identifies author metadata, not feature responsibility or a PR merger. (role: source-line author; confidence: medium; commits: 13a1aaea1348; files: internal/cmd/gmail_forward.go)

Review metrics

Metric Value Why it matters
Production and test scope Production +222/-115; tests +391/-287; documentation and changelog +8. Includes moved mailbox helpers and tests; remaining growth covers shared address-list formatting, escaping, layout, and both forward commands.

Labels

Label changes:

  • add P3: This is bounded email-formatting polish without a delivery, authentication, or runtime failure.
  • add proof: sufficient: Contributor real behavior proof is sufficient.
  • add rating: 🦞 diamond lobster: Overall readiness is 🦞 diamond lobster; proof is 🦞 diamond lobster and patch quality is 🦞 diamond lobster.
  • add status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR.

Label justifications:

  • P3: This is bounded email-formatting polish without a delivery, authentication, or runtime failure.
  • rating: 🦞 diamond lobster: Overall readiness is 🦞 diamond lobster; proof is 🦞 diamond lobster and patch quality is 🦞 diamond lobster.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR.
  • proof: sufficient: Contributor real behavior proof is sufficient.

Rating scale

6/6 🦀 challenger crab · 5/6 🦞 diamond lobster · 4/6 🐚 platinum hermit · 3/6 🦐 gold shrimp · 2/6 🦪 silver shellfish · 1/6 🧂 unranked krab. Overall follows the weaker of proof and patch quality; ✨ marks media proof (a screenshot, video, or linked artifact) that directly shows the changed behavior.

Workflow

ClawSweeper edits this one comment on every review. Comment @clawsweeper re-review for a fresh review only. Repair and merge execution lanes are retired.

Reviewed October 19, 2026, 4:17 PM ET / 20:17 UTC.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P3 Low-risk cleanup, docs, polish, ergonomics, or speculative feature. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant