diff --git a/.surface b/.surface index c688616..4fbbb80 100644 --- a/.surface +++ b/.surface @@ -32,13 +32,16 @@ hey completion hey compose hey compose --bcc hey compose --cc +hey compose --from hey compose --message hey compose --subject hey compose --thread-id hey compose --to hey config +hey config get hey config set hey config show +hey config unset hey doctor hey drafts hey drafts --all @@ -61,7 +64,14 @@ hey recordings --ends-on hey recordings --limit hey recordings --starts-on hey reply +hey reply --from hey reply --message +hey screener +hey screener approve +hey screener deny +hey screener feed +hey screener spam +hey screener trail hey seen hey setup hey skill diff --git a/internal/cmd/compose.go b/internal/cmd/compose.go index 0add1f4..bc6c4bd 100644 --- a/internal/cmd/compose.go +++ b/internal/cmd/compose.go @@ -19,6 +19,7 @@ type composeCommand struct { subject string message string threadID string + from string } func newComposeCommand() *composeCommand { @@ -42,6 +43,7 @@ func newComposeCommand() *composeCommand { composeCommand.cmd.Flags().StringVar(&composeCommand.subject, "subject", "", "Message subject (required)") composeCommand.cmd.Flags().StringVarP(&composeCommand.message, "message", "m", "", "Message body (or opens $EDITOR)") composeCommand.cmd.Flags().StringVar(&composeCommand.threadID, "thread-id", "", "Thread ID to post message to") + composeCommand.cmd.Flags().StringVar(&composeCommand.from, "from", "", "Sender email address (overrides default)") return composeCommand } @@ -80,20 +82,70 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() + // When --from or default_sender is set, we bypass the SDK's service methods + // and call PostMutation directly so we can control the acting_sender_id. + hasSenderOverride := c.from != "" || cfg.DefaultSender != "" + if c.threadID != "" { topicID, err := strconv.ParseInt(c.threadID, 10, 64) if err != nil { return output.ErrUsage(fmt.Sprintf("invalid thread ID: %s", c.threadID)) } - if err := sdk.Messages().CreateTopicMessage(ctx, topicID, message); err != nil { - return convertSDKError(err) + if hasSenderOverride { + senderID, err := effectiveSenderID(ctx, c.from) + if err != nil { + return err + } + body := map[string]any{ + "acting_sender_id": senderID, + "message": map[string]any{ + "content": message, + }, + } + if _, err := sdk.PostMutation(ctx, fmt.Sprintf("/topics/%d/entries.json", topicID), body); err != nil { + return convertSDKError(err) + } + } else { + if err := sdk.Messages().CreateTopicMessage(ctx, topicID, message); err != nil { + return convertSDKError(err) + } } } else { to := parseAddresses(c.to) cc := parseAddresses(c.cc) bcc := parseAddresses(c.bcc) - if err := sdk.Messages().Create(ctx, c.subject, message, to, cc, bcc); err != nil { - return convertSDKError(err) + if hasSenderOverride { + senderID, err := effectiveSenderID(ctx, c.from) + if err != nil { + return err + } + addressed := map[string]any{} + if len(to) > 0 { + addressed["directly"] = to + } + if len(cc) > 0 { + addressed["copied"] = cc + } + if len(bcc) > 0 { + addressed["blindcopied"] = bcc + } + body := map[string]any{ + "acting_sender_id": senderID, + "message": map[string]any{ + "subject": c.subject, + "content": message, + }, + "entry": map[string]any{ + "addressed": addressed, + }, + } + if _, err := sdk.PostMutation(ctx, "/messages.json", body); err != nil { + return convertSDKError(err) + } + } else { + if err := sdk.Messages().Create(ctx, c.subject, message, to, cc, bcc); err != nil { + return convertSDKError(err) + } } } diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 1378e2f..bca2c44 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/spf13/cobra" @@ -12,6 +13,8 @@ type configCommand struct { cmd *cobra.Command } +var configKeys = []string{"base_url", "default_sender"} + func newConfigCommand() *configCommand { configCommand := &configCommand{} configCommand.cmd = &cobra.Command{ @@ -21,27 +24,35 @@ func newConfigCommand() *configCommand { configCommand.cmd.AddCommand(newConfigShowCommand()) configCommand.cmd.AddCommand(newConfigSetCommand()) + configCommand.cmd.AddCommand(newConfigGetCommand()) + configCommand.cmd.AddCommand(newConfigUnsetCommand()) return configCommand } +// normalizeConfigKey converts hyphens to underscores for config key lookup. +func normalizeConfigKey(key string) string { + return strings.ReplaceAll(key, "-", "_") +} + func newConfigSetCommand() *cobra.Command { return &cobra.Command{ Use: "set ", Short: "Set a configuration value in the global config", Example: ` hey config set base_url http://app.hey.localhost:3003 - hey config set base_url https://app.hey.com`, + hey config set base_url https://app.hey.com + hey config set default_sender you@hey.com`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - key, value := args[0], args[1] + key, value := normalizeConfigKey(args[0]), args[1] switch key { - case "base_url": + case "base_url", "default_sender": if err := cfg.SetFromFlag(key, value); err != nil { return err } default: - return output.ErrUsage(fmt.Sprintf("unknown config key: %s (available: base_url)", key)) + return output.ErrUsage(fmt.Sprintf("unknown config key: %s (available: %s)", key, strings.Join(configKeys, ", "))) } if err := cfg.Save(); err != nil { @@ -59,6 +70,72 @@ func newConfigSetCommand() *cobra.Command { } } +func newConfigGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get a configuration value", + Example: ` hey config get default_sender + hey config get base_url`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := normalizeConfigKey(args[0]) + + var value string + switch key { + case "base_url": + value = cfg.BaseURL + case "default_sender": + value = cfg.DefaultSender + default: + return output.ErrUsage(fmt.Sprintf("unknown config key: %s (available: %s)", key, strings.Join(configKeys, ", "))) + } + + if writer.IsStyled() { + if value == "" { + fmt.Fprintf(cmd.OutOrStdout(), "%s is not set\n", key) + } else { + fmt.Fprintln(cmd.OutOrStdout(), value) + } + return nil + } + return writeOK(map[string]string{"key": key, "value": value}, + output.WithSummary(value), + ) + }, + } +} + +func newConfigUnsetCommand() *cobra.Command { + return &cobra.Command{ + Use: "unset ", + Short: "Clear a configuration value", + Example: ` hey config unset default_sender`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := normalizeConfigKey(args[0]) + + switch key { + case "default_sender": + cfg.UnsetField(key) + default: + return output.ErrUsage(fmt.Sprintf("cannot unset key: %s (unsettable keys: default_sender)", key)) + } + + if err := cfg.Save(); err != nil { + return err + } + + if writer.IsStyled() { + fmt.Fprintf(cmd.OutOrStdout(), "Unset %s\n", key) + return nil + } + return writeOK(map[string]string{"key": key}, + output.WithSummary(fmt.Sprintf("Unset %s", key)), + ) + }, + } +} + func newConfigShowCommand() *cobra.Command { return &cobra.Command{ Use: "show", @@ -70,6 +147,11 @@ func newConfigShowCommand() *cobra.Command { "value": cfg.BaseURL, "source": string(cfg.SourceOf("base_url")), }, + { + "key": "default_sender", + "value": cfg.DefaultSender, + "source": string(cfg.SourceOf("default_sender")), + }, } if writer.IsStyled() { diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index 16d2c20..5ddaeb0 100644 --- a/internal/cmd/reply.go +++ b/internal/cmd/reply.go @@ -14,6 +14,7 @@ import ( type replyCommand struct { cmd *cobra.Command message string + from string } func newReplyCommand() *replyCommand { @@ -31,6 +32,7 @@ func newReplyCommand() *replyCommand { } replyCommand.cmd.Flags().StringVarP(&replyCommand.message, "message", "m", "", "Reply message (or opens $EDITOR)") + replyCommand.cmd.Flags().StringVar(&replyCommand.from, "from", "", "Sender email address (overrides default)") return replyCommand } @@ -90,8 +92,40 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { } } - if err = sdk.Entries().CreateReply(ctx, latestEntryID, message, addressed.To, addressed.CC, addressed.BCC); err != nil { - return convertSDKError(err) + hasSenderOverride := c.from != "" || cfg.DefaultSender != "" + if hasSenderOverride { + senderID, err := effectiveSenderID(ctx, c.from) + if err != nil { + return err + } + body := map[string]any{ + "acting_sender_id": senderID, + "message": map[string]any{ + "content": message, + }, + } + addrMap := map[string]any{} + if len(addressed.To) > 0 { + addrMap["directly"] = addressed.To + } + if len(addressed.CC) > 0 { + addrMap["copied"] = addressed.CC + } + if len(addressed.BCC) > 0 { + addrMap["blindcopied"] = addressed.BCC + } + if len(addrMap) > 0 { + body["entry"] = map[string]any{ + "addressed": addrMap, + } + } + if _, err := sdk.PostMutation(ctx, fmt.Sprintf("/entries/%d/replies.json", latestEntryID), body); err != nil { + return convertSDKError(err) + } + } else { + if err = sdk.Entries().CreateReply(ctx, latestEntryID, message, addressed.To, addressed.CC, addressed.BCC); err != nil { + return convertSDKError(err) + } } if writer.IsStyled() { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 568b3a2..0db050a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -138,6 +138,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newCompletionCommand()) root.AddCommand(newDoctorCommand()) root.AddCommand(newConfigCommand().cmd) + root.AddCommand(newScreenerCommand().cmd) return root } diff --git a/internal/cmd/screener.go b/internal/cmd/screener.go new file mode 100644 index 0000000..dbbef8d --- /dev/null +++ b/internal/cmd/screener.go @@ -0,0 +1,356 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/output" +) + +// screenerItem represents a pending item in The Screener. +type screenerItem struct { + PostingID string `json:"posting_id"` + Sender string `json:"sender"` + Subject string `json:"subject"` +} + +type screenerCommand struct { + cmd *cobra.Command +} + +func newScreenerCommand() *screenerCommand { + sc := &screenerCommand{} + sc.cmd = &cobra.Command{ + Use: "screener", + Short: "Manage The Screener (pending sender approvals)", + Annotations: map[string]string{ + "agent_notes": "List pending screener items and approve/deny senders. Use approve/deny/spam/feed/trail subcommands with posting ID.", + }, + Example: ` hey screener + hey screener approve 123456 + hey screener deny 123456 + hey screener spam 123456 + hey screener feed 123456 + hey screener trail 123456`, + RunE: sc.list, + } + + sc.cmd.AddCommand(&cobra.Command{ + Use: "approve ", + Short: "Screen in a sender to Imbox", + Args: cobra.ExactArgs(1), + RunE: sc.approve, + }) + sc.cmd.AddCommand(&cobra.Command{ + Use: "deny ", + Short: "Screen out a sender", + Args: cobra.ExactArgs(1), + RunE: sc.deny, + }) + sc.cmd.AddCommand(&cobra.Command{ + Use: "spam ", + Short: "Mark a sender as spam", + Args: cobra.ExactArgs(1), + RunE: sc.markSpam, + }) + sc.cmd.AddCommand(&cobra.Command{ + Use: "feed ", + Short: "Screen in a sender to The Feed", + Args: cobra.ExactArgs(1), + RunE: sc.feed, + }) + sc.cmd.AddCommand(&cobra.Command{ + Use: "trail ", + Short: "Screen in a sender to Paper Trail", + Args: cobra.ExactArgs(1), + RunE: sc.trail, + }) + + return sc +} + +func (sc *screenerCommand) list(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + items, feedBoxID, trailBoxID, err := fetchScreenerItems(cmd.Context()) + if err != nil { + return err + } + _ = feedBoxID + _ = trailBoxID + + if writer.IsStyled() { + if len(items) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "The Screener is empty.") + return nil + } + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"Posting ID", "Sender", "Subject"}) + for _, item := range items { + table.addRow([]string{item.PostingID, item.Sender, item.Subject}) + } + table.print() + fmt.Fprintf(cmd.OutOrStdout(), "\n%d pending\n", len(items)) + return nil + } + + return writeOK(items, + output.WithSummary(fmt.Sprintf("%d pending", len(items))), + output.WithBreadcrumbs( + output.Breadcrumb{ + Action: "approve", + Command: "hey screener approve ", + Description: "Screen in to Imbox", + }, + output.Breadcrumb{ + Action: "deny", + Command: "hey screener deny ", + Description: "Screen out sender", + }, + ), + ) +} + +func (sc *screenerCommand) approve(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + return patchClearance(cmd, args[0], url.Values{ + "status": {"approved"}, + }, "Screened in to Imbox") +} + +func (sc *screenerCommand) deny(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + return patchClearance(cmd, args[0], url.Values{ + "status": {"denied"}, + }, "Screened out") +} + +func (sc *screenerCommand) markSpam(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + return patchClearance(cmd, args[0], url.Values{ + "status": {"denied"}, + "spam": {"true"}, + }, "Marked as spam") +} + +func (sc *screenerCommand) feed(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + _, feedBoxID, _, err := fetchScreenerItems(cmd.Context()) + if err != nil { + return err + } + if feedBoxID == "" { + return fmt.Errorf("could not determine Feed box ID") + } + + return patchClearance(cmd, args[0], url.Values{ + "status": {"approved"}, + "designation_box_id": {feedBoxID}, + }, "Screened in to Feed") +} + +func (sc *screenerCommand) trail(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + _, _, trailBoxID, err := fetchScreenerItems(cmd.Context()) + if err != nil { + return err + } + if trailBoxID == "" { + return fmt.Errorf("could not determine Paper Trail box ID") + } + + return patchClearance(cmd, args[0], url.Values{ + "status": {"approved"}, + "designation_box_id": {trailBoxID}, + }, "Screened in to Paper Trail") +} + +// fetchScreenerItems parses the /clearances HTML page to extract pending items +// and the feed/trail box IDs from form hidden inputs. +func fetchScreenerItems(ctx context.Context) ([]screenerItem, string, string, error) { + body, err := authenticatedGet(ctx, "/clearances") + if err != nil { + return nil, "", "", err + } + + var items []screenerItem + var feedBoxID, trailBoxID string + + // Extract emails as posting identifiers + // Pattern: forms POSTing to /clearances/ + postingIDs := regexp.MustCompile(`action="/clearances/(\d+)"`).FindAllStringSubmatch(body, -1) + seen := map[string]bool{} + var uniqueIDs []string + for _, m := range postingIDs { + if !seen[m[1]] { + seen[m[1]] = true + uniqueIDs = append(uniqueIDs, m[1]) + } + } + + // Extract emails + emailRe := regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`) + allEmails := emailRe.FindAllString(body, -1) + + // Filter out the authenticated user's own emails (they appear in nav/header). + // Look up dynamically via the identity endpoint. + ownEmails := map[string]bool{} + if identity, err := sdk.Identity().GetIdentity(ctx); err == nil && identity != nil { + for _, s := range identity.Senders { + ownEmails[strings.ToLower(s.EmailAddress)] = true + } + } + senderEmails := []string{} + for _, e := range allEmails { + if !ownEmails[strings.ToLower(e)] { + senderEmails = append(senderEmails, e) + } + } + + // Extract subjects + subjectRe := regexp.MustCompile(`]*>([^<]*(?:Re:|Fwd:|We'|Your |Hi |Hello|Thank|Welcome|Confirm|Order|Invoice|Receipt|Upgrade)[^<]*)`) + subjects := subjectRe.FindAllStringSubmatch(body, -1) + var subjectTexts []string + for _, s := range subjects { + text := strings.TrimSpace(s[1]) + if text != "" { + subjectTexts = append(subjectTexts, text) + } + } + + // Extract feed/trail box IDs from hidden form inputs. + // The HTML has forms where designation_box_id appears before the feedbox/trailbox button, + // so we use (?s) to match across newlines. + feedRe := regexp.MustCompile(`(?s)designation_box_id[^>]*value="(\d+)"[^<]*?[^<]*]*feedboxButton`) + if m := feedRe.FindStringSubmatch(body); m != nil { + feedBoxID = m[1] + } + // Alternative pattern: button target appears after the input in the same form + if feedBoxID == "" { + altFeedRe := regexp.MustCompile(`(?s)designation_box_id"[^>]*value="(\d+)".*?feedboxButton`) + if m := altFeedRe.FindStringSubmatch(body); m != nil { + feedBoxID = m[1] + } + } + + trailRe := regexp.MustCompile(`(?s)designation_box_id[^>]*value="(\d+)"[^<]*?[^<]*]*trailboxButton`) + if m := trailRe.FindStringSubmatch(body); m != nil { + trailBoxID = m[1] + } + if trailBoxID == "" { + altTrailRe := regexp.MustCompile(`(?s)designation_box_id"[^>]*value="(\d+)".*?trailboxButton`) + if m := altTrailRe.FindStringSubmatch(body); m != nil { + trailBoxID = m[1] + } + } + + // Build items: match posting IDs with senders and subjects + for i, id := range uniqueIDs { + item := screenerItem{PostingID: id} + if i < len(senderEmails) { + item.Sender = senderEmails[i] + } + if i < len(subjectTexts) { + item.Subject = subjectTexts[i] + } + items = append(items, item) + } + + return items, feedBoxID, trailBoxID, nil +} + +// patchClearance sends a PATCH request to /clearances/ with the given form values. +func patchClearance(cmd *cobra.Command, postingID string, values url.Values, successMsg string) error { + ctx := cmd.Context() + values.Set("_method", "patch") + + reqURL := cfg.BaseURL + "/clearances/" + postingID + req, err := http.NewRequestWithContext(ctx, "PATCH", reqURL, strings.NewReader(values.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + if err := authMgr.AuthenticateRequest(ctx, req); err != nil { + return err + } + + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse // Don't follow redirects + }, + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 302 && resp.StatusCode != 200 { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + if writer.IsStyled() { + fmt.Fprintf(cmd.OutOrStdout(), "%s (posting %s)\n", successMsg, postingID) + return nil + } + + return writeOK(map[string]string{ + "posting_id": postingID, + "action": successMsg, + }) +} + +// authenticatedGet makes an authenticated GET request and returns the response body as a string. +func authenticatedGet(ctx context.Context, path string) (string, error) { + reqURL := cfg.BaseURL + path + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "text/html") + + if err := authMgr.AuthenticateRequest(ctx, req); err != nil { + return "", err + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("unexpected status %d for %s", resp.StatusCode, path) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(bodyBytes), nil +} diff --git a/internal/cmd/screener_test.go b/internal/cmd/screener_test.go new file mode 100644 index 0000000..1a88d83 --- /dev/null +++ b/internal/cmd/screener_test.go @@ -0,0 +1,315 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/basecamp/hey-cli/internal/output" +) + +const clearancesHTMLTemplate = ` + +The Screener + +
+%s +
+ +` + +func clearanceItemHTML(postingID, senderEmail, subject, feedBoxID, trailBoxID string) string { + return fmt.Sprintf(` +
+ %[3]s + %[2]s +
+ + + +
+
+ + + + +
+
+ + + + +
+
+ + + +
+
+ + + + +
+
`, + postingID, senderEmail, subject, feedBoxID, trailBoxID) +} + +type clearanceTestItem struct { + PostingID string + Sender string + Subject string +} + +func screenerServer(t *testing.T, items []clearanceTestItem) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/clearances.json": + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"pending_clearances_count":%d}`, len(items)) + + case r.Method == "GET" && r.URL.Path == "/clearances": + var itemsHTML string + for _, item := range items { + itemsHTML += clearanceItemHTML(item.PostingID, item.Sender, item.Subject, "4848561", "4848564") + } + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, clearancesHTMLTemplate, itemsHTML) + + case r.Method == "PATCH" && strings.HasPrefix(r.URL.Path, "/clearances/"): + _ = r.ParseForm() + w.Header().Set("Location", "/clearances") + w.WriteHeader(302) + + case r.Method == "GET" && r.URL.Path == "/me.json": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": 1}`)) + + default: + w.WriteHeader(404) + } + })) +} + +func runScreener(t *testing.T, server *httptest.Server, args ...string) (output.Response, error) { + t.Helper() + t.Setenv("HEY_TOKEN", "test-token") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("XDG_STATE_HOME", tmpDir) + t.Setenv("XDG_CACHE_HOME", tmpDir) + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs(append([]string{"screener", "--json", "--base-url", server.URL}, args...)) + + err := root.Execute() + var resp output.Response + if buf.Len() > 0 { + _ = json.Unmarshal(buf.Bytes(), &resp) + } + return resp, err +} + +func TestScreenerListEmpty(t *testing.T) { + server := screenerServer(t, nil) + defer server.Close() + + resp, err := runScreener(t, server) + if err != nil { + t.Fatalf("execute: %v", err) + } + if resp.Summary != "0 pending" { + t.Errorf("summary = %q, want %q", resp.Summary, "0 pending") + } +} + +func TestScreenerListWithItems(t *testing.T) { + items := []clearanceTestItem{ + {PostingID: "123456", Sender: "promo@example.com", Subject: "Your order is ready"}, + {PostingID: "789012", Sender: "noreply@shop.com", Subject: "Welcome to our store"}, + } + server := screenerServer(t, items) + defer server.Close() + + resp, err := runScreener(t, server) + if err != nil { + t.Fatalf("execute: %v", err) + } + if resp.Summary != "2 pending" { + t.Errorf("summary = %q, want %q", resp.Summary, "2 pending") + } + if resp.Data == nil { + t.Fatal("expected data, got nil") + } + dataSlice, ok := resp.Data.([]any) + if !ok { + t.Fatalf("expected []any, got %T", resp.Data) + } + if len(dataSlice) != 2 { + t.Errorf("expected 2 items, got %d", len(dataSlice)) + } +} + +func TestScreenerApprove(t *testing.T) { + items := []clearanceTestItem{ + {PostingID: "123456", Sender: "promo@example.com", Subject: "Your order"}, + } + server := screenerServer(t, items) + defer server.Close() + + resp, err := runScreener(t, server, "approve", "123456") + if err != nil { + t.Fatalf("execute: %v", err) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("expected map, got %T", resp.Data) + } + if data["posting_id"] != "123456" { + t.Errorf("posting_id = %v, want %q", data["posting_id"], "123456") + } + if action, _ := data["action"].(string); action != "Screened in to Imbox" { + t.Errorf("action = %q, want %q", action, "Screened in to Imbox") + } +} + +func TestScreenerDeny(t *testing.T) { + items := []clearanceTestItem{ + {PostingID: "123456", Sender: "spam@bad.com", Subject: "You won!"}, + } + server := screenerServer(t, items) + defer server.Close() + + resp, err := runScreener(t, server, "deny", "123456") + if err != nil { + t.Fatalf("execute: %v", err) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("expected map, got %T", resp.Data) + } + if action, _ := data["action"].(string); action != "Screened out" { + t.Errorf("action = %q, want %q", action, "Screened out") + } +} + +func TestScreenerSpam(t *testing.T) { + items := []clearanceTestItem{ + {PostingID: "123456", Sender: "spam@bad.com", Subject: "Buy now!"}, + } + server := screenerServer(t, items) + defer server.Close() + + resp, err := runScreener(t, server, "spam", "123456") + if err != nil { + t.Fatalf("execute: %v", err) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("expected map, got %T", resp.Data) + } + if action, _ := data["action"].(string); action != "Marked as spam" { + t.Errorf("action = %q, want %q", action, "Marked as spam") + } +} + +func TestScreenerFeed(t *testing.T) { + items := []clearanceTestItem{ + {PostingID: "123456", Sender: "newsletter@blog.com", Subject: "Weekly digest"}, + } + server := screenerServer(t, items) + defer server.Close() + + resp, err := runScreener(t, server, "feed", "123456") + if err != nil { + t.Fatalf("execute: %v", err) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("expected map, got %T", resp.Data) + } + if action, _ := data["action"].(string); action != "Screened in to Feed" { + t.Errorf("action = %q, want %q", action, "Screened in to Feed") + } +} + +func TestScreenerTrail(t *testing.T) { + items := []clearanceTestItem{ + {PostingID: "123456", Sender: "receipts@store.com", Subject: "Your receipt"}, + } + server := screenerServer(t, items) + defer server.Close() + + resp, err := runScreener(t, server, "trail", "123456") + if err != nil { + t.Fatalf("execute: %v", err) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("expected map, got %T", resp.Data) + } + if action, _ := data["action"].(string); action != "Screened in to Paper Trail" { + t.Errorf("action = %q, want %q", action, "Screened in to Paper Trail") + } +} + +func TestScreenerApproveNoArgs(t *testing.T) { + server := screenerServer(t, nil) + defer server.Close() + + _, err := runScreener(t, server, "approve") + if err == nil { + t.Fatal("expected error for missing posting ID") + } +} + +func TestScreenerDenyNoArgs(t *testing.T) { + server := screenerServer(t, nil) + defer server.Close() + + _, err := runScreener(t, server, "deny") + if err == nil { + t.Fatal("expected error for missing posting ID") + } +} + +func TestScreenerSpamNoArgs(t *testing.T) { + server := screenerServer(t, nil) + defer server.Close() + + _, err := runScreener(t, server, "spam") + if err == nil { + t.Fatal("expected error for missing posting ID") + } +} + +func TestScreenerFeedNoArgs(t *testing.T) { + server := screenerServer(t, nil) + defer server.Close() + + _, err := runScreener(t, server, "feed") + if err == nil { + t.Fatal("expected error for missing posting ID") + } +} + +func TestScreenerTrailNoArgs(t *testing.T) { + server := screenerServer(t, nil) + defer server.Close() + + _, err := runScreener(t, server, "trail") + if err == nil { + t.Fatal("expected error for missing posting ID") + } +} diff --git a/internal/cmd/sender.go b/internal/cmd/sender.go new file mode 100644 index 0000000..0b85ac6 --- /dev/null +++ b/internal/cmd/sender.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/basecamp/hey-cli/internal/output" +) + +// resolveSenderID looks up a sender ID by email address from the identity endpoint. +// Returns an error if the email doesn't match any configured sender. +func resolveSenderID(ctx context.Context, email string) (int64, error) { + identity, err := sdk.Identity().GetIdentity(ctx) + if err != nil { + return 0, convertSDKError(err) + } + if identity == nil { + return 0, output.ErrAPI(0, "could not fetch identity") + } + + email = strings.ToLower(strings.TrimSpace(email)) + var available []string + for _, s := range identity.Senders { + if strings.ToLower(s.EmailAddress) == email { + return s.Id, nil + } + available = append(available, s.EmailAddress) + } + + return 0, output.ErrUsage(fmt.Sprintf( + "no sender matching %q (available: %s)", + email, strings.Join(available, ", "), + )) +} + +// effectiveSenderID determines the sender ID to use for a mutation. +// Priority: --from flag > config default_sender > SDK default. +func effectiveSenderID(ctx context.Context, fromFlag string) (int64, error) { + if fromFlag != "" { + return resolveSenderID(ctx, fromFlag) + } + if cfg.DefaultSender != "" { + return resolveSenderID(ctx, cfg.DefaultSender) + } + id, err := sdk.DefaultSenderID(ctx) + if err != nil { + return 0, convertSDKError(err) + } + return id, nil +} diff --git a/internal/cmd/sender_test.go b/internal/cmd/sender_test.go new file mode 100644 index 0000000..af51636 --- /dev/null +++ b/internal/cmd/sender_test.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "testing" +) + +func TestNormalizeConfigKey(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"default-sender", "default_sender"}, + {"default_sender", "default_sender"}, + {"base-url", "base_url"}, + {"base_url", "base_url"}, + {"no-hyphens", "no_hyphens"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := normalizeConfigKey(tt.input) + if got != tt.want { + t.Errorf("normalizeConfigKey(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 71b8468..65e629f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,7 +33,8 @@ type Value struct { } type Config struct { - BaseURL string `json:"base_url"` + BaseURL string `json:"base_url"` + DefaultSender string `json:"default_sender,omitempty"` sources map[string]Source } @@ -115,7 +116,10 @@ func localConfigPath() string { func Load() (*Config, error) { cfg := &Config{ BaseURL: defaultBase, - sources: map[string]Source{"base_url": SourceDefault}, + sources: map[string]Source{ + "base_url": SourceDefault, + "default_sender": SourceDefault, + }, } // Layer 1: global config @@ -158,7 +162,8 @@ func (c *Config) loadFile(path string, source Source) error { } var file struct { - BaseURL string `json:"base_url"` + BaseURL string `json:"base_url"` + DefaultSender string `json:"default_sender"` } if err := json.Unmarshal(data, &file); err != nil { return fmt.Errorf("could not parse config %s: %w", path, err) @@ -168,6 +173,10 @@ func (c *Config) loadFile(path string, source Source) error { c.BaseURL = file.BaseURL c.sources["base_url"] = source } + if file.DefaultSender != "" { + c.DefaultSender = file.DefaultSender + c.sources["default_sender"] = source + } return nil } @@ -194,13 +203,31 @@ func (c *Config) SetFromFlag(key, value string) error { c.sources = map[string]Source{} } c.sources["base_url"] = SourceFlag + case "default_sender": + c.DefaultSender = value + if c.sources == nil { + c.sources = map[string]Source{} + } + c.sources["default_sender"] = SourceFlag } return nil } +// UnsetField clears a configuration field so it reverts to default. +func (c *Config) UnsetField(key string) { + switch key { + case "default_sender": + c.DefaultSender = "" + if c.sources != nil { + c.sources["default_sender"] = SourceDefault + } + } +} + func (c *Config) Values() []Value { return []Value{ {Value: c.BaseURL, Source: c.SourceOf("base_url")}, + {Value: c.DefaultSender, Source: c.SourceOf("default_sender")}, } } diff --git a/internal/config/config_default_sender_test.go b/internal/config/config_default_sender_test.go new file mode 100644 index 0000000..25e1cbe --- /dev/null +++ b/internal/config/config_default_sender_test.go @@ -0,0 +1,194 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestDefaultSenderDefault(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("HEY_BASE_URL", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.DefaultSender != "" { + t.Errorf("DefaultSender = %q, want empty string", cfg.DefaultSender) + } + if src := cfg.SourceOf("default_sender"); src != SourceDefault { + t.Errorf("source = %q, want %q", src, SourceDefault) + } +} + +func TestDefaultSenderFromGlobalConfig(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("HEY_BASE_URL", "") + + dir := filepath.Join(tmp, configDirName) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + + data, _ := json.Marshal(map[string]string{"default_sender": "alice@hey.com"}) + if err := os.WriteFile(filepath.Join(dir, configFile), data, 0600); err != nil { + t.Fatal(err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.DefaultSender != "alice@hey.com" { + t.Errorf("DefaultSender = %q, want %q", cfg.DefaultSender, "alice@hey.com") + } + if src := cfg.SourceOf("default_sender"); src != SourceGlobal { + t.Errorf("source = %q, want %q", src, SourceGlobal) + } +} + +func TestDefaultSenderSetFromFlag(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("HEY_BASE_URL", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + if err := cfg.SetFromFlag("default_sender", "test@example.com"); err != nil { + t.Fatalf("SetFromFlag: %v", err) + } + + if cfg.DefaultSender != "test@example.com" { + t.Errorf("DefaultSender = %q, want %q", cfg.DefaultSender, "test@example.com") + } + if src := cfg.SourceOf("default_sender"); src != SourceFlag { + t.Errorf("source = %q, want %q", src, SourceFlag) + } +} + +func TestDefaultSenderUnset(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("HEY_BASE_URL", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + // Set then unset + if err := cfg.SetFromFlag("default_sender", "test@example.com"); err != nil { + t.Fatalf("SetFromFlag: %v", err) + } + cfg.UnsetField("default_sender") + + if cfg.DefaultSender != "" { + t.Errorf("DefaultSender = %q after unset, want empty", cfg.DefaultSender) + } + if src := cfg.SourceOf("default_sender"); src != SourceDefault { + t.Errorf("source = %q after unset, want %q", src, SourceDefault) + } +} + +func TestDefaultSenderSaveAndReload(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("HEY_BASE_URL", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + if err := cfg.SetFromFlag("default_sender", "saved@example.com"); err != nil { + t.Fatalf("SetFromFlag: %v", err) + } + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + // Reload and verify persistence + cfg2, err := Load() + if err != nil { + t.Fatalf("Load after save: %v", err) + } + + if cfg2.DefaultSender != "saved@example.com" { + t.Errorf("DefaultSender after reload = %q, want %q", cfg2.DefaultSender, "saved@example.com") + } +} + +func TestDefaultSenderOmittedFromJSONWhenEmpty(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("HEY_BASE_URL", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + // Save with empty default_sender + if err := cfg.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + // Read the raw JSON and verify default_sender is not present + configPath := filepath.Join(tmp, configDirName, configFile) + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + + if _, exists := raw["default_sender"]; exists { + t.Errorf("default_sender should be omitted from JSON when empty, got: %s", string(data)) + } +} + +func TestDefaultSenderInValues(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("HEY_BASE_URL", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + if err := cfg.SetFromFlag("default_sender", "val@example.com"); err != nil { + t.Fatalf("SetFromFlag: %v", err) + } + + values := cfg.Values() + // Should have at least 2 values (base_url + default_sender) + if len(values) < 2 { + t.Fatalf("Values() returned %d entries, want at least 2", len(values)) + } + + found := false + for _, v := range values { + if v.Value == "val@example.com" { + found = true + if v.Source != SourceFlag { + t.Errorf("default_sender source = %q, want %q", v.Source, SourceFlag) + } + } + } + if !found { + t.Error("default_sender not found in Values() output") + } +}