Skip to content

fix(gmail): mark quote header direction and add Gmail's quote classes - #1250

Closed
malob wants to merge 2 commits into
openclaw:mainfrom
malob:feat/gmail-quote-wrapper-markup
Closed

malob wants to merge 2 commits into
openclaw:mainfrom
malob:feat/gmail-quote-wrapper-markup

Conversation

@malob

@malob malob commented Oct 22, 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. This brings the markup of the line gog writes to introduce a quote, a reply's "On … wrote:" line or a forward's header block, in line with Gmail's, and goes a step further where Gmail's own lines still break next to right-to-left text. Before, that line broke in two ways:

  • Quoted in a right-to-left reply. When someone using Gmail in Hebrew replies, Gmail wraps the whole reply, quote included, in <div dir="rtl">. gog's English line carried no direction of its own, so everyone reading that reply saw it scrambled: :On Tue, Oct 20, 2026 at 12:30 PM … wrote, <From: Example Sender <sender@example.com. Gmail marks the line it writes with the direction of its own language, dir="ltr" for English.
  • A right-to-left name. Next to an address that starts with digits, a Hebrew or Arabic name reorders the line even in an English message: From: 0501234567> ישראל ישראלי@example.com>, with the link split in two.

Both show in Gmail Web; see the screenshots under Behavior proof.

Changes

This covers every command that quotes a message: gmail reply and reply-all, send, drafts create and drafts update with --quote, drafts reply and drafts reply-all, and gmail forward and gmail drafts forward. They share the two formatters changed here.

In line with Gmail:

  • The line that introduces the quote is marked dir="ltr", since gog writes it in English.
  • The forwarded sender's name and address are wrapped in dir="auto", as Gmail writes them: <strong class="gmail_sendername" dir="auto"> and a <span dir="auto"> around the bracketed address. dir="auto" gives the text the direction of its first letter and keeps it from reordering the text around it.
  • Gmail's gmail_quote_container class on the quote's outer div, and gmail_attr on the forward's header block, as on the reply's attribution line. For the tested English messages, neither changed the delivered text part, the rendering or Gmail Web's "…" collapsing. They let tools that look for Gmail's markup find gog's quotes too; libredesk, for one, recognizes a quoted Gmail forward only by gmail_quote_container.

Beyond Gmail, following the W3C's guidance for text of unknown direction:

  • Every other value taken from the original is wrapped in <span dir="auto"> too: the names and addresses in a reply's attribution line and a forward's To and Cc, the date, and the forwarded subject. In its English attribution line Gmail wraps a right-to-left sender name in invisible Unicode direction marks, and its To and Cc lines only as a whole, so a right-to-left name still scrambles them: a Gmail Web forward of a test message read To: 42> דנה כהן@example.com>. The W3C recommends dir="auto" on a tight element around names and addresses, and markup rather than control characters in HTML. The date is gog's English text unless the original's Date header doesn't parse; then it is shown as it came.
  • A value that doesn't parse is wrapped as a whole; an empty value stays empty, so an attribution with no sender still reads "Original message:".

Left alone, on purpose:

  • The quoted message's own markup, including a nested gmail_quote_container. Gmail Web removes that class when it quotes a message, keeping one per message; gog doesn't rewrite the original's markup. Other mail clients don't write Gmail's classes at all, the next reply from Gmail restores the one-container form, and in testing the nested form displayed and converted the same.

The opening markup and the dir="auto" span live in a new gmail_quote_markup.go, shared by the reply and forward formatters, with a helper that escapes a value before wrapping it. docs/gmail-workflows.md mentions the direction marking.

Not in this PR

  • Direction in text/plain. This change is about the HTML part; the text/plain lines are unchanged. Plain text has no markup, so the same protection there would mean inserting invisible Unicode direction characters, a separate change for anyone who wants it.
  • The message body's direction. gog's own text has no direction either, so an English gog message quoted in a right-to-left reply still shows its paragraphs with the punctuation at the wrong end. Gmail Web writes the composer's direction there, which gog doesn't know; the fix applies to every message gog sends, not just replies and forwards, and means wrapping the user's HTML, so it belongs in its own change.

Behavior proof

In Gmail Web

A forward and a quoted reply of the same synthetic message, sent from one account to another with a build of main and with this branch, as Gmail Web shows the delivered copies. The original is from a sender with a Hebrew name and an address that starts with digits, with Hebrew and Arabic recipients and a Hebrew subject. The last row is a reply started in Gmail's Hebrew interface, quoting the delivered forward.

Message As Gmail Web shows it
Forward, main Forward from main: the From, To and Cc lines are scrambled
Forward, this branch Forward from this branch: every line reads correctly
Reply, main Reply from main: the attribution line is scrambled
Reply, this branch Reply from this branch: the attribution line reads correctly
Quoted in a Hebrew-interface reply, main Forward from main, quoted in a reply written in Gmail's Hebrew interface: the lines reorder
Quoted in a Hebrew-interface reply, this branch Forward from this branch, quoted in a reply written in Gmail's Hebrew interface: the lines stay left-to-right

Drafts, read back with gog

Live against a personal Gmail account (personal is an account alias); nothing is sent. proof_quote_direction.py (below; Python 3 standard library plus gog) inserts two synthetic source messages with gmail.users.messages.insert, both from a sender with a Hebrew name and an address that starts with digits. It saves a drafts reply of the first, which carries its own Gmail quote, and a drafts forward of the second, which has a Hebrew subject and Hebrew and Arabic recipient names; the other commands share these formatters. Reading each draft back with gog gmail raw, it checks the HTML line that introduces the quote, that the reply's blockquote holds exactly the quoted message's HTML, and that the text/plain header lines read as expected with no invisible direction characters (all after removing carriage returns). It prints HTML one <br> per line. At the end it runs gmail trash on every draft and source message it recorded; its last check counts the trash commands that succeeded.

Run against a build of current main without this change (gog-main: the two HTML checks fail, exit 1), then a build of this branch (gog-branch: all seven checks pass, exit 0):

$ GOG_ACCOUNT=personal GOG=./gog-main python3 -I proof_quote_direction.py
# v0.46.0-2-gea582609 (ea582609ec6e 2026-10-22T18:07:19Z)
not ok 1 - reply: text/html attribution line
#   got:
#     <div class="gmail_quote"><div class="gmail_attr">On Tue, Oct 20, 2026 at 12:30 PM ישראל ישראלי &lt;<a href="mailto:0501234567@example.com">0501234567@example.com</a>&gt; wrote:</div>
#   want:
#     <div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">On <span dir="auto">Tue, Oct 20, 2026 at 12:30 PM</span> <span dir="auto">ישראל ישראלי</span> <span dir="auto">&lt;<a href="mailto:0501234567@example.com">0501234567@example.com</a>&gt;</span> wrote:</div>
ok 2 - reply: the blockquote holds exactly the quoted message's HTML
#   got:
#     <blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex"><div dir="ltr">Earlier reply in the thread.<br>
#     </div><br>
#     <div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">On Mon, Oct 19, 2026 at 9:00 AM Bob &lt;<a href="mailto:bob@example.com">bob@example.com</a>&gt; wrote:<br>
#     </div><blockquote class="gmail_quote">First message.</blockquote></div></blockquote>
ok 3 - reply: text/plain attribution line, without invisible marks
#   got:
#     On Tue, Oct 20, 2026 at 12:30 PM ישראל ישראלי <0501234567@example.com> wrote:
#     invisible marks: []
not ok 4 - forward: text/html header block
#   got:
#     <div class="gmail_quote"><div>---------- Forwarded message ---------<br>
#     From: <strong class="gmail_sendername">ישראל ישראלי</strong> &lt;<a href="mailto:0501234567@example.com">0501234567@example.com</a>&gt;<br>
#     Date: Tue, Oct 20, 2026 at 12:30 PM<br>
#     Subject: שלום! gog quote direction proof (forward)<br>
#     To: דנה כהן &lt;<a href="mailto:42@example.com">42@example.com</a>&gt;, Bob &lt;<a href="mailto:bob@example.com">bob@example.com</a>&gt;<br>
#     Cc: محمد علي &lt;<a href="mailto:2024.reports@example.com">2024.reports@example.com</a>&gt;<br>
#     </div>
#   want:
#     <div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">---------- Forwarded message ---------<br>
#     From: <strong class="gmail_sendername" dir="auto">ישראל ישראלי</strong> <span dir="auto">&lt;<a href="mailto:0501234567@example.com">0501234567@example.com</a>&gt;</span><br>
#     Date: <span dir="auto">Tue, Oct 20, 2026 at 12:30 PM</span><br>
#     Subject: <span dir="auto">שלום! gog quote direction proof (forward)</span><br>
#     To: <span dir="auto">דנה כהן</span> <span dir="auto">&lt;<a href="mailto:42@example.com">42@example.com</a>&gt;</span>, <span dir="auto">Bob</span> <span dir="auto">&lt;<a href="mailto:bob@example.com">bob@example.com</a>&gt;</span><br>
#     Cc: <span dir="auto">محمد علي</span> <span dir="auto">&lt;<a href="mailto:2024.reports@example.com">2024.reports@example.com</a>&gt;</span><br>
#     </div>
ok 5 - forward: text/plain header block, without invisible marks
#   got:
#     ---------- Forwarded message ---------
#     From: ישראל ישראלי <0501234567@example.com>
#     Date: Tue, Oct 20, 2026 at 12:30 PM
#     Subject: שלום! gog quote direction proof (forward)
#     To: דנה כהן <42@example.com>, Bob <bob@example.com>
#     Cc: محمد علي <2024.reports@example.com>
#     (blank line)
#     (blank line)
#     invisible marks: []
ok 6 - every reply and forward is an unsent draft
#   got:
#     ['DRAFT']
ok 7 - a trash command succeeded for every recorded draft and source message
#   got:
#     4 of 4
1..7
$ GOG_ACCOUNT=personal GOG=./gog-branch python3 -I proof_quote_direction.py
# v0.46.0-3-gdb4cb943 (db4cb9433d6f 2026-10-22T19:22:05Z)
ok 1 - reply: text/html attribution line
#   got:
#     <div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">On <span dir="auto">Tue, Oct 20, 2026 at 12:30 PM</span> <span dir="auto">ישראל ישראלי</span> <span dir="auto">&lt;<a href="mailto:0501234567@example.com">0501234567@example.com</a>&gt;</span> wrote:</div>
ok 2 - reply: the blockquote holds exactly the quoted message's HTML
#   got:
#     <blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex"><div dir="ltr">Earlier reply in the thread.<br>
#     </div><br>
#     <div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">On Mon, Oct 19, 2026 at 9:00 AM Bob &lt;<a href="mailto:bob@example.com">bob@example.com</a>&gt; wrote:<br>
#     </div><blockquote class="gmail_quote">First message.</blockquote></div></blockquote>
ok 3 - reply: text/plain attribution line, without invisible marks
#   got:
#     On Tue, Oct 20, 2026 at 12:30 PM ישראל ישראלי <0501234567@example.com> wrote:
#     invisible marks: []
ok 4 - forward: text/html header block
#   got:
#     <div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">---------- Forwarded message ---------<br>
#     From: <strong class="gmail_sendername" dir="auto">ישראל ישראלי</strong> <span dir="auto">&lt;<a href="mailto:0501234567@example.com">0501234567@example.com</a>&gt;</span><br>
#     Date: <span dir="auto">Tue, Oct 20, 2026 at 12:30 PM</span><br>
#     Subject: <span dir="auto">שלום! gog quote direction proof (forward)</span><br>
#     To: <span dir="auto">דנה כהן</span> <span dir="auto">&lt;<a href="mailto:42@example.com">42@example.com</a>&gt;</span>, <span dir="auto">Bob</span> <span dir="auto">&lt;<a href="mailto:bob@example.com">bob@example.com</a>&gt;</span><br>
#     Cc: <span dir="auto">محمد علي</span> <span dir="auto">&lt;<a href="mailto:2024.reports@example.com">2024.reports@example.com</a>&gt;</span><br>
#     </div>
ok 5 - forward: text/plain header block, without invisible marks
#   got:
#     ---------- Forwarded message ---------
#     From: ישראל ישראלי <0501234567@example.com>
#     Date: Tue, Oct 20, 2026 at 12:30 PM
#     Subject: שלום! gog quote direction proof (forward)
#     To: דנה כהן <42@example.com>, Bob <bob@example.com>
#     Cc: محمد علي <2024.reports@example.com>
#     (blank line)
#     (blank line)
#     invisible marks: []
ok 6 - every reply and forward is an unsent draft
#   got:
#     ['DRAFT']
ok 7 - a trash command succeeded for every recorded draft and source message
#   got:
#     4 of 4
1..7
proof_quote_direction.py
#!/usr/bin/env python3
"""Live proof: the lines gog writes around a quote keep their direction.

Inserts two synthetic text+HTML source messages with
gmail.users.messages.insert (nothing is sent; every address is under
example.com). Both come from a sender with a Hebrew display name and an
address that starts with digits. The first carries its own Gmail quote
(gmail_quote_container); the second has a Hebrew subject and Hebrew and
Arabic recipient names. Saves a `gog gmail drafts reply` of the first and a
`gog gmail drafts forward` of the second, reads each draft back with
`gog gmail raw`, and checks:

- the HTML line that introduces the quote: marked left-to-right, with Gmail's
  gmail_quote_container and gmail_attr classes, and each name, address, the
  date and the subject in a span (or Gmail's sender <strong>) with dir="auto";
- that the quoted message's HTML is exactly the reply's blockquote content;
- that the text/plain header lines read as expected and the text/plain part
  carries no invisible direction characters.

HTML and text are compared after removing carriage returns.

At the end it runs `gmail trash` on every draft and source message it
recorded, even after an error or Ctrl-C; its last check counts the 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 quote direction proof" to find it.

Usage: GOG_ACCOUNT=you@example.com [GOG=./bin/gog] python3 proof_quote_direction.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
from email.header import Header

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
DATE = "Tue, Oct 20, 2026 at 12:30 PM"  # Gmail's narrow no-break space before PM
SENDER_NAME = "ישראל ישראלי"
SENDER = "0501234567@example.com"
QUOTED_HTML = ('<div dir="ltr">Earlier reply in the thread.<br></div><br>'
               '<div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">'
               'On Mon, Oct 19, 2026 at 9:00 AM Bob &lt;<a href="mailto:bob@example.com">bob@example.com</a>&gt; wrote:<br></div>'
               '<blockquote class="gmail_quote">First message.</blockquote></div>')
QUOTED_TEXT = "Earlier reply in the thread.\n\nOn Mon, Oct 19, 2026 at 9:00 AM Bob <bob@example.com> wrote:\n\n> First message.\n"
FORWARD_SUBJECT = "שלום! gog quote direction proof (forward)"
OPEN = '<div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr">'
BIDI_CONTROLS = {chr(c) for c in (0x061C, 0x200E, 0x200F, *range(0x202A, 0x202F), *range(0x2066, 0x206A))}
COMMANDS = {"api", "call", "gmail", "drafts", "reply", "forward", "raw"}  # safe to name in errors


def isolated(text):
    return f'<span dir="auto">{text}</span>'


def address(addr):
    return isolated(f'&lt;<a href="mailto:{addr}">{addr}</a>&gt;')


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 in COMMANDS)
    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(subject, to, cc, plain, html):
    enc = lambda s: Header(s, "utf-8").encode()  # noqa: E731
    headers = (f"From: {enc(SENDER_NAME)} <{SENDER}>\r\nTo: {to}\r\n" + (f"Cc: {cc}\r\n" if cc else "")
               + f"Subject: {enc(subject)}\r\n")
    mime = (headers + "Date: Tue, 20 Oct 2026 12:30:00 -0700\r\n"
            f"Message-ID: <gog-quote-direction-{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{plain}\r\n"
            f"--b\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n{html}\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 between(text, start, end):
    """text from start through the first end after it, or None."""
    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 ["(not found)"]:
            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.
    pieces = text.split("<br>")
    return [piece + "<br>" for piece in pieces[:-1]] + [pieces[-1]]


def marks(text):
    return sorted(f"U+{ord(c):04X}" for c in set(text) & BIDI_CONTROLS)


def run():
    say("# " + subprocess.run([GOG, "version"], capture_output=True, text=True).stdout.strip())
    labels = []

    # Reply: the attribution line, and the quoted message as it came.
    source = insert("gog quote direction proof (reply)", "recipient@example.com", None, QUOTED_TEXT, QUOTED_HTML)
    draft = gog("gmail", "drafts", "reply", source, "--body", "Reply body.")
    created.append(draft["message"]["id"])
    draft_labels, parts = text_parts(draft["message"]["id"])
    labels.append(draft_labels)
    html, plain = parts.get("text/html", ""), parts.get("text/plain", "")

    want = f"{OPEN}On {isolated(DATE)} {isolated(SENDER_NAME)} {address(SENDER)} wrote:</div>"
    got = between(html, '<div class="gmail_quote', "</div>")
    check("reply: text/html attribution line", got == want, got, want, html_lines)
    start, end = html.find("<blockquote"), html.rfind("</blockquote>")
    got = html[start:end + len("</blockquote>")] if 0 <= start < end else None
    inner = got[got.index(">") + 1:-len("</blockquote>")] if got else None
    check("reply: the blockquote holds exactly the quoted message's HTML", inner == QUOTED_HTML,
          got, f'<blockquote …>{QUOTED_HTML}</blockquote>', html_lines)
    want = f"On {DATE} {SENDER_NAME} <{SENDER}> wrote:"
    got = next((line for line in plain.split("\n") if line.endswith(" wrote:") and SENDER in line), None)
    check("reply: text/plain attribution line, without invisible marks",
          got == want and not marks(plain), got, want, lambda v: [v, f"invisible marks: {marks(plain)}"])

    # Forward: the header block, with a Hebrew subject and RTL recipient names.
    source = insert(FORWARD_SUBJECT, f"{Header('דנה כהן', 'utf-8').encode()} <42@example.com>, Bob <bob@example.com>",
                    f"{Header('محمد علي', 'utf-8').encode()} <2024.reports@example.com>",
                    "Original message body.", "<div>Original message body.</div>")
    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)
    html, plain = parts.get("text/html", ""), parts.get("text/plain", "")

    want = (f"{OPEN}---------- Forwarded message ---------<br>"
            f'From: <strong class="gmail_sendername" dir="auto">{SENDER_NAME}</strong> {address(SENDER)}<br>'
            f"Date: {isolated(DATE)}<br>"
            f"Subject: {isolated(FORWARD_SUBJECT)}<br>"
            f"To: {isolated('דנה כהן')} {address('42@example.com')}, {isolated('Bob')} {address('bob@example.com')}<br>"
            f"Cc: {isolated('محمد علي')} {address('2024.reports@example.com')}<br></div>")
    got = between(html, '<div class="gmail_quote', "</div>")
    check("forward: text/html header block", got == want, got, want, html_lines)
    want = "\n".join(["---------- Forwarded message ---------",
                      f"From: {SENDER_NAME} <{SENDER}>", f"Date: {DATE}", f"Subject: {FORWARD_SUBJECT}",
                      "To: דנה כהן <42@example.com>, Bob <bob@example.com>",
                      "Cc: محمد علي <2024.reports@example.com>", "", ""])
    got = between(plain, "---------- Forwarded", "\n\n")
    check("forward: text/plain header block, without invisible marks",
          got == want and not marks(plain), got, want, lambda v: plain_lines(v) + [f"invisible marks: {marks(plain)}"])

    unsent = bool(labels) and all("DRAFT" in ls and "SENT" not in ls for ls in labels)
    check("every reply and forward is an unsent draft", unsent,
          str(sorted({l for ls in labels for l in ls})), None, lambda v: [v])


def cleanup():
    global checks
    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, OSError):
            done = False
        trashed += done
    if not created:
        checks += 1
        say(f"ok {checks} # SKIP nothing was created, so nothing to trash")
        return
    check("a trash command succeeded for every recorded draft and source message", 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

  • Direction and classes: TestQuoteLinesKeepTheirDirection pins the whole HTML of a reply's quote and a forward's header block for a Hebrew sender with an address that starts with digits, a Hebrew subject and a Hebrew-named recipient: the ltr line, both classes, each dir="auto" element, the quoted message unchanged (its own gmail_quote_container included), and text/plain lines without markup.
  • Existing tests, on the new markup: TestForwardedHeaderLines (every header case, including unparseable values and escaping inside the new elements), TestForwardedHeaderLayout (which now also checks the opening markup for styling), TestGmailForward_HeaderBlockLikeGmail (both forward commands), TestQuoteAttribution (with new blank-sender and escaped-markup cases; no sender still reads "Original message:"), and the drafts, signature and note tests that locate the quote. They now use the shared opening constant instead of their own copies of the markup, and two older reply tests are removed: TestQuoteAttribution now covers their date and escaping checks exactly, and TestQuoteLinesKeepTheirDirection their check that the quoted body passes through.
  • Mutation-checked: removing each part (the ltr mark, either class, dir="auto" on a name, the sender, an address, the date, the subject or an unparseable value, the escaping before it, the empty-value guard, the trim before it), rewriting the quoted message's container, or styling the opening markup fails a named test (19 of 19).
  • make ci green.

🤖 Generated with Claude Code

Keep a reply's attribution line and a forward's header block readable
next to right-to-left text. The line that introduces a quote is marked
dir="ltr", as Gmail Web marks its own in English, since gog writes it in
English: unmarked, it scrambles when a reply written in Hebrew quotes
it. Each value taken from the original (every name and address, the
date, and the forwarded subject) is wrapped in dir="auto", so it keeps
its own direction and can't reorder the text around it; unmarked, a
Hebrew name next to an address that starts with digits scrambles even
in an English message. Gmail marks only a forward's sender this way. A
value that doesn't parse is marked as a whole, and an empty value stays
empty. The text/plain lines are unchanged.

The quote's outer div also gets Gmail's gmail_quote_container class,
and the forward's header block gets gmail_attr, like the reply's
attribution line. Neither changes what Gmail shows. The quoted message
is left as it came, including any gmail_quote_container of its own.

The opening markup and the dir="auto" span live in a new
gmail_quote_markup.go, shared by the reply, forward and mailbox
renderers, with a helper that escapes a value before wrapping it. The
tests use the shared opening constant in place of their own copies of
the markup. Two older reply tests are removed: TestQuoteAttribution's
cases now cover their date and escaping checks exactly, and
TestQuoteLinesKeepTheirDirection their check that the quoted body passes
through.

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

clawsweeper Bot commented Oct 22, 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 P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. proof: 📸 screenshot Contributor real behavior proof includes screenshot evidence. 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 22, 2026
@clawsweeper

clawsweeper Bot commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge.

What this changes

This PR adds direction isolation and Gmail quote classes to shared HTML reply and forward headers.

Example: Forward a message from ישראל ישראלי <0501234567@example.com> and quote it in a Hebrew-interface Gmail reply.

  • Before: The sender name, address, brackets, and English header labels reorder.
  • After: The English headers remain left-to-right, and the name and complete address display separately in their own direction.

Review scores

Measure Result What it means
Overall readiness 🦞 diamond lobster (5/6) Focused shared fix with directly inspected visual proof, live CLI readback, relevant regression coverage, and successful reviewed-head checks.
Proof confidence 🦞 diamond lobster (5/6) ✨ media proof bonus Sufficient (screenshot): Contributor screenshots show the changed shared formatters producing readable delivered reply and forward headers in Gmail Web, including Hebrew-interface quoting; real drafts reply and drafts forward followed by Gmail raw readback verify isolated HTML values, unchanged quoted content, and unchanged plain-text headers on db4cb94, with only a changelog addition afterward.
Patch quality 🦞 diamond lobster (5/6) No actionable review findings were identified.

Product

Kind: Bug fix · Worth it: Yes · Fix scope: Complete
User problem: HTML quote headers become scrambled around Hebrew or Arabic names and when quoted inside right-to-left Gmail replies.
Reason: The demonstrated readability defect merits a focused fix that continues the established Gmail-style composition behavior and its explicitly deferred wrapper work.

Merge readiness

✅ Ready for maintainer review

Keep this PR open: current main still produces scrambled quote headers beside right-to-left text, and the focused fix has relevant live Gmail proof with no actionable findings.

Priority: P2
Reviewed head: 112e4edeb9d25ae07e806d235db9962c1c7711f1

Before merge

None.

Findings

None.

Agent review details

How this fits together

Gmail send and draft commands pass original message headers and bodies through shared composition formatters, producing plain-text and HTML MIME parts for the Gmail API.

flowchart LR
A[Original Gmail message] --> B[Reply and forward commands]
B --> C[Shared composition formatters]
C --> D[Mailbox escaping and direction isolation]
D --> E[Plain and HTML MIME bodies]
E --> F[Gmail send and draft API]
Loading

Technical review

Best possible solution:

Land the shared direction-isolation fix while preserving plain-text output, escaped header values, and original quoted HTML.

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

Current-main source exposes the missing direction isolation, and contributor screenshots plus baseline live Gmail draft/readback output demonstrate the failure; this review ran no target code.

Is this the best way to solve the issue?

The existing shared formatters are the correct owners, and the small common markup helper fixes the demonstrated HTML failure without adding a competing composition path.

AGENTS.md: found and applied where relevant.

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

Provenance checked

Testing

Proof path: shipped entry point.

Security

None.

Evidence

What I checked:

  • Applicable repository policy: Read the complete root AGENTS.md; no nested AGENTS.md, maintainer-notes directory, or pr-simplify skill was present. The change includes an Unreleased changelog entry with its PR reference and contributor thanks. (AGENTS.md:45, 112e4edeb9d2)
  • Verified introduced change: The pinned introduction diff updates both existing formatters and their shared mailbox renderer; the new helper centralizes the common opening markup and escaped direction spans. (internal/cmd/gmail_quote_markup.go:14, 112e4edeb9d2)
  • Current main still needs the fix: Main's reply attribution lacks an explicit direction, its forward header opens with an unmarked div, and mailbox values lack direction isolation. (internal/cmd/gmail_reply.go:352, ea582609ec6e)
  • Supplied behavior proof: Read the complete REST body, matching SHA-256 691a161d2d6993af2b02c505052413048204faa980fab7cba5bd289a7192b0f9, and inspected all six linked screenshots. They show corrected delivered reply and forward headers, including Hebrew-interface quoting. Live CLI draft/readback output shows baseline HTML failures and all seven checks passing on production commit db4cb94; the final commit changes only CHANGELOG.md. (internal/cmd/gmail_reply.go:357, db4cb9433d6f)
  • Earlier formatting intent: Merged fix(gmail): match Gmail's reply attribution line #1213 and fix(gmail): match Gmail's forwarded-message header block #1236 explicitly deferred direction attributes and quote-container classes to a shared follow-up; they do not implement this PR's behavior. The latter's collaborator landing comment confirms the established Gmail-style formatting direction. (internal/cmd/gmail_forward.go:389, 9d76657d0d80)
  • Escaping and regression coverage: Header text remains escaped, mailto encoding remains unchanged, and original quoted HTML passes through unchanged. Tests cover literal direction markup, blank and malformed senders, escaped headers, recipient groups, command composition, notes, and signature placement. (internal/cmd/gmail_quote_mailbox.go:117, 112e4edeb9d2)

Likely related people:

  • Malo Bourgon: Raw commit e35aad9 adds internal/cmd/gmail_reply.go:293 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_reply.go)
  • Peter Steinberger: Raw commit 896cccc adds internal/cmd/gmail_reply.go:267 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)

Review metrics

Metric Value Why it matters
Production and test scope Production +71/-24; tests +158/-87; documentation and changelog +10/-5. Growth covers shared direction markup and regression assertions, while existing command tests adapt to the changed HTML.

Labels

Label changes:

  • add P2: Mixed-direction quote headers visibly scramble sender and recipient addresses, with a limited email-rendering blast radius.
  • add proof: sufficient: Contributor real behavior proof is sufficient.
  • add proof: 📸 screenshot: Contributor real behavior proof includes screenshot evidence.
  • 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:

  • P2: Mixed-direction quote headers visibly scramble sender and recipient addresses, with a limited email-rendering blast radius.
  • 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.
  • proof: 📸 screenshot: Contributor real behavior proof includes screenshot evidence.

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 22, 2026, 4:11 PM ET / 20:11 UTC.

steipete added a commit that referenced this pull request Oct 23, 2026
Preserve Gmail search results and reduce retry work: back off on recognized 403 rate limits, retain response bodies and read errors, cancel remaining detail fetches after a failure, and keep quoted operators out of label filters. Include nonempty Cc/Bcc in thread text and isolate reply/forward header direction without altering plain text or quoted HTML.

Preserve requested false booleans in Drive and Sites list JSON for grouped fields, slash paths, and wildcards while leaving conditional fields omitted.

Reproduces and incorporates #1242, #1246, #1247, #1248, and #1250; addresses #1241. Focused regression tests and independent Codex review pass.

Co-authored-by: Serghei <xyrolle@gmail.com>
Co-authored-by: Sebastien Tardif <SebTardif@ncf.ca>
Co-authored-by: Kit <kit@alexpriest.com>
Co-authored-by: Malo Bourgon <mbourgon@gmail.com>
@steipete

Copy link
Copy Markdown
Collaborator

Thanks for this fix. Incorporated into #1253 with contributor credit and a changelog acknowledgment: preserving reply and forward header direction next to right-to-left text, with Gmail quote classes and unchanged plain text and quoted HTML.

The regression was reproduced before the change. Focused regression tests, the full remote make ci gate, and independent Codex review passed. Exact-head CI: https://github.com/openclaw/gogcli/actions/runs/39888593907. Landed as 60ae623da5b5955b5e3127d8bb9d5f63d23f8397. Closing this PR because the fix is now on main.

@steipete steipete closed this Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Normal priority bug or improvement with limited blast radius. proof: 📸 screenshot Contributor real behavior proof includes screenshot evidence. 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.

2 participants