Skip to content

Commit 4aebff7

Browse files
authored
feat(mail): add draft composition command (#113)
* feat(mail): add draft composition command Add `gro mail draft` for composing Gmail drafts via the users.drafts.create API. Drafts land in the Drafts folder for human review; the CLI never calls drafts.send, preserving the non-destructive philosophy. Body input is markdown by default and rendered to HTML via goldmark (table, strikethrough, and task-list extensions enabled). Use --plain for plain text or --html to pass raw HTML verbatim. Body comes from --body, --stdin, or --file. Supports --to/--cc/--bcc as comma-separated address lists, --from for send-as aliases, and --attach (repeatable) for file attachments. MIME assembly is RFC 5322/2045 compliant: MIME-Version: 1.0, CRLF line endings, Content-Transfer-Encoding: base64 with 76-column wrapping. Single- part for plain bodies, multipart/mixed for attachments. The gmail package owns MIME construction and defends the raw boundary against CR/LF header injection independently of command-layer validation. Attachment filenames are basenamed (filepath.Base) so local paths never leak into the message. Coverage: - 21 MIME builder + httptest API wiring tests in internal/gmail/drafts_test.go - 21 command-level tests in internal/cmd/mail/draft_test.go covering flag presence, validation negatives (missing flags, mutual exclusions, header injection, invalid addresses), and all success paths (markdown default, plain, html, file, stdin, attachments, JSON output) - 18 manual integration tests against live Gmail API — all green; see integration-tests.md Closes #112 * fix(mail/draft): address Codex + TDD review feedback - Add cobra.NoArgs so unexpected positional args fail loud instead of being silently dropped (Codex M1). - Normalise --from via parsed.Address so display-name input like "Work <work@me.com>" reduces to the bare address before MIME assembly, matching --to/--cc/--bcc handling (Codex M2). - Use cmd.Flags().Changed("file") for body-source detection so an explicit --file "" is counted alongside --body (Codex m1). - Extend test coverage: positional-arg rejection, --from display-name normalisation, header-injection guard across --cc/--bcc/--from, and detectMimeType fallback branches (no-ext, unknown-ext) (Codex + TDD). * fix(mail/draft): address pr-review-daemon findings Should-fix (2): - drafts.go: replace %q with mime.FormatMediaType for multipart boundary (%q is Go-escape, not RFC 2045 quoted-string — happened to work today only because the boundary contains no escape-relevant chars). - drafts.go: document the implicit header-vs-CreatePart ordering contract so a future refactor can't silently corrupt MIME by reordering calls. Suggestions adopted: - Swap iota so DraftBodyPlainText is the zero value of DraftBodyKind. Library callers constructing DraftMessage{} get the safer default. - Print Cc/Bcc on success output (silent omission could hide misconfig). - Flag help: "Send body as ..." → "Treat body as ..." to align with the never-sends philosophy. - Document display-name stripping in --to/--cc/--bcc help and README (Alice <alice@example.com> reduces to alice@example.com). - integration-tests.md T2: backslash-pipe inside bash $'...' doesn't produce a renderable markdown table — switch to a heredoc setup block. Tests strengthened: - Pin error message content on header-injection, plain+html, and attachment-missing tests so regressions in the validator wording can't slip past with a still-non-nil error. - Use prefix-match on attachment MimeType so the test stays portable across macOS/Linux MIME databases. - Add coverage for --to "" (distinct code path from missing --to) and --file pointing at a missing path. Acknowledged but not fixed (low-value): - Static MIME fallback for minimal containers — gro ships as a local binary, not a container. - 25 MB pre-flight size guard — Gmail's API error is acceptable for v1. - Multipart CRLF assertion — net/mail.ReadMessage parsing implicitly validates RFC 5322 compliance. - result.ID sanitization — API-controlled alphanumeric. - Two-way body-source pair coverage — three-way "exactly one" guard is already exercised; pairs are redundant. * fix(mail): address daemon run 2 findings — nil/empty guards, test coverage - buildMIME: reject empty To recipients (defense in depth at gmail boundary) - draft cmd: nil-guard CreateDraft response - draft cmd: --stdin uses cmd.Flags().Changed() to match --body/--file - detectMimeType: lowercase ext for cross-platform consistency - mock: return non-nil DraftResult stub by default - tests: assert BodyKind in MultipleRecipients; pin 'CR or LF' in injection table - tests: empty body, multipart HTML body, multipart base64 line length, empty To - integration-tests.md: replace 'gro me --id' (not a real command) with hardcoded email * fix(mail): revert --stdin counter to bool check to match dispatch Daemon caught a regression from the previous cycle: using cmd.Flags().Changed('stdin') for the counter while the switch dispatched on the bool diverges when a user passes --stdin=false (Changed=true, bool=false), letting validation pass and falling through to an empty body. Revert to bool check on both sides.
1 parent ac1a8b9 commit 4aebff7

12 files changed

Lines changed: 1889 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ gro is a **non-destructive** command-line interface for Google services written
1010
**Module:** `github.com/open-cli-collective/google-readonly`
1111

1212
### Features
13-
- Gmail: Search, read, thread viewing, labels, attachments, archive, star, mark read/unread, label, categorize
13+
- Gmail: Search, read, thread viewing, labels, attachments, archive, star, mark read/unread, label, categorize, draft (compose-only, never sent)
1414
- Google Calendar: List calendars, view events, today/week shortcuts
1515
- Google Contacts: List contacts, search, view details, list groups
1616
- Google Drive: List files, search, get details, download, tree view, shared drives

README.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ A non-destructive command-line interface for Google services. Search, read, and
55
## Features
66

77
- **Non-destructive by design** - Read access plus organizational operations; no send, delete, or trash
8-
- **Gmail support** - Search messages, read content, view threads, list labels, download attachments, archive, star, label, categorize, mark read/unread
8+
- **Gmail support** - Search messages, read content, view threads, list labels, download attachments, archive, star, label, categorize, mark read/unread, compose drafts (never sent automatically)
99
- **Calendar support** - List calendars, view events, today/week shortcuts, RSVP, color-coding
1010
- **Contacts support** - List contacts, search, view details, list groups, star, group management
1111
- **Drive support** - List files, search, view metadata, download files, folder tree, star/unstar
@@ -273,8 +273,25 @@ gro mail unlabel "Promotions" <id>
273273

274274
# Recategorize messages
275275
gro mail categorize promotions <id1> <id2>
276+
277+
# Compose a draft (markdown body by default, rendered to HTML)
278+
gro mail draft --to alice@example.com --subject "Hi" --body "**hello**"
279+
gro mail draft --to "a@x.com, b@x.com" --subject "Sync" --file notes.md
280+
echo "# Hello" | gro mail draft --to a@x.com --subject "Hi" --stdin
281+
282+
# Plain text or raw HTML body
283+
gro mail draft --to a@x.com --subject "Plain" --body "no formatting" --plain
284+
gro mail draft --to a@x.com --subject "Raw" --body "<h1>Hi</h1>" --html
285+
286+
# With attachments
287+
gro mail draft --to a@x.com --subject "See attached" --body "..." --attach report.pdf
288+
289+
# From a send-as alias
290+
gro mail draft --from work@me.com --to a@x.com --subject "Hi" --body "..."
276291
```
277292

293+
Drafts always land in your Gmail Drafts folder for human review. The CLI never calls `drafts.send` — sending requires explicit action in Gmail.
294+
278295
### Calendar Commands
279296

280297
All Calendar commands are under `gro calendar` (or `gro cal`):
@@ -694,6 +711,34 @@ Flags:
694711
-j, --json Output results as JSON
695712
```
696713

714+
### gro mail draft
715+
716+
Compose a Gmail draft and save it to the Drafts folder. The CLI never calls `drafts.send`; the draft sits in Gmail for human review and explicit send.
717+
718+
Body input is markdown by default and rendered to HTML. Use `--plain` for plain text or `--html` for raw HTML (no rendering). Body source is one of `--body`, `--stdin`, or `--file`.
719+
720+
```
721+
Usage: gro mail draft [flags]
722+
723+
Flags:
724+
--to string Recipient(s), comma-separated (required)
725+
--cc string Cc recipient(s), comma-separated
726+
--bcc string Bcc recipient(s), comma-separated
727+
--from string From address (Gmail send-as alias)
728+
-s, --subject string Subject line (required; empty string allowed)
729+
--body string Body content (markdown by default)
730+
-f, --file string Read body from file
731+
--stdin Read body from stdin
732+
--plain Send body as plain text (no markdown rendering)
733+
--html Send body as raw HTML (no markdown rendering)
734+
-a, --attach strings File path to attach (repeat for multiple)
735+
-j, --json Output result as JSON
736+
```
737+
738+
`--body`, `--stdin`, and `--file` are mutually exclusive (exactly one required). `--plain` and `--html` are mutually exclusive.
739+
740+
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.
741+
697742
### gro calendar list
698743

699744
List all calendars the user has access to.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/charmbracelet/lipgloss v1.1.0
99
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
1010
github.com/spf13/cobra v1.8.0
11+
github.com/yuin/goldmark v1.8.2
1112
golang.org/x/oauth2 v0.34.0
1213
google.golang.org/api v0.262.0
1314
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
106106
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
107107
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
108108
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
109+
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
110+
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
109111
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
110112
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
111113
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=

integration-tests.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,126 @@ gro mail thread "$THREAD_ID" --json | jq -r '.[].body'
455455

456456
---
457457

458+
## Draft Composition (`gro mail draft`)
459+
460+
These scenarios exercise the full path: flag parsing → MIME assembly → Gmail API `users.drafts.create` → draft visible in Gmail Drafts UI. Each test creates a draft addressed to the authenticated user; verification is by visual inspection in Gmail and by inspecting CLI stdout/exit code.
461+
462+
**Setup:**
463+
```bash
464+
make build
465+
ME="you@example.com" # set to the authenticated Google account
466+
echo "user under test: $ME"
467+
```
468+
469+
**Cleanup (after running):** Open Gmail → Drafts → select all `T*` drafts → Discard.
470+
471+
### Happy Paths
472+
473+
| # | Test Case | Command | Expected Result |
474+
|---|-----------|---------|-----------------|
475+
| T1 | Plain text body | `./bin/gro mail draft --to "$ME" --subject "T1 plain" --body "hello from gro" --plain` | Exit 0. Stdout: `Draft created: r-...`. Gmail Drafts shows plain-text body, no HTML conversion. |
476+
| T2 | Markdown rendered to HTML (default) | See setup block below for T2 (the markdown table in the body needs a real heredoc, not inline bash quoting). | Gmail Drafts shows real H1 heading, bold/italic text, bullet list, and 2-column table — rendered, not raw markdown. |
477+
| T3 | Raw HTML body | `./bin/gro mail draft --to "$ME" --subject "T3 html" --body '<h1 style="color:tomato">Tomato</h1><p>Inline <span style="background:yellow">highlight</span>.</p>' --html` | Gmail Drafts shows tomato-colored heading and yellow highlight. Confirms raw HTML is not double-processed. |
478+
| T4 | Body from stdin (agentic flow) | `echo $'## Status\n\n- [x] done\n- [ ] todo' \| ./bin/gro mail draft --to "$ME" --subject "T4 stdin" --stdin` | Gmail Drafts shows H2 + task list with checkboxes (TaskList extension). |
479+
| T5 | Body from file | (see T5 setup below) | Gmail Drafts renders headings + strikethrough + numbered list. |
480+
| T6 | Multiple recipients (To/Cc/Bcc) | `./bin/gro mail draft --to "$ME, ${ME%@*}+to2@${ME#*@}" --cc "${ME%@*}+cc@${ME#*@}" --bcc "${ME%@*}+bcc@${ME#*@}" --subject "T6 recipients" --body "x" --plain` | Gmail Drafts shows all four addresses populated. Plus-addressing routes to same inbox but Gmail records them distinctly. |
481+
| T7 | Single attachment | (see T7 setup below) | Stdout: `Attachments: 1`, `- gro-t7.txt`. Gmail Drafts has paperclip; attachment downloadable with original content. |
482+
| T8 | Multiple attachments, mixed types | (see T8 setup below) | Stdout: `Attachments: 2`. Gmail previews CSV inline; both files downloadable. |
483+
| T9 | Basename only — no path leak | (see T9 setup below) | Attachment shown as `quarterly.txt` (not `/tmp/gro-secret-dir/quarterly.txt`). |
484+
| T10 | Empty subject explicitly allowed | `./bin/gro mail draft --to "$ME" --subject "" --body "T10" --plain` | Exit 0. Gmail Drafts shows `(no subject)`. |
485+
| T11 | JSON output | `./bin/gro mail draft --to "$ME" --subject "T11 json" --body "x" --plain --json \| jq .` | Stdout is valid JSON with `id`, `messageId`, `threadId`. `jq` exits 0. |
486+
487+
### Setup blocks
488+
489+
**T2:**
490+
```bash
491+
cat > /tmp/gro-t2.md <<'EOF'
492+
# Heading
493+
494+
**bold** and _italic_
495+
496+
- one
497+
- two
498+
- three
499+
500+
| col1 | col2 |
501+
|------|------|
502+
| a | b |
503+
| c | d |
504+
EOF
505+
./bin/gro mail draft --to "$ME" --subject "T2 markdown" --file /tmp/gro-t2.md
506+
```
507+
508+
**T5:**
509+
```bash
510+
cat > /tmp/gro-t5.md <<'EOF'
511+
# Weekly report
512+
513+
## Highlights
514+
515+
- Shipped `gro mail draft` (#112)
516+
- ~~Cancelled~~ rescheduled the migration
517+
518+
## Next week
519+
520+
1. Roll out to prod
521+
2. Monitor
522+
3. Iterate
523+
EOF
524+
./bin/gro mail draft --to "$ME" --subject "T5 file body" --file /tmp/gro-t5.md
525+
```
526+
527+
**T7:**
528+
```bash
529+
echo "T7 attachment content" > /tmp/gro-t7.txt
530+
./bin/gro mail draft --to "$ME" --subject "T7 single attachment" --body "see attached" --plain --attach /tmp/gro-t7.txt
531+
```
532+
533+
**T8:**
534+
```bash
535+
printf 'a,b,c\n1,2,3\n4,5,6\n' > /tmp/gro-t8.csv
536+
printf '{"key":"value","nested":{"a":1}}' > /tmp/gro-t8.json
537+
./bin/gro mail draft --to "$ME" --subject "T8 multi attachments" --body "csv + json" --plain --attach /tmp/gro-t8.csv --attach /tmp/gro-t8.json
538+
```
539+
540+
**T9:**
541+
```bash
542+
mkdir -p /tmp/gro-secret-dir
543+
echo "report contents" > /tmp/gro-secret-dir/quarterly.txt
544+
./bin/gro mail draft --to "$ME" --subject "T9 path leak check" --body "attachment name should be 'quarterly.txt' only" --plain --attach /tmp/gro-secret-dir/quarterly.txt
545+
```
546+
547+
### Validation / Safety
548+
549+
| # | Test Case | Command | Expected Result |
550+
|---|-----------|---------|-----------------|
551+
| T12 | Missing `--to` rejected before API call | `./bin/gro mail draft --subject "T12" --body "x" --plain; echo "exit=$?"` | Non-zero exit. Error mentions `--to is required`. No draft in Gmail. |
552+
| T13 | Missing `--subject` rejected | `./bin/gro mail draft --to "$ME" --body "x" --plain; echo "exit=$?"` | Non-zero exit. Error mentions `--subject is required`. |
553+
| T14 | Two body sources rejected | `./bin/gro mail draft --to "$ME" --subject "T14" --body "x" --stdin; echo "exit=$?"` | Non-zero exit. Error mentions `exactly one of`. |
554+
| T15 | `--plain` + `--html` rejected | `./bin/gro mail draft --to "$ME" --subject "T15" --body "x" --plain --html; echo "exit=$?"` | Non-zero exit. Error mentions `mutually exclusive`. |
555+
| T16 | Invalid email in `--to` | `./bin/gro mail draft --to "not-an-email" --subject "T16" --body "x" --plain; echo "exit=$?"` | Non-zero exit. Error mentions `--to`. |
556+
| T17 | CR/LF header injection rejected | `./bin/gro mail draft --to "$ME" --subject $'T17\r\nBcc: evil@x.com' --body "x" --plain; echo "exit=$?"` | Non-zero exit. Error mentions CR or LF. No draft created. |
557+
| T18 | Missing attachment file rejected | `./bin/gro mail draft --to "$ME" --subject "T18" --body "x" --plain --attach /nope/does/not/exist; echo "exit=$?"` | Non-zero exit before API call. Error mentions the missing path. No draft in Gmail. |
558+
559+
### Optional: send-as alias (`--from`)
560+
561+
Skip if no Gmail send-as alias is configured for the test user.
562+
563+
| # | Test Case | Command | Expected Result |
564+
|---|-----------|---------|-----------------|
565+
| T19 | Valid alias | `./bin/gro mail draft --from <configured-alias> --to "$ME" --subject "T19 alias" --body "x" --plain` | Exit 0. Gmail Drafts shows the alias in From line. |
566+
| T20 | Unconfigured alias | `./bin/gro mail draft --from "not-my-alias@example.com" --to "$ME" --subject "T20 bad alias" --body "x" --plain; echo "exit=$?"` | Non-zero exit. API error surfaced verbatim from `creating draft: ...`. |
567+
568+
### Cleanup
569+
570+
```bash
571+
rm -f /tmp/gro-t2.md /tmp/gro-t5.md /tmp/gro-t7.txt /tmp/gro-t8.csv /tmp/gro-t8.json
572+
rm -rf /tmp/gro-secret-dir
573+
# Gmail UI: Drafts → select T* drafts → Discard
574+
```
575+
576+
---
577+
458578
## Adding New Tests
459579

460580
When adding new features or fixing bugs:

0 commit comments

Comments
 (0)