diff --git a/.surface b/.surface index 984150af..db69e184 100644 --- a/.surface +++ b/.surface @@ -69,6 +69,8 @@ hey doctor hey drafts hey drafts --all hey drafts --limit +hey everything +hey everything --page hey forward hey forward --bcc hey forward --cc @@ -113,10 +115,14 @@ hey search --subject hey search --to hey search filters hey seen +hey sent +hey sent --page hey setup hey skill hey skill install hey spam +hey spammed +hey spammed --page hey stop-ignoring hey threads hey timetrack @@ -137,5 +143,7 @@ hey todo list --all hey todo list --limit hey todo uncomplete hey trash +hey trashed +hey trashed --page hey tui hey unseen diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 8ebc25dc..4f8c829b 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -13,6 +13,10 @@ The remaining HTML-reading gaps use the SDK's authenticated HTML helper and are | `/asidebox.json` | GET | SDK `Boxes().GetAsidebox` | `hey box asidebox` | covered | | `/laterbox.json` | GET | SDK `Boxes().GetLaterbox` | `hey box laterbox` | covered | | `/bubblebox.json` | GET | SDK `Boxes().GetBubblebox` | `hey box bubblebox` | covered | +| `/topics/sent.json` | GET | SDK `Topics().GetSent` | `hey sent` | covered | +| `/topics/spam.json` | GET | SDK `Topics().GetSpam` | `hey spammed` | covered | +| `/topics/trash.json` | GET | SDK `Topics().GetTrash` | `hey trashed` | covered | +| `/topics/everything.json` | GET | SDK `Topics().GetEverything` | `hey everything` | covered | | `/advanced_search.json` | GET | SDK `Search().Search` | `hey search`, TUI `/` | covered | | `/advanced_search_filters.json` | GET | SDK `Search().Filters` | `hey search filters` | covered | | `/contacts.json` | GET | SDK `Contacts().List` | `hey contacts list`, Contacts TUI | covered | diff --git a/README.md b/README.md index 4567d0db..ed1af03f 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ All commands support `--json` for raw JSON output and `--base-url` to override t ```bash hey boxes # list mailboxes hey box imbox # list email threads in a box (by name or ID) +hey sent # list sent emails +hey spammed # list emails in Spam without changing them +hey trashed # list emails in Trash without changing them +hey everything # list all email in HEY's Everything view hey search "quarterly planning" # search threads and matching messages hey search --from jane@example.com --date last_30_days # refine a search hey search filters # list available refinement values @@ -102,6 +106,8 @@ hey ignore 12345 # ignore future activity on a thread hey stop-ignoring 12345 # resume attention for a thread ``` +The system-view commands are read-only. `hey spammed` lists Spam while `hey spam ` marks an email as spam. `hey trashed` lists Trash while `hey trash ` moves an email to Trash. Each result returns a thread ID for `hey threads`. + Search accepts free text plus `--required`, `--any`, `--none`, `--exact`, `--from`, `--to`, `--subject`, `--date`, `--in`, `--label`, and `--attachment`. Use `--page` for one page or `--all` to fetch up to 100 pages; capped searches report the next page for continuation. Search results include `topic_id` for reading the thread and the matching message summaries. Results with an active box item also include `id` for organization actions. Contact updates preserve omitted name, email, and alias fields. Supplying `--alias` replaces the complete alias list; `--alias=` clears it. Contact notes accept positional content, `--note`, stdin, or `$EDITOR`. HEY hides contacts rather than permanently deleting them; hidden contacts leave lists, autocomplete, and search, and can be shown again by ID. diff --git a/internal/cmd/help.go b/internal/cmd/help.go index a23a74f4..0fd52133 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -17,7 +17,7 @@ var curatedCategories = []struct { }{ { heading: "EMAIL", - names: []string{"boxes", "box", "search", "contacts", "threads", "attachments", "compose", "reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"}, + names: []string{"boxes", "box", "sent", "spammed", "trashed", "everything", "search", "contacts", "threads", "attachments", "compose", "reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"}, }, { heading: "CALENDAR & TASKS", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 06af45e9..29786fc1 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -94,6 +94,10 @@ USAGE EMAIL boxes List your HEY boxes box List email threads in a box + sent List sent emails + spammed List emails in Spam + trashed List emails in Trash + everything List all email search Search email threads and messages contacts Manage contacts threads Read a thread diff --git a/internal/cmd/root.go b/internal/cmd/root.go index e5ee58e5..6cd51416 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -119,6 +119,10 @@ func newRootCmd() *cobra.Command { root.AddCommand(newAuthCommand().cmd) root.AddCommand(newBoxesCommand().cmd) root.AddCommand(newBoxCommand().cmd) + root.AddCommand(newSentCommand().cmd) + root.AddCommand(newSpammedCommand().cmd) + root.AddCommand(newTrashedCommand().cmd) + root.AddCommand(newEverythingCommand().cmd) root.AddCommand(newSearchCommand().cmd) root.AddCommand(newContactsCommand().cmd) root.AddCommand(newThreadsCommand().cmd) diff --git a/internal/cmd/topic_views.go b/internal/cmd/topic_views.go new file mode 100644 index 00000000..19198ddb --- /dev/null +++ b/internal/cmd/topic_views.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/output" +) + +type topicViewFetcher func(context.Context, *string) (*generated.TopicListResponse, error) + +type topicViewCommand struct { + cmd *cobra.Command + page int + title string + fetch topicViewFetcher + emptyMsg string +} + +func newSentCommand() *topicViewCommand { + return newTopicViewCommand( + "sent", + "List sent emails", + "Sent", + "Returns sent email topics. Use a topic ID with hey threads to read the full conversation.", + func(ctx context.Context, page *string) (*generated.TopicListResponse, error) { + return sdk.Topics().GetSent(ctx, &generated.GetSentTopicsParams{Page: page}) + }, + ) +} + +func newSpammedCommand() *topicViewCommand { + return newTopicViewCommand( + "spammed", + "List emails in Spam", + "Spam", + "Returns topics in Spam. This command is read-only; hey spam marks a thread as spam.", + func(ctx context.Context, page *string) (*generated.TopicListResponse, error) { + return sdk.Topics().GetSpam(ctx, &generated.GetSpamTopicsParams{Page: page}) + }, + ) +} + +func newTrashedCommand() *topicViewCommand { + return newTopicViewCommand( + "trashed", + "List emails in Trash", + "Trash", + "Returns topics in Trash. This command is read-only; hey trash moves a thread to Trash.", + func(ctx context.Context, page *string) (*generated.TopicListResponse, error) { + return sdk.Topics().GetTrash(ctx, &generated.GetTrashTopicsParams{Page: page}) + }, + ) +} + +func newEverythingCommand() *topicViewCommand { + return newTopicViewCommand( + "everything", + "List all email", + "Everything", + "Returns topics from HEY's Everything view. Use a topic ID with hey threads to read the full conversation.", + func(ctx context.Context, page *string) (*generated.TopicListResponse, error) { + return sdk.Topics().GetEverything(ctx, &generated.GetEverythingTopicsParams{Page: page}) + }, + ) +} + +func newTopicViewCommand(name, short, title, agentNotes string, fetch topicViewFetcher) *topicViewCommand { + viewCommand := &topicViewCommand{ + title: title, + fetch: fetch, + emptyMsg: fmt.Sprintf("No emails in %s.", title), + } + viewCommand.cmd = &cobra.Command{ + Use: name, + Short: short, + Annotations: map[string]string{ + "agent_notes": agentNotes, + }, + Example: fmt.Sprintf(" hey %s\n hey %s --page 2\n hey %s --json", name, name, name), + RunE: viewCommand.run, + Args: cobra.NoArgs, + } + viewCommand.cmd.Flags().IntVar(&viewCommand.page, "page", 1, "Result page (starting at 1)") + return viewCommand +} + +func (c *topicViewCommand) run(cmd *cobra.Command, _ []string) error { + if c.page < 1 { + return output.ErrUsage("--page must be at least 1") + } + if err := requireAuth(); err != nil { + return err + } + + var page *string + if c.page > 1 { + value := strconv.Itoa(c.page) + page = &value + } + result, err := c.fetch(cmd.Context(), page) + if err != nil { + return convertSDKError(err) + } + if result == nil { + result = &generated.TopicListResponse{} + } + topics := result.Topics + if topics == nil { + topics = make([]generated.Topic, 0) + } + title := result.Title + if title == "" { + title = c.title + } + + if writer.IsStyled() { + if len(topics) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), c.emptyMsg) + return nil + } + + fmt.Fprintf(cmd.OutOrStdout(), "%s\n\n", terminalSafeText(title)) + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"Thread", "Subject", "From", "Date"}) + for _, topic := range topics { + table.addRow([]string{ + fmt.Sprintf("%d", topic.Id), + truncate(terminalSafeText(topic.Name), 48), + terminalSafeText(topicViewSender(topic)), + formatDate(topicViewDate(topic)), + }) + } + table.print() + return nil + } + + return writeOK(topics, + output.WithSummary(topicViewSummary(len(topics), title)), + output.WithBreadcrumbs(output.Breadcrumb{ + Action: "read", + Command: "hey threads ", + Description: "Read an email thread", + }), + ) +} + +func topicViewSummary(count int, title string) string { + noun := "emails" + if count == 1 { + noun = "email" + } + return fmt.Sprintf("%d %s in %s", count, noun, title) +} + +func topicViewSender(topic generated.Topic) string { + if topic.Creator.Name != "" { + return topic.Creator.Name + } + return topic.Creator.EmailAddress +} + +func topicViewDate(topic generated.Topic) time.Time { + if !topic.ActiveAt.IsZero() { + return topic.ActiveAt + } + if !topic.UpdatedAt.IsZero() { + return topic.UpdatedAt + } + return topic.CreatedAt +} diff --git a/internal/cmd/topic_views_test.go b/internal/cmd/topic_views_test.go new file mode 100644 index 00000000..b0e38cf3 --- /dev/null +++ b/internal/cmd/topic_views_test.go @@ -0,0 +1,197 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/output" +) + +func topicViewServer(t *testing.T, path, title string, requests *atomic.Int32) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodGet || r.URL.Path != path { + w.WriteHeader(http.StatusNotFound) + return + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Errorf("page = %q, want 2", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "title": "` + title + `", + "topics": [{ + "id": 42, + "name": "Quarterly planning notes", + "active_at": "2026-08-16T15:00:00Z", + "creator": {"id": 7, "name": "Amanda Jones", "email_address": "amanda@example.com"} + }] +}`)) + })) +} + +func runTopicView(t *testing.T, server *httptest.Server, name string, args ...string) (string, 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{name, "--base-url", server.URL}, args...)) + + err := root.Execute() + return buf.String(), err +} + +func runTopicViewJSON(t *testing.T, server *httptest.Server, name string, args ...string) (output.Response, string, error) { + t.Helper() + stdout, err := runTopicView(t, server, name, append(args, "--json")...) + var response output.Response + if stdout != "" { + if decodeErr := json.Unmarshal([]byte(stdout), &response); decodeErr != nil { + t.Fatalf("decode response: %v\n%s", decodeErr, stdout) + } + } + return response, stdout, err +} + +func TestTopicViews(t *testing.T) { + tests := []struct { + name string + path string + title string + summary string + }{ + {name: "sent", path: "/topics/sent.json", title: "Sent", summary: "1 email in Sent"}, + {name: "spammed", path: "/topics/spam.json", title: "Spam", summary: "1 email in Spam"}, + {name: "trashed", path: "/topics/trash.json", title: "Trash", summary: "1 email in Trash"}, + {name: "everything", path: "/topics/everything.json", title: "Everything", summary: "1 email in Everything"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var requests atomic.Int32 + server := topicViewServer(t, test.path, test.title, &requests) + defer server.Close() + + resp, _, err := runTopicViewJSON(t, server, test.name, "--page", "2") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !resp.OK || resp.Summary != test.summary { + t.Fatalf("response = %#v", resp) + } + data, err := json.Marshal(resp.Data) + if err != nil { + t.Fatal(err) + } + var topics []generated.Topic + if err := json.Unmarshal(data, &topics); err != nil { + t.Fatal(err) + } + if len(topics) != 1 || topics[0].Id != 42 { + t.Fatalf("topics = %#v", topics) + } + if requests.Load() != 1 { + t.Errorf("requests = %d, want 1", requests.Load()) + } + }) + } +} + +func TestTopicViewStyledOutput(t *testing.T) { + var requests atomic.Int32 + server := topicViewServer(t, "/topics/sent.json", "Sent", &requests) + defer server.Close() + + stdout, err := runTopicView(t, server, "sent", "--page", "2", "--styled") + if err != nil { + t.Fatalf("execute: %v", err) + } + for _, want := range []string{"Sent", "42", "Quarterly planning notes", "Amanda Jones", "2026-08-16"} { + if !strings.Contains(stdout, want) { + t.Errorf("output missing %q:\n%s", want, stdout) + } + } +} + +func TestTopicViewStyledOutputSanitizesUntrustedFields(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "title": "Sent\u001b[8m\rHidden", + "topics": [{ + "id": 42, + "name": "Quarterly planning\u001b[31m\rnotes", + "creator": {"id": 7, "name": "Amanda\u001b[2J\nJones"} + }] +}`)) + })) + defer server.Close() + + stdout, err := runTopicView(t, server, "sent", "--styled") + if err != nil { + t.Fatalf("execute: %v", err) + } + for _, unsafe := range []string{"\x1b[8m", "\rHidden", "\x1b[31m", "\x1b[2J", "\r", "\nJones"} { + if strings.Contains(stdout, unsafe) { + t.Errorf("styled output contains unsafe text %q:\n%s", unsafe, stdout) + } + } +} + +func TestTopicViewsRejectInvalidPageBeforeRequest(t *testing.T) { + for _, name := range []string{"sent", "spammed", "trashed", "everything"} { + t.Run(name, func(t *testing.T) { + var requests atomic.Int32 + server := topicViewServer(t, "/unused", "Unused", &requests) + defer server.Close() + + _, err := runTopicView(t, server, name, "--page", "0", "--json") + if err == nil || !strings.Contains(err.Error(), "--page must be at least 1") { + t.Fatalf("error = %v", err) + } + if requests.Load() != 0 { + t.Errorf("requests = %d, want 0", requests.Load()) + } + }) + } +} + +func TestTopicViewEmptyJSONUsesArray(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"title":"Spam","topics":null}`)) + })) + defer server.Close() + + resp, stdout, err := runTopicViewJSON(t, server, "spammed") + if err != nil { + t.Fatalf("execute: %v", err) + } + if resp.Summary != "0 emails in Spam" { + t.Errorf("summary = %q", resp.Summary) + } + data, err := json.Marshal(resp.Data) + if err != nil { + t.Fatal(err) + } + if string(data) != "[]" { + t.Fatalf("empty JSON data = %s, want []\n%s", data, stdout) + } +} diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 61c3e4e6..3fbf2716 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -11,6 +11,10 @@ triggers: # Email actions - hey boxes - hey box + - hey sent + - hey spammed + - hey trashed + - hey everything - hey search - hey contacts - hey threads @@ -105,6 +109,10 @@ CLI for HEY: mailboxes, email threads, contacts, replies, compose, calendars, to |------|---------| | List mailboxes | `hey boxes --json` | | List emails in a box | `hey box imbox --json` | +| List sent emails | `hey sent --json` | +| List emails in Spam | `hey spammed --json` | +| List emails in Trash | `hey trashed --json` | +| List all email | `hey everything --json` | | Search email | `hey search "quarterly planning" --json` | | List search filters | `hey search filters --json` | | List contacts | `hey contacts list --json` | @@ -157,6 +165,10 @@ CLI for HEY: mailboxes, email threads, contacts, replies, compose, calendars, to Want to read email? ├── Which mailbox? → hey boxes --json ├── List emails in box? → hey box --json +├── List sent emails? → hey sent --json +├── List emails in Spam? → hey spammed --json +├── List emails in Trash? → hey trashed --json +├── List all email? → hey everything --json ├── Search threads and messages? → hey search --json ├── Need available refinements? → hey search filters --json ├── List or view contacts? → hey contacts list --json / hey contacts show --json @@ -215,6 +227,18 @@ Box names: `imbox`, `feedbox`, `trailbox`, `asidebox`, `laterbox`, `bubblebox` **Response format:** `hey box` returns `{"box": {...}, "postings": [...]}`. The `postings` array is the API representation of the email threads in that box. Each item has: `id` (box item ID), `topic_id` (thread ID), `name` (subject), `seen` (read status), `created_at`, `contacts`, `summary`, `app_url`. Use `id` for `hey seen`, `hey unseen`, `hey move`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring`. Use `topic_id` for `hey threads`, `hey reply`, and `hey forward`. +### Email - System Views + +```bash +hey sent --json # List sent emails +hey spammed --json # List emails in Spam +hey trashed --json # List emails in Trash +hey everything --json # List all email +hey sent --page 2 --json # Read another result page +``` + +These read-only commands return email topics. Use each result's `id` with `hey threads`. `hey spammed` and `hey trashed` are named differently from the state-changing `hey spam ` and `hey trash ` commands. + ### Email - Search ```bash diff --git a/tests/smoke/topic_views_test.go b/tests/smoke/topic_views_test.go new file mode 100644 index 00000000..6bb5d950 --- /dev/null +++ b/tests/smoke/topic_views_test.go @@ -0,0 +1,39 @@ +package smoke_test + +import ( + "strings" + "testing" +) + +type topicViewItem struct { + ID int64 `json:"id"` +} + +func TestTopicViews(t *testing.T) { + for _, command := range []string{"sent", "spammed", "trashed", "everything"} { + t.Run(command, func(t *testing.T) { + response := heyJSON(t, command) + if strings.TrimSpace(response.Summary) == "" { + t.Fatal("expected a result summary") + } + if string(response.Data) == "null" { + t.Fatal("expected an array, got null") + } + for _, topic := range dataAs[[]topicViewItem](t, response) { + if topic.ID <= 0 { + t.Errorf("expected positive topic ID, got %d", topic.ID) + } + } + }) + } +} + +func TestTopicViewPage(t *testing.T) { + response := heyJSON(t, "sent", "--page", "2") + if strings.TrimSpace(response.Summary) == "" { + t.Fatal("expected a result summary") + } + if string(response.Data) == "null" { + t.Fatal("expected an array, got null") + } +}