Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ make install # Install to /usr/local/bin

See `docs/golden-principles.md` for the full set of enforced rules.

## Design Principles

- **Gmail browser parity**: Mail features must integrate with how people expect Gmail to behave in the browser. `gro` is one client among many on a shared mailbox — drafts, quoting, threading, and labels should look and behave like native Gmail when later opened or sent from the web UI. Emit the conventions/markers Gmail's own client recognizes (e.g. wrap reply quotes in `gmail_quote` markup so Gmail collapses them behind its `…` toggle; `Re:` subject handling; RFC threading headers) rather than reimplementing client-side rendering. Prefer Gmail-native parity over technically-valid-but-foreign output.

## Testing

Run tests: `make test`
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ gro mail draft --from work@me.com --to a@x.com --subject "Hi" --body "..."

# Reply to an existing message (preserves thread, adds In-Reply-To / References)
gro mail draft --reply-to <message-id> --body "thanks, will review"
gro mail draft --reply-to <message-id> # quote-only reply (no typed text)
gro mail draft --reply-to <message-id> --no-quote --body "ack"
gro mail draft --reply-to <message-id> --reply-all --body "looping everyone in"
gro mail draft --reply-to <message-id> --subject "Re: customised" --body "..."
```
Expand All @@ -301,6 +303,8 @@ Drafts always land in your Gmail Drafts folder for human review. The CLI never c

With `--reply-to`, `--to` and `--subject` are derived from the source message (To = original From; Subject = `Re: <original>`, no double prefix). Explicit `--to` / `--cc` / `--subject` flags override the derived values. `--reply-all` adds the original To and Cc as Cc on the reply, filtered to remove your own address (and any `--from` alias).

By default a reply quotes the source message below your text, like Gmail's web UI: an `On <date> <sender> wrote:` line followed by the original body (`> ` line prefixes for plain replies; a collapsible `gmail_quote` block for HTML/markdown replies, which Gmail's UI hides behind its “…” toggle). A body source is optional when replying — a bare `--reply-to` yields a quote-only draft. Use `--no-quote` to reply without quoting (with no body source the draft is left blank). Source bodies are quoted the way Gmail itself does on reply: a plain-text source is escaped and shown with `> ` / `<br>`, while an HTML source (common for alert/marketing/SaaS senders that ship no plain-text part) is nested as HTML so it renders normally rather than appearing as escaped tags. The attribution line is always HTML-escaped.

### Calendar Commands

All Calendar commands are under `gro calendar` (or `gro cal`):
Expand Down Expand Up @@ -744,13 +748,14 @@ Flags:
-j, --json Output result as JSON
--reply-to string Source Gmail message ID to reply to (derives To/Subject/threading)
--reply-all Include the source To/Cc as Cc on the reply (requires --reply-to)
--no-quote Reply without quoting the source message (requires --reply-to)
```

`--body`, `--stdin`, and `--file` are mutually exclusive (exactly one required). `--plain` and `--html` are mutually exclusive.
`--body`, `--stdin`, and `--file` are mutually exclusive; exactly one is required, except in reply mode where all three are optional (a bare reply is just the quote). `--plain` and `--html` are mutually exclusive.

Display names in `--to`/`--cc`/`--bcc` (e.g., `Alice <alice@example.com>`) are stripped; only the email address is sent. Edit the draft in Gmail to set display names.

With `--reply-to`, the draft is threaded onto the source conversation (`In-Reply-To` and `References` headers are set; `Draft.Message.ThreadId` is set to the source thread). `--to` and `--subject` are derived from the source (To = original From; Subject = `Re: <original>` with no double prefix). Explicit `--to`/`--cc`/`--subject` flags override the derived values. `--reply-all` populates Cc with the source To+Cc minus your authenticated account and any `--from` alias.
With `--reply-to`, the draft is threaded onto the source conversation (`In-Reply-To` and `References` headers are set; `Draft.Message.ThreadId` is set to the source thread). `--to` and `--subject` are derived from the source (To = original From; Subject = `Re: <original>` with no double prefix). Explicit `--to`/`--cc`/`--subject` flags override the derived values. `--reply-all` populates Cc with the source To+Cc minus your authenticated account and any `--from` alias. The source message is quoted below your text by default (Gmail-style `On <date> <sender> wrote:` attribution; `gmail_quote` markup on HTML replies so Gmail collapses it natively); `--no-quote` suppresses the quote.

### gro calendar list

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/spf13/cobra v1.8.0
github.com/yuin/goldmark v1.8.2
golang.org/x/net v0.49.0
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.262.0
)
Expand Down Expand Up @@ -54,7 +55,6 @@ require (
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions integration-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,12 @@ DRAFT_MSG_ID=$(./bin/gro mail draft --reply-to "$SRC_ID" --body "T21 reply" --pl
| T24 | Explicit Cc override | `./bin/gro mail draft --reply-to "$SRC_ID" --cc "$ME" --body "T24 cc" --plain --json` | Exit 0. Draft Cc is exactly `$ME` (replaces, not merges with, derived Cc). |
| T25 | No double Re: prefix | First find a source message whose subject already starts with `Re:`: `RE_SRC=$(./bin/gro mail search "subject:Re:" --json \| jq -r '.[0].id')`. Then: `./bin/gro mail draft --reply-to "$RE_SRC" --body "T25" --plain --json` | Exit 0. Read-back subject is unchanged (no `Re: Re: ` doubling). |
| T26 | Reply-all without reply-to | `./bin/gro mail draft --to "$ME" --subject "x" --body "y" --reply-all; echo "exit=$?"` | Non-zero exit. Error mentions `--reply-all requires --reply-to`. |
| T27 | Quoted reply (plain) | `./bin/gro mail draft --reply-to "$SRC_ID" --body "T27 reply" --plain --json` then read back the draft body | Exit 0. Draft body starts with `T27 reply`, then a blank line, an `On <date> <sender> wrote:` line, then the source body with every line `> `-prefixed (empty lines are `>`). |
| T28 | Quoted reply (HTML) collapses in Gmail | `./bin/gro mail draft --reply-to "$SRC_ID" --body "T28 **reply**" --json` then open the draft in the Gmail web UI | Exit 0. Draft body contains a `<div class="gmail_quote">` / `<blockquote class="gmail_quote" …>` wrapper. **Manual:** opening the draft in Gmail shows the quoted history collapsed behind Gmail's “…” affordance (not automatable — Gmail client-side rendering). |
| T29 | Quote-only reply (no body source) | `./bin/gro mail draft --reply-to "$SRC_ID" --json` then read back | Exit 0. Draft body is exactly the quote block (attribution + quoted source), no leading blank lines, no authored text. Threading headers set as T21. |
| T30 | `--no-quote` reply | `./bin/gro mail draft --reply-to "$SRC_ID" --no-quote --body "T30" --plain --json` then read back | Exit 0. Draft body is exactly `T30` — no attribution, no quote. Threading headers still set. |
| T31 | `--no-quote` bare (blank draft) | `./bin/gro mail draft --reply-to "$SRC_ID" --no-quote --json` then read back | Exit 0. Draft body is empty. Threading headers still set. |
| T32 | `--no-quote` without reply-to | `./bin/gro mail draft --to "$ME" --subject "x" --body "y" --no-quote; echo "exit=$?"` | Non-zero exit. Error mentions `--no-quote requires --reply-to`. |

### Cleanup

Expand Down
159 changes: 155 additions & 4 deletions internal/cmd/mail/draft.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package mail
import (
"bytes"
"fmt"
"html"
"io"
"mime"
"net/mail"
Expand All @@ -13,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
xhtml "golang.org/x/net/html"

gmailapi "github.com/open-cli-collective/google-readonly/internal/gmail"
)
Expand All @@ -33,6 +35,7 @@ func newDraftCommand() *cobra.Command {
jsonOut bool
replyTo string
replyAll bool
noQuote bool
)

cmd := &cobra.Command{
Expand All @@ -45,7 +48,10 @@ The CLI never calls drafts.send — sending requires explicit action in Gmail.

Body input is markdown by default and rendered to HTML. Use --plain for plain
text, --html to send raw HTML verbatim. Body source is one of --body, --stdin,
or --file.
or --file (optional when replying — a bare reply is just the quote).

In reply mode the source message is quoted below your text, like Gmail's web
UI. Use --no-quote to reply without quoting.

Examples:
gro mail draft --to alice@example.com --subject "Hi" --body "**hello**"
Expand All @@ -54,6 +60,8 @@ Examples:
gro mail draft --to a@x.com --subject "Plain" --body "no md" --plain
gro mail draft --to a@x.com --subject "Report" --body "see attached" --attach report.pdf
gro mail draft --reply-to <message-id> --body "thanks, will review"
gro mail draft --reply-to <message-id> # quote-only reply
gro mail draft --reply-to <message-id> --no-quote --body "ack"
gro mail draft --reply-to <message-id> --reply-all --body "..."`,
RunE: func(cmd *cobra.Command, _ []string) error {
// --reply-to with an empty value is a user error (often a shell
Expand All @@ -69,6 +77,9 @@ Examples:
if replyAll && !isReply {
return fmt.Errorf("--reply-all requires --reply-to")
}
if noQuote && !isReply {
return fmt.Errorf("--no-quote requires --reply-to")
}
if !isReply {
if !cmd.Flags().Changed("to") {
return fmt.Errorf("--to is required")
Expand Down Expand Up @@ -125,7 +136,10 @@ Examples:
return fmt.Errorf("--to must contain at least one address")
}

// 4. Body source: exactly one of --body, --stdin, --file.
// 4. Body source: at most one of --body, --stdin, --file.
// Non-reply mode requires exactly one. Reply mode allows zero
// (an empty authored body — the reply is then just the quote,
// matching Gmail's "reply with no typed text" behavior).
bodySources := 0
if cmd.Flags().Changed("body") {
bodySources++
Expand All @@ -136,7 +150,10 @@ Examples:
if cmd.Flags().Changed("file") {
bodySources++
}
if bodySources != 1 {
if bodySources > 1 {
return fmt.Errorf("at most one of --body, --stdin, or --file may be used")
}
if bodySources == 0 && !isReply {
return fmt.Errorf("exactly one of --body, --stdin, or --file is required")
}

Expand Down Expand Up @@ -208,7 +225,7 @@ Examples:
references []string
)
if isReply {
src, err := client.GetMessage(cmd.Context(), replyTo, false)
src, err := client.GetMessage(cmd.Context(), replyTo, !noQuote)
if err != nil {
return fmt.Errorf("fetching reply source %s: %w", replyTo, err)
}
Expand Down Expand Up @@ -246,6 +263,27 @@ Examples:
if needs.Subject {
subject = derived.Subject
}

// Quote the source body (default; suppressed by --no-quote).
// Skip entirely if the source has no body part. The leading
// separator is omitted when there is no authored body, so a
// bare reply is exactly the quote block.
if !noQuote && src.Body != "" {
attrib := replyAttribution(src)
if kind == gmailapi.DraftBodyHTML {
sep := ""
if len(outBody) > 0 {
sep = "\n"
}
outBody = append(outBody, []byte(sep+quoteHTML(attrib, src.Body, src.BodyIsHTML))...)
} else {
sep := ""
if len(outBody) > 0 {
sep = "\n\n"
}
outBody = append(outBody, []byte(sep+attrib+"\n\n"+quotePlain(src.Body))...)
}
}
}

// 9b. Post-derivation resolved-recipient validation.
Expand Down Expand Up @@ -310,6 +348,7 @@ Examples:
cmd.Flags().BoolVarP(&jsonOut, "json", "j", false, "Output result as JSON")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "Source Gmail message ID to reply to (derives To/Subject/threading)")
cmd.Flags().BoolVar(&replyAll, "reply-all", false, "Include the source To/Cc as Cc on the reply (requires --reply-to)")
cmd.Flags().BoolVar(&noQuote, "no-quote", false, "Reply without quoting the source message (requires --reply-to)")

return cmd
}
Expand Down Expand Up @@ -431,6 +470,118 @@ func addRePrefix(subject string) string {
return "Re: " + subject
}

// normalizeLF collapses CRLF and lone CR to LF. Real email text/plain and
// text/html parts decode as CRLF; quoting must not leak stray CRs into the
// draft body (a "> \r" plain marker, or "\r<br>" in HTML).
func normalizeLF(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
return strings.ReplaceAll(s, "\r", "\n")
}

// replyAttribution builds the Gmail-style "On <date> <from> wrote:" line that
// precedes a quoted reply. The date is rendered in the source message's own
// timezone (the offset parsed from its Date header) — never converted to UTC
// or local — and uses a fixed en-US layout. This is deliberately not localized:
// Gmail's quote-collapse is driven by the gmail_quote markup, not this text,
// and the CLI has no reliable recipient-locale source. On a Date parse failure
// the raw header value is echoed. The returned string is unescaped; the HTML
// branch escapes it before embedding.
func replyAttribution(src *gmailapi.Message) string {
when := src.Date
if t, err := mail.ParseDate(src.Date); err == nil {
when = t.Format("Mon, Jan 2, 2006 at 3:04 PM")
}
return fmt.Sprintf("On %s %s wrote:", when, src.From)
}

// quotePlain prefixes each line of body for a plain-text reply: a non-empty
// line becomes "> " + line; an empty line becomes ">" (no trailing space, so
// the output is Gmail-faithful and free of trailing whitespace). Input is
// normalized to LF first so a CRLF blank line yields ">" not "> \r".
func quotePlain(body string) string {
lines := strings.Split(normalizeLF(body), "\n")
for i, ln := range lines {
if ln == "" {
lines[i] = ">"
} else {
lines[i] = "> " + ln
}
}
return strings.Join(lines, "\n")
}

// quoteHTML wraps the attribution and source body in Gmail's own quote markup
// so the Gmail web UI collapses it behind the "…" affordance when the draft is
// opened.
//
// The attribution is always HTML-escaped: it carries the raw From header
// (display name + "<addr>") and a crafted display name must not inject markup.
//
// The body is treated per its source kind, matching what Gmail itself does on
// reply: an HTML source (bodyIsHTML) is nested as-is so it renders normally;
// a plain-text source is escaped with newlines converted to <br>. This is the
// difference between a readable quote and a wall of visible tags for the many
// senders (alerts, marketing, SaaS notifications) that are HTML-only.
func quoteHTML(attrib, body string, bodyIsHTML bool) string {
quoted := normalizeLF(body)
if bodyIsHTML {
quoted = htmlBodyFragment(quoted)
} else {
quoted = strings.ReplaceAll(html.EscapeString(quoted), "\n", "<br>\n")
}
return fmt.Sprintf(
"<div class=\"gmail_quote\">%s<br>\n"+
"<blockquote class=\"gmail_quote\" style=\"margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex\">\n"+
"%s\n"+
"</blockquote></div>",
html.EscapeString(attrib), quoted,
)
}

// htmlBodyFragment reduces a source HTML body to a balanced fragment suitable
// for nesting inside the gmail_quote blockquote. It parses the HTML and
// re-serializes the <body> children (or the whole tree if there is no body),
// which: (1) drops the DOCTYPE/<html>/<head> so we don't nest a full document
// inside a blockquote, and (2) makes container breakout impossible — stray
// "</blockquote></div>" in the source cannot escape our wrapper because we
// emit a re-serialized parse tree, not the raw bytes. Active content
// (script/handlers) is intentionally left for Gmail's render-time sanitizer,
// exactly as when the original message was read. On a parse failure the body
// is escaped, degrading safely to the plain-source treatment.
func htmlBodyFragment(s string) string {
doc, err := xhtml.Parse(strings.NewReader(s))
if err != nil {
return strings.ReplaceAll(html.EscapeString(s), "\n", "<br>\n")
}
var body *xhtml.Node
var find func(*xhtml.Node)
find = func(n *xhtml.Node) {
if body != nil {
return
}
if n.Type == xhtml.ElementNode && n.Data == "body" {
body = n
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
find(c)
}
}
find(doc)
var buf bytes.Buffer
render := func(n *xhtml.Node) {
for c := n.FirstChild; c != nil; c = c.NextSibling {
_ = xhtml.Render(&buf, c)
}
}
if body != nil {
render(body)
} else {
render(doc)
}
return buf.String()
}

// parseAddressList parses a comma-separated address list. An empty input is OK
// and returns nil — the caller decides whether emptiness is an error.
func parseAddressList(flag, raw string) ([]string, error) {
Expand Down
Loading
Loading