Skip to content

feat(gmail): add drafts reply/reply-all/forward - #977

Closed
malob wants to merge 3 commits into
openclaw:mainfrom
malob:feat/gmail-drafts-mirror
Closed

feat(gmail): add drafts reply/reply-all/forward#977
malob wants to merge 3 commits into
openclaw:mainfrom
malob:feat/gmail-drafts-mirror

Conversation

@malob

@malob malob commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

Adds the drafts-side mirror of the send-side compose verbs:

  • gog gmail drafts reply <messageId> — save a reply as a draft
  • gog gmail drafts reply-all <messageId> — save a reply-all as a draft
  • gog gmail drafts forward <messageId> — save a forward as a draft

Each mirrors its send-side counterpart exactly — same positional argument, same flags (they embed the same options structs), same dry-run dictionary (only the action name differs) — and differs only at finalize: Drafts.Create instead of Messages.Send, under the non-send service gate.

Why

This completes the human-in-the-loop story from #239 (“I always want a human in the loop”, i.e. draft-only Gmail). The no-send guardrails closed that issue for new mail — drafts create works under --gmail-no-send and in the agent-safe build — but replying and forwarding remained send-only, so an agent prepared to draft a reply for human review simply couldn't. #804 (draft replies didn't set recipients) got its minimal fix at the drafts create --reply-to-message-id level; real reply verbs with recipient auto-population are the ergonomic completion.

The layering mirrors how reply/reply-all/forward were themselves layered onto send: the drafts verbs reuse the existing resolve/build helpers verbatim and add only a different finalize step.

Commits

Three commits, each building/vetting/testing green standalone:

  1. refactor(gmail): extract shared compose builders and signature options — behavior-neutral split of the reply/forward Run methods into service-free resolve+validate, a pure message builder taking an already-acquired service, and an orchestrating Run (resolve → dry-run → acquire → build → finalize). Extracts the shared signature options into an embedded struct, removing the throwaway GmailSendCmd the reply path constructed to borrow those methods. CLI schema verified byte-identical before/after (full gog schema --json diff).
  2. feat(gmail): add drafts reply/reply-all/forward — the three commands, safety-profile entries, docs.
  3. fix(gmail): parse compose recipients address-aware across all commandssend, forward, and drafts create/update parsed --to/--cc/--bcc by naive comma-splitting while reply already parsed addresses. The new drafts verbs would have shipped those bugs; instead every compose command now shares one address-aware parse.

Behavior changes (complete ledger)

New behavior:

  • gmail drafts reply|reply-all|forward exist (aliases replyall, fwd), work under --gmail-no-send/config no-send, and are allowed in the agent-safe profile (blocked in readonly).
  • drafts forward permits an addressless draft (no --to), like drafts create and Gmail's UI. The send-side gmail forward still requires --to; that check moved from a kong required:"" tag to a runtime check (same error text, and the --to help now says “required when sending, optional when saving a draft”).
  • Reply drafts report inReplyTo/references/replyContextSource in their result JSON (same shape drafts create reports after the reply-context work); forward drafts report the Gmail-assigned thread id.
  • Inherited automatically by embedding the send-side options: --auto-from-addressed-alias (feat(gmail): env-configure --auto-from-addressed-alias via GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS #964) and the signature flags work on the drafts verbs identically to their send counterparts.

Fixes (commit 3), applying to send, forward, and drafts create/update:

  • A quoted display name containing a comma ("Smith, John" <john@example.com>) is one recipient everywhere. Previously --track counted it as two and refused, --track-split built one message per fragment, and dry-runs reported broken fragments.
  • Malformed recipients fail fast with a flag-named usage error before any API call (previously send could transmit garbage/empty To headers).
  • RFC 5322 group syntax (undisclosed-recipients:; — parses to zero addresses without an error) is rejected up front instead of silently dropped or saved.
  • Duplicate recipients within a flag dedup case-insensitively (matching reply's existing behavior).
  • A nameless address whose bare form is invalid on the wire (quoted local part, "john smith"@example.com) is re-quoted instead of emitted broken.
  • drafts update without --to keeps the existing draft's To header leniently: strict parse when it parses, verbatim fragments when it doesn't, so a body-only edit of a legacy-malformed header (e.g. Outlook-style To: a@x.com; b@y.com, which Gmail accepts and Go's parser rejects) cannot fail. The MIME writer renders kept fragments exactly as before this change.
  • The tracking-pixel identity is always the bare email address, independent of how the recipient was typed.

Not changed: reply's cross-field overlap rejection stays reply-only by design (reply's --to/--cc/--bcc are add-or-move operations on an existing recipient set, where one address in two flags is contradictory; compose flags assign whole fields, where overlap is well-defined).

Proof

Two self-contained TAP scripts (Python stdlib only). proof-live.py runs against a real Gmail account: it inserts one synthetic source message via messages.insert (an IMAP-append — nothing is ever sent, all counterparties are example.com), asserts each behavior above, and deletes every draft it created — the final check asserts the account's full draft set is byte-identical to the pre-run baseline. The account address is structurally redacted (never printed, even in failure diagnostics). proof-profiles.py needs no account: it bakes agent-safe.yaml and readonly.yaml with the real bake-safety-profile tool, builds real -tags safety_profile binaries, and probes the allow/block matrix.

Re-run them yourself from the branch:

GOG_ACCOUNT=you@example.com GOG=./bin/gog python3 proof-live.py
python3 proof-profiles.py   # from the repo root

Scope notes: this is targeted behavior evidence, not a substitute for the CI gate. The inherited flag matrix (quote/HTML/attachments/send-as/signature variants) is covered by the unit suite, including byte-parity tests between the send and draft builders. Tracking identity/split behavior is unit-tested (TestResolveTrackingConfig and the tracking batch tests) — proving it live would require real sends. Config-based no-send variants are unit-tested; the live script exercises the flag form.

proof-live.py output (23/23, real Gmail account, redacted by construction):

# gog build: v0.35.1-0.20260811012155-612e439b921a
# fixture message inserted: 19fee7a0d5ba05c4 (moved to Trash at the end)
ok 1 - group syntax rejected up front
ok 2 - malformed address rejected up front
ok 3 - send-forward requires --to (draft-forward does not)
ok 4 - reply draft lands in the source thread
ok 5 - reply In-Reply-To equals source Message-ID
ok 6 - reply To is the original sender
ok 7 - reply-all Cc carries the other original recipient
ok 8 - forward has Fwd: subject and carries the note
ok 9 - addressless forward draft is allowed (empty To on a fetched Fwd: message)
ok 10 - display-name comma stays one recipient (name preserved)
ok 11 - duplicate recipients dedup to one
ok 12 - quoted local part is re-quoted on the wire
ok 13 - gmail send --dry-run reports exactly one parsed recipient
ok 14 - gmail forward --dry-run reports exactly one parsed recipient
ok 15 - update dry-run labels the kept-existing To (right op, draft, empty to)
ok 16 - body-only update preserves the To header
ok 17 - body-only update of a legacy-malformed To succeeds
ok 18 - legacy recipients preserved through the update
ok 19 - drafts reply creates a real draft under --gmail-no-send
ok 20 - drafts reply-all creates a real draft under --gmail-no-send
ok 21 - drafts forward creates a real draft under --gmail-no-send
ok 22 - gmail send is blocked under --gmail-no-send
ok 23 - pre-existing drafts untouched; session drafts all removed
1..23
proof-live.py (369 lines, stdlib only)
#!/usr/bin/env python3
"""Live behavior proof for `gog gmail drafts reply/reply-all/forward` and the
address-aware compose recipient fix. Emits TAP; exit 0 means every check and
all cleanup succeeded.

Requirements: gog (this branch) and Python 3.8+, no third-party packages.
Set GOG_ACCOUNT to an authenticated account email; optionally set GOG to the
gog binary path.

What it touches
---------------
* Inserts one synthetic source message via gmail.users.messages.insert — an
  IMAP-append; nothing is ever sent, and every counterparty address is under
  example.com.
* Creates drafts, then deletes every draft it created. The fixture message is
  moved to Trash (reversible; Gmail purges Trash after 30 days).
* Pre-existing drafts are recorded at the start, never touched, and the full
  draft list (all pages) is compared against that baseline at the end.
* Any cleanup failure fails the proof.

Redaction guarantee
-------------------
sys.stdout and sys.stderr are wrapped so every byte the script emits — checks,
diagnostics, even unhandled tracebacks — has the account address and its
URL-encoded form replaced with the literal string $GOG_ACCOUNT.

Failure model
-------------
A behavior mismatch prints `not ok`. An infrastructure error (gog exiting
nonzero where success is required, unparseable JSON, a missing field) raises,
prints `Bail out!`, and still runs cleanup — it can never be mistaken for a
passing check.
"""

import base64
import json
import os
import subprocess
import sys
import time
import urllib.parse

GOG = os.environ.get("GOG", "gog")
try:
    ACCOUNT = os.environ["GOG_ACCOUNT"]
except KeyError:
    sys.exit("set GOG_ACCOUNT to an authenticated account email")


class Redactor:
    """File-like wrapper that blots out the account address on every write."""

    def __init__(self, raw: object):
        self.raw = raw
        self.secrets = [ACCOUNT, urllib.parse.quote(ACCOUNT, safe="")]

    def write(self, text: str) -> None:
        for secret in self.secrets:
            text = text.replace(secret, "$GOG_ACCOUNT")
        self.raw.write(text)

    def flush(self) -> None:
        self.raw.flush()


sys.stdout = Redactor(sys.stdout)
sys.stderr = Redactor(sys.stderr)


# --- TAP ---------------------------------------------------------------------

checks = 0
failures = 0


def ok(description: str) -> None:
    global checks
    checks += 1
    print(f"ok {checks} - {description}")


def not_ok(description: str, diagnostic: str = "") -> None:
    global checks, failures
    checks += 1
    failures += 1
    print(f"not ok {checks} - {description}")
    if diagnostic:
        print(f"# {diagnostic}")


def check(description: str, actual: object, expected: object) -> None:
    if actual == expected:
        ok(description)
    else:
        not_ok(description, f"expected {expected!r} got {actual!r}")


def diag(message: str) -> None:
    print(f"# {message}")


# --- gog runners -------------------------------------------------------------

def gog(*args: str, stdin: str = "") -> subprocess.CompletedProcess:
    """Run gog for ACCOUNT; returns the completed process, success or not."""
    return subprocess.run(
        [GOG, *args, "-a", ACCOUNT],
        input=stdin, capture_output=True, text=True,
    )


def gog_json(*args: str, stdin: str = "") -> dict:
    """Run gog and parse its stdout as JSON; raises on failure — an
    infrastructure error here must never satisfy a check."""
    proc = gog(*args, stdin=stdin)
    if proc.returncode != 0:
        raise RuntimeError(f"gog {' '.join(args)} exited {proc.returncode}: "
                           f"{proc.stdout}{proc.stderr}")
    return json.loads(proc.stdout)


def expect_reject(description: str, proc: subprocess.CompletedProcess,
                  needle: str) -> None:
    """The command must fail AND say why."""
    combined = proc.stdout + proc.stderr
    if proc.returncode == 0:
        not_ok(description, "expected failure, exit 0")
    elif needle in combined:
        ok(description)
    else:
        not_ok(description, f"output lacks {needle!r}: {combined!r}")


def header(message_id: str, name: str) -> str:
    """A named header from the raw message resource ('' when absent)."""
    payload = gog_json("gmail", "raw", message_id)["payload"]
    return next((h["value"] for h in payload["headers"]
                 if h["name"].lower() == name.lower()), "")


def draft_ids() -> "set[str]":
    """The full (all pages) set of draft IDs in the account."""
    return {d["id"] for d in gog_json("gmail", "drafts", "list", "--all", "--json")["drafts"]}


def mime_b64url(mime: str) -> str:
    return base64.urlsafe_b64encode(mime.encode()).decode().rstrip("=")


# --- the proof ---------------------------------------------------------------

created_drafts: "list[str]" = []
fixture_messages: "list[str]" = []


def register(response: dict) -> dict:
    """Record a compose response's draft for cleanup; returns the response."""
    created_drafts.append(response["draftId"])
    return response


def cleanup() -> None:
    """Delete session drafts, trash fixtures. Failures fail the proof."""
    global failures
    for draft in created_drafts:
        if gog("gmail", "drafts", "delete", draft, "--force").returncode != 0:
            failures += 1
            diag(f"cleanup FAILED: draft {draft} not deleted")
    for message in fixture_messages:
        if gog("gmail", "trash", message).returncode != 0:
            failures += 1
            diag(f"cleanup FAILED: message {message} not trashed")


def run() -> None:
    diag("gog build: " + gog("version").stdout.strip())

    # Fixture: synthetic source message, inserted (never sent).
    fixture_mid = f"<gog-proof-source-{int(time.time())}@example.com>"
    fixture = mime_b64url(
        "From: Proof Sender <proof-sender@example.com>\r\n"
        f"To: {ACCOUNT}\r\n"
        "Cc: proof-cc@example.com\r\n"
        "Subject: gog drafts-mirror proof source\r\n"
        f"Message-ID: {fixture_mid}\r\n"
        "MIME-Version: 1.0\r\n"
        "Content-Type: text/plain; charset=UTF-8\r\n\r\n"
        "Synthetic source message for the drafts proof. Never sent.\r\n")
    src = gog_json("api", "call", "gmail", "v1", "gmail.users.messages.insert",
                   "--params", '{"userId":"me"}', "--body", "@/dev/stdin",
                   "--allow-write", "--force", "--json",
                   stdin=json.dumps({"raw": fixture, "labelIds": ["INBOX"]}))["id"]
    fixture_messages.append(src)
    diag(f"fixture message inserted: {src} (moved to Trash at the end)")

    baseline = draft_ids()

    # 1-3: fail-fast validation (no API call, no artifacts).
    expect_reject("group syntax rejected up front",
                  gog("gmail", "drafts", "reply", src, "--body", "b",
                      "--to", "undisclosed-recipients:;"),
                  "contains no recipients")
    expect_reject("malformed address rejected up front",
                  gog("gmail", "drafts", "forward", src, "--to", "not an address <<>"),
                  "invalid --to recipient list")
    expect_reject("send-forward requires --to (draft-forward does not)",
                  gog("gmail", "forward", src, "--note", "n"),
                  "required: --to")

    # 4-6: drafts reply threads correctly.
    reply = register(gog_json("gmail", "drafts", "reply", src,
                              "--body", "Proof reply.", "--json"))
    check("reply draft lands in the source thread", reply["threadId"], src)
    check("reply In-Reply-To equals source Message-ID",
          header(reply["message"]["id"], "In-Reply-To"), fixture_mid)
    check("reply To is the original sender",
          header(reply["message"]["id"], "To"),
          "Proof Sender <proof-sender@example.com>")

    # 7: drafts reply-all populates the other recipients.
    reply_all = register(gog_json("gmail", "drafts", "reply-all", src,
                                  "--body", "Proof reply-all.", "--json"))
    check("reply-all Cc carries the other original recipient",
          header(reply_all["message"]["id"], "Cc"), "proof-cc@example.com")

    # 8: drafts forward carries the note under a Fwd: subject.
    forward = register(gog_json("gmail", "drafts", "forward", src,
                                "--to", ACCOUNT,
                                "--note", "Proof forward note.", "--json"))
    forward_raw = gog_json("gmail", "raw", forward["message"]["id"])
    subject = header(forward["message"]["id"], "Subject")
    if subject.startswith("Fwd:") and "Proof forward note." in forward_raw["snippet"]:
        ok("forward has Fwd: subject and carries the note")
    else:
        not_ok("forward has Fwd: subject and carries the note",
               f"subject {subject!r} snippet {forward_raw['snippet']!r}")

    # 9: an addressless forward draft is allowed. The Fwd: subject read from
    # the same fetched message proves the header lookup worked, so the empty
    # To is a real absence, not a failed fetch.
    addressless = register(gog_json("gmail", "drafts", "forward", src, "--json"))
    a_subject = header(addressless["message"]["id"], "Subject")
    a_to = header(addressless["message"]["id"], "To")
    if a_subject.startswith("Fwd:") and a_to == "":
        ok("addressless forward draft is allowed (empty To on a fetched Fwd: message)")
    else:
        not_ok("addressless forward draft is allowed (empty To on a fetched Fwd: message)",
               f"subject {a_subject!r} to {a_to!r}")

    # 10-12: address-aware compose parsing on the draft surfaces.
    display_name = register(gog_json("gmail", "drafts", "create",
                                     "--to", f'"Doe, Jane" <{ACCOUNT}>',
                                     "--subject", "display-name comma",
                                     "--body", "b", "--json"))
    check("display-name comma stays one recipient (name preserved)",
          header(display_name["message"]["id"], "To"), f'"Doe, Jane" <{ACCOUNT}>')

    dedup = register(gog_json("gmail", "drafts", "create",
                              "--to", f"{ACCOUNT}, {ACCOUNT}",
                              "--subject", "dedup", "--body", "b", "--json"))
    check("duplicate recipients dedup to one",
          header(dedup["message"]["id"], "To"), ACCOUNT)

    quoted = register(gog_json("gmail", "drafts", "create",
                               "--to", '"john smith"@example.com',
                               "--subject", "quoted local", "--body", "b", "--json"))
    check("quoted local part is re-quoted on the wire",
          header(quoted["message"]["id"], "To"), '<"john smith"@example.com>')

    # 13-14: the same parser feeds the send-path commands. --dry-run --json
    # reports the parsed request and exits before any service call, so this
    # proves send-path parsing without sending. The assertion is exact: the
    # parsed list must be exactly one full formatted mailbox.
    send_dry = gog_json("gmail", "send", "--to", f'"Doe, Jane" <{ACCOUNT}>',
                        "--subject", "s", "--body", "b", "--dry-run", "--json")
    check("gmail send --dry-run reports exactly one parsed recipient",
          (send_dry["op"], send_dry["request"]["to"]),
          ("gmail.send", [f'"Doe, Jane" <{ACCOUNT}>']))

    forward_dry = gog_json("gmail", "forward", src,
                           "--to", f'"Doe, Jane" <{ACCOUNT}>', "--dry-run", "--json")
    check("gmail forward --dry-run reports exactly one parsed recipient",
          (forward_dry["op"], forward_dry["request"]["to"]),
          ("gmail.forward", [f'"Doe, Jane" <{ACCOUNT}>']))

    # 15-16: update keeps the existing To when --to is omitted.
    update_dry = gog_json("gmail", "drafts", "update", display_name["draftId"],
                          "--subject", "display-name comma", "--body", "b2",
                          "--dry-run", "--json")
    kept_to = update_dry["request"]["to"]
    check("update dry-run labels the kept-existing To (right op, draft, empty to)",
          (update_dry["op"], update_dry["request"]["draft_id"],
           update_dry["request"]["to_keep_existing"], kept_to in (None, [])),
          ("gmail.drafts.update", display_name["draftId"], True, True))

    updated = gog_json("gmail", "drafts", "update", display_name["draftId"],
                       "--subject", "display-name comma", "--body", "b2", "--json")
    check("body-only update preserves the To header",
          header(updated["message"]["id"], "To"), f'"Doe, Jane" <{ACCOUNT}>')

    # 17-18: a legacy-malformed To (Gmail accepts it; Go's parser rejects it)
    # must survive a body-only update. The Outlook-style semicolon separator
    # is such a header; the MIME writer normalizes the separator, and both
    # recipients must survive exactly.
    legacy_mime = mime_b64url("To: a@x.com; b@y.com\r\n"
                              "Subject: legacy semicolon To\r\n"
                              "MIME-Version: 1.0\r\n"
                              "Content-Type: text/plain\r\n\r\nlegacy body\r\n")
    legacy_id = gog_json("api", "call", "gmail", "v1", "gmail.users.drafts.create",
                         "--params", '{"userId":"me"}', "--body", "@/dev/stdin",
                         "--allow-write", "--force", "--json",
                         stdin=json.dumps({"message": {"raw": legacy_mime}}))["id"]
    created_drafts.append(legacy_id)
    legacy_update = gog("gmail", "drafts", "update", legacy_id,
                        "--subject", "legacy semicolon To", "--body", "updated", "--json")
    if legacy_update.returncode == 0:
        ok("body-only update of a legacy-malformed To succeeds")
        check("legacy recipients preserved through the update",
              header(json.loads(legacy_update.stdout)["message"]["id"], "To"),
              "a@x.com, b@y.com")
    else:
        not_ok("body-only update of a legacy-malformed To succeeds",
               f"exit {legacy_update.returncode}: {legacy_update.stdout}{legacy_update.stderr}")
        not_ok("legacy recipients preserved through the update", "update failed")

    # 19-21: every new command composes under --gmail-no-send. Success means
    # the Gmail API returned real draft and message IDs, not just exit 0.
    for command, extra in (("reply", ["--body", "Composed under no-send."]),
                           ("reply-all", ["--body", "Composed under no-send."]),
                           ("forward", ["--to", ACCOUNT])):
        no_send = register(gog_json("gmail", "drafts", command, src,
                                    "--gmail-no-send", *extra, "--json"))
        if no_send["draftId"] and no_send["message"]["id"]:
            ok(f"drafts {command} creates a real draft under --gmail-no-send")
        else:
            not_ok(f"drafts {command} creates a real draft under --gmail-no-send",
                   f"response: {no_send!r}")

    # 22: gmail send stays blocked under the same flag.
    expect_reject("gmail send is blocked under --gmail-no-send",
                  gog("gmail", "send", "--gmail-no-send", "--to", ACCOUNT,
                      "--subject", "x", "--body", "x"),
                  "blocked by --gmail-no-send")

    # 23: cleanup restores the exact pre-run draft set (all pages).
    cleanup()
    check("pre-existing drafts untouched; session drafts all removed",
          draft_ids(), baseline)


def main() -> int:
    cleaned = False
    try:
        run()
        cleaned = True  # run() ends with the explicit cleanup + baseline check
    except Exception:
        import traceback
        print("Bail out! unexpected infrastructure error")
        traceback.print_exc()
        return 1
    finally:
        if not cleaned:
            cleanup()
    print(f"1..{checks}")
    return 1 if failures else 0


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

proof-profiles.py output (9/9, no account needed):

# HEAD: 612e439b
ok 1 - agent-safe allows drafts reply
ok 2 - agent-safe allows drafts reply-all
ok 3 - agent-safe allows drafts forward
ok 4 - agent-safe still allows drafts create
ok 5 - agent-safe still blocks gmail send
ok 6 - readonly blocks drafts reply
ok 7 - readonly blocks drafts reply-all
ok 8 - readonly blocks drafts forward
ok 9 - readonly blocks drafts create
1..9
proof-profiles.py (144 lines, stdlib only)
#!/usr/bin/env python3
"""Safety-profile proof for the drafts compose commands. Emits TAP; exit 0
means every check passed and any pre-existing generated file was restored.

Needs no Google account: --dry-run exits before auth, and profile blocks exit
even earlier. Run from the gogcli repo root with Go available. For each
shipped restricted profile this script bakes the YAML into generated Go code,
builds a real binary with -tags safety_profile, and probes it:

* agent-safe must allow the three new drafts compose commands (and drafts
  create) while still blocking gmail send.
* readonly must block all four.

A pre-existing internal/cmd/safety_profile_baked_gen.go is saved first and
restored afterward; it is only ever removed after a successful restore, and a
failed restore fails the proof.
"""

import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

GEN = Path("internal/cmd/safety_profile_baked_gen.go")

checks = 0
failures = 0


def ok(description: str) -> None:
    global checks
    checks += 1
    print(f"ok {checks} - {description}")


def not_ok(description: str, diagnostic: str = "") -> None:
    global checks, failures
    checks += 1
    failures += 1
    print(f"not ok {checks} - {description}")
    if diagnostic:
        print(f"# {diagnostic}")


def bake(profile: str, workdir: Path) -> Path:
    """Bake a profile YAML and build the restricted binary; raises on failure."""
    subprocess.run(["go", "run", "./cmd/bake-safety-profile",
                    f"safety-profiles/{profile}.yaml", str(GEN)], check=True)
    binary = workdir / f"gog-{profile}"
    subprocess.run(["go", "build", "-tags", "safety_profile",
                    "-o", str(binary), "./cmd/gog"], check=True)
    return binary


def probe(binary: Path, *args: str) -> subprocess.CompletedProcess:
    return subprocess.run([str(binary), *args, "--dry-run"],
                          capture_output=True, text=True)


def allowed(binary: Path, description: str, *args: str) -> None:
    """The profile must let the command through to its dry-run exit."""
    proc = probe(binary, *args)
    if proc.returncode == 0 and proc.stdout.startswith("Dry run: would "):
        ok(description)
    else:
        not_ok(description, f"exit {proc.returncode}: {proc.stdout}{proc.stderr}")


def blocked(binary: Path, description: str, *args: str) -> None:
    """The profile must refuse the command by name, with a failing exit."""
    proc = probe(binary, *args)
    combined = proc.stdout + proc.stderr
    if proc.returncode != 0 and "blocked by baked safety profile" in combined:
        ok(description)
    else:
        not_ok(description, f"exit {proc.returncode}: {combined}")


def run(workdir: Path) -> None:
    head = subprocess.run(["git", "rev-parse", "--short", "HEAD"],
                          capture_output=True, text=True).stdout.strip()
    print(f"# HEAD: {head or 'unknown'}")

    agent_safe = bake("agent-safe", workdir)
    allowed(agent_safe, "agent-safe allows drafts reply",
            "gmail", "drafts", "reply", "m1", "--body", "b")
    allowed(agent_safe, "agent-safe allows drafts reply-all",
            "gmail", "drafts", "reply-all", "m1", "--body", "b")
    allowed(agent_safe, "agent-safe allows drafts forward",
            "gmail", "drafts", "forward", "m1", "--to", "a@x.com")
    allowed(agent_safe, "agent-safe still allows drafts create",
            "gmail", "drafts", "create", "--to", "a@x.com", "--subject", "s", "--body", "b")
    blocked(agent_safe, "agent-safe still blocks gmail send",
            "gmail", "send", "--to", "a@x.com", "--subject", "s", "--body", "b")

    readonly = bake("readonly", workdir)
    blocked(readonly, "readonly blocks drafts reply",
            "gmail", "drafts", "reply", "m1", "--body", "b")
    blocked(readonly, "readonly blocks drafts reply-all",
            "gmail", "drafts", "reply-all", "m1", "--body", "b")
    blocked(readonly, "readonly blocks drafts forward",
            "gmail", "drafts", "forward", "m1", "--to", "a@x.com")
    blocked(readonly, "readonly blocks drafts create",
            "gmail", "drafts", "create", "--to", "a@x.com", "--subject", "s", "--body", "b")


def main() -> int:
    global failures
    workdir = Path(tempfile.mkdtemp(prefix="gog-profile-proof-"))
    had_gen = GEN.exists()
    saved = None
    try:
        if had_gen:
            backup = workdir / "saved_gen.go"
            shutil.copy2(GEN, backup)
            saved = backup  # assigned only after a successful copy
        run(workdir)
    except Exception:
        import traceback
        print("Bail out! bake or build failed")
        traceback.print_exc()
        failures += 1
    finally:
        try:
            if saved is not None:
                shutil.copy2(saved, GEN)
            elif not had_gen:
                GEN.unlink(missing_ok=True)
            # A pre-existing GEN whose backup failed was never overwritten
            # (run() didn't start), so it needs no restore. The temp dir —
            # and with it the backup — is only removed after the restore
            # above succeeded.
            shutil.rmtree(workdir)
        except OSError as err:
            failures += 1
            print(f"# restore FAILED ({err}); backup preserved at {saved}")

    print(f"1..{checks}")
    return 1 if failures else 0


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

Docs

docs/spec.md synopsis hand-synced; generated command pages included (make docs-commands, 712 pages); the agent workflow section of docs/gmail-workflows.md updated to cover drafting replies under no-send. No CHANGELOG.md entry per release convention.

Review notes

🤖 Generated with Claude Code

malob and others added 3 commits August 10, 2026 18:00
Split the message-build half of gmail reply/reply-all/forward into pure builders (buildReplyComposeMessage/buildForwardComposeMessage) that take an already-acquired *gmail.Service, plus service-free resolveReplyInputs/resolveForwardInputs that resolve body/note exactly once and validate. The Run methods now orchestrate resolve -> dry-run -> acquire -> build -> finalize, so the acquire gate and finalize step are no longer baked into the builders.

Extract composeSignatureOptions (the shared signature flags + signatureRequested/validateSignatureOptions/resolveComposeSignature methods) and embed it into GmailSendCmd and GmailReplyOptions, removing the throwaway GmailSendCmd the reply path constructed just to borrow those methods. Extract a GmailForwardOptions embed from GmailForwardCmd.

No behavior change: CLI flags are byte-identical, and validation order, dry-run output, error wrapping, and finalize are unchanged; the existing reply/reply-all/forward/send/signature tests pass with assertions unchanged. This sets up reuse by upcoming drafts reply/reply-all/forward commands, which will share resolve+build and finalize with Drafts.Create under the non-send service gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `gmail drafts reply`, `drafts reply-all`, and `drafts forward`, giving the drafts surface full flag/ergonomic parity with the send-side reply/reply-all/forward commands. They embed the same options structs and reuse the shared resolve/build helpers, differing only at finalize: they save a draft (Drafts.Create) instead of sending. Drafts use the non-send service gate, so they work under --gmail-no-send, matching gmail drafts create.

`drafts forward` allows an addressless draft (no --to), matching drafts create and Gmail's UI. The recipient requirement is resolved explicitly per call, which also aligns gmail forward's required---to handling with send/reply (runtime check + explanatory help) while keeping the MIME missing-To backstop on the send path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gmail send, forward, and drafts create/update parsed --to/--cc/--bcc with naive comma-splitting, while reply already parsed them as addresses. The splitting mangled quoted display names ("Smith, John" <john@example.com>) wherever the fragment list was consumed directly: --track counted such a recipient as two and refused to send, --track-split built one message per fragment, and dry-runs reported the broken fragments. Unparseable input was not rejected: send transmitted messages with garbage or empty To headers, and RFC 5322 group syntax ("undisclosed-recipients:;", which parses to zero addresses without an error) was silently dropped or silently saved.

Route every compose command through shared address-aware parsing (parseComposeRecipients -> mail.ParseAddressList), with reply's within-flag case-insensitive dedup applying everywhere. Recipients are parsed service-free before the dry-run, so the dry-run reports the same lists the built message carries; malformed input and non-empty flags that parse to zero recipients now fail fast with a flag-named error and no API call, on every compose command. A draft update without --to keeps the existing draft's To header leniently (verbatim if it does not parse), so legacy drafts stay editable. Nameless addresses whose bare form is not valid on the wire (quoted local parts) are re-quoted instead of silently emitted broken. The tracking pixel identity is now always the bare email address, independent of how the recipient was typed. Includes final-review test and comment polish across the compose paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clawsweeper

clawsweeper Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

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

@clawsweeper clawsweeper Bot added merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 message-delivery 🚨 Merging this PR could drop, duplicate, misroute, suppress, or wrongly target messages. P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 11, 2026
@clawsweeper

clawsweeper Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed August 10, 2026, 9:44 PM ET / August 11, 2026, 01:44 UTC.

ClawSweeper review

What this changes

Adds Gmail draft reply, reply-all, and forward commands, refactors shared compose code, and makes recipient parsing address-aware across compose commands.

Merge readiness

⚠️ Ready for maintainer review - 4 items remain

Keep open for maintainer sponsorship: current main lacks these draft commands, while the PR has credible live Gmail proof but combines a new command surface with a broad compose refactor that VISION.md says to discuss first.

Priority: P2
Reviewed head: 612e439b921acfed706bc71ddc555d2eb91f9038
Owner decision: Required. See Decision needed.

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) Strong real-provider evidence and extensive parity coverage support a good patch, pending the separate maintainer scope decision.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (live_output): The PR body contains a redacted live Gmail TAP transcript showing after-fix draft creation, no-send operation, recipient behavior, and cleanup.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (live_output): The PR body contains a redacted live Gmail TAP transcript showing after-fix draft creation, no-send operation, recipient behavior, and cleanup.
Evidence reviewed 6 items Current-main capability: The current drafts command group exposes list, get, delete, send, create, and update only; it has no reply, reply-all, or forward draft verb.
PR implementation: The branch adds draft reply and forward execution paths that reuse shared builders and call Gmail Drafts.Create instead of Messages.Send.
Scope policy: VISION.md places large PRs, broad refactors, and new API surfaces in the discuss-first category; this branch changes 31 files.
Findings None None.
Security None None.

How this fits together

Gmail compose commands resolve source messages and recipient inputs, build an RFC 822 message, then either send it or save it as a draft through the Gmail API. This PR reuses the reply and forward composition paths while changing finalization to draft creation under no-send guardrails.

flowchart LR
A[CLI compose command] --> B[Resolve message and recipients]
B --> C[Build MIME message]
C --> D{Final action}
D -->|Send| E[Gmail send API]
D -->|Save draft| F[Gmail drafts API]
E --> G[Message result]
F --> G
Loading

Decision needed

Question Recommendation
Should gogcli add these three Gmail draft command verbs and accept the shared compose refactor as the supported draft-only workflow? Sponsor the draft workflow: Accept the new command surface and continue compatibility review against the existing compose commands.

Why: VISION.md requires maintainer discussion for new API surfaces and broad refactors; source review cannot make that product-scope choice.

Before merge

  • Resolve merge risk (P1) - Existing automation that supplied malformed or group-only recipient values will now fail early instead of attempting to send or save an addressless message; that compatibility change is intentional but needs maintainer acceptance.
  • Resolve merge risk (P1) - The new command verbs share refactored send, reply, forward, draft, and safety-profile paths, so regressions could affect established Gmail message delivery behavior.
  • Complete next step (P2) - A maintainer must first sponsor the new Gmail command surface and its intended recipient-validation contract.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Changed surface 31 files; +2,834 / -286 lines The change spans shared Gmail composition, drafts, safety profiles, generated docs, and tests.
Production vs test delta production +650/-205; tests +1,965/-75 The substantial test coverage supports the broad compose refactor, but the production surface still merits compatibility review.

Merge-risk options

Maintainer options:

  1. Accept the stricter recipient contract (recommended)
    Merge with explicit maintainer acceptance that malformed and group-only compose recipients now fail before Gmail API calls.
  2. Split recipient hardening
    Move the recipient-parser behavior change into a focused compatibility-reviewed PR before landing the new draft commands.
  3. Pause the surface expansion
    Pause or close this PR if maintainers do not want draft reply and forward verbs in the supported CLI.

Technical review

Best possible solution:

Sponsor the three draft-only Gmail verbs, then merge the shared implementation only after accepting the stricter recipient contract and confirming existing send/reply/forward compatibility.

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

Not applicable: this PR adds new commands rather than reporting an existing failure. Its body supplies a redacted live Gmail run with 23 passing checks for the proposed behavior.

Is this the best way to solve the issue?

Unclear: builder reuse is a maintainable implementation approach, but the repository policy requires maintainer agreement on this new command surface and broad refactor.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 71c6c1e63787.

Labels

Label changes:

  • add P2: This is a substantial but non-emergency Gmail workflow expansion requiring normal maintainer review.
  • add merge-risk: 🚨 compatibility: The PR changes shared compose parsing and command behavior used by established send, reply, forward, and draft workflows.
  • add merge-risk: 🚨 message-delivery: Recipient normalization and MIME composition changes can alter how existing Gmail messages are addressed.
  • add proof: sufficient: Contributor real behavior proof is sufficient. The PR body contains a redacted live Gmail TAP transcript showing after-fix draft creation, no-send operation, recipient behavior, and cleanup.
  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • add status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR body contains a redacted live Gmail TAP transcript showing after-fix draft creation, no-send operation, recipient behavior, and cleanup.

Label justifications:

  • P2: This is a substantial but non-emergency Gmail workflow expansion requiring normal maintainer review.
  • merge-risk: 🚨 compatibility: The PR changes shared compose parsing and command behavior used by established send, reply, forward, and draft workflows.
  • merge-risk: 🚨 message-delivery: Recipient normalization and MIME composition changes can alter how existing Gmail messages are addressed.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR body contains a redacted live Gmail TAP transcript showing after-fix draft creation, no-send operation, recipient behavior, and cleanup.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body contains a redacted live Gmail TAP transcript showing after-fix draft creation, no-send operation, recipient behavior, and cleanup.

Evidence

What I checked:

  • Current-main capability: The current drafts command group exposes list, get, delete, send, create, and update only; it has no reply, reply-all, or forward draft verb. (internal/cmd/gmail_drafts.go:19, 71c6c1e63787)
  • PR implementation: The branch adds draft reply and forward execution paths that reuse shared builders and call Gmail Drafts.Create instead of Messages.Send. (internal/cmd/gmail_drafts_compose.go:50, 612e439b921a)
  • Scope policy: VISION.md places large PRs, broad refactors, and new API surfaces in the discuss-first category; this branch changes 31 files. (VISION.md:17, 71c6c1e63787)
  • Behavior parity coverage: The added tests compare send and draft RFC 822 output and cover no-send execution, recipient derivation, and addressless forward drafts. (internal/cmd/gmail_drafts_compose_test.go:93, 612e439b921a)
  • Real behavior proof: The PR body provides a redacted 23-check live Gmail TAP transcript covering draft creation, no-send behavior, recipient handling, and cleanup of created drafts. (612e439b921a)
  • Area provenance: Current-main blame ties the drafts command surface to the v0.35.0 release commit, making Peter Steinberger the strongest available current-main routing candidate. (internal/cmd/gmail_drafts.go:19, 402def5041d6)

Likely related people:

  • Peter Steinberger: Current-main blame attributes the existing drafts command surface to the v0.35.0 release commit, and recent history shows continued Gmail-area changes on main. (role: recent Gmail compose-area contributor; confidence: medium; commits: 402def5041d6, 71c6c1e63787; files: internal/cmd/gmail_drafts.go, internal/cmd/gmail_reply_commands.go, internal/cmd/gmail_forward.go)
  • Ronny Rentner: Recent Gmail history includes the opt-in addressed-alias reply behavior that this PR carries into the shared reply option surface. (role: adjacent Gmail reply-option contributor; confidence: medium; commits: 88eb36191a1c, d06fe6dc0720; files: internal/cmd/gmail_reply_commands.go, internal/cmd/gmail_drafts.go)

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

chrischall added a commit to chrischall/gogcli-mcp that referenced this pull request Aug 14, 2026
…nitized get (#272)

Raises `MIN_GOG_VERSION` **0.35.0 → 0.37.0** (and
`fly-gog-runner/Dockerfile`'s pin with it) and takes up what 0.36.0 /
0.37.0 added.

## New tools

**Gmail — draft-side reply/forward** (gog 0.36.0, openclaw/gogcli#977)

| Tool | |
|---|---|
| `gog_gmail_drafts_reply` | Save a reply as a draft — inherited
recipients, subject and quote; never sends |
| `gog_gmail_drafts_reply_all` | Same, to every participant |
| `gog_gmail_drafts_forward` | Save a forward as a draft; `to` is
optional, so it can be staged with no recipients at all |

They take the same flag set as the send commands and share gog's
composition path, so `replySchema` / `appendReplyFlags` are reused
verbatim rather than re-declared. Staging a reply previously meant
`gog_gmail_drafts_create` + `replyToThreadId`, which threads the draft
but inherits neither the original's recipients nor its quoted body —
both had to be rebuilt by hand, and a missed Cc is invisible until the
draft goes out.

**Sheets — Connected Sheets reads** (gog 0.37.0, openclaw/gogcli#938)

`gog_sheets_datasource_list` / `_describe`, and
`gog_sheets_datasource_table_list` / `_describe` / `_read`. Read-only by
construction (gog exposes no create/update/refresh/delete here). A
data-source table has no id of its own in the Sheets API — its
definition lives on its top-left cell — so extracts are addressed by a
sheet-qualified A1 anchor like `Extracts!B3`.

## Behaviour these depend on

- **`gog_gmail_get` gains `sanitizeContent`.** The flag predates 0.37.0
but emitted the headers and body **twice** in JSON
(openclaw/gogcli#992), so the flag meant to shrink the payload enlarged
it. Verified against a live 0.37.0 build: the sanitized message now
arrives under a single `message` key.
- **`gog_calendar_events` gains `days`**, and it and
`gog_calendar_search` now describe the window rules gog 0.36.0 enforces
(openclaw/gogcli#981). `--days` is a window *length* anchored at
`--from`; it used to discard `--from` silently and answer for today, at
exit 0, in a well-formed table. Fixed presets no longer combine with
`from`/`to`/`days`, and `days` no longer combines with `to`. The old
descriptions invited exactly the combinations that now fail, and the
base calendar test asserted an arg array gog refuses to run.
- **The auth tools gain `extraScopes`** (plus `--force-consent` on the
interactive one, since Google re-prompts for a *new* scope only when
consent is forced). Nothing else can request `bigquery.readonly`, which
Google demands whenever a Sheets response *contains* BigQuery Connected
Sheets data — without it the new sheets tools cannot be authorized
through the wrapper at all. Documented in `docs/auth-scopes.md`.

## Not adopted: `gmail search --count`

gog 0.36.0 (openclaw/gogcli#985) upstreamed this wrapper's match-count
probe, down to the page size and the exact/lower-bound split. The local
probe stays, and the stale comment claiming gog *cannot* supply the
count is corrected to say why:

- it is spent only on a result set already known to be truncated, where
`--count` is decided before the search runs and would cost every search
an extra Gmail request;
- it is best-effort, where gog returns the probe's error from the whole
command — a failed count would turn a search that *did* succeed into an
error.

## Verification

Beyond the unit tests (the mocked suites only assert arg arrays), every
new command was run against the real gog v0.37.0 binary with fake ids,
which parses flags before any API call:

- `gmail drafts reply` / `reply-all` / `forward` — full flag sets parse;
failures are the API's 400 on the fake id (and a send-as validation for
`--from`), not unknown flags.
- `sheets datasource list` / `describe` / `table list` / `table
describe` / `table read` — all reach the API and 404 on the fake
spreadsheet.
- `calendar events --from 2026-09-25 --days 5` returns the anchored
window; `--today --from …` and `--days … --to …` are both rejected with
the messages the tool descriptions now quote.
- `gmail get --sanitize-content --json` on a real message: top-level
keys are exactly `["message"]`.

`npm test` (all workspaces + fly-gog-runner), `npm run typecheck` and
`npm run build` are green; the 100% coverage gate holds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EjH6C4jVKavpHiFwBW574N

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 message-delivery 🚨 Merging this PR could drop, duplicate, misroute, suppress, or wrongly target messages. P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary 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