diff --git a/internal/cmd/config_cmd.go b/internal/cmd/config_cmd.go index 00ac02bf..c387a7c3 100644 --- a/internal/cmd/config_cmd.go +++ b/internal/cmd/config_cmd.go @@ -10,12 +10,13 @@ import ( ) type ConfigCmd struct { - Get ConfigGetCmd `cmd:"" aliases:"show" help:"Get a config value"` - Keys ConfigKeysCmd `cmd:"" aliases:"list-keys,names" help:"List available config keys"` - Set ConfigSetCmd `cmd:"" aliases:"add,update" help:"Set a config value"` - Unset ConfigUnsetCmd `cmd:"" aliases:"rm,del,remove" help:"Unset a config value"` - List ConfigListCmd `cmd:"" aliases:"ls,all" help:"List all config values"` - Path ConfigPathCmd `cmd:"" aliases:"where" help:"Print config file path"` + Get ConfigGetCmd `cmd:"" aliases:"show" help:"Get a config value"` + Keys ConfigKeysCmd `cmd:"" aliases:"list-keys,names" help:"List available config keys"` + Set ConfigSetCmd `cmd:"" aliases:"add,update" help:"Set a config value"` + Unset ConfigUnsetCmd `cmd:"" aliases:"rm,del,remove" help:"Unset a config value"` + List ConfigListCmd `cmd:"" aliases:"ls,all" help:"List all config values"` + Path ConfigPathCmd `cmd:"" aliases:"where" help:"Print config file path"` + NoSend ConfigNoSendCmd `cmd:"" name:"no-send" aliases:"nosend" help:"Manage per-account send blocking"` } type ConfigGetCmd struct { diff --git a/internal/cmd/config_no_send.go b/internal/cmd/config_no_send.go new file mode 100644 index 00000000..17492433 --- /dev/null +++ b/internal/cmd/config_no_send.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/steipete/gogcli/internal/config" + "github.com/steipete/gogcli/internal/outfmt" +) + +type ConfigNoSendCmd struct { + Set ConfigNoSendSetCmd `cmd:"" aliases:"add,block" help:"Block send for an account"` + Remove ConfigNoSendRemoveCmd `cmd:"" aliases:"rm,unblock,allow" help:"Allow send for an account"` + List ConfigNoSendListCmd `cmd:"" aliases:"ls" help:"List accounts with send blocked"` +} + +type ConfigNoSendSetCmd struct { + Account string `arg:"" help:"Account email to block send for"` +} + +func (c *ConfigNoSendSetCmd) Run(ctx context.Context, flags *RootFlags) error { + if err := dryRunExit(ctx, flags, "config.no-send.set", map[string]any{ + "account": c.Account, + }); err != nil { + return err + } + + if err := config.UpdateConfig(func(cfg *config.File) error { + return config.SetNoSendAccount(cfg, c.Account, true) + }); err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "account": c.Account, + "noSend": true, + }) + } + fmt.Fprintf(os.Stdout, "Send blocked for %s\n", c.Account) + return nil +} + +type ConfigNoSendRemoveCmd struct { + Account string `arg:"" help:"Account email to allow send for"` +} + +func (c *ConfigNoSendRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { + if err := dryRunExit(ctx, flags, "config.no-send.remove", map[string]any{ + "account": c.Account, + }); err != nil { + return err + } + + if err := config.UpdateConfig(func(cfg *config.File) error { + return config.SetNoSendAccount(cfg, c.Account, false) + }); err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "account": c.Account, + "noSend": false, + }) + } + fmt.Fprintf(os.Stdout, "Send allowed for %s\n", c.Account) + return nil +} + +type ConfigNoSendListCmd struct{} + +func (c *ConfigNoSendListCmd) Run(ctx context.Context) error { + cfg, err := config.ReadConfig() + if err != nil { + return err + } + + accounts := config.ListNoSendAccounts(cfg) + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "noSendAccounts": accounts, + }) + } + + if len(accounts) == 0 { + fmt.Fprintln(os.Stdout, "No accounts have send blocked") + return nil + } + for _, acct := range accounts { + fmt.Fprintln(os.Stdout, acct) + } + return nil +} diff --git a/internal/cmd/gmail_autoreply.go b/internal/cmd/gmail_autoreply.go index b68fbb50..4b5b3da2 100644 --- a/internal/cmd/gmail_autoreply.go +++ b/internal/cmd/gmail_autoreply.go @@ -128,6 +128,10 @@ func (c *GmailAutoReplyCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } + if err = checkAccountNoSend(account); err != nil { + return err + } + summary, err := runGmailAutoReply(ctx, svc, account, input) if err != nil { return err diff --git a/internal/cmd/gmail_drafts.go b/internal/cmd/gmail_drafts.go index 89e5980a..30a827be 100644 --- a/internal/cmd/gmail_drafts.go +++ b/internal/cmd/gmail_drafts.go @@ -225,11 +225,15 @@ func (c *GmailDraftsSendCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - _, svc, err := requireGmailService(ctx, flags) + account, svc, err := requireGmailService(ctx, flags) if err != nil { return err } + if err = checkAccountNoSend(account); err != nil { + return err + } + msg, err := svc.Users.Drafts.Send("me", &gmail.Draft{Id: draftID}).Do() if err != nil { return err diff --git a/internal/cmd/gmail_no_send.go b/internal/cmd/gmail_no_send.go new file mode 100644 index 00000000..da025d63 --- /dev/null +++ b/internal/cmd/gmail_no_send.go @@ -0,0 +1,102 @@ +package cmd + +import ( + "strings" + + "github.com/alecthomas/kong" + + "github.com/steipete/gogcli/internal/config" +) + +// gmailSendCommands lists command-path prefixes that transmit email. +var gmailSendCommands = [][]string{ + {"send"}, // top-level alias → GmailSendCmd + {"gmail", "send"}, // gog gmail send + {"gmail", "drafts", "send"}, // gog gmail drafts send + {"gmail", "autoreply"}, // gog gmail autoreply +} + +func enforceGmailNoSend(kctx *kong.Context, flagNoSend bool) error { + noSend := flagNoSend + source := "--gmail-no-send (GOG_GMAIL_NO_SEND)" + + if !noSend { + if cfg, err := config.ReadConfig(); err == nil && cfg.GmailNoSend { + noSend = true + source = "gmail_no_send config" + } + } + + if !noSend { + return nil + } + + parts := strings.Fields(kctx.Command()) + if len(parts) == 0 { + return nil + } + + for i := range parts { + parts[i] = strings.ToLower(parts[i]) + } + + for _, prefix := range gmailSendCommands { + if hasCmdPrefix(parts, prefix) { + return usagef( + "send blocked by %s: use \"gog gmail drafts create\" to compose without sending", + source, + ) + } + } + + return nil +} + +func hasCmdPrefix(parts, prefix []string) bool { + if len(parts) < len(prefix) { + return false + } + + for i, p := range prefix { + if parts[i] != p { + return false + } + } + + return true +} + +// checkAccountNoSend reads the config and blocks send if the resolved account +// is in the no_send_accounts list. Call this after requireGmailService(). +func checkAccountNoSend(account string) error { + cfg, err := config.ReadConfig() + if err != nil { + return err + } + + if config.IsNoSendAccount(cfg, account) { + return usagef( + "send blocked for account %q (no_send_accounts): use \"gog gmail drafts create\" to compose without sending", + account, + ) + } + + return nil +} + +// isGmailSendCommand reports whether the kong command string is a send path. +// Exported for testing. +func isGmailSendCommand(command string) bool { + parts := strings.Fields(strings.ToLower(command)) + if len(parts) == 0 { + return false + } + + for _, prefix := range gmailSendCommands { + if hasCmdPrefix(parts, prefix) { + return true + } + } + + return false +} diff --git a/internal/cmd/gmail_no_send_test.go b/internal/cmd/gmail_no_send_test.go new file mode 100644 index 00000000..a50d2d7c --- /dev/null +++ b/internal/cmd/gmail_no_send_test.go @@ -0,0 +1,242 @@ +package cmd + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/config" +) + +func TestIsGmailSendCommand(t *testing.T) { + tests := []struct { + command string + want bool + }{ + // Blocked send paths + {"send", true}, + {"send --to x@y.com --subject S --body B", true}, + {"gmail send", true}, + {"gmail send --to x@y.com", true}, + {"gmail drafts send ", true}, + {"gmail autoreply ", true}, + + // Non-send paths (must NOT match) + {"gmail search ", false}, + {"gmail drafts create", false}, + {"gmail drafts list", false}, + {"gmail drafts get ", false}, + {"gmail drafts update ", false}, + {"gmail labels ls", false}, + {"gmail get ", false}, + {"gmail thread get ", false}, + {"gmail batch", false}, + {"gmail watch serve", false}, + {"drive ls", false}, + {"calendar events", false}, + {"", false}, + } + for _, tc := range tests { + if got := isGmailSendCommand(tc.command); got != tc.want { + t.Errorf("isGmailSendCommand(%q) = %v, want %v", tc.command, got, tc.want) + } + } +} + +func TestGmailNoSendBlocksViaCLI(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {"gmail send", []string{ + "--gmail-no-send", "gmail", "send", + "--to", "x@y.com", "--subject", "S", "--body", "B", + }}, + {"top-level send alias", []string{ + "--gmail-no-send", "send", + "--to", "x@y.com", "--subject", "S", "--body", "B", + }}, + {"gmail drafts send", []string{"--gmail-no-send", "gmail", "drafts", "send", "DRAFT123"}}, + {"gmail autoreply", []string{ + "--gmail-no-send", "gmail", "autoreply", + "from:someone", "--body", "reply", + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := Execute(tc.args) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "send blocked") { + t.Fatalf("expected 'send blocked' error, got: %v", err) + } + }) + } +} + +func TestGmailNoSendAllowsNonSendCommands(t *testing.T) { + err := Execute([]string{"--gmail-no-send", "--account", "a@b.com", "gmail", "search", "test"}) + if err == nil { + return // unlikely without auth, but acceptable + } + if strings.Contains(err.Error(), "send blocked") { + t.Fatalf("non-send command should not be blocked: %v", err) + } +} + +func TestGmailNoSendEnvVar(t *testing.T) { + t.Setenv("GOG_GMAIL_NO_SEND", "1") + err := Execute([]string{ + "gmail", "send", + "--to", "x@y.com", "--subject", "S", "--body", "B", + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "send blocked") { + t.Fatalf("expected 'send blocked' error, got: %v", err) + } +} + +func TestGmailNoSendNotSetAllowsSend(t *testing.T) { + // Ensure the env var is not set (may be set in the shell). + t.Setenv("GOG_GMAIL_NO_SEND", "") + // Without --gmail-no-send, send should NOT be blocked + // (it will fail for other reasons like missing auth) + err := Execute([]string{ + "gmail", "send", + "--to", "x@y.com", "--subject", "S", "--body", "B", + }) + if err == nil { + return + } + if strings.Contains(err.Error(), "send blocked") { + t.Fatalf("send should not be blocked without --gmail-no-send: %v", err) + } +} + +// writeNoSendConfig writes a config.json to a temp dir and sets +// XDG_CONFIG_HOME so config.ReadConfig() picks it up. +func writeNoSendConfig(t *testing.T, accounts map[string]bool) { + t.Helper() + + writeNoSendConfigFull(t, false, accounts) +} + +func writeNoSendConfigFull(t *testing.T, globalNoSend bool, accounts map[string]bool) { + t.Helper() + + xdgHome := t.TempDir() + configDir := filepath.Join(xdgHome, "gogcli") + + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + + cfg := config.File{ + GmailNoSend: globalNoSend, + NoSendAccounts: accounts, + } + b, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(configDir, "config.json"), b, 0o600); err != nil { + t.Fatal(err) + } + + t.Setenv("XDG_CONFIG_HOME", xdgHome) +} + +func TestCheckAccountNoSend_Blocked(t *testing.T) { + writeNoSendConfig(t, map[string]bool{"blocked@gmail.com": true}) + + err := checkAccountNoSend("blocked@gmail.com") + if err == nil { + t.Fatal("expected error for blocked account") + } + if !strings.Contains(err.Error(), "send blocked") { + t.Fatalf("expected 'send blocked' error, got: %v", err) + } + if !strings.Contains(err.Error(), "blocked@gmail.com") { + t.Fatalf("expected error to mention account, got: %v", err) + } +} + +func TestCheckAccountNoSend_Allowed(t *testing.T) { + writeNoSendConfig(t, map[string]bool{"blocked@gmail.com": true}) + + err := checkAccountNoSend("allowed@gmail.com") + if err != nil { + t.Fatalf("expected no error for allowed account, got: %v", err) + } +} + +func TestCheckAccountNoSend_NoConfig(t *testing.T) { + // Point to empty config dir — no config.json exists. + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + err := checkAccountNoSend("any@gmail.com") + if err != nil { + t.Fatalf("expected no error when config is absent, got: %v", err) + } +} + +func TestGmailNoSendGlobalOverridesPerAccount(t *testing.T) { + // Global flag should block even if the account is not in no_send_accounts. + writeNoSendConfig(t, nil) + + err := Execute([]string{ + "--gmail-no-send", "gmail", "send", + "--to", "x@y.com", "--subject", "S", "--body", "B", + }) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "send blocked") { + t.Fatalf("expected 'send blocked' error, got: %v", err) + } +} + +func TestGmailNoSendConfigGlobal(t *testing.T) { + // gmail_no_send: true in config.json should block send without the CLI flag. + t.Setenv("GOG_GMAIL_NO_SEND", "") + writeNoSendConfigFull(t, true, nil) + + err := Execute([]string{ + "gmail", "send", + "--to", "x@y.com", "--subject", "S", "--body", "B", + }) + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "send blocked") { + t.Fatalf("expected 'send blocked' error, got: %v", err) + } + + if !strings.Contains(err.Error(), "gmail_no_send config") { + t.Fatalf("expected error to mention config source, got: %v", err) + } +} + +func TestGmailNoSendConfigGlobalAllowsNonSend(t *testing.T) { + t.Setenv("GOG_GMAIL_NO_SEND", "") + writeNoSendConfigFull(t, true, nil) + + err := Execute([]string{ + "--account", "a@b.com", "gmail", "search", "test", + }) + if err == nil { + return + } + + if strings.Contains(err.Error(), "send blocked") { + t.Fatalf("non-send command should not be blocked: %v", err) + } +} diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index 25b77dbe..826c83a2 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -133,6 +133,10 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { return err } + if err = checkAccountNoSend(account); err != nil { + return err + } + sendAsList, sendAsListErr := listSendAs(ctx, svc) from, err := resolveComposeFrom(ctx, svc, account, c.From, sendAsList, sendAsListErr) if err != nil { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c898444b..0ed24e8a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -39,6 +39,7 @@ type RootFlags struct { Select string `name:"select" aliases:"pick,project" help:"In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands."` DryRun bool `help:"Do not make changes; print intended actions and exit successfully" aliases:"noop,preview,dryrun" short:"n"` Force bool `help:"Skip confirmations for destructive commands" aliases:"yes,assume-yes" short:"y"` + GmailNoSend bool `help:"Block all Gmail send operations (agent safety) ($GOG_GMAIL_NO_SEND)" default:"${gmail_no_send}"` NoInput bool `help:"Never prompt; fail instead (useful for CI)" aliases:"non-interactive,noninteractive"` Verbose bool `help:"Enable verbose logging" short:"v"` } @@ -127,6 +128,11 @@ func Execute(args []string) (err error) { return err } + if err = enforceGmailNoSend(kctx, cli.GmailNoSend); err != nil { + _, _ = fmt.Fprintln(os.Stderr, errfmt.Format(err)) + return err + } + logLevel := slog.LevelWarn if cli.Verbose { logLevel = slog.LevelDebug @@ -310,6 +316,7 @@ func newParser(description string) (*kong.Kong, *CLI, error) { "calendar_weekday": envOr("GOG_CALENDAR_WEEKDAY", "false"), "client": envOr("GOG_CLIENT", ""), "enabled_commands": envOr("GOG_ENABLE_COMMANDS", ""), + "gmail_no_send": boolString(envBool("GOG_GMAIL_NO_SEND")), "json": boolString(envMode.JSON), "plain": boolString(envMode.Plain), "version": VersionString(), diff --git a/internal/config/config.go b/internal/config/config.go index 2d0e40c1..35596068 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,8 @@ type File struct { AccountClients map[string]string `json:"account_clients,omitempty"` ClientDomains map[string]string `json:"client_domains,omitempty"` CalendarAliases map[string]string `json:"calendar_aliases,omitempty"` + GmailNoSend bool `json:"gmail_no_send,omitempty"` + NoSendAccounts map[string]bool `json:"no_send_accounts,omitempty"` } var errConfigLockTimeout = errors.New("acquire config lock timeout") diff --git a/internal/config/keys.go b/internal/config/keys.go index 9801be29..dbd10463 100644 --- a/internal/config/keys.go +++ b/internal/config/keys.go @@ -12,6 +12,7 @@ type Key string const ( KeyTimezone Key = "timezone" KeyKeyringBackend Key = "keyring_backend" + KeyGmailNoSend Key = "gmail_no_send" ) type KeySpec struct { @@ -25,6 +26,7 @@ type KeySpec struct { var keyOrder = []Key{ KeyTimezone, KeyKeyringBackend, + KeyGmailNoSend, } var keySpecs = map[Key]KeySpec{ @@ -65,14 +67,52 @@ var keySpecs = map[Key]KeySpec{ return "(not set, using auto)" }, }, + KeyGmailNoSend: { + Key: KeyGmailNoSend, + Get: func(cfg File) string { + if cfg.GmailNoSend { + return "true" + } + + return "false" + }, + Set: func(cfg *File, value string) error { + b, err := parseBoolValue(value) + if err != nil { + return fmt.Errorf("invalid gmail_no_send value %q: %w (use true/false)", value, err) + } + + cfg.GmailNoSend = b + + return nil + }, + Unset: func(cfg *File) { + cfg.GmailNoSend = false + }, + EmptyHint: func() string { + return "(not set, send allowed)" + }, + }, } var ( errUnknownConfigKey = errors.New("unknown config key") errConfigKeyCannotSet = errors.New("config key cannot be set") errConfigKeyCannotUnset = errors.New("config key cannot be unset") + errInvalidBoolValue = errors.New("invalid boolean value") ) +func parseBoolValue(s string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "true", "1", "yes", "on": + return true, nil + case "false", "0", "no", "off", "": + return false, nil + default: + return false, fmt.Errorf("%w: %q", errInvalidBoolValue, s) + } +} + func (k Key) String() string { return string(k) } diff --git a/internal/config/no_send.go b/internal/config/no_send.go new file mode 100644 index 00000000..2b4abc2d --- /dev/null +++ b/internal/config/no_send.go @@ -0,0 +1,57 @@ +package config + +import ( + "sort" + "strings" +) + +// IsNoSendAccount reports whether send is blocked for the given account. +func IsNoSendAccount(cfg File, email string) bool { + email = strings.ToLower(strings.TrimSpace(email)) + + if email == "" { + return false + } + + return cfg.NoSendAccounts[email] +} + +// SetNoSendAccount adds or removes an account from the no-send list. +func SetNoSendAccount(cfg *File, email string, block bool) error { + email = strings.ToLower(strings.TrimSpace(email)) + + if email == "" { + return errMissingEmail + } + + if block { + if cfg.NoSendAccounts == nil { + cfg.NoSendAccounts = make(map[string]bool) + } + + cfg.NoSendAccounts[email] = true + } else { + delete(cfg.NoSendAccounts, email) + + if len(cfg.NoSendAccounts) == 0 { + cfg.NoSendAccounts = nil + } + } + + return nil +} + +// ListNoSendAccounts returns sorted email addresses with send blocked. +func ListNoSendAccounts(cfg File) []string { + out := make([]string, 0, len(cfg.NoSendAccounts)) + + for email, blocked := range cfg.NoSendAccounts { + if blocked { + out = append(out, email) + } + } + + sort.Strings(out) + + return out +} diff --git a/internal/config/no_send_test.go b/internal/config/no_send_test.go new file mode 100644 index 00000000..0c30612c --- /dev/null +++ b/internal/config/no_send_test.go @@ -0,0 +1,86 @@ +package config + +import "testing" + +func TestIsNoSendAccount(t *testing.T) { + cfg := File{ + NoSendAccounts: map[string]bool{ + "blocked@gmail.com": true, + }, + } + + tests := []struct { + email string + want bool + }{ + {"blocked@gmail.com", true}, + {"BLOCKED@gmail.com", true}, + {" blocked@gmail.com ", true}, + {"allowed@gmail.com", false}, + {"", false}, + } + for _, tc := range tests { + if got := IsNoSendAccount(cfg, tc.email); got != tc.want { + t.Errorf("IsNoSendAccount(%q) = %v, want %v", tc.email, got, tc.want) + } + } +} + +func TestIsNoSendAccount_NilMap(t *testing.T) { + cfg := File{} + + if IsNoSendAccount(cfg, "any@gmail.com") { + t.Error("expected false for nil map") + } +} + +func TestSetNoSendAccount(t *testing.T) { + cfg := File{} + + if err := SetNoSendAccount(&cfg, "user@gmail.com", true); err != nil { + t.Fatal(err) + } + + if !cfg.NoSendAccounts["user@gmail.com"] { + t.Error("expected account to be blocked") + } + + if err := SetNoSendAccount(&cfg, "user@gmail.com", false); err != nil { + t.Fatal(err) + } + + if cfg.NoSendAccounts != nil { + t.Errorf("expected nil map after removing only entry, got %v", cfg.NoSendAccounts) + } +} + +func TestSetNoSendAccount_EmptyEmail(t *testing.T) { + cfg := File{} + + if err := SetNoSendAccount(&cfg, "", true); err == nil { + t.Error("expected error for empty email") + } +} + +func TestListNoSendAccounts(t *testing.T) { + cfg := File{ + NoSendAccounts: map[string]bool{ + "b@gmail.com": true, + "a@gmail.com": true, + }, + } + got := ListNoSendAccounts(cfg) + + if len(got) != 2 || got[0] != "a@gmail.com" || got[1] != "b@gmail.com" { + t.Errorf("ListNoSendAccounts = %v, want [a@gmail.com b@gmail.com]", got) + } +} + +func TestListNoSendAccounts_Empty(t *testing.T) { + cfg := File{} + got := ListNoSendAccounts(cfg) + + if len(got) != 0 { + t.Errorf("expected empty list, got %v", got) + } +}