Skip to content

feat(mail): add non-destructive organizational operations for Gmail #98

@rianjs

Description

@rianjs

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 by ResolveIDs)
  • Stdin reading: bufio.Scanner, trim whitespace, skip empty lines
  • resolve_test.go covers 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 — add GrantedScopes []string to Config struct
  • internal/auth/auth.go — add CheckScopesMigration() that compares AllScopes vs config's GrantedScopes; add ScopeDescriptions map for human-friendly explanations
  • internal/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:

  1. Rename TestAllScopesAreReadOnlyTestAllScopesAreNonDestructive — 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)
    }
  2. Rename TestNoWriteAPIMethodsInProductionCodeTestNoDestructiveAPIMethodsInProductionCode — update forbidden patterns:

    • Keep forbidden: .Send(, .Trash(, .Untrash(, .BatchDelete(
    • Remove from forbidden: .BatchModify( (needed for bulk label operations)

D. Documentation updates

  • docs/golden-principles.md — Principle 5: "Read-only only" → "Non-destructive only"
  • CLAUDE.md — update project description and key constraints
  • README.md — add organizational operations section with bulk workflow examples
  • integration-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-run

New 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 resolutionGetLabelID needs reverse lookup; add labelsByName map populated in FetchLabels
  • Category labels — when recategorizing, must remove existing CATEGORY_* labels and add the new one

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions