diff --git a/README.md b/README.md index 5125911..3b39ed5 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ 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, and contacts command trees extend `gro` with delete and restore, folder management, filters, and calendar/contact creation, updates, 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, 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, and Contacts writes. +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. ## Safety model @@ -60,9 +60,11 @@ gro mail list gro calendar today grw init +grw mail send --dry-run grw mail delete --query "older_than:1y" --dry-run grw calendar create --summary "Planning" --start 2026-10-01 --dry-run grw contacts create --given-name Test --email t@example.com --dry-run +grw drive trash --query "name contains 'old'" --dry-run ``` One desktop OAuth client can be used by both tools, but each tool asks for consent and stores its token under its own identity. Google Workspace administrators should start with [`WORKSPACE_ADMINS.md`](WORKSPACE_ADMINS.md). diff --git a/WORKSPACE_ADMINS.md b/WORKSPACE_ADMINS.md index 2dea480..80eaf09 100644 --- a/WORKSPACE_ADMINS.md +++ b/WORKSPACE_ADMINS.md @@ -48,9 +48,12 @@ https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events https://www.googleapis.com/auth/contacts https://www.googleapis.com/auth/userinfo.profile +https://www.googleapis.com/auth/drive +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, and the profile scope supports `grw me`. +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`. ## Distribute and verify diff --git a/docs/architecture.md b/docs/architecture.md index 9433a8f..308fa41 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,10 @@ cmd/grw -> internal/cmd/contacts -> internal/rw/contacts -> internal/api/contacts + -> internal/rwcmd/drive + -> internal/cmd/drive + -> internal/rw/drive + -> internal/api/drive -> internal/{auth,config,keychain,...} ``` @@ -67,9 +71,9 @@ The write `NewCommand()` starts with `internal/cmd/.NewCommand()` and at `cmd/gro/main.go` registers `internal/app/gro.Identity()` before running the `gro` root. That root composes setup/configuration commands and all five read domains. -`cmd/grw/main.go` registers `internal/app/grw.Identity()` before running the `grw` root. That root composes setup/configuration and profile commands with the extended Gmail, Calendar, and Contacts commands. +`cmd/grw/main.go` registers `internal/app/grw.Identity()` before running the `grw` root. That root composes setup/configuration and profile commands with the extended Gmail, Calendar, Contacts, and Drive commands. -Both identities drive the same config, cache, credential-reference, and keyring code. They use different directory names, default credential references, environment-variable prefixes, and keyring namespaces. Their OAuth client JSON may be reused across identities, but tokens and consent remain separate. `gro` requests its non-destructive multi-service scopes; `grw` requests Gmail write scopes, Calendar and Contacts read/write scopes, and basic profile access. +Both identities drive the same config, cache, credential-reference, and keyring code. They use different directory names, default credential references, environment-variable prefixes, and keyring namespaces. Their OAuth client JSON may be reused across identities, but tokens and consent remain separate. `gro` requests its non-destructive multi-service scopes; `grw` requests Gmail write scopes, Calendar, Contacts, and Drive read/write scopes, and basic profile access. ## Structural enforcement diff --git a/docs/golden-principles.md b/docs/golden-principles.md index 154bcb6..ff5d1e5 100644 --- a/docs/golden-principles.md +++ b/docs/golden-principles.md @@ -6,6 +6,8 @@ These rules keep the two binaries mechanically distinct. Structural tests named `gro` must have no dependency path into `internal/rw` or `internal/rwcmd`, its scopes must stay on the non-destructive allowlist, and its production graph must contain no forbidden destructive Google API calls. +The only send path is `grw mail send` in `internal/rw/gmail`; it is unreachable from `gro` by the link-graph test. + Enforced by `TestGroNeverLinksWriteCode`, `TestAllScopesAreNonDestructive`, and `TestNoDestructiveAPIMethodsInProductionCode`. ## 2. `grw` covers the read scope for every service it touches @@ -24,6 +26,8 @@ Enforced by `TestWriteClientsEmbedReadClients`, `TestWriteCommandClientsEmbedRea Every leaf added by a write command declares boolean `--dry-run` with shorthand `-n`. The read verbs `list`, `get`, `show`, and `search` are exempt. A command exposing `--permanent` must also expose `--yes`; recoverable behavior remains the default. +Confirmation is reserved for unrecoverable data loss. Actions that are irreversible but not destructive, such as `grw mail send`, take an explicit ID, print what will happen, and proceed; `--dry-run` is the review step, and a prompt would only break the piping and agent flows grw exists for. + Enforced by `TestWriteLeavesHaveDryRun` and `TestPermanentWriteLeavesRequireYes`. `TestDelete_DryRunDoesNotMutate` and `TestDelete_PermanentWithYes` verify behavior. ## 5. Resource leaves are text-only diff --git a/internal/api/drive/client.go b/internal/api/drive/client.go index 9b9e9c9..718f512 100644 --- a/internal/api/drive/client.go +++ b/internal/api/drive/client.go @@ -17,6 +17,9 @@ type Client struct { service *drive.Service } +// Service returns the underlying Drive API service. +func (c *Client) Service() *drive.Service { return c.service } + // NewClient creates a new Drive client with OAuth2 authentication func NewClient(ctx context.Context) (*Client, error) { client, err := auth.GetHTTPClient(ctx) diff --git a/internal/api/gmail/drafts.go b/internal/api/gmail/drafts.go index 1ce1c28..2647985 100644 --- a/internal/api/gmail/drafts.go +++ b/internal/api/gmail/drafts.go @@ -67,9 +67,29 @@ type DraftResult struct { ThreadID string `json:"threadId,omitempty"` } +// DraftSummary is the metadata shown before sending a draft. +type DraftSummary struct { + ID string + MessageID string + ThreadID string + To string + Cc string + Bcc string + Subject string + From string + AttachmentCount int +} + +// SentResult identifies the message created by sending a draft. +type SentResult struct { + ID string + ThreadID string + LabelIDs []string +} + // CreateDraft assembles a MIME message and POSTs to users.drafts.create. -// The CLI never calls drafts.send — drafts sit in the user's Drafts folder -// for explicit human review. +// This shared read layer never calls drafts.send; sending stays isolated in +// internal/rw/gmail for grw. func (c *Client) CreateDraft(ctx context.Context, msg DraftMessage) (*DraftResult, error) { raw, err := buildMIME(msg) if err != nil { diff --git a/internal/app/grw/identity.go b/internal/app/grw/identity.go index 2172ba6..623f606 100644 --- a/internal/app/grw/identity.go +++ b/internal/app/grw/identity.go @@ -12,6 +12,7 @@ package grw import ( "google.golang.org/api/calendar/v3" + "google.golang.org/api/drive/v3" "google.golang.org/api/gmail/v1" "google.golang.org/api/people/v1" @@ -31,6 +32,9 @@ var Scopes = []string{ gmail.MailGoogleComScope, calendar.CalendarReadonlyScope, calendar.CalendarEventsScope, + drive.DriveScope, + drive.DriveReadonlyScope, + drive.DriveMetadataScope, people.ContactsScope, people.UserinfoProfileScope, } @@ -43,6 +47,9 @@ var ScopeDescriptions = map[string]string{ 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.", + drive.DriveScope: "Drive Full Access — upload files, create folders, rename, move, trash, restore, and permanently delete files.", + drive.DriveReadonlyScope: "Drive Read-Only — read files and metadata.", + drive.DriveMetadataScope: "Drive Metadata — read and update file metadata (star/unstar).", people.ContactsScope: "Contacts — read, create, update, and delete contacts and contact groups.", people.UserinfoProfileScope: "Basic Profile — read the authenticated user's name and email for `me`.", } diff --git a/internal/app/grw/identity_test.go b/internal/app/grw/identity_test.go index 2ad0586..9dbc588 100644 --- a/internal/app/grw/identity_test.go +++ b/internal/app/grw/identity_test.go @@ -5,6 +5,7 @@ import ( "testing" "google.golang.org/api/calendar/v3" + "google.golang.org/api/drive/v3" "google.golang.org/api/gmail/v1" "google.golang.org/api/people/v1" ) @@ -19,6 +20,9 @@ func TestScopes(t *testing.T) { gmail.MailGoogleComScope, calendar.CalendarReadonlyScope, calendar.CalendarEventsScope, + drive.DriveScope, + drive.DriveReadonlyScope, + drive.DriveMetadataScope, people.ContactsScope, people.UserinfoProfileScope, } @@ -50,6 +54,9 @@ func TestReadWriteScopesPresent(t *testing.T) { if !strings.Contains(joined, "mail.google.com") { t.Error("grw must request mail.google.com (for permanent delete)") } + if !strings.Contains(joined, "/auth/drive ") { + t.Error("grw must request full Drive access") + } } func TestIdentity(t *testing.T) { diff --git a/internal/app/grw/root.go b/internal/app/grw/root.go index 6fb38c7..eb8b3f6 100644 --- a/internal/app/grw/root.go +++ b/internal/app/grw/root.go @@ -17,6 +17,7 @@ import ( "github.com/open-cli-collective/google-cli/internal/rootutil" rwcalendar "github.com/open-cli-collective/google-cli/internal/rwcmd/calendar" rwcontacts "github.com/open-cli-collective/google-cli/internal/rwcmd/contacts" + rwdrive "github.com/open-cli-collective/google-cli/internal/rwcmd/drive" "github.com/open-cli-collective/google-cli/internal/rwcmd/mail" "github.com/open-cli-collective/google-cli/internal/version" ) @@ -35,7 +36,7 @@ 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, -and deletes Google Calendar events, contacts, and contact groups. +and deletes Google Calendar events, contacts, contact groups, and Drive files. grw stores its credentials separately from gro (keyring namespace "google-readwrite"), so the two can be used in isolation — e.g. give an agent @@ -79,5 +80,6 @@ func init() { rootCmd.AddCommand(mail.NewCommand()) rootCmd.AddCommand(rwcalendar.NewCommand()) rootCmd.AddCommand(rwcontacts.NewCommand()) + rootCmd.AddCommand(rwdrive.NewCommand()) rootCmd.AddCommand(refreshcmd.NewCommand()) } diff --git a/internal/app/grw/root_test.go b/internal/app/grw/root_test.go index 5ff717e..efc1589 100644 --- a/internal/app/grw/root_test.go +++ b/internal/app/grw/root_test.go @@ -3,7 +3,7 @@ package grw import "testing" func TestRootCommands(t *testing.T) { - want := map[string]bool{"contacts": false, "me": false, "profiles": false} + want := map[string]bool{"contacts": false, "drive": false, "me": false, "profiles": false} for _, command := range rootCmd.Commands() { if _, ok := want[command.Name()]; ok { want[command.Name()] = true diff --git a/internal/architecture/architecture_test.go b/internal/architecture/architecture_test.go index 291d670..13671db 100644 --- a/internal/architecture/architecture_test.go +++ b/internal/architecture/architecture_test.go @@ -25,6 +25,7 @@ import ( mecmd "github.com/open-cli-collective/google-cli/internal/cmd/me" rwcalendarcmd "github.com/open-cli-collective/google-cli/internal/rwcmd/calendar" rwcontactscmd "github.com/open-cli-collective/google-cli/internal/rwcmd/contacts" + rwdrivecmd "github.com/open-cli-collective/google-cli/internal/rwcmd/drive" rwmailcmd "github.com/open-cli-collective/google-cli/internal/rwcmd/mail" ) @@ -50,6 +51,7 @@ type commandPair struct { var writeCommandPairs = map[string]commandPair{ "calendar": {read: calcmd.NewCommand, write: rwcalendarcmd.NewCommand}, "contacts": {read: contactscmd.NewCommand, write: rwcontactscmd.NewCommand}, + "drive": {read: drivecmd.NewCommand, write: rwdrivecmd.NewCommand}, "mail": {read: mailcmd.NewCommand, write: rwmailcmd.NewCommand}, } @@ -423,6 +425,7 @@ var knownGrwScopes = map[string]bool{ "https://www.googleapis.com/auth/userinfo.profile": true, "https://www.googleapis.com/auth/drive.readonly": true, "https://www.googleapis.com/auth/drive.metadata": true, + "https://www.googleapis.com/auth/drive": true, "https://www.googleapis.com/auth/gmail.settings.basic": true, "https://mail.google.com/": true, } @@ -530,8 +533,10 @@ func TestGrwLinksWriteCode(t *testing.T) { modulePath + "/internal/rw/calendar": false, modulePath + "/internal/rw/contacts": false, modulePath + "/internal/rw/gmail": false, + modulePath + "/internal/rw/drive": false, modulePath + "/internal/rwcmd/calendar": false, modulePath + "/internal/rwcmd/contacts": false, + modulePath + "/internal/rwcmd/drive": false, modulePath + "/internal/rwcmd/mail": false, } for _, pkg := range goListDeps(t, "./cmd/grw") { diff --git a/internal/rw/drive/client.go b/internal/rw/drive/client.go new file mode 100644 index 0000000..836eeec --- /dev/null +++ b/internal/rw/drive/client.go @@ -0,0 +1,155 @@ +// Package drive extends the read Drive client with file mutations. +package drive + +import ( + "context" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + + drivev3 "google.golang.org/api/drive/v3" + + driveapi "github.com/open-cli-collective/google-cli/internal/api/drive" +) + +// Client is grw's Drive client. +type Client struct { + *driveapi.Client + service *drivev3.Service +} + +// NewClient builds a write-capable Drive client. +func NewClient(ctx context.Context) (*Client, error) { + base, err := driveapi.NewClient(ctx) + if err != nil { + return nil, err + } + return &Client{Client: base, service: base.Service()}, nil +} + +// FileMIMEType detects a local file's MIME type. +func FileMIMEType(path string) (string, error) { + if typ := mime.TypeByExtension(strings.ToLower(filepath.Ext(path))); typ != "" { + return typ, nil + } + file, err := os.Open(path) //nolint:gosec // path is supplied by the user + if err != nil { + return "", fmt.Errorf("opening file: %w", err) + } + defer file.Close() + buf := make([]byte, 512) + n, err := file.Read(buf) + if err != nil && !errors.Is(err, io.EOF) { + return "", fmt.Errorf("reading file: %w", err) + } + return http.DetectContentType(buf[:n]), nil +} + +// UploadFile uploads a local file. +func (c *Client) UploadFile(ctx context.Context, localPath, parentID, name string) (*driveapi.File, error) { + file, err := os.Open(localPath) //nolint:gosec // path is supplied by the user + if err != nil { + return nil, fmt.Errorf("opening file: %w", err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("reading file info: %w", err) + } + if info.IsDir() { + return nil, fmt.Errorf("uploading file: %s is a directory", localPath) + } + if name == "" { + name = filepath.Base(localPath) + } + typ, err := FileMIMEType(localPath) + if err != nil { + return nil, err + } + metadata := &drivev3.File{Name: name, MimeType: typ} + if parentID != "" { + metadata.Parents = []string{parentID} + } + created, err := c.service.Files.Create(metadata).Media(file).SupportsAllDrives(true).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("uploading file: %w", err) + } + return driveapi.ParseFile(created), nil +} + +// CreateFolder creates a Drive folder. +func (c *Client) CreateFolder(ctx context.Context, name, parentID string) (*driveapi.File, error) { + metadata := &drivev3.File{Name: name, MimeType: driveapi.MimeTypeFolder} + if parentID != "" { + metadata.Parents = []string{parentID} + } + created, err := c.service.Files.Create(metadata).SupportsAllDrives(true).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("creating folder: %w", err) + } + return driveapi.ParseFile(created), nil +} + +// RenameFile renames a Drive file or folder. +func (c *Client) RenameFile(ctx context.Context, fileID, name string) (*driveapi.File, error) { + updated, err := c.service.Files.Update(fileID, &drivev3.File{Name: name}).SupportsAllDrives(true).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("renaming file: %w", err) + } + return driveapi.ParseFile(updated), nil +} + +// MoveFile moves a Drive file or folder to a new parent. +func (c *Client) MoveFile(ctx context.Context, fileID, newParentID string) (*driveapi.File, error) { + current, err := c.service.Files.Get(fileID).Fields("parents").SupportsAllDrives(true).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("getting file parents: %w", err) + } + updated, err := c.service.Files.Update(fileID, &drivev3.File{}). + AddParents(newParentID).RemoveParents(strings.Join(current.Parents, ",")). + SupportsAllDrives(true).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("moving file: %w", err) + } + return driveapi.ParseFile(updated), nil +} + +// TrashFiles moves files to Trash. +func (c *Client) TrashFiles(ctx context.Context, ids []string) error { + return c.setTrashed(ctx, ids, true) +} + +// UntrashFiles restores files from Trash. +func (c *Client) UntrashFiles(ctx context.Context, ids []string) error { + return c.setTrashed(ctx, ids, false) +} + +func (c *Client) setTrashed(ctx context.Context, ids []string, trashed bool) error { + for _, id := range ids { + metadata := &drivev3.File{Trashed: trashed} + if !trashed { + // false is the zero value, so the generated client omits it from + // the JSON body unless forced; without this, restore is a no-op. + metadata.ForceSendFields = []string{"Trashed"} + } + if _, err := c.service.Files.Update(id, metadata).SupportsAllDrives(true).Context(ctx).Do(); err != nil { + return fmt.Errorf("setting trashed state for file %s: %w", id, err) + } + } + return nil +} + +// DeleteFilesPermanently permanently deletes files. +func (c *Client) DeleteFilesPermanently(ctx context.Context, ids []string) error { + for _, id := range ids { + if err := c.service.Files.Delete(id).SupportsAllDrives(true).Context(ctx).Do(); err != nil { + return fmt.Errorf("permanently deleting file %s: %w", id, err) + } + } + return nil +} diff --git a/internal/rw/drive/client_test.go b/internal/rw/drive/client_test.go new file mode 100644 index 0000000..0f55e08 --- /dev/null +++ b/internal/rw/drive/client_test.go @@ -0,0 +1,133 @@ +package drive + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + drivev3 "google.golang.org/api/drive/v3" + "google.golang.org/api/option" +) + +func testClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + service, err := drivev3.NewService(context.Background(), option.WithEndpoint(server.URL+"/"), option.WithoutAuthentication()) + if err != nil { + t.Fatal(err) + } + return &Client{service: service} +} + +func TestFileMIMEType(t *testing.T) { + t.Run("extension", func(t *testing.T) { + got, err := FileMIMEType("report.pdf") + if err != nil || !strings.HasPrefix(got, "application/pdf") { + t.Fatalf("FileMIMEType = %q, %v", got, err) + } + }) + t.Run("content fallback", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "data.unknown-extension") + if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + got, err := FileMIMEType(path) + if err != nil || !strings.HasPrefix(got, "text/plain") { + t.Fatalf("FileMIMEType = %q, %v", got, err) + } + }) +} + +func TestFileMutations(t *testing.T) { + localPath := filepath.Join(t.TempDir(), "upload.txt") + if err := os.WriteFile(localPath, []byte("content"), 0o600); err != nil { + t.Fatal(err) + } + var requests []string + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("supportsAllDrives") != "true" { + t.Errorf("%s %s missing supportsAllDrives=true", r.Method, r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + requests = append(requests, r.Method+" "+r.URL.Path+" "+r.URL.RawQuery+" "+string(body)) + switch r.Method { + case http.MethodGet: + _, _ = fmt.Fprint(w, `{"parents":["old-parent"]}`) + case http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + _, _ = fmt.Fprint(w, `{"id":"file-1","name":"result","mimeType":"text/plain","parents":["parent-1"]}`) + } + }) + ctx := context.Background() + uploaded, err := client.UploadFile(ctx, localPath, "parent-1", "") + if err != nil || uploaded.ID != "file-1" { + t.Fatalf("upload = %#v, %v", uploaded, err) + } + folder, err := client.CreateFolder(ctx, "Folder", "parent-1") + if err != nil || folder.ID != "file-1" { + t.Fatalf("mkdir = %#v, %v", folder, err) + } + renamed, err := client.RenameFile(ctx, "file-1", "Renamed") + if err != nil || renamed.ID != "file-1" { + t.Fatalf("rename = %#v, %v", renamed, err) + } + moved, err := client.MoveFile(ctx, "file-1", "new-parent") + if err != nil || moved.ID != "file-1" { + t.Fatalf("move = %#v, %v", moved, err) + } + if err := client.TrashFiles(ctx, []string{"file-1"}); err != nil { + t.Fatal(err) + } + if err := client.UntrashFiles(ctx, []string{"file-1"}); err != nil { + t.Fatal(err) + } + if err := client.DeleteFilesPermanently(ctx, []string{"file-1"}); err != nil { + t.Fatal(err) + } + joined := strings.Join(requests, "\n") + for _, want := range []string{ + "POST /upload/drive/v3/files", filepath.Base(localPath), "content", + `"mimeType":"application/vnd.google-apps.folder"`, `"name":"Renamed"`, + "addParents=new-parent", "removeParents=old-parent", `"trashed":true`, `"trashed":false`, + "DELETE /files/file-1", + } { + if !strings.Contains(joined, want) { + t.Errorf("requests missing %q:\n%s", want, joined) + } + } +} + +func TestMutationErrorsWrap(t *testing.T) { + path := filepath.Join(t.TempDir(), "upload.txt") + if err := os.WriteFile(path, []byte("content"), 0o600); err != nil { + t.Fatal(err) + } + client := testClient(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusInternalServerError) }) + tests := []struct { + name, want string + call func() error + }{ + {"upload", "uploading file", func() error { _, err := client.UploadFile(context.Background(), path, "", ""); return err }}, + {"mkdir", "creating folder", func() error { _, err := client.CreateFolder(context.Background(), "x", ""); return err }}, + {"rename", "renaming file", func() error { _, err := client.RenameFile(context.Background(), "x", "y"); return err }}, + {"move", "getting file parents", func() error { _, err := client.MoveFile(context.Background(), "x", "y"); return err }}, + {"trash", "setting trashed state", func() error { return client.TrashFiles(context.Background(), []string{"x"}) }}, + {"restore", "setting trashed state", func() error { return client.UntrashFiles(context.Background(), []string{"x"}) }}, + {"delete", "permanently deleting file", func() error { return client.DeleteFilesPermanently(context.Background(), []string{"x"}) }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.call(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} diff --git a/internal/rw/gmail/client.go b/internal/rw/gmail/client.go index 27b017f..dc7adef 100644 --- a/internal/rw/gmail/client.go +++ b/internal/rw/gmail/client.go @@ -1,5 +1,5 @@ // Package gmail extends the read/organize Gmail API client in internal/api/gmail -// with the read-WRITE operations grw adds: trash and +// with the read-WRITE operations grw adds: draft sending, trash and // permanent delete, label lifecycle (create/rename/delete — the "folders" and // "subfolders" users think in), and filter management. It embeds the shared // *gmail.Client so all read and non-destructive-organize methods (search, read, @@ -20,6 +20,8 @@ import ( // *gmail.Client and adds the write operations below. type Client struct { *gmailapi.Client + service *gmailv1.Service + userID string } // NewClient builds a write-capable Gmail client using the shared OAuth/token @@ -30,7 +32,7 @@ func NewClient(ctx context.Context) (*Client, error) { if err != nil { return nil, err } - return &Client{Client: base}, nil + return &Client{Client: base, service: base.Service(), userID: base.UserID()}, nil } // --------------------------------------------------------------------------- diff --git a/internal/rw/gmail/drafts.go b/internal/rw/gmail/drafts.go new file mode 100644 index 0000000..de1ca1b --- /dev/null +++ b/internal/rw/gmail/drafts.go @@ -0,0 +1,86 @@ +package gmail + +import ( + "context" + "fmt" + "strings" + + gmailv1 "google.golang.org/api/gmail/v1" + + gmailapi "github.com/open-cli-collective/google-cli/internal/api/gmail" +) + +// GetDraft returns the headers and attachment count needed to preview a draft +// before sending it. It asks for the full format because the metadata format +// omits payload parts, which would hide every attachment from the preview. +func (c *Client) GetDraft(ctx context.Context, draftID string) (*gmailapi.DraftSummary, error) { + draft, err := c.service.Users.Drafts.Get(c.userID, draftID).Format("full").Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("getting draft: %w", err) + } + + out := &gmailapi.DraftSummary{ID: draft.Id} + if draft.Message == nil { + return out, nil + } + out.MessageID = draft.Message.Id + out.ThreadID = draft.Message.ThreadId + if draft.Message.Payload == nil { + return out, nil + } + for _, header := range draft.Message.Payload.Headers { + switch strings.ToLower(header.Name) { + case "to": + out.To = header.Value + case "cc": + out.Cc = header.Value + case "bcc": + out.Bcc = header.Value + case "subject": + out.Subject = header.Value + case "from": + out.From = header.Value + } + } + out.AttachmentCount = countAttachments(draft.Message.Payload) + return out, nil +} + +// SendDraft sends an existing Gmail draft. +func (c *Client) SendDraft(ctx context.Context, draftID string) (*gmailapi.SentResult, error) { + message, err := c.service.Users.Drafts.Send(c.userID, &gmailv1.Draft{Id: draftID}).Context(ctx).Do() + if err != nil { + return nil, fmt.Errorf("sending draft: %w", err) + } + return &gmailapi.SentResult{ID: message.Id, ThreadID: message.ThreadId, LabelIDs: message.LabelIds}, nil +} + +// countAttachments counts the parts a recipient would see as attachments: any +// part whose Content-Disposition is "attachment", plus named parts without a +// disposition. Named parts marked "inline" (embedded images, signatures) are +// not counted. +func countAttachments(part *gmailv1.MessagePart) int { + count := 0 + switch disposition(part) { + case "attachment": + count++ + case "": + if part.Filename != "" { + count++ + } + } + for _, child := range part.Parts { + count += countAttachments(child) + } + return count +} + +func disposition(part *gmailv1.MessagePart) string { + for _, header := range part.Headers { + if strings.EqualFold(header.Name, "content-disposition") { + value, _, _ := strings.Cut(header.Value, ";") + return strings.ToLower(strings.TrimSpace(value)) + } + } + return "" +} diff --git a/internal/rw/gmail/drafts_test.go b/internal/rw/gmail/drafts_test.go new file mode 100644 index 0000000..cfa8f29 --- /dev/null +++ b/internal/rw/gmail/drafts_test.go @@ -0,0 +1,125 @@ +package gmail + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + gmailv1 "google.golang.org/api/gmail/v1" + "google.golang.org/api/option" +) + +func testDraftClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + service, err := gmailv1.NewService(context.Background(), option.WithEndpoint(server.URL+"/"), option.WithoutAuthentication()) + if err != nil { + t.Fatal(err) + } + return &Client{service: service, userID: "me"} +} + +func TestGetDraft(t *testing.T) { + t.Parallel() + client := testDraftClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/gmail/v1/users/me/drafts/draft-1" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("format"); got != "full" { + t.Errorf("format = %q, want full", got) + } + _, _ = fmt.Fprint(w, `{ + "id":"draft-1", + "message":{ + "id":"message-1", + "threadId":"thread-1", + "payload":{ + "headers":[ + {"name":"From","value":"sender@example.com"}, + {"name":"To","value":"to@example.com"}, + {"name":"Cc","value":"cc@example.com"}, + {"name":"Bcc","value":"bcc@example.com"}, + {"name":"Subject","value":"Hello"} + ], + "parts":[ + {"filename":"report.pdf"}, + {"filename":"logo.png","headers":[{"name":"Content-Disposition","value":"inline; filename=logo.png"}]}, + {"parts":[{"headers":[{"name":"Content-Disposition","value":"attachment; filename=notes.txt"}]}]} + ] + } + } +}`) + }) + + got, err := client.GetDraft(context.Background(), "draft-1") + if err != nil { + t.Fatal(err) + } + if got.ID != "draft-1" || got.MessageID != "message-1" || got.ThreadID != "thread-1" || + got.From != "sender@example.com" || got.To != "to@example.com" || got.Cc != "cc@example.com" || + got.Bcc != "bcc@example.com" || got.Subject != "Hello" || got.AttachmentCount != 2 { + t.Errorf("GetDraft() = %+v", got) + } +} + +func TestGetDraftWithoutMessageOrPayload(t *testing.T) { + t.Parallel() + for name, body := range map[string]string{ + "no message": `{"id":"draft-1"}`, + "no payload": `{"id":"draft-1","message":{"id":"message-1","threadId":"thread-1"}}`, + } { + t.Run(name, func(t *testing.T) { + client := testDraftClient(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = fmt.Fprint(w, body) }) + got, err := client.GetDraft(context.Background(), "draft-1") + if err != nil { + t.Fatal(err) + } + if got.ID != "draft-1" || got.To != "" || got.AttachmentCount != 0 { + t.Errorf("GetDraft() = %+v", got) + } + }) + } +} + +func TestSendDraft(t *testing.T) { + t.Parallel() + client := testDraftClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/gmail/v1/users/me/drafts/send" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + var draft gmailv1.Draft + if err := json.NewDecoder(r.Body).Decode(&draft); err != nil { + t.Fatal(err) + } + if draft.Id != "draft-1" { + t.Errorf("draft ID = %q", draft.Id) + } + _, _ = fmt.Fprint(w, `{"id":"message-1","threadId":"thread-1","labelIds":["SENT"]}`) + }) + + got, err := client.SendDraft(context.Background(), "draft-1") + if err != nil { + t.Fatal(err) + } + if got.ID != "message-1" || got.ThreadID != "thread-1" || len(got.LabelIDs) != 1 || got.LabelIDs[0] != "SENT" { + t.Errorf("SendDraft() = %+v", got) + } +} + +func TestDraftAPIErrorsWrap(t *testing.T) { + t.Parallel() + client := testDraftClient(t, func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no", http.StatusInternalServerError) + }) + if _, err := client.GetDraft(context.Background(), "draft-1"); err == nil || !strings.Contains(err.Error(), "getting draft") { + t.Errorf("GetDraft error = %v", err) + } + if _, err := client.SendDraft(context.Background(), "draft-1"); err == nil || !strings.Contains(err.Error(), "sending draft") { + t.Errorf("SendDraft error = %v", err) + } +} diff --git a/internal/rwcmd/drive/bulk.go b/internal/rwcmd/drive/bulk.go new file mode 100644 index 0000000..78ed4d1 --- /dev/null +++ b/internal/rwcmd/drive/bulk.go @@ -0,0 +1,49 @@ +package drive + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/google-cli/internal/bulk" +) + +// resolveFileIDs turns the leaf's ID source into IDs and, unless this is a +// dry-run, a client to act on them. A client is built only when something +// needs it: --query needs one to search, and a real run needs one to mutate. +// A dry-run over positional or stdin IDs therefore never touches credentials, +// and the client built for --query is reused rather than constructed twice. +func resolveFileIDs(cmd *cobra.Command, args []string, stdin bool, query string, dryRun bool) (WriteClient, []string, error) { + var client WriteClient + ids, err := bulk.ResolveIDs(bulk.Config{Args: args, Stdin: stdin, Query: query}, func(q string) ([]string, error) { + var err error + client, err = newWriteClient(cmd.Context()) + if err != nil { + return nil, fmt.Errorf("creating Drive client: %w", err) + } + return client.SearchFileIDs(cmd.Context(), q, 0) + }) + if err != nil || dryRun { + return client, ids, err + } + if client == nil { + client, err = newWriteClient(cmd.Context()) + if err != nil { + return nil, nil, fmt.Errorf("creating Drive client: %w", err) + } + } + return client, ids, nil +} + +func printBulkResult(action string, ids []string, dryRun bool) error { + result := &bulk.Result{Action: action, IDs: ids, Count: len(ids), DryRun: dryRun, ItemNoun: "file"} + if err := result.Print(); err != nil { + return err + } + if dryRun { + for _, id := range ids { + fmt.Println(id) + } + } + return nil +} diff --git a/internal/rwcmd/drive/delete.go b/internal/rwcmd/drive/delete.go new file mode 100644 index 0000000..83065cb --- /dev/null +++ b/internal/rwcmd/drive/delete.go @@ -0,0 +1,78 @@ +package drive + +import ( + "bufio" + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +func newDeleteCommand() *cobra.Command { + var dryRun, stdin, permanent, yes bool + var query string + cmd := &cobra.Command{ + Use: "delete [file-ids...]", + Short: "Move files to Trash (or permanently delete with --permanent)", + Long: `Delete files. + +By default this moves files to Trash, where they can be restored. With +--permanent the files are erased immediately and cannot be recovered; that +path requires a typed confirmation (or --yes). + +File IDs come from positional args, --stdin, or --query (exactly one source).`, + RunE: func(cmd *cobra.Command, args []string) error { + client, ids, err := resolveFileIDs(cmd, args, stdin, query, dryRun) + if err != nil { + return err + } + if len(ids) == 0 { + fmt.Println("No files matched.") + return nil + } + if permanent { + if dryRun { + return printBulkResult("permanently delete", ids, true) + } + if err := confirmPermanent(cmd, len(ids), stdin, yes); err != nil { + return err + } + if err := client.DeleteFilesPermanently(cmd.Context(), ids); err != nil { + return fmt.Errorf("permanently deleting files: %w", err) + } + return printBulkResult("permanently deleted", ids, false) + } + if dryRun { + return printBulkResult("trash", ids, true) + } + if err := client.TrashFiles(cmd.Context(), ids); err != nil { + return fmt.Errorf("trashing files: %w", err) + } + return printBulkResult("trashed", ids, false) + }, + } + cmd.Flags().BoolVar(&stdin, "stdin", false, "Read file IDs from stdin") + cmd.Flags().StringVar(&query, "query", "", "Search query to resolve file IDs") + cmd.Flags().BoolVar(&permanent, "permanent", false, "Permanently erase instead of moving to Trash (irreversible)") + cmd.Flags().BoolVar(&yes, "yes", false, "Skip the typed confirmation required by --permanent") + cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Preview without making changes") + return cmd +} + +func confirmPermanent(cmd *cobra.Command, n int, stdin, yes bool) error { + if yes { + return nil + } + if stdin { + return errors.New("--permanent with --stdin requires --yes (stdin is consumed by the file IDs, so it can't be used for confirmation)") + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "This will PERMANENTLY delete %d file(s). This cannot be undone.\nType 'delete' to confirm: ", n) + reader := bufio.NewReader(cmd.InOrStdin()) + line, _ := reader.ReadString('\n') + if strings.TrimSpace(line) != "delete" { + return errors.New("aborted: confirmation did not match") + } + return nil +} diff --git a/internal/rwcmd/drive/drive.go b/internal/rwcmd/drive/drive.go new file mode 100644 index 0000000..d771328 --- /dev/null +++ b/internal/rwcmd/drive/drive.go @@ -0,0 +1,14 @@ +package drive + +import ( + "github.com/spf13/cobra" + + drivecmd "github.com/open-cli-collective/google-cli/internal/cmd/drive" +) + +// NewCommand extends the shared Drive command with file mutations. +func NewCommand() *cobra.Command { + cmd := drivecmd.NewCommand() + cmd.AddCommand(newUploadCommand(), newMkdirCommand(), newRenameCommand(), newMoveCommand(), newTrashCommand(), newRestoreCommand(), newDeleteCommand()) + return cmd +} diff --git a/internal/rwcmd/drive/handlers_test.go b/internal/rwcmd/drive/handlers_test.go new file mode 100644 index 0000000..224ea0d --- /dev/null +++ b/internal/rwcmd/drive/handlers_test.go @@ -0,0 +1,225 @@ +package drive + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + driveapi "github.com/open-cli-collective/google-cli/internal/api/drive" + "github.com/open-cli-collective/google-cli/internal/testutil" +) + +func withFactory(factory func(context.Context) (WriteClient, error), f func()) { + testutil.WithFactory(&ClientFactory, factory, f) +} + +func runCommand(cmd *cobra.Command, args ...string) (string, error) { + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + cmd.SetArgs(args) + err := cmd.Execute() + return output.String(), err +} + +func localFile(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "upload.txt") + if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestCommandsSuccess(t *testing.T) { + file := &driveapi.File{ID: "file-1", Name: "result", MimeType: "text/plain", Parents: []string{"parent-1"}} + mock := &mockWriteClient{ + UploadFileFunc: func(_ context.Context, _, parent, name string) (*driveapi.File, error) { + testutil.Equal(t, parent, "parent-1") + testutil.Equal(t, name, "remote.txt") + return file, nil + }, + CreateFolderFunc: func(_ context.Context, name, parent string) (*driveapi.File, error) { + testutil.Equal(t, name, "Folder") + testutil.Equal(t, parent, "parent-1") + return file, nil + }, + RenameFileFunc: func(_ context.Context, id, name string) (*driveapi.File, error) { + testutil.Equal(t, id, "file-1") + testutil.Equal(t, name, "Renamed") + return file, nil + }, + MoveFileFunc: func(_ context.Context, id, parent string) (*driveapi.File, error) { + testutil.Equal(t, id, "file-1") + testutil.Equal(t, parent, "folder-1") + return file, nil + }, + TrashFilesFunc: func(_ context.Context, ids []string) error { + testutil.Equal(t, strings.Join(ids, ","), "one,two") + return nil + }, + UntrashFilesFunc: func(_ context.Context, ids []string) error { + testutil.Equal(t, strings.Join(ids, ","), "one,two") + return nil + }, + } + path := localFile(t) + tests := []struct { + name string + cmd *cobra.Command + args []string + want string + }{ + {"upload", newUploadCommand(), []string{path, "--parent", "parent-1", "--name", "remote.txt"}, "ID: file-1"}, + {"mkdir", newMkdirCommand(), []string{"Folder", "--parent", "parent-1"}, "ID: file-1"}, + {"rename", newRenameCommand(), []string{"file-1", "Renamed"}, "ID: file-1"}, + {"move", newMoveCommand(), []string{"file-1", "folder-1"}, "ID: file-1"}, + {"trash", newTrashCommand(), []string{"one", "two"}, "Trashed 2 file(s)."}, + {"restore", newRestoreCommand(), []string{"one", "two"}, "Restored 2 file(s)."}, + } + withFactory(func(context.Context) (WriteClient, error) { return mock, nil }, func() { + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + output := testutil.CaptureStdout(t, func() { + _, err := runCommand(test.cmd, test.args...) + testutil.NoError(t, err) + }) + testutil.Contains(t, output, test.want) + }) + } + }) +} + +func TestCommandErrors(t *testing.T) { + boom := errors.New("API error") + file := localFile(t) + tests := []struct { + name string + cmd func() *cobra.Command + args []string + mock *mockWriteClient + want string + }{ + {"upload", newUploadCommand, []string{file}, &mockWriteClient{UploadFileFunc: func(context.Context, string, string, string) (*driveapi.File, error) { return nil, boom }}, "uploading file"}, + {"mkdir", newMkdirCommand, []string{"Folder"}, &mockWriteClient{CreateFolderFunc: func(context.Context, string, string) (*driveapi.File, error) { return nil, boom }}, "creating folder"}, + {"rename", newRenameCommand, []string{"one", "name"}, &mockWriteClient{RenameFileFunc: func(context.Context, string, string) (*driveapi.File, error) { return nil, boom }}, "renaming file"}, + {"move", newMoveCommand, []string{"one", "folder"}, &mockWriteClient{MoveFileFunc: func(context.Context, string, string) (*driveapi.File, error) { return nil, boom }}, "moving file"}, + {"trash", newTrashCommand, []string{"one"}, &mockWriteClient{TrashFilesFunc: func(context.Context, []string) error { return boom }}, "trashing files"}, + {"restore", newRestoreCommand, []string{"one"}, &mockWriteClient{UntrashFilesFunc: func(context.Context, []string) error { return boom }}, "restoring files"}, + {"delete", newDeleteCommand, []string{"one"}, &mockWriteClient{TrashFilesFunc: func(context.Context, []string) error { return boom }}, "trashing files"}, + } + for _, test := range tests { + t.Run(test.name+" API error", func(t *testing.T) { + withFactory(func(context.Context) (WriteClient, error) { return test.mock, nil }, func() { + _, err := runCommand(test.cmd(), test.args...) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + }) + t.Run(test.name+" client error", func(t *testing.T) { + withFactory(func(context.Context) (WriteClient, error) { return nil, errors.New("no client") }, func() { + _, err := runCommand(test.cmd(), test.args...) + if err == nil || !strings.Contains(err.Error(), "creating Drive client") { + t.Fatalf("error = %v", err) + } + }) + }) + } +} + +func TestDryRunsDoNotConstructClient(t *testing.T) { + path := localFile(t) + withFactory(func(context.Context) (WriteClient, error) { t.Fatal("dry-run constructed client"); return nil, nil }, func() { + tests := []struct { + name string + cmd *cobra.Command + args []string + }{ + {"upload", newUploadCommand(), []string{path, "--dry-run"}}, + {"mkdir", newMkdirCommand(), []string{"Folder", "--dry-run"}}, + {"rename", newRenameCommand(), []string{"one", "name", "--dry-run"}}, + {"move", newMoveCommand(), []string{"one", "folder", "--dry-run"}}, + {"trash", newTrashCommand(), []string{"one", "--dry-run"}}, + {"restore", newRestoreCommand(), []string{"one", "--dry-run"}}, + {"delete", newDeleteCommand(), []string{"one", "--dry-run"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + output := testutil.CaptureStdout(t, func() { _, err := runCommand(test.cmd, test.args...); testutil.NoError(t, err) }) + testutil.Contains(t, output, "[dry-run]") + }) + } + }) +} + +func TestTrashResolvesQuery(t *testing.T) { + var trashed []string + mock := &mockWriteClient{ + SearchFileIDsFunc: func(_ context.Context, query string, maxResults int64) ([]string, error) { + testutil.Equal(t, query, "name contains 'old'") + testutil.Equal(t, maxResults, int64(0)) + return []string{"one", "two"}, nil + }, + TrashFilesFunc: func(_ context.Context, ids []string) error { trashed = ids; return nil }, + } + withFactory(func(context.Context) (WriteClient, error) { return mock, nil }, func() { + testutil.CaptureStdout(t, func() { + _, err := runCommand(newTrashCommand(), "--query", "name contains 'old'") + testutil.NoError(t, err) + }) + }) + testutil.Equal(t, strings.Join(trashed, ","), "one,two") +} + +func TestDelete(t *testing.T) { + t.Run("permanent requires confirmation", func(t *testing.T) { + mock := &mockWriteClient{DeleteFilesPermanentlyFunc: func(context.Context, []string) error { t.Fatal("delete called"); return nil }} + withFactory(func(context.Context) (WriteClient, error) { return mock, nil }, func() { + cmd := newDeleteCommand() + cmd.SetIn(strings.NewReader("no\n")) + _, err := runCommand(cmd, "one", "--permanent") + if err == nil || !strings.Contains(err.Error(), "aborted") { + t.Fatalf("error = %v", err) + } + }) + }) + t.Run("yes skips confirmation", func(t *testing.T) { + var deleted []string + mock := &mockWriteClient{DeleteFilesPermanentlyFunc: func(_ context.Context, ids []string) error { deleted = ids; return nil }} + withFactory(func(context.Context) (WriteClient, error) { return mock, nil }, func() { + output := testutil.CaptureStdout(t, func() { + _, err := runCommand(newDeleteCommand(), "one", "--permanent", "--yes") + testutil.NoError(t, err) + }) + testutil.Equal(t, strings.Join(deleted, ","), "one") + testutil.Contains(t, output, "Permanently deleted 1 file(s).") + }) + }) + t.Run("stdin and query conflict", func(t *testing.T) { + withFactory(func(context.Context) (WriteClient, error) { t.Fatal("client constructed"); return nil, nil }, func() { + _, err := runCommand(newDeleteCommand(), "--stdin", "--query", "trashed = true") + if err == nil || !strings.Contains(err.Error(), "only one input source") { + t.Fatalf("error = %v", err) + } + }) + }) +} + +func TestNewCommandComposition(t *testing.T) { + names := map[string]bool{} + for _, command := range NewCommand().Commands() { + names[command.Name()] = true + } + for _, want := range []string{"list", "search", "get", "download", "tree", "drives", "star", "unstar", "upload", "mkdir", "rename", "move", "trash", "restore", "delete"} { + if !names[want] { + t.Errorf("missing %s command", want) + } + } +} diff --git a/internal/rwcmd/drive/mkdir.go b/internal/rwcmd/drive/mkdir.go new file mode 100644 index 0000000..1527123 --- /dev/null +++ b/internal/rwcmd/drive/mkdir.go @@ -0,0 +1,43 @@ +package drive + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newMkdirCommand() *cobra.Command { + var parent string + var dryRun bool + cmd := &cobra.Command{ + Use: "mkdir ", + Short: "Create a folder", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if dryRun { + fmt.Printf("[dry-run] Would create folder %q in parent %s.\n", args[0], displayParent(parent)) + return nil + } + client, err := newWriteClient(cmd.Context()) + if err != nil { + return fmt.Errorf("creating Drive client: %w", err) + } + file, err := client.CreateFolder(cmd.Context(), args[0], parent) + if err != nil { + return fmt.Errorf("creating folder: %w", err) + } + printFile(file) + return nil + }, + } + cmd.Flags().StringVar(&parent, "parent", "", "Parent folder ID") + cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Preview without making changes") + return cmd +} + +func displayParent(parent string) string { + if parent == "" { + return "root" + } + return parent +} diff --git a/internal/rwcmd/drive/mock_test.go b/internal/rwcmd/drive/mock_test.go new file mode 100644 index 0000000..8ab93ff --- /dev/null +++ b/internal/rwcmd/drive/mock_test.go @@ -0,0 +1,47 @@ +package drive + +import ( + "context" + + driveapi "github.com/open-cli-collective/google-cli/internal/api/drive" + drivecmd "github.com/open-cli-collective/google-cli/internal/cmd/drive" +) + +type mockWriteClient struct { + drivecmd.DriveClient + SearchFileIDsFunc func(context.Context, string, int64) ([]string, error) + UploadFileFunc func(context.Context, string, string, string) (*driveapi.File, error) + CreateFolderFunc func(context.Context, string, string) (*driveapi.File, error) + RenameFileFunc func(context.Context, string, string) (*driveapi.File, error) + MoveFileFunc func(context.Context, string, string) (*driveapi.File, error) + TrashFilesFunc func(context.Context, []string) error + UntrashFilesFunc func(context.Context, []string) error + DeleteFilesPermanentlyFunc func(context.Context, []string) error +} + +var _ WriteClient = (*mockWriteClient)(nil) + +func (m *mockWriteClient) SearchFileIDs(ctx context.Context, query string, maxResults int64) ([]string, error) { + return m.SearchFileIDsFunc(ctx, query, maxResults) +} +func (m *mockWriteClient) UploadFile(ctx context.Context, path, parent, name string) (*driveapi.File, error) { + return m.UploadFileFunc(ctx, path, parent, name) +} +func (m *mockWriteClient) CreateFolder(ctx context.Context, name, parent string) (*driveapi.File, error) { + return m.CreateFolderFunc(ctx, name, parent) +} +func (m *mockWriteClient) RenameFile(ctx context.Context, id, name string) (*driveapi.File, error) { + return m.RenameFileFunc(ctx, id, name) +} +func (m *mockWriteClient) MoveFile(ctx context.Context, id, parent string) (*driveapi.File, error) { + return m.MoveFileFunc(ctx, id, parent) +} +func (m *mockWriteClient) TrashFiles(ctx context.Context, ids []string) error { + return m.TrashFilesFunc(ctx, ids) +} +func (m *mockWriteClient) UntrashFiles(ctx context.Context, ids []string) error { + return m.UntrashFilesFunc(ctx, ids) +} +func (m *mockWriteClient) DeleteFilesPermanently(ctx context.Context, ids []string) error { + return m.DeleteFilesPermanentlyFunc(ctx, ids) +} diff --git a/internal/rwcmd/drive/move.go b/internal/rwcmd/drive/move.go new file mode 100644 index 0000000..4520f2f --- /dev/null +++ b/internal/rwcmd/drive/move.go @@ -0,0 +1,34 @@ +package drive + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newMoveCommand() *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "move ", + Short: "Move a file or folder", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if dryRun { + fmt.Printf("[dry-run] Would move file %s to folder %s.\n", args[0], args[1]) + return nil + } + client, err := newWriteClient(cmd.Context()) + if err != nil { + return fmt.Errorf("creating Drive client: %w", err) + } + file, err := client.MoveFile(cmd.Context(), args[0], args[1]) + if err != nil { + return fmt.Errorf("moving file: %w", err) + } + printFile(file) + return nil + }, + } + cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Preview without making changes") + return cmd +} diff --git a/internal/rwcmd/drive/output.go b/internal/rwcmd/drive/output.go new file mode 100644 index 0000000..1c639e6 --- /dev/null +++ b/internal/rwcmd/drive/output.go @@ -0,0 +1,41 @@ +// Package drive provides grw's Drive command surface. +package drive + +import ( + "context" + "fmt" + + driveapi "github.com/open-cli-collective/google-cli/internal/api/drive" + drivecmd "github.com/open-cli-collective/google-cli/internal/cmd/drive" + mailcmd "github.com/open-cli-collective/google-cli/internal/cmd/mail" + driverw "github.com/open-cli-collective/google-cli/internal/rw/drive" +) + +// WriteClient is the Drive surface used by grw commands. +type WriteClient interface { + drivecmd.DriveClient + UploadFile(context.Context, string, string, string) (*driveapi.File, error) + CreateFolder(context.Context, string, string) (*driveapi.File, error) + RenameFile(context.Context, string, string) (*driveapi.File, error) + MoveFile(context.Context, string, string) (*driveapi.File, error) + TrashFiles(context.Context, []string) error + UntrashFiles(context.Context, []string) error + DeleteFilesPermanently(context.Context, []string) error +} + +// ClientFactory constructs grw's write Drive client. +var ClientFactory = func(ctx context.Context) (WriteClient, error) { return driverw.NewClient(ctx) } + +func newWriteClient(ctx context.Context) (WriteClient, error) { return ClientFactory(ctx) } + +// printFile prints a file's identity. Names come from Drive, where a +// collaborator may have set them, so they are sanitized before reaching the +// terminal. +func printFile(file *driveapi.File) { + fmt.Printf("ID: %s\n", mailcmd.SanitizeOutput(file.ID)) + fmt.Printf("Name: %s\n", mailcmd.SanitizeFilename(file.Name)) + fmt.Printf("Type: %s\n", mailcmd.SanitizeOutput(file.MimeType)) + if len(file.Parents) > 0 { + fmt.Printf("Parent: %s\n", mailcmd.SanitizeOutput(file.Parents[0])) + } +} diff --git a/internal/rwcmd/drive/rename.go b/internal/rwcmd/drive/rename.go new file mode 100644 index 0000000..bfc5572 --- /dev/null +++ b/internal/rwcmd/drive/rename.go @@ -0,0 +1,34 @@ +package drive + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newRenameCommand() *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "rename ", + Short: "Rename a file or folder", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if dryRun { + fmt.Printf("[dry-run] Would rename file %s to %q.\n", args[0], args[1]) + return nil + } + client, err := newWriteClient(cmd.Context()) + if err != nil { + return fmt.Errorf("creating Drive client: %w", err) + } + file, err := client.RenameFile(cmd.Context(), args[0], args[1]) + if err != nil { + return fmt.Errorf("renaming file: %w", err) + } + printFile(file) + return nil + }, + } + cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Preview without making changes") + return cmd +} diff --git a/internal/rwcmd/drive/restore.go b/internal/rwcmd/drive/restore.go new file mode 100644 index 0000000..f08746a --- /dev/null +++ b/internal/rwcmd/drive/restore.go @@ -0,0 +1,37 @@ +package drive + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newRestoreCommand() *cobra.Command { + var dryRun, stdin bool + var query string + cmd := &cobra.Command{ + Use: "restore [file-ids...]", + Short: "Restore files from Trash", + RunE: func(cmd *cobra.Command, args []string) error { + client, ids, err := resolveFileIDs(cmd, args, stdin, query, dryRun) + if err != nil { + return err + } + if len(ids) == 0 { + fmt.Println("No files matched.") + return nil + } + if dryRun { + return printBulkResult("restore", ids, true) + } + if err := client.UntrashFiles(cmd.Context(), ids); err != nil { + return fmt.Errorf("restoring files: %w", err) + } + return printBulkResult("restored", ids, false) + }, + } + cmd.Flags().BoolVar(&stdin, "stdin", false, "Read file IDs from stdin") + cmd.Flags().StringVar(&query, "query", "", "Search query to resolve file IDs") + cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Preview without making changes") + return cmd +} diff --git a/internal/rwcmd/drive/trash.go b/internal/rwcmd/drive/trash.go new file mode 100644 index 0000000..9899b0c --- /dev/null +++ b/internal/rwcmd/drive/trash.go @@ -0,0 +1,37 @@ +package drive + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newTrashCommand() *cobra.Command { + var dryRun, stdin bool + var query string + cmd := &cobra.Command{ + Use: "trash [file-ids...]", + Short: "Move files to Trash", + RunE: func(cmd *cobra.Command, args []string) error { + client, ids, err := resolveFileIDs(cmd, args, stdin, query, dryRun) + if err != nil { + return err + } + if len(ids) == 0 { + fmt.Println("No files matched.") + return nil + } + if dryRun { + return printBulkResult("trash", ids, true) + } + if err := client.TrashFiles(cmd.Context(), ids); err != nil { + return fmt.Errorf("trashing files: %w", err) + } + return printBulkResult("trashed", ids, false) + }, + } + cmd.Flags().BoolVar(&stdin, "stdin", false, "Read file IDs from stdin") + cmd.Flags().StringVar(&query, "query", "", "Search query to resolve file IDs") + cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Preview without making changes") + return cmd +} diff --git a/internal/rwcmd/drive/upload.go b/internal/rwcmd/drive/upload.go new file mode 100644 index 0000000..7b85359 --- /dev/null +++ b/internal/rwcmd/drive/upload.go @@ -0,0 +1,52 @@ +package drive + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + driverw "github.com/open-cli-collective/google-cli/internal/rw/drive" +) + +func newUploadCommand() *cobra.Command { + var parent, name string + var dryRun bool + cmd := &cobra.Command{ + Use: "upload ", + Short: "Upload a file", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + info, err := os.Stat(args[0]) + if err != nil { + return fmt.Errorf("reading local file: %w", err) + } + typ, err := driverw.FileMIMEType(args[0]) + if err != nil { + return err + } + if dryRun { + target := parent + if target == "" { + target = "root" + } + fmt.Printf("[dry-run] Would upload file:\nPath: %s\nSize: %d bytes\nMIME type: %s\nParent: %s\n", args[0], info.Size(), typ, target) + return nil + } + client, err := newWriteClient(cmd.Context()) + if err != nil { + return fmt.Errorf("creating Drive client: %w", err) + } + file, err := client.UploadFile(cmd.Context(), args[0], parent, name) + if err != nil { + return fmt.Errorf("uploading file: %w", err) + } + printFile(file) + return nil + }, + } + cmd.Flags().StringVar(&parent, "parent", "", "Parent folder ID") + cmd.Flags().StringVar(&name, "name", "", "Drive filename (defaults to the local filename)") + cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Preview without making changes") + return cmd +} diff --git a/internal/rwcmd/mail/mail.go b/internal/rwcmd/mail/mail.go index 94cfa21..78138dc 100644 --- a/internal/rwcmd/mail/mail.go +++ b/internal/rwcmd/mail/mail.go @@ -7,12 +7,13 @@ import ( ) // NewCommand returns grw's `mail` command: the shared non-destructive leaves -// from mailcmd plus grw's read-write leaves (delete, folder, filter). The +// from mailcmd plus grw's read-write leaves (send, delete, folder, filter). 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. func NewCommand() *cobra.Command { cmd := mailcmd.NewCommand() + cmd.AddCommand(newSendCommand()) cmd.AddCommand(newDeleteCommand()) cmd.AddCommand(newRestoreCommand()) cmd.AddCommand(newFolderCommand()) diff --git a/internal/rwcmd/mail/mail_test.go b/internal/rwcmd/mail/mail_test.go index 9f3dde1..fb7e80d 100644 --- a/internal/rwcmd/mail/mail_test.go +++ b/internal/rwcmd/mail/mail_test.go @@ -14,7 +14,7 @@ func TestNewCommandComposition(t *testing.T) { } // grw's read-write additions - for _, want := range []string{"delete", "restore", "folder", "filter"} { + for _, want := range []string{"send", "delete", "restore", "folder", "filter"} { if !names[want] { t.Errorf("grw mail is missing the %q command", want) } diff --git a/internal/rwcmd/mail/mock_test.go b/internal/rwcmd/mail/mock_test.go index 8ce6556..6b272c2 100644 --- a/internal/rwcmd/mail/mock_test.go +++ b/internal/rwcmd/mail/mock_test.go @@ -5,6 +5,7 @@ import ( gmailv1 "google.golang.org/api/gmail/v1" + gmailapi "github.com/open-cli-collective/google-cli/internal/api/gmail" mailcmd "github.com/open-cli-collective/google-cli/internal/cmd/mail" ) @@ -12,6 +13,8 @@ import ( // zero values, so each test wires only the methods it exercises. type mockWriteClient struct { mailcmd.MailClient + GetDraftFunc func(ctx context.Context, draftID string) (*gmailapi.DraftSummary, error) + SendDraftFunc func(ctx context.Context, draftID string) (*gmailapi.SentResult, error) SearchMessageIDsFunc func(ctx context.Context, query string, maxResults int64) ([]string, error) FetchLabelsFunc func(ctx context.Context) error GetLabelsFunc func() []*gmailv1.Label @@ -29,6 +32,20 @@ type mockWriteClient struct { var _ WriteClient = (*mockWriteClient)(nil) +func (m *mockWriteClient) GetDraft(ctx context.Context, draftID string) (*gmailapi.DraftSummary, error) { + if m.GetDraftFunc != nil { + return m.GetDraftFunc(ctx, draftID) + } + return &gmailapi.DraftSummary{}, nil +} + +func (m *mockWriteClient) SendDraft(ctx context.Context, draftID string) (*gmailapi.SentResult, error) { + if m.SendDraftFunc != nil { + return m.SendDraftFunc(ctx, draftID) + } + return &gmailapi.SentResult{}, nil +} + func (m *mockWriteClient) SearchMessageIDs(ctx context.Context, query string, maxResults int64) ([]string, error) { if m.SearchMessageIDsFunc != nil { return m.SearchMessageIDsFunc(ctx, query, maxResults) diff --git a/internal/rwcmd/mail/output.go b/internal/rwcmd/mail/output.go index cd1f178..f1e8003 100644 --- a/internal/rwcmd/mail/output.go +++ b/internal/rwcmd/mail/output.go @@ -1,8 +1,8 @@ // Package mail is grw's Gmail command surface. It composes the non-destructive // leaves from internal/cmd/mail (search, read, thread, // archive, label, move/categorize, star, mark read/unread, attachments, draft) -// and adds grw's read-WRITE leaves: delete (trash by default, permanent behind -// a guard), folder (label lifecycle), and filter management. +// and adds grw's read-WRITE leaves: send, delete (trash by default, permanent +// behind a guard), folder (label lifecycle), and filter management. package mail import ( @@ -10,6 +10,7 @@ import ( gmailv1 "google.golang.org/api/gmail/v1" + gmailapi "github.com/open-cli-collective/google-cli/internal/api/gmail" mailcmd "github.com/open-cli-collective/google-cli/internal/cmd/mail" gmailrw "github.com/open-cli-collective/google-cli/internal/rw/gmail" ) @@ -19,6 +20,8 @@ import ( // client, so the message-search and label-resolution reads come for free). type WriteClient interface { mailcmd.MailClient + GetDraft(ctx context.Context, draftID string) (*gmailapi.DraftSummary, error) + SendDraft(ctx context.Context, draftID string) (*gmailapi.SentResult, error) TrashMessages(ctx context.Context, ids []string) error UntrashMessages(ctx context.Context, ids []string) error diff --git a/internal/rwcmd/mail/send.go b/internal/rwcmd/mail/send.go new file mode 100644 index 0000000..4ccba99 --- /dev/null +++ b/internal/rwcmd/mail/send.go @@ -0,0 +1,59 @@ +package mail + +import ( + "fmt" + + "github.com/spf13/cobra" + + gmailapi "github.com/open-cli-collective/google-cli/internal/api/gmail" + mailcmd "github.com/open-cli-collective/google-cli/internal/cmd/mail" +) + +func newSendCommand() *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "send ", + Short: "Send an existing Gmail draft", + Long: `Preview and send an existing Gmail draft by its ID. + +The draft is fetched and its headers are displayed before sending. Dry-run also +constructs a Gmail client because fetching the draft is required for preview. +Gmail sends whatever the draft contains at send time; if the draft is edited +elsewhere between the preview and the send, the edited version goes out.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := newWriteClient(cmd.Context()) + if err != nil { + return fmt.Errorf("creating Gmail client: %w", err) + } + draft, err := client.GetDraft(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("getting draft: %w", err) + } + printDraftSummary(draft) + if draft.To == "" && draft.Cc == "" && draft.Bcc == "" { + return fmt.Errorf("draft has no recipients") + } + if dryRun { + return nil + } + sent, err := client.SendDraft(cmd.Context(), args[0]) + if err != nil { + return fmt.Errorf("sending draft: %w", err) + } + fmt.Printf("Sent message %s in thread %s\n", sent.ID, sent.ThreadID) + return nil + }, + } + cmd.Flags().BoolVarP(&dryRun, "dry-run", "n", false, "Preview without sending the draft") + return cmd +} + +func printDraftSummary(draft *gmailapi.DraftSummary) { + fmt.Printf("From: %s\n", mailcmd.SanitizeOutput(draft.From)) + fmt.Printf("To: %s\n", mailcmd.SanitizeOutput(draft.To)) + fmt.Printf("Cc: %s\n", mailcmd.SanitizeOutput(draft.Cc)) + fmt.Printf("Bcc: %s\n", mailcmd.SanitizeOutput(draft.Bcc)) + fmt.Printf("Subject: %s\n", mailcmd.SanitizeOutput(draft.Subject)) + fmt.Printf("Attachments: %d\n", draft.AttachmentCount) +} diff --git a/internal/rwcmd/mail/send_test.go b/internal/rwcmd/mail/send_test.go new file mode 100644 index 0000000..d3ea0d0 --- /dev/null +++ b/internal/rwcmd/mail/send_test.go @@ -0,0 +1,161 @@ +package mail + +import ( + "context" + "errors" + "strings" + "testing" + + gmailapi "github.com/open-cli-collective/google-cli/internal/api/gmail" + "github.com/open-cli-collective/google-cli/internal/testutil" +) + +func draftWithRecipients() *gmailapi.DraftSummary { + return &gmailapi.DraftSummary{ + From: "sender@example.com", To: "to@example.com", Cc: "cc@example.com", + Bcc: "bcc@example.com", Subject: "Hello", AttachmentCount: 2, + } +} + +func TestSend_Success(t *testing.T) { + mock := &mockWriteClient{ + GetDraftFunc: func(_ context.Context, id string) (*gmailapi.DraftSummary, error) { + if id != "draft-1" { + t.Errorf("draft ID = %q", id) + } + return draftWithRecipients(), nil + }, + SendDraftFunc: func(_ context.Context, id string) (*gmailapi.SentResult, error) { + if id != "draft-1" { + t.Errorf("draft ID = %q", id) + } + return &gmailapi.SentResult{ID: "message-1", ThreadID: "thread-1"}, nil + }, + } + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + _, err := runCmd(newSendCommand(), "draft-1") + testutil.NoError(t, err) + }) + for _, want := range []string{ + "From: sender@example.com", "To: to@example.com", "Cc: cc@example.com", + "Bcc: bcc@example.com", "Subject: Hello", "Attachments: 2", + "Sent message message-1 in thread thread-1", + } { + testutil.Contains(t, output, want) + } + }) +} + +func TestSend_DryRunDoesNotSend(t *testing.T) { + mock := &mockWriteClient{ + GetDraftFunc: func(context.Context, string) (*gmailapi.DraftSummary, error) { return draftWithRecipients(), nil }, + SendDraftFunc: func(context.Context, string) (*gmailapi.SentResult, error) { + t.Fatal("dry-run must not send") + return nil, nil + }, + } + withMockClient(mock, func() { + output := testutil.CaptureStdout(t, func() { + _, err := runCmd(newSendCommand(), "draft-1", "--dry-run") + testutil.NoError(t, err) + }) + testutil.Contains(t, output, "Subject: Hello") + }) +} + +func TestSend_BccOnlyRecipientSends(t *testing.T) { + sent := false + mock := &mockWriteClient{ + GetDraftFunc: func(context.Context, string) (*gmailapi.DraftSummary, error) { + return &gmailapi.DraftSummary{Bcc: "bcc@example.com", Subject: "Hello"}, nil + }, + SendDraftFunc: func(context.Context, string) (*gmailapi.SentResult, error) { + sent = true + return &gmailapi.SentResult{ID: "message-1", ThreadID: "thread-1"}, nil + }, + } + withMockClient(mock, func() { + testutil.CaptureStdout(t, func() { + _, err := runCmd(newSendCommand(), "draft-1") + testutil.NoError(t, err) + }) + }) + if !sent { + t.Fatal("a Bcc-only draft must send") + } +} + +func TestSend_DryRunReportsMissingRecipients(t *testing.T) { + mock := &mockWriteClient{ + GetDraftFunc: func(context.Context, string) (*gmailapi.DraftSummary, error) { return &gmailapi.DraftSummary{}, nil }, + SendDraftFunc: func(context.Context, string) (*gmailapi.SentResult, error) { + t.Fatal("dry-run must not send") + return nil, nil + }, + } + withMockClient(mock, func() { + testutil.CaptureStdout(t, func() { + _, err := runCmd(newSendCommand(), "draft-1", "--dry-run") + if err == nil || !strings.Contains(err.Error(), "no recipients") { + t.Fatalf("error = %v", err) + } + }) + }) +} + +func TestSend_MissingRecipients(t *testing.T) { + mock := &mockWriteClient{ + GetDraftFunc: func(context.Context, string) (*gmailapi.DraftSummary, error) { return &gmailapi.DraftSummary{}, nil }, + SendDraftFunc: func(context.Context, string) (*gmailapi.SentResult, error) { + t.Fatal("draft without recipients must not send") + return nil, nil + }, + } + withMockClient(mock, func() { + testutil.CaptureStdout(t, func() { + _, err := runCmd(newSendCommand(), "draft-1") + if err == nil || !strings.Contains(err.Error(), "no recipients") { + t.Fatalf("error = %v", err) + } + }) + }) +} + +func TestSend_GetDraftError(t *testing.T) { + mock := &mockWriteClient{GetDraftFunc: func(context.Context, string) (*gmailapi.DraftSummary, error) { + return nil, errors.New("API error") + }} + withMockClient(mock, func() { + _, err := runCmd(newSendCommand(), "draft-1") + if err == nil || !strings.Contains(err.Error(), "getting draft") { + t.Fatalf("error = %v", err) + } + }) +} + +func TestSend_SendDraftError(t *testing.T) { + mock := &mockWriteClient{ + GetDraftFunc: func(context.Context, string) (*gmailapi.DraftSummary, error) { return draftWithRecipients(), nil }, + SendDraftFunc: func(context.Context, string) (*gmailapi.SentResult, error) { return nil, errors.New("API error") }, + } + withMockClient(mock, func() { + testutil.CaptureStdout(t, func() { + _, err := runCmd(newSendCommand(), "draft-1") + if err == nil || !strings.Contains(err.Error(), "sending draft") { + t.Fatalf("error = %v", err) + } + }) + }) +} + +func TestSend_ClientCreationError(t *testing.T) { + testutil.WithFactory(&ClientFactory, func(context.Context) (WriteClient, error) { + return nil, errors.New("no client") + }, func() { + _, err := runCmd(newSendCommand(), "draft-1") + if err == nil || !strings.Contains(err.Error(), "creating Gmail client") { + t.Fatalf("error = %v", err) + } + }) +}