From b793cbaa508f02580f1ab1fc31ddef1772e48778 Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Mon, 16 Mar 2026 12:31:51 -0400 Subject: [PATCH 1/2] feat(gmail): add --gmail-no-send flag to block send operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a CLI-layer kill switch (--gmail-no-send / GOG_GMAIL_NO_SEND=1) that blocks all send operations in the gmail command group, regardless of OAuth scopes. gmail.modify is the right scope for an agent that needs to read, label, and create drafts, but it also permits send. Google provides no scope that grants modify-level access without send capability. This flag gives agent operators a hard invariant: deploy gog with GOG_GMAIL_NO_SEND=1 and send is impossible, full stop, regardless of what the agent is told or forgets. Blocked paths: send, gmail send, gmail drafts send, gmail autoreply. Enforced centrally in Execute() using the same pattern as enforceEnabledCommands — post-parse, pre-Run(), exit code 2. --- internal/cmd/gmail_no_send.go | 63 ++++++++++++++++ internal/cmd/gmail_no_send_test.go | 112 +++++++++++++++++++++++++++++ internal/cmd/root.go | 6 ++ 3 files changed, 181 insertions(+) create mode 100644 internal/cmd/gmail_no_send.go create mode 100644 internal/cmd/gmail_no_send_test.go diff --git a/internal/cmd/gmail_no_send.go b/internal/cmd/gmail_no_send.go new file mode 100644 index 00000000..245ff9cb --- /dev/null +++ b/internal/cmd/gmail_no_send.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "strings" + + "github.com/alecthomas/kong" +) + +// 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, noSend bool) error { + 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 --gmail-no-send (GOG_GMAIL_NO_SEND): use \"gog gmail drafts create\" to compose without sending", + ) + } + } + 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 +} + +// 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..4af21cad --- /dev/null +++ b/internal/cmd/gmail_no_send_test.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "strings" + "testing" +) + +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) { + // 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) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c898444b..9b8cc83c 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)" env:"GOG_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 From fdf5c18c18f528aac9376f8258d904603ca4c8c5 Mon Sep 17 00:00:00 2001 From: VeteranBV Date: Tue, 17 Mar 2026 13:21:49 -0400 Subject: [PATCH 2/2] feat(gmail): add per-account no-send config and gmail_no_send config key Extend the global --gmail-no-send flag with per-account control and a persistent config key, based on community feedback from @codefly. Per-account: gog config no-send set/remove/list manages a no_send_accounts map in config.json. The check runs in each send command's Run() after account resolution, since account identity isn't known until requireAccount() resolves aliases and defaults. Global config key: gog config set gmail_no_send true persists the global block in config.json without requiring an env var or CLI flag. Precedence: CLI flag > env var > config key > per-account > default. Also fixes the env var handling to use the envBool/boolString pattern (matching JSON and Plain flags) so that GOG_GMAIL_NO_SEND="" doesn't cause a Kong parse error. --- internal/cmd/config_cmd.go | 13 +-- internal/cmd/config_no_send.go | 96 +++++++++++++++++++++ internal/cmd/gmail_autoreply.go | 4 + internal/cmd/gmail_drafts.go | 6 +- internal/cmd/gmail_no_send.go | 43 +++++++++- internal/cmd/gmail_no_send_test.go | 130 +++++++++++++++++++++++++++++ internal/cmd/gmail_send.go | 4 + internal/cmd/root.go | 3 +- internal/config/config.go | 2 + internal/config/keys.go | 40 +++++++++ internal/config/no_send.go | 57 +++++++++++++ internal/config/no_send_test.go | 86 +++++++++++++++++++ 12 files changed, 474 insertions(+), 10 deletions(-) create mode 100644 internal/cmd/config_no_send.go create mode 100644 internal/config/no_send.go create mode 100644 internal/config/no_send_test.go 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 index 245ff9cb..da025d63 100644 --- a/internal/cmd/gmail_no_send.go +++ b/internal/cmd/gmail_no_send.go @@ -4,6 +4,8 @@ import ( "strings" "github.com/alecthomas/kong" + + "github.com/steipete/gogcli/internal/config" ) // gmailSendCommands lists command-path prefixes that transmit email. @@ -14,24 +16,39 @@ var gmailSendCommands = [][]string{ {"gmail", "autoreply"}, // gog gmail autoreply } -func enforceGmailNoSend(kctx *kong.Context, noSend bool) error { +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 --gmail-no-send (GOG_GMAIL_NO_SEND): use \"gog gmail drafts create\" to compose without sending", + "send blocked by %s: use \"gog gmail drafts create\" to compose without sending", + source, ) } } + return nil } @@ -39,14 +56,34 @@ 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 { @@ -54,10 +91,12 @@ func isGmailSendCommand(command string) bool { 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 index 4af21cad..a50d2d7c 100644 --- a/internal/cmd/gmail_no_send_test.go +++ b/internal/cmd/gmail_no_send_test.go @@ -1,8 +1,13 @@ package cmd import ( + "encoding/json" + "os" + "path/filepath" "strings" "testing" + + "github.com/steipete/gogcli/internal/config" ) func TestIsGmailSendCommand(t *testing.T) { @@ -97,6 +102,8 @@ func TestGmailNoSendEnvVar(t *testing.T) { } 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{ @@ -110,3 +117,126 @@ func TestGmailNoSendNotSetAllowsSend(t *testing.T) { 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 9b8cc83c..0ed24e8a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -39,7 +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)" env:"GOG_GMAIL_NO_SEND"` + 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"` } @@ -316,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) + } +}