-
Notifications
You must be signed in to change notification settings - Fork 0
feat(mail): add non-destructive organizational operations for Gmail #98
Description
Context
gro is currently read-only by design. This issue adds non-destructive organizational operations for Gmail — things humans do routinely without agonizing: labeling, archiving, starring, marking read, categorizing. This is the foundation PR that establishes shared infrastructure reused by Calendar, Drive, and Contacts in follow-up issues.
The name gro/google-readonly stays; docs get repositioned as "read-only + non-destructive organization."
Shared Infrastructure (built here, reused later)
A. internal/bulk/ package (new)
Shared ID resolution for 3 bulk input modes. All organizational commands across all domains will reuse this.
internal/bulk/resolve.go:
type Config struct {
Args []string // positional args
Stdin bool // --stdin flag
Query string // --query value
}
// ResolveIDs returns IDs from exactly one input source.
// queryFn is called when Query is set; caller provides domain-specific search.
// Returns error if zero or multiple sources provided.
func ResolveIDs(cfg Config, queryFn func(string) ([]string, error)) ([]string, error)--stdin,--query, and positional args are mutually exclusive (enforced byResolveIDs)- Stdin reading:
bufio.Scanner, trim whitespace, skip empty lines resolve_test.gocovers all three modes plus mutual-exclusion errors
internal/bulk/result.go:
type Result struct {
Action string `json:"action"`
IDs []string `json:"ids"`
Count int `json:"count"`
DryRun bool `json:"dryRun"`
Details any `json:"details,omitempty"`
}Text output: "Archived 5 message(s)." or "[dry-run] Would archive 5 message(s)."
B. Scope migration detection
Store granted scopes in config.json at auth time. On client construction, compare stored scopes against auth.AllScopes. If insufficient, return a clear error before making API calls:
This command requires additional permissions (gmail.modify).
Your current token only has read-only access.
Run 'gro init' to re-authenticate with the updated scopes.
New scope: Gmail Modify — allows labeling, archiving, starring, and
marking messages as read/unread. No send or delete access is granted.
Files:
internal/config/config.go— addGrantedScopes []stringtoConfigstructinternal/auth/auth.go— addCheckScopesMigration()that comparesAllScopesvs config'sGrantedScopes; addScopeDescriptionsmap for human-friendly explanationsinternal/cmd/initcmd/init.go— after successful token exchange, save granted scopes to config; show scope explanation when re-auth needed
C. Architecture test evolution
internal/architecture/architecture_test.go:
-
Rename
TestAllScopesAreReadOnly→TestAllScopesAreNonDestructive— switch from "contains readonly" check to an allowlist:var allowedScopes = map[string]bool{ gmail.GmailReadonlyScope: true, gmail.GmailModifyScope: true, // label, archive, star, read/unread (NOT send/delete) calendar.CalendarReadonlyScope: true, calendar.CalendarEventsScope: true, // RSVP, color (NOT delete) people.ContactsReadonlyScope: true, people.ContactsScope: true, // group membership drive.DriveReadonlyScope: true, drive.DriveMetadataScope: true, // star (metadata only, NOT content write) }
-
Rename
TestNoWriteAPIMethodsInProductionCode→TestNoDestructiveAPIMethodsInProductionCode— update forbidden patterns:- Keep forbidden:
.Send(,.Trash(,.Untrash(,.BatchDelete( - Remove from forbidden:
.BatchModify((needed for bulk label operations)
- Keep forbidden:
D. Documentation updates
docs/golden-principles.md— Principle 5: "Read-only only" → "Non-destructive only"CLAUDE.md— update project description and key constraintsREADME.md— add organizational operations section with bulk workflow examplesintegration-tests.md— add Gmail organizational test scenarios
Scope Change
internal/auth/auth.go: Replace gmail.GmailReadonlyScope with gmail.GmailModifyScope (superset — includes all read access plus modify).
New API Client Methods
internal/gmail/client.go:
// GetLabelID resolves a label display name to its ID. Calls FetchLabels if needed.
func (c *Client) GetLabelID(ctx context.Context, name string) (string, error)
// ModifyMessages modifies labels on one or more messages.
// For single messages uses Messages.Modify; for multiple uses Messages.BatchModify.
func (c *Client) ModifyMessages(ctx context.Context, ids []string, addLabels, removeLabels []string) error
// SearchMessageIDs returns only message IDs (no metadata fetch). Efficient for piping/bulk.
func (c *Client) SearchMessageIDs(ctx context.Context, query string, maxResults int64) ([]string, error)ModifyMessages uses Messages.Modify() for single IDs and Messages.BatchModify() for multiple. Gmail limits batch to 1000 IDs — implement chunking.
GetLabelID adds a reverse lookup to the existing label cache. Add a labelsByName map[string]string field to Client, populated alongside labels in FetchLabels.
New Interface Methods
internal/cmd/mail/output.go — add to MailClient:
GetLabelID(ctx context.Context, name string) (string, error)
ModifyMessages(ctx context.Context, ids []string, addLabels, removeLabels []string) error
SearchMessageIDs(ctx context.Context, query string, maxResults int64) ([]string, error)New Commands
All commands support: --json/-j, --dry-run/-n, --stdin, --query. All accept variadic message IDs as positional args.
| Command | Action (label modification) |
|---|---|
gro mail archive <ids...> |
Remove INBOX label |
gro mail star <ids...> |
Add STARRED label |
gro mail unstar <ids...> |
Remove STARRED label |
gro mail mark-read <ids...> |
Remove UNREAD label |
gro mail mark-unread <ids...> |
Add UNREAD label |
gro mail label <name> <ids...> |
Add user label (resolved by name) |
gro mail unlabel <name> <ids...> |
Remove user label |
gro mail categorize <category> <ids...> |
Add target category label, remove other CATEGORY_* labels |
Categories: personal, social, promotions, updates, forums → mapped to CATEGORY_PERSONAL, etc.
--ids Flag on Search
internal/cmd/mail/search.go — add --ids flag. Outputs bare message IDs (one per line). Mutually exclusive with --json. Uses SearchMessageIDs (efficient — no metadata fetch).
Bulk workflow examples:
# Direct IDs
gro mail archive msg123 msg456
# Piped from search (preview first, then act)
gro mail search "from:newsletter older_than:7d" --ids | gro mail archive --stdin
# Inline query (one-liner)
gro mail archive --query "from:noreply older_than:30d"
# Dry run to preview
gro mail label "Work" --query "from:boss is:unread" --dry-runNew Files
| File | Purpose |
|---|---|
internal/bulk/resolve.go |
Shared ID resolution |
internal/bulk/resolve_test.go |
Tests |
internal/bulk/result.go |
Shared result type + formatting |
internal/bulk/result_test.go |
Tests |
internal/cmd/mail/archive.go |
Archive command |
internal/cmd/mail/star.go |
Star/unstar commands |
internal/cmd/mail/markread.go |
Mark-read/mark-unread commands |
internal/cmd/mail/label.go |
Label/unlabel commands |
internal/cmd/mail/categorize.go |
Categorize command |
+ *_test.go for each |
Following existing mock pattern |
Files to Modify
| File | Change |
|---|---|
internal/auth/auth.go |
Replace readonly with modify scope; add CheckScopesMigration, ScopeDescriptions |
internal/auth/auth_test.go |
Update scope count/assertions |
internal/config/config.go |
Add GrantedScopes field |
internal/architecture/architecture_test.go |
Rename tests, switch to allowlist, remove .BatchModify( from forbidden |
internal/gmail/client.go |
Add labelsByName field, GetLabelID, ModifyMessages, update FetchLabels |
internal/gmail/messages.go |
Add SearchMessageIDs |
internal/cmd/mail/output.go |
Extend MailClient interface |
internal/cmd/mail/mock_test.go |
Add mock function fields |
internal/cmd/mail/mail.go |
Register new subcommands, update Long description |
internal/cmd/mail/search.go |
Add --ids flag |
internal/cmd/mail/search_test.go |
Test --ids behavior |
internal/cmd/initcmd/init.go |
Save granted scopes; detect scope migration with explanation |
docs/golden-principles.md |
Principle 5 update |
docs/architecture.md |
Add internal/bulk/ to dependency graph |
CLAUDE.md |
Update positioning |
README.md |
Add organizational operations + bulk workflow examples |
integration-tests.md |
Add Gmail organizational test section |
Integration Tests
Archive
| Test Case | Command | Expected |
|---|---|---|
| Archive by ID | MSG=$(gro mail search "is:inbox" --max 1 --ids); gro mail archive "$MSG" |
"Archived 1 message(s)." |
| Archive dry run | gro mail search "is:inbox" --max 3 --ids | gro mail archive --stdin --dry-run |
"[dry-run] Would archive 3 message(s)." |
| Archive with --query | gro mail archive --query "is:inbox from:noreply" --dry-run |
Shows count of matching messages |
| Archive JSON output | gro mail archive --query "subject:test" --dry-run --json |
Valid JSON with action, ids, count, dryRun |
Star / Unstar
| Test Case | Command | Expected |
|---|---|---|
| Star by pipe | gro mail search "is:inbox" --max 2 --ids | gro mail star --stdin |
"Starred 2 message(s)." |
| Unstar | gro mail search "is:starred" --max 1 --ids | gro mail unstar --stdin |
"Unstarred 1 message(s)." |
Mark Read / Unread
| Test Case | Command | Expected |
|---|---|---|
| Mark read | gro mail search "is:unread" --max 2 --ids | gro mail mark-read --stdin |
"Marked 2 message(s) as read." |
| Mark unread dry run | gro mail mark-unread --query "subject:test" --dry-run |
"[dry-run] Would mark N message(s) as unread." |
Label / Unlabel
| Test Case | Command | Expected |
|---|---|---|
| Add label | gro mail search "is:inbox" --max 1 --ids | gro mail label "Work" --stdin |
"Added label 'Work' to 1 message(s)." |
| Remove label dry run | gro mail unlabel "Work" --query "label:Work" --dry-run |
"[dry-run] Would remove label 'Work' from N message(s)." |
Categorize
| Test Case | Command | Expected |
|---|---|---|
| Recategorize dry run | gro mail categorize promotions --query "category:updates from:noreply" --dry-run |
"[dry-run] Would recategorize N message(s) to promotions." |
Piping Workflow
| Test Case | Command | Expected |
|---|---|---|
| Search -> Archive pipe | gro mail search "is:inbox older_than:30d" --ids | gro mail archive --stdin |
Archives matching messages |
| Search IDs output | gro mail search "is:inbox" --max 3 --ids |
3 bare message IDs, one per line |
| IDs + JSON exclusive | gro mail search "is:inbox" --ids --json |
Error: --ids and --json are mutually exclusive |
Scope Migration
| Test Case | Steps | Expected |
|---|---|---|
| Old token detection | Use old readonly token, run gro mail archive --dry-run --query "is:inbox" |
Error with instruction to run gro init |
| Re-auth flow | Run gro init |
Shows new scope explanation, guides re-auth |
Key Risks
- Gmail BatchModify 1000-ID limit — must implement chunking in
ModifyMessages - Label name resolution —
GetLabelIDneeds reverse lookup; addlabelsByNamemap populated inFetchLabels - Category labels — when recategorizing, must remove existing
CATEGORY_*labels and add the new one