Skip to content

fix(gmail): mark the direction of the author's text in HTML - #1255

Open
malob wants to merge 3 commits into
openclaw:mainfrom
malob:fix/gmail-body-direction
Open

malob wants to merge 3 commits into
openclaw:mainfrom
malob:fix/gmail-body-direction

Conversation

@malob

@malob malob commented Oct 23, 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. Gmail's apps mark the direction of the text you write; gog writes yours into the HTML part with no direction at all, so it takes whatever direction surrounds it. That breaks in two ways:

  • Right-to-left text scrambles. A Hebrew body shows left to right in Gmail Web and Apple Mail: תודה, דנה! אשלח את הדוח (PDF) עד יום שלישי. reads with its parts out of order and the period at the wrong end. Gmail Web doesn't take a direction from a message's language, even when it labels the message Hebrew.
  • Quoted in a right-to-left reply. When someone replies from Gmail's Hebrew interface, Gmail wraps their reply, quote included, in <div dir="rtl">, and gog's English text shows with its punctuation at the wrong end: .Thanks, Dana! I'll send the report (PDF) by Tuesday.

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

Changes

This covers every command that composes a message: gmail send (with or without --quote), reply, reply-all, forward, autoreply, and gmail drafts create, update, reply, reply-all and forward. The choices rest on the Unicode and HTML standards for text whose direction isn't known, on what Gmail's own apps write, and on delivered test messages in Gmail Web and Apple Mail.

  • Text gog turns into HTML gets one <div dir="auto"> per line, with a blank line as <div dir="auto"><br></div>. That's a --body above a quote or beside a draft's signature, a forward's --note, and a plain-text --signature-file. dir="auto" gives each line the direction of its first letter, as the standards do for plain text: the Unicode bidirectional algorithm decides direction separately for each paragraph, and in plain text a newline ends one; HTML applies dir="auto" paragraph by paragraph in <pre> and <textarea>. Gmail's mobile apps write this markup, and Mastodon moved to the same paragraph-by-paragraph rule. One <div> around the whole body isn't enough: it takes the direction of its first line, and in a separate delivered test it left a Hebrew sentence under Hi Dana, scrambled. Gmail Web marks the composer's own direction instead, which gog doesn't know.
  • HTML you give (--body-html, --note-html and their file forms) goes into one <div dir="auto">, added before the signature and the quote. gog doesn't split or rewrite the HTML it's handed, and dir attributes inside it still apply, so the paragraphs of a mixed-language message can carry their own.
  • gog adds no <div> to a whole HTML document: one that opens with a doctype or an <html>, <head> or <body> tag, or with what belongs in a document's head (a <base>, <link>, <meta>, <style> or <title>). Someone who hands over a whole document has laid it out themselves, so the conservative assumption is that it goes through as it is; a <div> around it would also move what belongs in its head, styles included, into the body. A quote or signature still follows it, as before.
  • The signature block is <div dir="auto" class="gmail_signature">, so it keeps its own direction whatever the body's. Gmail marks its signature block's direction too.
  • One <br> after the author's text, as Gmail writes it. The text now ends in a block, so a reply's former two <br> before its quote would add a blank line; with one, the spacing, and the text part Gmail rebuilds on send, are as before in the messages tested. A whole document, which may end in inline text, and a reply with no text get two. A forward's note follows the same rule, in place of its former bare <div>.
  • A draft's plain body, converted for an HTML signature, is trimmed first, by the rule a reply's body already follows, so a body file's final newline doesn't become a blank line above the signature.

Plain text you give goes into the text part unchanged, and a message with no HTML part, or one sent as is with --raw-file, isn't touched. The first commit moves the compose text conversions, which were spread over three files (composeText, escapeTextToHTML, and htmlToPlainText with its helpers), into gmail_compose_text.go beside its tests, with no change in behavior. docs/gmail-workflows.md describes the marking.

Open #1251 (--html-from-text) also turns the author's plain text into HTML, with escapeTextToHTML; whichever of the two lands second should switch that call to directedTextToHTML, so text converted on request gets the same per-line marking.

Behavior proof

In Gmail Web

Replies and a message sent from one account to another with a build of main and with this branch, as Gmail Web shows the delivered copies: a Hebrew reply, a reply that mixes English and Hebrew lines, and a Hebrew message given as HTML. The last two rows are a reply started in Gmail's Hebrew interface, quoting an English reply from gog. For each of the four messages gog sent, from both builds (the Hebrew reply, the mixed reply, the Hebrew HTML message and the English reply), the text part Gmail rebuilt on send came out byte for byte the same.

Message As Gmail Web shows it
Hebrew reply, main Hebrew reply from main: the sentence is out of order, the period at the wrong end
Hebrew reply, this branch Hebrew reply from this branch: the sentence reads right to left
Mixed reply, main Mixed reply from main: the Hebrew line is scrambled
Mixed reply, this branch Mixed reply from this branch: the English lines read left to right, the Hebrew line right to left
Hebrew --body-html, main Hebrew HTML message from main: every paragraph is scrambled
Hebrew --body-html, this branch Hebrew HTML message from this branch: every paragraph reads right to left
Quoted in a Hebrew-interface reply, main English reply from main, quoted in a reply written in Gmail's Hebrew interface: the period moves to the front
Quoted in a Hebrew-interface reply, this branch English reply from this branch, quoted in a reply written in Gmail's Hebrew interface: the line reads left to right

Drafts, read back with gog

Live against a personal Gmail account (personal is an account alias); nothing is sent. proof_body_direction.py (below; Python 3 standard library plus gog) inserts a synthetic source message with gmail.users.messages.insert. It then saves a drafts reply with a plain body that mixes English and Hebrew lines, a drafts reply with an HTML body, a drafts forward with a plain note, a drafts create with an HTML body and a plain-text --signature-file, and a drafts create whose body is a whole HTML document; the other commands share these paths. Reading each draft back with gog gmail raw, it checks how the HTML part opens, up to where the quote opens, and, for the plain body and the plain note, that the text/plain part opens with the text as given and has no invisible direction characters (all after removing carriage returns). It prints HTML one block or <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 four HTML checks fail, exit 1), then a build of this branch (gog-branch: all nine checks pass, exit 0):

$ GOG_ACCOUNT=personal GOG=./gog-main python3 -I proof_body_direction.py
# v0.46.0-4-g60ae623d (60ae623da5b5 2026-10-23T02:40:38Z)
not ok 1 - reply with a plain body: a dir=auto div per line, then the quote opens
#   got:
#     Hi Dana,<br>
#     <br>
#     תודה! אשלח את הדוח (PDF) עד יום שלישי.<br>
#     <br>
#     Best,<br>
#     Malo<br>
#     <br>
#     <div class="gmail_quote gmail_quote_container">
#   want:
#     <div dir="auto">Hi Dana,</div>
#     <div dir="auto"><br></div>
#     <div dir="auto">תודה! אשלח את הדוח (PDF) עד יום שלישי.</div>
#     <div dir="auto"><br></div>
#     <div dir="auto">Best,</div>
#     <div dir="auto">Malo</div>
#     <br>
#     <div class="gmail_quote gmail_quote_container">
ok 2 - reply with a plain body: text/plain opens with the text as given, no invisible marks
#   got:
#     Hi Dana,
#     (blank line)
#     תודה! אשלח את הדוח (PDF) עד יום שלישי.
#     (blank line)
#     Best,
#     Malo
#     (blank line)
#     (blank line)
#     invisible marks: []
not ok 3 - reply with an HTML body: the HTML in one dir=auto div, then the quote opens
#   got:
#     <p>שלום <b>דנה</b>! הדוח (PDF) מצורף.</p><br>
#     <br>
#     <div class="gmail_quote gmail_quote_container">
#   want:
#     <div dir="auto"><p>שלום <b>דנה</b>! הדוח (PDF) מצורף.</p></div>
#     <br>
#     <div class="gmail_quote gmail_quote_container">
not ok 4 - forward with a plain note: a dir=auto div per line, then the forwarded message opens
#   got:
#     <div>Hi Dana,<br>
#     <br>
#     תודה! אשלח את הדוח (PDF) עד יום שלישי.<br>
#     <br>
#     Best,<br>
#     Malo</div>
#     <br>
#     <div class="gmail_quote gmail_quote_container">
#   want:
#     <div dir="auto">Hi Dana,</div>
#     <div dir="auto"><br></div>
#     <div dir="auto">תודה! אשלח את הדוח (PDF) עד יום שלישי.</div>
#     <div dir="auto"><br></div>
#     <div dir="auto">Best,</div>
#     <div dir="auto">Malo</div>
#     <br>
#     <div class="gmail_quote gmail_quote_container">
ok 5 - forward with a plain note: text/plain opens with the text as given, no invisible marks
#   got:
#     Hi Dana,
#     (blank line)
#     תודה! אשלח את הדוח (PDF) עד יום שלישי.
#     (blank line)
#     Best,
#     Malo
#     (blank line)
#     (blank line)
#     invisible marks: []
not ok 6 - draft with an HTML body and a plain signature file: body and signature each marked
#   got:
#     <p>שלום <b>דנה</b>! הדוח (PDF) מצורף.</p>
#     <div class="gmail_signature">Malo<br>
#     https://example.com</div>
#   want:
#     <div dir="auto"><p>שלום <b>דנה</b>! הדוח (PDF) מצורף.</p></div>
#     <div dir="auto" class="gmail_signature"><div dir="auto">Malo</div>
#     <div dir="auto">https://example.com</div>
#     </div>
ok 7 - draft with a whole HTML document: not wrapped
#   got:
#     <!DOCTYPE html><html><body><p>שלום דנה! הדוח (PDF) מצורף.</p></body></html>
ok 8 - every draft is unsent
#   got:
#     ['DRAFT']
ok 9 - a trash command succeeded for every recorded draft and source message
#   got:
#     6 of 6
1..9
$ GOG_ACCOUNT=personal GOG=./gog-branch python3 -I proof_body_direction.py
# v0.46.0-6-g6b43c30c (6b43c30cfa69 2026-10-23T02:40:40Z)
ok 1 - reply with a plain body: a dir=auto div per line, then the quote opens
#   got:
#     <div dir="auto">Hi Dana,</div>
#     <div dir="auto"><br></div>
#     <div dir="auto">תודה! אשלח את הדוח (PDF) עד יום שלישי.</div>
#     <div dir="auto"><br></div>
#     <div dir="auto">Best,</div>
#     <div dir="auto">Malo</div>
#     <br>
#     <div class="gmail_quote gmail_quote_container">
ok 2 - reply with a plain body: text/plain opens with the text as given, no invisible marks
#   got:
#     Hi Dana,
#     (blank line)
#     תודה! אשלח את הדוח (PDF) עד יום שלישי.
#     (blank line)
#     Best,
#     Malo
#     (blank line)
#     (blank line)
#     invisible marks: []
ok 3 - reply with an HTML body: the HTML in one dir=auto div, then the quote opens
#   got:
#     <div dir="auto"><p>שלום <b>דנה</b>! הדוח (PDF) מצורף.</p></div>
#     <br>
#     <div class="gmail_quote gmail_quote_container">
ok 4 - forward with a plain note: a dir=auto div per line, then the forwarded message opens
#   got:
#     <div dir="auto">Hi Dana,</div>
#     <div dir="auto"><br></div>
#     <div dir="auto">תודה! אשלח את הדוח (PDF) עד יום שלישי.</div>
#     <div dir="auto"><br></div>
#     <div dir="auto">Best,</div>
#     <div dir="auto">Malo</div>
#     <br>
#     <div class="gmail_quote gmail_quote_container">
ok 5 - forward with a plain note: text/plain opens with the text as given, no invisible marks
#   got:
#     Hi Dana,
#     (blank line)
#     תודה! אשלח את הדוח (PDF) עד יום שלישי.
#     (blank line)
#     Best,
#     Malo
#     (blank line)
#     (blank line)
#     invisible marks: []
ok 6 - draft with an HTML body and a plain signature file: body and signature each marked
#   got:
#     <div dir="auto"><p>שלום <b>דנה</b>! הדוח (PDF) מצורף.</p></div>
#     <div dir="auto" class="gmail_signature"><div dir="auto">Malo</div>
#     <div dir="auto">https://example.com</div>
#     </div>
ok 7 - draft with a whole HTML document: not wrapped
#   got:
#     <!DOCTYPE html><html><body><p>שלום דנה! הדוח (PDF) מצורף.</p></body></html>
ok 8 - every draft is unsent
#   got:
#     ['DRAFT']
ok 9 - a trash command succeeded for every recorded draft and source message
#   got:
#     6 of 6
1..9
proof_body_direction.py
#!/usr/bin/env python3
"""Live proof: the author's text in gog's HTML part carries its own direction.

Inserts one synthetic text+HTML source message with gmail.users.messages.insert
(nothing is sent; every address is under example.com), then saves drafts with
gog and reads each back with `gog gmail raw`:

- a `drafts reply` with a plain-text body that mixes an English greeting, a
  Hebrew sentence and an English sign-off;
- a `drafts reply` with an HTML body;
- a `drafts forward` with a plain-text note;
- a `drafts create` with an HTML body and a plain-text signature file;
- a `drafts create` with a whole HTML document as its body.

It checks how each draft's HTML part opens, up to where the quote opens:
plain text one <div dir="auto"> per line, given HTML in one <div dir="auto">,
the signature in its own dir="auto" block, one <br> before the quote, and a
whole document left unwrapped. For the plain body and the plain note, it also
checks that the text/plain part opens with the text as given and carries no
invisible direction characters. HTML and text are compared after removing
carriage returns; HTML is printed one block or <br> per line.

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

Usage: GOG_ACCOUNT=you@example.com [GOG=./bin/gog] python3 proof_body_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 tempfile
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
SUBJECT = "gog body direction proof"
MIXED = "Hi Dana,\n\nתודה! אשלח את הדוח (PDF) עד יום שלישי.\n\nBest,\nMalo"
MIXED_HTML = "\n".join(['<div dir="auto">Hi Dana,</div>', '<div dir="auto"><br></div>',
                        '<div dir="auto">תודה! אשלח את הדוח (PDF) עד יום שלישי.</div>',
                        '<div dir="auto"><br></div>', '<div dir="auto">Best,</div>', '<div dir="auto">Malo</div>'])
GIVEN = "<p>שלום <b>דנה</b>! הדוח (PDF) מצורף.</p>"
SIGNATURE = "Malo\nhttps://example.com"
DOCUMENT = "<!DOCTYPE html><html><body><p>שלום דנה! הדוח (PDF) מצורף.</p></body></html>"
QUOTE_OPEN = '<div class="gmail_quote gmail_quote_container">'
BIDI_CONTROLS = {chr(c) for c in (0x061C, 0x200E, 0x200F, *range(0x202A, 0x202F), *range(0x2066, 0x206A))}
COMMANDS = {"api", "call", "gmail", "drafts", "reply", "forward", "create", "raw"}  # safe to name in errors

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_source():
    mime = ("From: Dana Cohen <dana@example.com>\r\nTo: recipient@example.com\r\n"
            f"Subject: {SUBJECT}\r\nDate: Thu, 22 Oct 2026 09:15:00 -0700\r\n"
            f"Message-ID: <gog-body-direction-{time.time_ns()}@example.com>\r\n"
            'MIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary="b"\r\n\r\n'
            "--b\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nCould you send the report by Tuesday?\r\n"
            "--b\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n"
            '<div dir="ltr">Could you send the report by Tuesday?</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 draft(*args):
    """Save a draft with gog; return its labels and its text/plain and text/html bodies."""
    result = gog("gmail", "drafts", *args)
    msg_id = result["message"]["id"]
    created.append(msg_id)
    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.get("text/plain", ""), parts.get("text/html", "")


def check(description, ok, got, want):
    """One TAP check; got and want are lists of lines, want shown only on failure."""
    global checks, failures
    checks += 1
    failures += not ok
    say(f"{'ok' if ok else 'not ok'} {checks} - {description}")
    for label, lines in (("got", got),) + ((("want", want),) if not ok else ()):
        say(f"#   {label}:")
        for line in lines:
            say(f"#     {line}")


def html_lines(text):
    # One line per block or line break: break after each </div> and <br>, and
    # at the HTML's own line breaks, so each line of the author's text shows on
    # its own.
    marked = text.replace("<br>", "<br>\n").replace("<br>\n</div>", "<br></div>").replace("</div>", "</div>\n")
    return [line for line in marked.split("\n") if line] or ["(empty)"]


def opening(html, end):
    """html up to and including end, or all of it if end is not there."""
    i = html.find(end)
    return html if i < 0 else html[:i + len(end)]


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


def plain_check(description, plain, want_start):
    got = plain[:len(want_start)]
    def lines(text):
        return [line or "(blank line)" for line in text.split("\n")]

    check(description, got == want_start and not marks(plain),
          lines(got) + [f"invisible marks: {marks(plain)}"], lines(want_start) + ["invisible marks: []"])


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

    ls, plain, html = draft("reply", source, "--body", MIXED)
    labels.append(ls)
    want = MIXED_HTML + "<br>" + QUOTE_OPEN
    got = opening(html, QUOTE_OPEN)
    check("reply with a plain body: a dir=auto div per line, then the quote opens", got == want, html_lines(got), html_lines(want))
    plain_check("reply with a plain body: text/plain opens with the text as given, no invisible marks", plain, MIXED + "\n\n")

    ls, plain, html = draft("reply", source, "--body-html", GIVEN)
    labels.append(ls)
    want = '<div dir="auto">' + GIVEN + "</div><br>" + QUOTE_OPEN
    got = opening(html, QUOTE_OPEN)
    check("reply with an HTML body: the HTML in one dir=auto div, then the quote opens", got == want, html_lines(got), html_lines(want))

    ls, plain, html = draft("forward", source, "--to", "recipient@example.com", "--note", MIXED)
    labels.append(ls)
    want = MIXED_HTML + "<br>" + QUOTE_OPEN
    got = opening(html, QUOTE_OPEN)
    check("forward with a plain note: a dir=auto div per line, then the forwarded message opens", got == want, html_lines(got), html_lines(want))
    plain_check("forward with a plain note: text/plain opens with the text as given, no invisible marks", plain, MIXED + "\n\n")

    with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
        f.write(SIGNATURE)
    try:
        ls, plain, html = draft("create", "--to", "recipient@example.com", "--subject", SUBJECT,
                                "--body-html", GIVEN, "--signature-file", f.name)
    finally:
        os.unlink(f.name)
    labels.append(ls)
    want = ('<div dir="auto">' + GIVEN + "</div>\n\n" + '<div dir="auto" class="gmail_signature">'
            '<div dir="auto">Malo</div>\n<div dir="auto">https://example.com</div></div>')
    check("draft with an HTML body and a plain signature file: body and signature each marked", html.rstrip("\n") == want,
          html_lines(html), html_lines(want))

    ls, plain, html = draft("create", "--to", "recipient@example.com", "--subject", SUBJECT, "--body-html", DOCUMENT)
    labels.append(ls)
    check("draft with a whole HTML document: not wrapped", html.rstrip("\n") == DOCUMENT, html_lines(html), html_lines(DOCUMENT))

    unsent = bool(labels) and all("DRAFT" in ls and "SENT" not in ls for ls in labels)
    check("every draft is unsent", unsent, [str(sorted({l for ls in labels for l in ls}))], ["['DRAFT']"])


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)}"], [])


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

  • The author's text in the HTML part: TestComposedTextKeepsItsOwnDirection checks everything above the quote in the HTML part of a sent message, a draft and a reply: the author's text in its marked block exactly once, the signature block after it, one <br> before the quote, and a whole document unwrapped with its two. Its plain text has Windows line ends, a leading line break, a whitespace-only line and characters to escape. TestHTMLDocumentsAreNotWrapped covers which HTML goes in as given (a doctype, an XML prolog, <html>, <HEAD>, <body> after a byte order mark and a comment, <style>, <meta>, a self-closing <link>) and which is wrapped (<header>, text before <html>, a paragraph from a file with a byte order mark). TestGmailForward_HTMLDocumentNote covers a forward note given as a whole document, and TestApplyQuoteToBodiesWithoutTextOpensWithTwoBreaks a quoted reply with no text.
  • Existing tests, on the new markup: the forward note tests (both forward commands), the signature tests (which now share a gmailSignatureOpen constant; two forward signature tests that differed only in the command are now one test over both), TestApplyQuoteToBodiesShowsPlainReplyInBothParts, TestQuoteLinesKeepTheirDirection, and TestRunGmailAutoReply_RepliesAndArchives, which now also checks the autoreply's HTML.
  • Mutation-checked: 37 targeted mutations each fail a named test. They remove the marking at each call site (send, reply, drafts, forward, autoreply), or the dir from the lines, the wrapper or the signature; convert plain text the old way in replies, notes, drafts or signature files; or break the escaping, whitespace-only lines, Windows line ends, the line breaks between lines, either trimming, the byte order mark, the empty-input or document check, a document-detection rule (comments, byte order mark, leading text, doctype, <head>, <style>, <meta>, <link>, self-closing tags, exact tag names), any of the separator's cases, or the forward note's spacing.
  • make ci green.

🤖 Generated with Claude Code

@clawsweeper

clawsweeper Bot commented Oct 23, 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.

@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: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Oct 23, 2026
@clawsweeper

clawsweeper Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge.

What this changes

Adds automatic text direction to authored HTML bodies, converted plain-text lines, and signatures across Gmail composition commands.

Example: Reply with “Hi Dana,” followed by “תודה! אשלח את הדוח (PDF) עד יום שלישי.”

  • Before: The Hebrew line inherits left-to-right direction and displays reordered text and misplaced punctuation.
  • After: Each converted line has its own dir="auto" block, so English and Hebrew retain their respective directions.

Review scores

Measure Result What it means
Overall readiness 🦞 diamond lobster (5/6) A focused, useful repair with direct Gmail behavior proof, preserved composition contracts, passing current-head checks, and no actionable findings.
Proof confidence 🦞 diamond lobster (5/6) ✨ media proof bonus Sufficient (screenshot): The contributor exercised the changed composition owners through real CLI sends and Gmail draft creation/readback; directly inspected screenshots demonstrate corrected rendering, and terminal output covers bodies, forward notes, signatures, whole documents, and supplied plain-text preservation using Gmail code identical to the reviewed head.
Patch quality 🦞 diamond lobster (5/6) No actionable review findings were identified.

Product

Kind: Bug fix · Worth it: Yes · Fix scope: Complete
User problem: Hebrew authored text and English text quoted inside RTL replies display reordered words or misplaced punctuation.
Reason: The supplied comparisons establish a concrete rendering defect, and the repair uses existing compose paths without adding a public option or changing message-delivery policy.

Merge readiness

✅ Ready for maintainer review

Keep open: this PR fixes a demonstrated rendering bug that remains on main; no actionable defects were found, and passing current-head checks clear the previous CI blocker.

Priority: P2
Reviewed head: 2d56428be5c7632444a2d4eb8e825d7b36f0d02f

Before merge

None.

Findings

None.

Agent review details

How this fits together

Gmail compose commands combine caller-supplied bodies, notes, signatures, and quoted messages into MIME submitted through the existing Gmail send and draft APIs.

flowchart LR
A[CLI bodies and notes] --> B[Shared text conversion]
B --> C[Signatures and quotes]
C --> D[MIME serialization]
D --> E[Gmail send and draft APIs]
E --> F[Mail client rendering]
Loading

Technical review

Best possible solution:

Land the shared direction handling while preserving supplied plain text, explicit inner direction, whole HTML documents, signatures, quoted content, and raw MIME.

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

Current-main source exposes the unmarked authored-text path, corroborated by the contributor's before/after Gmail screenshots and failing-main/passing-branch live draft transcript; no target code was run during review.

Is this the best way to solve the issue?

The change fixes direction at the existing shared composition owners, with narrowly justified document detection and separator changes that preserve the demonstrated rendering and MIME contracts.

AGENTS.md: found and applied where relevant.

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

Provenance checked

  • Shared body and forward-note conversion changes intended behavior with a stated reason (d48a86f: Share plain/HTML conversion rules, preserve note paragraph breaks, and retain existing MIME alternatives.)
  • Signature formatting and placement keeps the original intent (feat(gmail): add signature flags to draft create, update, and forward #1193: Preserve HTML-only signatures and place signatures above quoted or forwarded content.)
  • Quote-header direction and original quoted HTML keeps the original intent (60ae623: Isolate quote headers beside RTL text while preserving plain text and original quoted content.)
  • HTML separators around authored content changes intended behavior with a stated reason (feat(gmail): add forward command #482: Separate the author's note from forwarded content; the PR explains the separator adjustment required by the new block markup and supplies delivered-message comparisons.)
  • Existing conversion helper placement keeps the original intent (77a16d1: Support signatures and their plain-text representation; the initial PR commit relocates these helpers without introducing a competing conversion path.)

Testing

Proof path: shipped entry point.

Security

None.

Evidence

What I checked:

Likely related people:

  • adameq: Raw commit cae4fdc adds internal/cmd/gmail_send_signature.go:59 relative to its recorded parents. This identifies author metadata, not feature responsibility or a PR merger. (role: source-line author; confidence: medium; commits: cae4fdcf49af; files: internal/cmd/gmail_send_signature.go)

Review metrics

Metric Value Why it matters
Introduced change size Production +264/-170; tests +160/-59; documentation +20/-0 The initial commit relocates existing conversion helpers; the remaining growth implements shared direction handling and exercises distinct composition and document-preservation cases.

Labels

Label changes:

  • 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.
  • remove rating: 🦐 gold shrimp: Current PR rating is rating: 🦞 diamond lobster, so this older rating label is no longer current.

Label justifications:

  • P2: Multilingual Gmail messages display reordered text and punctuation, with no demonstrated delivery failure or runtime outage.
  • 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.

History

Review history (1 earlier review cycle)
  • reviewed 2026-10-23T03:08:22.410Z sha a6fa1e6 :: blocked before merge. :: none

Reviewed October 23, 2026, 1:46 AM ET / 05:46 UTC (Revision 2).

malob and others added 2 commits October 22, 2026 21:05
The conversions between plain text and HTML that compose uses were spread
over three files: composeText in gmail_compose.go, escapeTextToHTML in
gmail_reply.go (though forward, signatures and drafts use it too), and
htmlToPlainText with its helpers in gmail_send_signature.go. Move them
into gmail_compose_text.go, next to the existing gmail_compose_text_test.go,
before adding another conversion beside them. No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Give the text the author writes its own direction in the HTML part, so
it reads correctly next to right-to-left text. Unmarked, a Hebrew body
renders left to right and scrambled in Gmail Web and Apple Mail, and an
English message quoted in a reply written in Gmail's Hebrew interface
shows its punctuation at the wrong end, since that reply wraps
everything in dir="rtl".

Plain text gog turns into HTML (a --body above a quote or beside a
draft's signature, a forward's --note, a plain-text --signature-file)
now gets one <div dir="auto"> per line, a blank line
<div dir="auto"><br></div>, as Gmail's mobile apps write it, one div to
a line of HTML. dir="auto" gives each line the direction of its first
letter, as Unicode and HTML do for plain text, where one wrapper would
give a mixed message the direction of its first line. HTML the author
gives (--body-html, --note-html, and gmail autoreply's --body-html) is
trimmed, along with a leading byte order mark, and goes into one
<div dir="auto"> before the signature and quote are added; dir
attributes inside it still apply. HTML laid out as a whole document,
opening with a doctype, an <html>, <head> or <body> tag, or an element
that belongs in a document's head such as <style>, goes in as given,
since a div around it would move its head into the body. The signature
block is marked dir="auto" too.

The text now ends in a block, so one <br> leaves a blank line after it,
as in Gmail: before a reply's quote, where two would add a line, and
after a forward's note, which loses its bare <div>. A document, which
may end inline, and a reply with no text get two. A draft's plain body,
converted for its signature, is trimmed first, as a reply's is, so a
body file's final newline doesn't become a blank line. Text parts are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@malob
malob force-pushed the fix/gmail-body-direction branch from a6fa1e6 to 5633912 Compare October 23, 2026 04:09
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@malob
malob force-pushed the fix/gmail-body-direction branch from 5633912 to 2d56428 Compare October 23, 2026 05:05
@malob

malob commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

🦞👀
Exact review queued.

Re-review progress:

@clawsweeper clawsweeper Bot added 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. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. labels Oct 23, 2026

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

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.

1 participant