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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
This repository builds two Google command-line tools from one Go module:

- `gro` reads and organizes Gmail, Calendar, Contacts, and Drive data without exposing destructive operations.
- `grw` is the read-write Google tool. Its mail, calendar, contacts, and Drive command trees extend `gro` with delete and restore, folder management, filters, uploads, and resource creation, updates, moves, and deletion.
- `grw` is the read-write Google tool. Its mail, calendar, contacts, and Drive command trees extend `gro` with delete and restore, folder management, filters, the vacation responder, uploads, and resource creation, updates, moves, and deletion.

Both tools use the same configuration and keyring machinery, but register separate identities. Their configuration, tokens, environment variables, and keyring entries do not collide. `gro` requests only the scopes needed for its non-destructive surface; `grw` adds Gmail settings, permanent-delete access, Calendar event writes, Contacts writes, and full Drive access.

Expand Down
2 changes: 1 addition & 1 deletion WORKSPACE_ADMINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ https://www.googleapis.com/auth/drive.readonly
https://www.googleapis.com/auth/drive.metadata
```

Gmail settings access supports filters. The broad mail scope is required for permanent deletion; the command defaults to recoverable Trash and gates permanent deletion behind `--permanent --yes`. Calendar scopes support reading and mutating events, the Contacts scope supports reading and mutating contacts and groups, the Drive scopes support reading, uploading, organizing, trashing, restoring, and permanently deleting files, and the profile scope supports `grw me`.
Gmail settings access supports filters and the vacation responder, which sends automatic replies to incoming senders. The broad mail scope is required for permanent deletion; the command defaults to recoverable Trash and gates permanent deletion behind `--permanent --yes`. Calendar scopes support reading and mutating events, the Contacts scope supports reading and mutating contacts and groups, the Drive scopes support reading, uploading, organizing, trashing, restoring, and permanently deleting files, and the profile scope supports `grw me`.

## Distribute and verify

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/open-cli-collective/cli-common v0.5.0
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
github.com/yuin/goldmark v1.8.4
golang.org/x/net v0.57.0
golang.org/x/oauth2 v0.36.0
Expand Down Expand Up @@ -68,7 +69,6 @@ require (
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect
Expand Down
5 changes: 3 additions & 2 deletions internal/app/grw/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import (
// Scopes is the OAuth scope set grw requests:
// - gmail.modify: read + non-destructive organization (label, archive, move,
// trash-via-label) and label lifecycle (create/rename/delete folders).
// - gmail.settings.basic: create, list, and delete Gmail filters.
// - gmail.settings.basic: create, list, and delete Gmail filters; read and change the
// vacation responder.
// - https://mail.google.com/: permanent deletion (batchDelete), gated behind
// `--permanent` and a typed confirmation. This is the broadest Gmail scope;
// it is present only because Google offers no narrower delete scope.
Expand All @@ -43,7 +44,7 @@ var Scopes = []string{
// description, shown by the init wizard and the scope-drift re-auth prompt.
var ScopeDescriptions = map[string]string{
gmail.GmailModifyScope: "Gmail Modify — read messages, plus label, archive, move, trash, and create/rename/delete labels (folders).",
gmail.GmailSettingsBasicScope: "Gmail Settings (basic) — create, list, and delete filters.",
gmail.GmailSettingsBasicScope: "Gmail Settings (basic) — create, list, and delete filters; read and change the vacation responder.",
gmail.MailGoogleComScope: "Gmail Full Access — required for permanent deletion (gated behind --permanent and a typed confirmation).",
calendar.CalendarReadonlyScope: "Calendar Read-Only — read calendars and events.",
calendar.CalendarEventsScope: "Calendar Events — create, update, and delete events.",
Expand Down
2 changes: 1 addition & 1 deletion internal/app/grw/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ var rootCmd = &cobra.Command{
It reads and organizes mail like its read-only sibling gro, and adds the
operations gro deliberately cannot perform: deleting messages (Trash by
default, permanent behind a guard), managing labels as folders/subfolders, and
creating Gmail filters. It also reads profile information and creates, updates,
creating Gmail filters, and changing the vacation responder. It also reads profile information and creates, updates,
and deletes Google Calendar events, contacts, contact groups, and Drive files.

grw stores its credentials separately from gro (keyring namespace
Expand Down
22 changes: 22 additions & 0 deletions internal/rw/gmail/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,28 @@ func (c *Client) DeleteFilter(ctx context.Context, id string) error {
return nil
}

// ---------------------------------------------------------------------------
// Vacation responder
// ---------------------------------------------------------------------------

// GetVacation returns the account's vacation responder settings.
func (c *Client) GetVacation(ctx context.Context) (*gmailv1.VacationSettings, error) {
settings, err := c.service.Users.Settings.GetVacation(c.userID).Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("getting vacation responder: %w", err)
}
return settings, nil
}

// UpdateVacation replaces the account's vacation responder settings.
Comment thread
piekstra marked this conversation as resolved.
Resolved
func (c *Client) UpdateVacation(ctx context.Context, settings *gmailv1.VacationSettings) (*gmailv1.VacationSettings, error) {
Comment thread
piekstra marked this conversation as resolved.
Resolved
updated, err := c.service.Users.Settings.UpdateVacation(c.userID, settings).Context(ctx).Do()
if err != nil {
return nil, fmt.Errorf("updating vacation responder: %w", err)
}
return updated, nil
}

// chunkIDs splits ids into batches of at most size, so batch operations stay
// within the Gmail API's per-request limits.
func chunkIDs(ids []string, size int) [][]string {
Expand Down
65 changes: 65 additions & 0 deletions internal/rw/gmail/vacation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package gmail

import (
"context"
"fmt"
"io"
"net/http"
"strings"
"testing"

gmailv1 "google.golang.org/api/gmail/v1"
)

const vacationPath = "/gmail/v1/users/me/settings/vacation"

func TestGetVacation(t *testing.T) {
t.Parallel()
client := testDraftClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != vacationPath {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
}
_, _ = fmt.Fprint(w, `{"enableAutoReply":true,"responseSubject":"Away","endTime":"42"}`)
})
got, err := client.GetVacation(context.Background())
if err != nil {
t.Fatal(err)
}
if !got.EnableAutoReply || got.ResponseSubject != "Away" || got.EndTime != 42 {
t.Errorf("settings = %+v", got)
}
}

func TestUpdateVacation_SendsForcedFalse(t *testing.T) {
t.Parallel()
client := testDraftClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != vacationPath {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
}
if !strings.Contains(string(body), `"enableAutoReply":false`) {
t.Errorf("body = %s", body)
}
_, _ = fmt.Fprint(w, `{"enableAutoReply":false}`)
})
settings := &gmailv1.VacationSettings{ForceSendFields: []string{"EnableAutoReply"}}
if _, err := client.UpdateVacation(context.Background(), settings); err != nil {
t.Fatal(err)
}
}

func TestVacationAPIErrorsWrap(t *testing.T) {
t.Parallel()
client := testDraftClient(t, func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "no", http.StatusInternalServerError)
})
if _, err := client.GetVacation(context.Background()); err == nil || !strings.Contains(err.Error(), "getting vacation responder") {
t.Errorf("GetVacation error = %v", err)
}
if _, err := client.UpdateVacation(context.Background(), &gmailv1.VacationSettings{}); err == nil || !strings.Contains(err.Error(), "updating vacation responder") {
t.Errorf("UpdateVacation error = %v", err)
}
}
3 changes: 2 additions & 1 deletion internal/rwcmd/mail/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
)

// NewCommand returns grw's `mail` command: the shared non-destructive leaves
// from mailcmd plus grw's read-write leaves (send, delete, folder, filter). The
// from mailcmd plus grw's read-write leaves (send, delete, folder, filter, vacation). The
// shared leaves resolve their client through mailcmd's own ClientFactory, which
// uses the registered (grw) identity's credentials — so `grw mail search` and
// `grw mail archive` work against the same account as the write commands.
Expand All @@ -18,5 +18,6 @@ func NewCommand() *cobra.Command {
cmd.AddCommand(newRestoreCommand())
cmd.AddCommand(newFolderCommand())
cmd.AddCommand(newFilterCommand())
cmd.AddCommand(newVacationCommand())
Comment thread
piekstra marked this conversation as resolved.
Resolved
return cmd
}
8 changes: 7 additions & 1 deletion internal/rwcmd/mail/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func TestNewCommandComposition(t *testing.T) {
}

// grw's read-write additions
for _, want := range []string{"send", "delete", "restore", "folder", "filter"} {
for _, want := range []string{"send", "delete", "restore", "folder", "filter", "vacation"} {
if !names[want] {
t.Errorf("grw mail is missing the %q command", want)
}
Expand Down Expand Up @@ -54,4 +54,10 @@ func TestFolderAndFilterSubcommands(t *testing.T) {
t.Errorf("grw mail filter missing %q", want)
}
}
vacation := find("vacation")
for _, want := range []string{"get", "update", "enable", "disable"} {
if !vacation[want] {
t.Errorf("grw mail vacation missing %q", want)
}
}
}
16 changes: 16 additions & 0 deletions internal/rwcmd/mail/mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ type mockWriteClient struct {
ListFiltersFunc func(ctx context.Context) ([]*gmailv1.Filter, error)
CreateFilterFunc func(ctx context.Context, filter *gmailv1.Filter) (*gmailv1.Filter, error)
DeleteFilterFunc func(ctx context.Context, id string) error
GetVacationFunc func(ctx context.Context) (*gmailv1.VacationSettings, error)
UpdateVacationFunc func(ctx context.Context, settings *gmailv1.VacationSettings) (*gmailv1.VacationSettings, error)
}

var _ WriteClient = (*mockWriteClient)(nil)
Expand Down Expand Up @@ -136,3 +138,17 @@ func (m *mockWriteClient) DeleteFilter(ctx context.Context, id string) error {
}
return nil
}

func (m *mockWriteClient) GetVacation(ctx context.Context) (*gmailv1.VacationSettings, error) {
if m.GetVacationFunc != nil {
return m.GetVacationFunc(ctx)
}
return &gmailv1.VacationSettings{}, nil
}

func (m *mockWriteClient) UpdateVacation(ctx context.Context, settings *gmailv1.VacationSettings) (*gmailv1.VacationSettings, error) {
if m.UpdateVacationFunc != nil {
return m.UpdateVacationFunc(ctx, settings)
}
return settings, nil
}
3 changes: 3 additions & 0 deletions internal/rwcmd/mail/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ type WriteClient interface {
ListFilters(ctx context.Context) ([]*gmailv1.Filter, error)
CreateFilter(ctx context.Context, filter *gmailv1.Filter) (*gmailv1.Filter, error)
DeleteFilter(ctx context.Context, id string) error

GetVacation(ctx context.Context) (*gmailv1.VacationSettings, error)
UpdateVacation(ctx context.Context, settings *gmailv1.VacationSettings) (*gmailv1.VacationSettings, error)
}

// ClientFactory constructs grw's write client. Override in tests to inject a
Expand Down
Loading
Loading