diff --git a/.surface b/.surface index 7d35c367..b3ac289b 100644 --- a/.surface +++ b/.surface @@ -358,6 +358,11 @@ hey snippet update --name hey spam hey stop-ignoring hey thread +hey thread list +hey thread list --all +hey thread list --in +hey thread list --limit +hey thread list --page hey thread read hey thread read --allow-partial hey timetrack diff --git a/API-COVERAGE.md b/API-COVERAGE.md index ee2e7377..ed8895e5 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -1,7 +1,8 @@ # API Coverage Mapping of HEY API endpoints used by the CLI. API interactions use the HEY SDK (`hey-sdk/go`). -Every endpoint below is read as JSON through a typed SDK operation; nothing parses HTML. +Every endpoint below is read as JSON through the SDK; the Client column names its typed +operation except where a narrow generic document read is called out. Nothing parses HTML. **`/topics/{id}/entries.json` cannot be paged by number.** `Topics::EntriesController` uses `set_page_and_extract_portion_from`, so like every other list here its `page` is @@ -11,6 +12,13 @@ is the SDK read that keeps that header; `hey thread read` and `hey attachment li through `internal/threadload`. The drafts index (`/entries/drafts.json`) pages the same way, which is what `Entries().ListDraftsPage` and `hey draft list --page` exist for. +The four system topic views also use geared_pagination cursors from `Link`. The SDK's +`Topics().GetSent`, `GetSpam`, `GetTrash` and `GetEverything` methods return the generated +body but discard that header in v0.29.0. `hey thread list` therefore reads the same fixed +routes through the SDK document client, decodes `generated.TopicListResponse`, and retains +only the opaque `page` value from the next link. Authentication, account scoping, caching, +request limits and hooks still stay inside the SDK. + | Endpoint | Method | Client | CLI Command | Status | |----------|--------|--------|-------------|--------| | `/boxes.json` | GET | SDK `Boxes().List` | `hey box list` | covered | @@ -22,6 +30,10 @@ which is what `Entries().ListDraftsPage` and `hey draft list --page` exist for. | `/asidebox.json` | GET | SDK `Boxes().GetAsidebox` | `hey box view asidebox` | covered | | `/laterbox.json` | GET | SDK `Boxes().GetLaterbox` | `hey box view laterbox` | covered | | `/bubblebox.json` | GET | SDK `Boxes().GetBubblebox` | `hey box view bubblebox`, `hey bubble list` (scheduled bucket) | covered | +| `/topics/sent.json` | GET | SDK `Client.Get` + `generated.TopicListResponse` | `hey thread list --in sent` | covered | +| `/topics/spam.json` | GET | SDK `Client.Get` + `generated.TopicListResponse` | `hey thread list --in spam` | covered | +| `/topics/trash.json` | GET | SDK `Client.Get` + `generated.TopicListResponse` | `hey thread list --in trash` | covered | +| `/topics/everything.json` | GET | SDK `Client.Get` + `generated.TopicListResponse` | `hey thread list --in everything` | covered | | `/imbox/bubbled_up` | GET | — | — | not served: HTML only; the Imbox JSON orders bubbled-up threads first, so `hey bubble list` reads that prefix instead | | `/my/navigation.json` | GET | SDK `Identity().GetNavigation` | `hey label list`, Mail TUI navigation | covered | | `/folders/{id}.json` | GET | SDK `Folders().GetPage` | `hey label view `, Mail TUI labels | covered | diff --git a/README.md b/README.md index 0c27d406..2d8e20a4 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ screen a moment later. ```bash hey box view imbox # threads in a box +hey thread list --in sent # sent threads; also spam, trash, or everything hey thread read 12345 # a whole thread, as Markdown hey reply 12345 -m "Friday works for me." hey compose --to alice@example.com --subject "Lunch?" -m "Thursday at noon?" diff --git a/docs/cli.md b/docs/cli.md index 830e78bf..8394c1f7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -110,7 +110,7 @@ hey box list --quiet --jq '.[].id' Listing commands also answer `--markdown` for a table, `--styled` to force the human rendering when the output is piped, `--ids-only` for one ID per line, and `--count` for a bare number. `--ids-only` and `--count` need list data, so they work on `hey box list`, -`hey box view`, `hey bundle view`, `hey label list`, `hey label view`, `hey collection list`, `hey collection view`, +`hey box view`, `hey bundle view`, `hey thread list`, `hey label list`, `hey label view`, `hey collection list`, `hey collection view`, `hey workflow list`, `hey workflow view`, `hey clip list`, `hey snippet list`, `hey draft list`, `hey search`, `hey contact list`, `hey contact threads`, `hey screener list`, `hey screener history`, `hey calendar list`, `hey event list`, `hey event day`, `hey event week`, `hey todo list`, `hey habit list`, `hey timetrack list` and `hey journal list`. @@ -155,6 +155,10 @@ or through the direct-form escape (`hey box -- list`). ```bash hey box list # list mailboxes hey box view imbox # list email threads in a box (by name or ID) +hey thread list --in sent # list sent threads +hey thread list --in spam # list threads in Spam without changing them +hey thread list --in trash # list threads in Trash without changing them +hey thread list --in everything # list every thread hey bundle view 456 # list the unseen threads a bundle row groups hey label list # list labels and their IDs hey label view 789 --all # list all email threads with a label @@ -255,6 +259,14 @@ hey ignore 12345 # ignore future activity on a thread hey stop-ignoring 12345 # resume attention for a thread ``` +`hey thread list` is the read-only index for HEY's Sent, Spam, Trash and Everything +views. Its JSON data is an array of topics; each row carries `topic_id` explicitly, and +both `id` and `topic_id` on these rows are thread IDs for `hey thread read`, `hey reply`, +`hey forward`, `hey share` and `hey attachment list` — neither is a box item ID for +organization commands. `--limit` reads only enough pages for the requested result count, +`--all` follows up to 100 pages, and `--page ` continues from the opaque cursor +reported by an earlier listing of the same view. A page number does not name a position. + `hey thread read` reads a whole thread, oldest entry first, however many pages HEY serves it in — within limits it states: a hundred pages past the first, two thousand entries, as many bodies, 64 MiB of content and two minutes in all. A thread that could only be read in part — a body HEY would not serve, a limit reached — is refused rather than passed off as whole; `--allow-partial` takes what was read, with a `notice` saying what is missing and each entry's `body_state` saying whether its body was `hydrated`, `bodyless` (HEY served none), `over_limit` or `failed`. `--count` and `--ids-only` read the entry index and no bodies, so only a truncated index can make them partial. `--markdown` writes the thread as one Markdown document — a heading per entry naming the sender, date and ID, then the body — which is the shape to hand an agent or a notes app. `hey attachment list` reads the bodies in every format, since that is where attachment metadata lives, and answers a partial thread the same way. `hey reply` answers the thread's latest entry and addresses the reply the way HEY does: it asks HEY for the reply's recipients — everyone that entry was addressed to, its sender moved onto the To line, and your own addresses, aliases and catch-alls excluded — falling back to computing them from the entry when that read is unavailable. Email bodies come back as Markdown. `hey thread read` and the TUI render that Markdown for the terminal — headings, emphasis, lists, quotes, tables and code survive, and links keep their URLs and stay clickable where the terminal supports it. `--json` carries the same Markdown in `body`, so an agent reading a thread sees the structure a human sees rather than a flattened wall of text. `--html` still returns HEY's original HTML. @@ -275,7 +287,7 @@ The Screener is where first-time senders wait. `hey screener list` returns clear `--attach` is repeatable on `hey compose`, `hey reply`, and `hey bulk-reply send`, and attachment-only messages are supported. The CLI validates and uploads every file before sending the email. `hey attachment list ` returns every named downloadable file, including named inline images. Direct files keep stable message-and-position IDs such as `456:1`; files inside embedded HTML receive opaque IDs scoped to their message. Pass either returned ID to `hey attachment save`. Saving uses the original filename by default, accepts `--output` for a file or directory, and preserves existing files unless `--force` is set. -Organization actions take the `id` values returned by `hey box view --json`, `hey label view --json`, or `hey search --json`. Reading, replying to, and forwarding a thread take its `topic_id` instead, which `hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry alongside `id`. `hey box view` also returns `next_page` and accepts `--page ` to continue a box listing; it keeps `next_history_url` for the sync clients that read it, and `--page` accepts that URL as readily as the cursor inside it. Label IDs come from `hey label list`; `hey label view` returns `next_page` and `total_count`, accepts `--page ` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs. +Organization actions take the `id` values returned by `hey box view --json`, `hey label view --json`, or `hey search --json`. Reading, replying to, and forwarding a thread take its `topic_id` instead, which `hey thread list --json`, `hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` carry. A `hey thread list` row is the exception to the two-ID shape: its `id` is already a topic ID, and `topic_id` repeats it to make that unambiguous. `hey box view` also returns `next_page` and accepts `--page ` to continue a box listing; it keeps `next_history_url` for the sync clients that read it, and `--page` accepts that URL as readily as the cursor inside it. Label IDs come from `hey label list`; `hey label view` returns `next_page` and `total_count`, accepts `--page ` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs. Collection IDs come from `hey collection list`. `hey collection view` returns both each posting `id` and its `topic_id`, plus `next_page` and `total_count`. Collection membership commands take `topic_id`; posting organization commands continue to take `id`. Creating a collection returns a confirmed mutation, and `hey collection list` provides its ID for subsequent commands. Collection updates accept a non-empty name, summary, or both. diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index a09d6a83..f6ae9f8d 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -107,7 +107,7 @@ USAGE CORE COMMANDS tui Launch the interactive terminal UI box List HEY boxes and their email threads - thread Read email threads + thread List and read email threads reply Reply to a thread compose Write and send a new email search Search email threads and messages diff --git a/internal/cmd/thread_list.go b/internal/cmd/thread_list.go new file mode 100644 index 00000000..bfca07f0 --- /dev/null +++ b/internal/cmd/thread_list.go @@ -0,0 +1,341 @@ +package cmd + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/output" + "github.com/basecamp/hey-cli/internal/terminal" +) + +const maxThreadListPages = 100 + +type threadListCommand struct { + cmd *cobra.Command + in string + limit int + all bool + page string +} + +type threadListView struct { + title string + path string +} + +type threadListPage struct { + page pageResult[generated.Topic] + title string +} + +type threadListTopic struct { + generated.Topic + TopicID int64 `json:"topic_id"` +} + +type threadListRow struct { + TopicID int64 `json:"topic_id"` + Subject string `json:"subject,omitempty"` + From string `json:"from,omitempty"` + Date string `json:"date,omitempty"` +} + +var threadListViews = map[string]threadListView{ + "sent": {title: "Sent", path: "/topics/sent.json"}, + "spam": {title: "Spam", path: "/topics/spam.json"}, + "trash": {title: "Trash", path: "/topics/trash.json"}, + "everything": {title: "Everything", path: "/topics/everything.json"}, +} + +func newThreadListCommand() *threadListCommand { + command := &threadListCommand{} + command.cmd = &cobra.Command{ + Use: "list", + Short: "List threads in a system view", + Long: "List thread IDs from HEY's Sent, Spam, Trash, or Everything view.", + Annotations: map[string]string{ + "agent_notes": "Returns thread IDs, not box item IDs. Use topic_id with hey thread read, reply, forward, share, and attachment list. --page continues from the opaque next_page cursor of an earlier listing of the same view.", + }, + Example: ` hey thread list --in sent + hey thread list --in trash --limit 10 + hey thread list --in everything --all --json + hey thread list --in spam --page next-cursor`, + RunE: command.run, + Args: cobra.NoArgs, + } + + command.cmd.Flags().StringVar(&command.in, "in", "", "View: sent, spam, trash, or everything") + command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of threads to show") + command.cmd.Flags().BoolVar(&command.all, "all", false, "Fetch all results (override --limit)") + command.cmd.Flags().StringVar(&command.page, "page", "", "Continue from a next_page cursor") + + return command +} + +func (c *threadListCommand) run(cmd *cobra.Command, _ []string) error { + view, viewErr := c.selectedView() + if viewErr != nil { + return viewErr + } + if c.limit < 0 { + return apierr.ErrUsage("--limit must be at least 0") + } + if err := requireAuth(); err != nil { + return err + } + + first, err := readThreadListPage(cmd.Context(), view, c.page) + if err != nil { + return err + } + read := func(ctx context.Context, cursor string) (pageResult[generated.Topic], error) { + page, pageErr := readThreadListPage(ctx, view, cursor) + return page.page, pageErr + } + collected, err := collectPages(cmd.Context(), first.page, pageRequest{ + Limit: c.limit, + All: c.all, + MaxPages: maxThreadListPages, + }, read) + if err != nil { + return err + } + + topics := collected.Items + nextPage := collected.Cursor + if c.limit > 0 && !c.all && len(topics) > c.limit { + topics = topics[:c.limit] + nextPage = "" + } + title := first.title + if title == "" { + title = view.title + } + notice := threadListNotice(len(topics), collected.Total, nextPage != "", c.all, c.page != "") + + switch writer.EffectiveFormat() { + case output.FormatStyled: + return writeThreadListStyled(cmd, title, topics, notice) + case output.FormatIDs, output.FormatCount, output.FormatQuiet: + writeThreadListPagination(cmd, notice, nextPage) + return writeOK(makeThreadListTopics(topics)) + case output.FormatMarkdown: + return writeThreadListMarkdown(cmd, title, topics, nextPage, collected.Total, notice) + default: + opts := []output.ResponseOption{ + output.WithSummary(fmt.Sprintf("%d %s in %s", len(topics), threadNoun(len(topics)), title)), + output.WithNotice(notice), + output.WithMeta("pages_fetched", collected.Read), + output.WithMeta("total_count", collected.Total), + output.WithBreadcrumbs(output.Breadcrumb{ + Action: "read", + Command: "hey thread read ", + Description: "Read an email thread", + }), + } + if nextPage != "" { + opts = append(opts, output.WithMeta("next_page", nextPage)) + } + return writeOK(makeThreadListTopics(topics), opts...) + } +} + +func (c *threadListCommand) selectedView() (threadListView, error) { + view, ok := threadListViews[c.in] + if !ok { + return threadListView{}, apierr.ErrUsage("--in must be sent, spam, trash, or everything") + } + return view, nil +} + +func writeThreadListStyled(cmd *cobra.Command, title string, topics []generated.Topic, notice string) error { + if len(topics) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No threads in %s.\n", terminal.SanitizeLine(title)) + if notice != "" { + fmt.Fprintln(cmd.OutOrStdout(), notice) + } + return nil + } + + fmt.Fprintf(cmd.OutOrStdout(), "%s:\n\n", terminal.SanitizeLine(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(terminal.SanitizeLine(topic.Name), 48), + truncate(terminal.SanitizeLine(threadListSender(topic)), 32), + formatDate(threadListDate(topic)), + }) + } + table.print() + if notice != "" { + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", notice) + } + return nil +} + +func writeThreadListMarkdown(cmd *cobra.Command, title string, topics []generated.Topic, nextPage string, total int, notice string) error { + fmt.Fprintf(cmd.OutOrStdout(), "# %s\n\n", markdownSafeText(title)) + if err := writeOK(makeThreadListRows(topics)); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "\n**Total threads:** %d\n", total) + if nextPage != "" { + fmt.Fprintf(cmd.OutOrStdout(), "**Next page:** `%s`\n", terminal.SanitizeLine(nextPage)) + } + if notice != "" { + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", markdownSafeText(notice)) + } + return nil +} + +// The typed topic-list helpers return the JSON body but not the geared_pagination Link +// header. Read the same fixed route through the SDK document client so the command can +// carry its opaque cursor forward, then decode the generated response shape. +func readThreadListPage(ctx context.Context, view threadListView, cursor string) (threadListPage, error) { + response, err := sdk.Get(ctx, threadListPagePath(view.path, cursor)) + if err != nil { + return threadListPage{}, apierr.FromSDK(err) + } + if response == nil { + return threadListPage{}, apierr.ErrAPI(0, fmt.Sprintf("HEY returned no %s thread list", view.title)) + } + + var result generated.TopicListResponse + if err := response.UnmarshalData(&result); err != nil { + return threadListPage{}, apierr.ErrAPI(response.StatusCode, fmt.Sprintf("could not read the %s thread list: %v", view.title, err)) + } + topics := result.Topics + if topics == nil { + topics = []generated.Topic{} + } + total, _ := strconv.Atoi(response.Headers.Get("X-Total-Count")) + return threadListPage{ + page: pageResult[generated.Topic]{ + Items: topics, + Cursor: threadListNextPage(response.Headers), + Total: total, + }, + title: result.Title, + }, nil +} + +func threadListPagePath(path, cursor string) string { + if cursor == "" { + return path + } + query := url.Values{"page": {cursor}} + return path + "?" + query.Encode() +} + +func threadListNextPage(headers http.Header) string { + for _, value := range headers.Values("Link") { + for part := range strings.SplitSeq(value, ",") { + start := strings.IndexByte(part, '<') + end := strings.IndexByte(part, '>') + if start < 0 || end <= start+1 || !threadListLinkIsNext(part[end+1:]) { + continue + } + target, err := url.Parse(part[start+1 : end]) + if err == nil { + return target.Query().Get("page") + } + } + } + return "" +} + +func threadListLinkIsNext(parameters string) bool { + for _, parameter := range strings.Split(parameters, ";") { + parts := strings.SplitN(strings.TrimSpace(parameter), "=", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "rel") { + continue + } + for _, relation := range strings.Fields(strings.Trim(parts[1], `"`)) { + if strings.EqualFold(relation, "next") { + return true + } + } + } + return false +} + +func makeThreadListTopics(topics []generated.Topic) []threadListTopic { + rows := make([]threadListTopic, len(topics)) + for i, topic := range topics { + rows[i] = threadListTopic{Topic: topic, TopicID: topic.Id} + } + return rows +} + +func makeThreadListRows(topics []generated.Topic) []threadListRow { + rows := make([]threadListRow, len(topics)) + for i, topic := range topics { + rows[i] = threadListRow{ + TopicID: topic.Id, + Subject: topic.Name, + From: threadListSender(topic), + Date: formatDate(threadListDate(topic)), + } + } + return rows +} + +func writeThreadListPagination(cmd *cobra.Command, notice, nextPage string) { + if notice != "" { + fmt.Fprintln(cmd.ErrOrStderr(), "notice: "+notice) + } + if nextPage != "" { + fmt.Fprintln(cmd.ErrOrStderr(), "next_page: "+terminal.SanitizeLine(nextPage)) + } +} + +func threadListNotice(shown, total int, hasMore, all, fromCursor bool) string { + if all { + if hasMore { + return fmt.Sprintf("Showing %d results. Pagination limit reached; continue with --page using next_page.", shown) + } + if shown < total { + if fromCursor { + return fmt.Sprintf("Showing %d remaining results from this cursor (%d threads in the view).", shown, total) + } + return fmt.Sprintf("Showing %d of %d results; HEY returned no additional page cursor.", shown, total) + } + return "" + } + if shown < total { + return output.TruncationNotice(shown, total) + } + if hasMore { + return fmt.Sprintf("Showing %d results. More available; use --all to fetch all.", shown) + } + return "" +} + +func threadListSender(topic generated.Topic) string { + if topic.Creator.Name != "" { + return topic.Creator.Name + } + return topic.Creator.EmailAddress +} + +func threadListDate(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/thread_list_test.go b/internal/cmd/thread_list_test.go new file mode 100644 index 00000000..66cc5ead --- /dev/null +++ b/internal/cmd/thread_list_test.go @@ -0,0 +1,299 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "sync/atomic" + "testing" +) + +const threadListFixture = `{ + "title": "Served view", + "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 TestThreadListViews(t *testing.T) { + tests := []struct { + view string + path string + }{ + {view: "sent", path: "/topics/sent.json"}, + {view: "spam", path: "/topics/spam.json"}, + {view: "trash", path: "/topics/trash.json"}, + {view: "everything", path: "/topics/everything.json"}, + } + + for _, test := range tests { + t.Run(test.view, func(t *testing.T) { + var requests atomic.Int32 + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodGet || r.URL.Path != test.path { + t.Errorf("request = %s %s, want GET %s", r.Method, r.URL.Path, test.path) + http.NotFound(w, r) + return + } + if got := r.URL.Query().Get("page"); got != "current/cursor" { + t.Errorf("page = %q, want current/cursor", got) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Total-Count", "2") + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, threadListFixture) + }), "thread", "list", "--in", test.view, "--page", "current/cursor") + if err != nil { + t.Fatalf("execute: %v", err) + } + if requests.Load() != 1 { + t.Fatalf("requests = %d, want 1", requests.Load()) + } + if response.Summary != "1 thread in Served view" { + t.Errorf("summary = %q", response.Summary) + } + if response.Notice != "Showing 1 of 2 results. Use --all to see everything." { + t.Errorf("notice = %q", response.Notice) + } + if got := response.Meta["next_page"]; got != "next/cursor" { + t.Errorf("next_page = %#v, want next/cursor", got) + } + if got := response.Meta["total_count"]; got != float64(2) { + t.Errorf("total_count = %#v, want 2", got) + } + if got := response.Meta["pages_fetched"]; got != float64(1) { + t.Errorf("pages_fetched = %#v, want 1", got) + } + if len(response.Breadcrumbs) != 1 || response.Breadcrumbs[0].Command != "hey thread read " { + t.Errorf("breadcrumbs = %#v", response.Breadcrumbs) + } + + items, ok := response.Data.([]any) + if !ok || len(items) != 1 { + t.Fatalf("data = %#v, want one row", response.Data) + } + row, ok := items[0].(map[string]any) + if !ok || row["id"] != float64(42) || row["topic_id"] != float64(42) { + t.Fatalf("row = %#v, want id and topic_id 42", items[0]) + } + }) + } +} + +func TestThreadListAllFollowsOpaqueCursorsAndOverridesLimit(t *testing.T) { + wantCursors := []string{"", "cursor one", "cursor/two+"} + var requests atomic.Int32 + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + request := int(requests.Add(1)) - 1 + if request >= len(wantCursors) { + t.Errorf("unexpected request %d", request+1) + http.Error(w, "unexpected request", http.StatusInternalServerError) + return + } + if r.URL.Path != "/topics/everything.json" { + t.Errorf("request %d path = %q, want fixed Everything route", request+1, r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != wantCursors[request] { + t.Errorf("request %d page = %q, want %q", request+1, got, wantCursors[request]) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Total-Count", "3") + if request+1 < len(wantCursors) { + next := []string{ + "https://attacker.invalid/not-the-view?page=cursor+one", + "/topics/everything.json?page=cursor%2Ftwo%2B", + }[request] + w.Header().Set("Link", `<`+next+`>; rel="next"`) + } + _, _ = io.WriteString(w, `{"title":"Everything","topics":[{"id":`+strconv.Itoa(request+1)+`,"name":"Thread"}]}`) + }), "thread", "list", "--in", "everything", "--limit", "1", "--all") + if err != nil { + t.Fatalf("execute: %v", err) + } + if requests.Load() != 3 { + t.Fatalf("requests = %d, want 3", requests.Load()) + } + items, ok := response.Data.([]any) + if !ok || len(items) != 3 { + t.Fatalf("data = %#v, want three rows", response.Data) + } + if response.Notice != "" || response.Meta["next_page"] != nil { + t.Errorf("finished listing notice = %q, next_page = %#v", response.Notice, response.Meta["next_page"]) + } +} + +func TestThreadListLimitReadsEnoughPagesAndReportsContinuation(t *testing.T) { + var requests atomic.Int32 + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + request := requests.Add(1) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Total-Count", "3") + if request == 1 { + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, `{"title":"Sent","topics":[{"id":1}]}`) + return + } + if got := r.URL.Query().Get("page"); got != "second" { + t.Errorf("second page cursor = %q", got) + } + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, `{"title":"Sent","topics":[{"id":2}]}`) + }), "thread", "list", "--in", "sent", "--limit", "2") + if err != nil { + t.Fatalf("execute: %v", err) + } + if requests.Load() != 2 { + t.Fatalf("requests = %d, want 2", requests.Load()) + } + items, ok := response.Data.([]any) + if !ok || len(items) != 2 { + t.Fatalf("data = %#v, want two rows", response.Data) + } + if got := response.Meta["next_page"]; got != "third" { + t.Errorf("next_page = %#v, want third", got) + } +} + +func TestThreadListNoticeDistinguishesAllFromCursor(t *testing.T) { + got := threadListNotice(3, 8, false, true, true) + want := "Showing 3 remaining results from this cursor (8 threads in the view)." + if got != want { + t.Errorf("notice = %q, want %q", got, want) + } +} + +func TestThreadListRejectsInvalidOptionsBeforeRequest(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "missing view", args: []string{"thread", "list"}, want: "--in must be sent, spam, trash, or everything"}, + {name: "unknown view", args: []string{"thread", "list", "--in", "inbox"}, want: "--in must be sent, spam, trash, or everything"}, + {name: "negative limit", args: []string{"thread", "list", "--in", "sent", "--limit", "-1"}, want: "--limit must be at least 0"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var requests atomic.Int32 + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests.Add(1) + http.Error(w, "unexpected", http.StatusInternalServerError) + }), test.args...) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + if requests.Load() != 0 { + t.Errorf("requests = %d, want 0", requests.Load()) + } + }) + } +} + +func TestThreadListStyledOutputSanitizesUntrustedFields(t *testing.T) { + stdout, err := runStyledCommand(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{ + "title": "Sent\u001b[8m\rHidden", + "topics": [{ + "id": 42, + "name": "Quarterly planning\u001b[31m\rnotes", + "active_at": "2026-08-16T15:00:00Z", + "creator": {"name": "Amanda\u001b[2J\nJones"} + }] +}`) + }), "thread", "list", "--in", "sent") + if err != nil { + t.Fatalf("execute: %v", err) + } + for _, want := range []string{"Sent", "42", "Quarterly planning", "Amanda", "2026-08-16"} { + if !strings.Contains(stdout, want) { + t.Errorf("output missing %q:\n%s", want, stdout) + } + } + 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 TestThreadListOutputFormats(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Total-Count", "2") + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, threadListFixture) + }) + + tests := []struct { + name string + formatArgs []string + wantOut []string + wantErr []string + }{ + {name: "ids", formatArgs: []string{"--ids-only"}, wantOut: []string{"42\n"}, wantErr: []string{"next_page: next/cursor"}}, + {name: "count", formatArgs: []string{"--count"}, wantOut: []string{"1\n"}, wantErr: []string{"next_page: next/cursor"}}, + {name: "quiet", formatArgs: []string{"--quiet"}, wantOut: []string{`"id": 42`, `"topic_id": 42`}, wantErr: []string{"next_page: next/cursor"}}, + {name: "markdown", formatArgs: []string{"--markdown"}, wantOut: []string{"# Served view", "| date | from | subject | topic_id |", "Quarterly planning notes", "**Total threads:** 2", "**Next page:** `next/cursor`"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr, err := runFormattedCommandWithStderr(t, handler, test.formatArgs, "thread", "list", "--in", "sent") + if err != nil { + t.Fatalf("execute: %v", err) + } + for _, want := range test.wantOut { + if !strings.Contains(stdout, want) { + t.Errorf("stdout missing %q:\n%s", want, stdout) + } + } + for _, want := range test.wantErr { + if !strings.Contains(stderr, want) { + t.Errorf("stderr missing %q:\n%s", want, stderr) + } + } + }) + } +} + +func TestThreadListEmptyJSONUsesArray(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"title":"Spam","topics":null}`) + }), "thread", "list", "--in", "spam") + if err != nil { + t.Fatalf("execute: %v", err) + } + if response.Summary != "0 threads in Spam" { + t.Errorf("summary = %q", response.Summary) + } + data, err := json.Marshal(response.Data) + if err != nil { + t.Fatal(err) + } + if string(data) != "[]" { + t.Fatalf("empty JSON data = %s, want []", data) + } +} + +func TestThreadListHelpNamesThreadIDs(t *testing.T) { + stdout, err := runFormattedCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected HTTP request: %s %s", r.Method, r.URL.Path) + }), nil, "thread", "list", "--help") + if err != nil { + t.Fatalf("help: %v", err) + } + for _, want := range []string{"List thread IDs", "--in string", "--limit int", "--page string", "--all"} { + if !strings.Contains(stdout, want) { + t.Errorf("help missing %q:\n%s", want, stdout) + } + } +} diff --git a/internal/cmd/topic.go b/internal/cmd/topic.go index b315f128..741c35ad 100644 --- a/internal/cmd/topic.go +++ b/internal/cmd/topic.go @@ -52,11 +52,12 @@ type topicCommand struct { func newThreadCommand() *cobra.Command { thread := &cobra.Command{ Use: "thread", - Short: "Read email threads", + Short: "List and read email threads", Annotations: map[string]string{ - "agent_notes": "Subcommands: read. Thread IDs come from hey box view. Use the same ID with hey reply or hey forward.", + "agent_notes": "Subcommands: list, read. `hey thread list` returns thread IDs from Sent, Spam, Trash, or Everything. Box listings call the same value topic_id; use it with hey thread read, reply, or forward.", }, } + thread.AddCommand(newThreadListCommand().cmd) thread.AddCommand(newThreadsCommand().cmd) return thread } diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 747ffe67..fca1c3f3 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -19,6 +19,7 @@ triggers: - hey snippet - hey search - hey contact + - hey thread list - hey thread read - hey share - hey unshare @@ -140,7 +141,7 @@ listing that found nothing. For the two commonest shapes there is no need for an expression at all: `--ids-only` prints one ID per line and `--count` prints a bare number, both on stdout with any pagination notice on stderr. Both need list data, so they work on `hey box list`, `hey box view`, -`hey label list`, `hey label view`, `hey collection list`, `hey collection view`, `hey workflow list`, +`hey thread list`, `hey label list`, `hey label view`, `hey collection list`, `hey collection view`, `hey workflow list`, `hey workflow view`, `hey clip list`, `hey snippet list`, `hey draft list`, `hey search`, `hey contact list`, `hey screener list`, `hey screener history`, `hey calendar list`, `hey event list`, `hey event day`, `hey event week`, `hey todo list`, `hey habit list`, @@ -157,6 +158,10 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box | Trust this repository's settings | `hey config trust-local` (requires explicit user approval) | | List mailboxes | `hey box list --json` | | List emails in a box | `hey box view imbox --json` | +| List sent threads | `hey thread list --in sent --json` | +| List spam threads | `hey thread list --in spam --json` | +| List trashed threads | `hey thread list --in trash --json` | +| List every thread | `hey thread list --in everything --json` | | List labels | `hey label list --json` | | List emails with a label | `hey label view --all --json` | | Add a label to a thread | `hey label add --to ` | @@ -267,6 +272,7 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box Want to read email? ├── Which mailbox? → hey box list --json ├── List emails in box? → hey box view --json +├── List sent, spam, trash, or every thread? → hey thread list --in sent|spam|trash|everything --json ├── List labels or labeled email? → hey label list --json / hey label view --json ├── Add, create, or remove a label? → hey label add|create|remove ├── List collections or collection threads? → hey collection list --json / hey collection view --json @@ -432,12 +438,24 @@ HEY hides contacts instead of permanently deleting them. A hidden contact leaves ### Email - Threads ```bash +hey thread list --in sent --json # List sent threads +hey thread list --in spam --json # List threads in Spam +hey thread list --in trash --json # List threads in Trash +hey thread list --in everything --all --json # List every thread +hey thread list --in sent --page --json # Continue with the returned cursor hey thread read --json # Read full email thread hey thread read --html # Read with raw HTML content hey share # Get a sharing link hey unshare # Turn off the sharing link ``` +`hey thread list` is read-only. It lists HEY's Sent, Spam, Trash, or Everything view and +returns an array of topics. Each JSON row has `topic_id`; both `id` and `topic_id` on +these rows are thread IDs, not the box item IDs that organization commands take. Use +`--limit` to cap results, `--all` to follow up to 100 pages, or `--page ` to +continue an earlier listing of the same view. The cursor is opaque; never invent a page +number. + `hey thread read` returns every entry in the thread, oldest first. Each entry's `body` is **Markdown**, converted from HEY's Trix HTML at the edge, so headings, lists, quotes, tables and code survive and links keep their URLs — read it as structure rather than as @@ -446,9 +464,9 @@ on an entry; use `hey reply`, which works the addressing out itself. `hey share` returns a URL that shows the entire thread and future emails or replies sent to it. Anyone with the link can open it. `hey unshare` turns off the sharing link. -**ID note:** Every email thread has two IDs: an `id` (its box item ID) and a `topic_id` (its thread ID). `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring` expect `id`. `hey thread read`, `hey share`, `hey unshare`, `hey attachment list`, `hey reply`, `hey forward`, `hey collection add`, and `hey collection remove` expect `topic_id`. Passing the wrong one answers `not_found`, not a redirect. +**ID note:** A thread listed through a box, label, collection, or search normally has two IDs: an `id` (its box item ID) and a `topic_id` (its thread ID). `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring` expect the box item `id`. `hey thread read`, `hey share`, `hey unshare`, `hey attachment list`, `hey reply`, `hey forward`, `hey collection add`, and `hey collection remove` expect `topic_id`. A `hey thread list` row comes directly from a topic, so its `id` is also a thread ID and `topic_id` repeats it explicitly. Passing a box item ID where a thread ID is expected, or the reverse, answers `not_found`, not a redirect. -`hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry both — except a bundle posting, which can omit `topic_id` (see the Boxes section). +`hey thread list --json`, `hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry `topic_id`; a bundle posting can omit it (see the Boxes section). ### Email - Attachments diff --git a/tests/smoke/topic_views_test.go b/tests/smoke/topic_views_test.go new file mode 100644 index 00000000..18e5e257 --- /dev/null +++ b/tests/smoke/topic_views_test.go @@ -0,0 +1,46 @@ +package smoke_test + +import ( + "strings" + "testing" +) + +type threadListItem struct { + ID int64 `json:"id"` + TopicID int64 `json:"topic_id"` +} + +func TestThreadListSystemViews(t *testing.T) { + for _, view := range []string{"sent", "spam", "trash", "everything"} { + t.Run(view, func(t *testing.T) { + response := heyJSON(t, "thread", "list", "--in", view) + 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[[]threadListItem](t, response) { + if topic.ID <= 0 || topic.TopicID != topic.ID { + t.Errorf("expected matching positive id and topic_id, got %#v", topic) + } + } + }) + } +} + +func TestThreadListPage(t *testing.T) { + first := heyJSON(t, "thread", "list", "--in", "sent") + nextPage, _ := first.Meta["next_page"].(string) + if nextPage == "" { + t.Skip("Sent has no next page in this fixture") + } + + response := heyJSON(t, "thread", "list", "--in", "sent", "--page", nextPage) + if strings.TrimSpace(response.Summary) == "" { + t.Fatal("expected a result summary") + } + if string(response.Data) == "null" { + t.Fatal("expected an array, got null") + } +}