diff --git a/.surface b/.surface index ae0963fa..48834acd 100644 --- a/.surface +++ b/.surface @@ -61,6 +61,14 @@ hey bulk-reply send --attach hey bulk-reply send --message hey bulk-reply send --message-html hey bulk-reply undo +hey bundle +hey bundle --all +hey bundle --limit +hey bundle --page +hey bundle view +hey bundle view --all +hey bundle view --limit +hey bundle view --page hey calendar hey calendar list hey clip @@ -124,6 +132,10 @@ hey contact note set --note-html hey contact note show hey contact show hey contact show-again +hey contact threads +hey contact threads --all +hey contact threads --limit +hey contact threads --page hey contact unbundle hey contact update hey contact update --alias diff --git a/AGENTS.md b/AGENTS.md index d9575e64..ec0e243c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -439,8 +439,8 @@ has one answer in the TUI rather than one per section. ### A mail source reads its own page -`internal/mail` is where a box, a label and a collection stop being three endpoints and -become one `Source` with one `ReadPage`. It follows the shape `internal/folders` and +`internal/mail` is where a box, a label, a collection, a bundle's unseen threads and a +contact's threads stop being five endpoints and become one `Source` with one `ReadPage`. It follows the shape `internal/folders` and `internal/habit` already set: a domain package taking `client *hey.Client`, imported by whoever needs it. @@ -476,6 +476,15 @@ bundle with several unseen threads (or none) names no topic and answers zero. `internal/cmd` needs the same answer — `resolvePostingTopicID` in `sdk.go` is a call to them, not a second copy. +**A bundle row's mail is reached the way the TUI reaches it.** `hey bundle view` lists +the unseen threads a bundle groups (`KindBundle`, the `bundles/unseen` route) and +`hey contact threads` lists every thread with its contact (`KindContact`, the contact +show route's postings page) — a read-through bundle has no unseen threads and no single +topic, so its mail lives only on the contact's list. The likeliest misuse is handing +`hey thread read` a bundle row's own id, which the topic route 404s; `loadThread` checks +a not-found against the bundle route and, when it answers, says what the id really is +instead of letting "not found" read as "no content". + **`mail.Entry` is one message in a thread**, described by `mail.NewEntry` against the message HEY served for it, because a topic's entry list and a message read on its own disagree about what they carry: an entry under a bundle has no creator and no timestamp, diff --git a/README.md b/README.md index 561c78f4..94694a6c 100644 --- a/README.md +++ b/README.md @@ -318,8 +318,8 @@ 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 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 box view`, `hey bundle view`, `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`. The @@ -363,6 +363,7 @@ 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 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 hey label add 12345 --to 789 # add a label to a thread @@ -396,6 +397,7 @@ hey search --from jane@example.com --date last_30_days # refine a search hey search filters # list available refinement values hey contact list # list contacts hey contact show 12345 # view a contact and private note +hey contact threads 12345 # list every thread with a contact, seen and unseen hey contact add --name "Jane Doe" --email jane@example.com hey contact update 12345 --name "Jane Dawson" hey contact hide 12345 # hide without permanently deleting diff --git a/internal/cmd/box.go b/internal/cmd/box.go index 18eeefd3..db7ffd7c 100644 --- a/internal/cmd/box.go +++ b/internal/cmd/box.go @@ -40,6 +40,7 @@ var boxListing = postingsListing{ }, breadcrumbs: []output.Breadcrumb{ {Action: "read", Command: "hey thread read ", Description: "Read an email thread"}, + {Action: "bundle", Command: "hey bundle view ", Description: "List the unseen threads a bundle row groups"}, {Action: "move", Command: "hey move --to ", Description: "Move an email thread to another box"}, {Action: "compose", Command: "hey compose --to --subject ", Description: "Compose a new message"}, }, @@ -80,7 +81,7 @@ func newBoxReaderCommand(use, short, long, example string) *boxCommand { Short: short, Long: long, Annotations: map[string]string{ - "agent_notes": "Accepts a box name or numeric ID. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. --page continues from the next_page cursor of an earlier listing of the same box.", + "agent_notes": "Accepts a box name or numeric ID. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view , and every thread with that sender via hey contact threads . --page continues from the next_page cursor of an earlier listing of the same box.", }, Example: example, RunE: command.run, diff --git a/internal/cmd/bundle.go b/internal/cmd/bundle.go new file mode 100644 index 00000000..14f99837 --- /dev/null +++ b/internal/cmd/bundle.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "errors" + "fmt" + + "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/mail" + "github.com/basecamp/hey-cli/internal/output" + "github.com/basecamp/hey-cli/internal/terminal" +) + +type bundleCommand struct { + cmd *cobra.Command + limit int + all bool + page string +} + +// bundleOutput is what `hey bundle view --json` answers with: the bundled contact next +// to the unseen postings, because the contact's id is what reads the rest of the +// bundle's mail once these threads are seen (hey contact threads ). +type bundleOutput struct { + ID int64 `json:"id"` + Contact generated.Contact `json:"contact"` + Postings []sourcePostingOutput `json:"postings"` + NextPage string `json:"next_page,omitempty"` +} + +var bundleListing = postingsListing{ + heading: "Bundle", + summary: func(count int, name string) string { + return fmt.Sprintf("%d unseen %s bundled from %s", count, threadNoun(count), name) + }, + cursorNotice: func(shown, total int) string { + return fmt.Sprintf("Showing %d remaining results from this cursor (%d unseen threads read).", shown, total) + }, +} + +func newBundleCommand() *bundleCommand { + command := newBundleReaderCommand( + "bundle", + "List the unseen threads a bundle groups", + ` hey bundle view 12345 + hey bundle view 12345 --all + hey bundle view 12345 --json`, + ) + command.cmd.Annotations[compatibilityUsageAnnotation] = "bundle " + command.cmd.Args = cobra.MaximumNArgs(1) + command.cmd.AddCommand(newBundleViewCommand().cmd) + return command +} + +func newBundleViewCommand() *bundleCommand { + return newBundleReaderCommand( + "view ", + "List the unseen threads a bundle groups", + ` hey bundle view 12345 + hey bundle view 12345 --page next-cursor + hey bundle view 12345 --all + hey bundle view 12345 --json`, + ) +} + +func newBundleReaderCommand(use, short, example string) *bundleCommand { + command := &bundleCommand{} + command.cmd = &cobra.Command{ + Use: use, + Short: short, + Long: "List the unseen email threads a bundle groups. A bundle is a hey box view row with kind \"bundle\": one sender's mail rolled into a single row instead of a thread apiece.", + Annotations: map[string]string{ + "agent_notes": "The ID is a bundle row's own id from hey box view — a row with kind \"bundle\" and no topic_id. Returns the unseen threads the bundle groups, each with topic_id for hey thread read. A bundle read through has no unseen threads; every thread with its sender, seen and unseen, is listed by hey contact threads .", + }, + Example: example, + RunE: command.run, + Args: usageExactOneArg(), + } + + 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 *bundleCommand) run(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + if err := requireAuth(); err != nil { + return err + } + + postingID, err := parsePositiveID(args[0], "bundle") + if err != nil { + return err + } + + first, err := sdk.Postings().BundleUnseenPage(cmd.Context(), postingID, c.page) + if err != nil { + return bundleNotFound(args[0], apierr.FromSDK(err)) + } + if first == nil { + return apierr.ErrNotFound("bundle", args[0]) + } + + contact := first.Contact + seed := pageResult[generated.Posting]{Items: first.Postings, Cursor: first.NextPage} + request := pageRequest{Limit: c.limit, All: c.all, MaxPages: maxPostingPages} + + listing := bundleListing + listing.emptyNotice = fmt.Sprintf( + "This bundle has no unseen threads — everything in it has been read. List every thread with %s: hey contact threads %d", + terminal.SanitizeLine(contact.Name), contact.Id) + listing.breadcrumbs = []output.Breadcrumb{ + {Action: "read", Command: "hey thread read ", Description: "Read an email thread"}, + {Action: "contact_threads", Command: fmt.Sprintf("hey contact threads %d", contact.Id), + Description: "List every thread with this bundle's sender, seen and unseen"}, + } + listing.payload = func(_ mail.Source, postings []sourcePostingOutput, nextPage string, _ int) any { + return bundleOutput{ID: postingID, Contact: contact, Postings: postings, NextPage: nextPage} + } + return listing.write(cmd, mail.BundleSource(postingID, contact), seed, request, c.page != "") +} + +// bundleNotFound says what a 404 on the bundle route means: the ID was not a bundle +// row's. The route answers only for postings that are bundles, so a plain thread's box +// item id and a topic id both 404 here. +func bundleNotFound(identifier string, err error) error { + var apiErr *apierr.Error + if errors.As(err, &apiErr) && apiErr.Code == apierr.CodeNotFound { + return apierr.ErrNotFoundHint("bundle", identifier, + "The ID must be a bundle row's own id — a hey box view row with kind \"bundle\".") + } + return err +} diff --git a/internal/cmd/bundle_test.go b/internal/cmd/bundle_test.go new file mode 100644 index 00000000..8327fdc4 --- /dev/null +++ b/internal/cmd/bundle_test.go @@ -0,0 +1,190 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" +) + +// bundleUnseenHandler answers the bundle unseen route the way haystack's +// Postings::Bundles::UnseenController does: the bundled contact and one page of unseen +// postings, with a geared Link cursor while there are pages below. +func bundleUnseenHandler(t *testing.T, pages map[string]string, links map[string]string) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/postings/9/bundles/unseen.json" { + t.Errorf("request = %s %s, want GET /postings/9/bundles/unseen.json", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + cursor := r.URL.Query().Get("page") + body, ok := pages[cursor] + if !ok { + t.Errorf("no page set up for cursor %q", cursor) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + if next, ok := links[cursor]; ok { + w.Header().Set("Link", fmt.Sprintf("; rel=\"next\"", r.Host, next)) + } + _, _ = io.WriteString(w, body) + } +} + +func TestBundleViewListsTheUnseenThreads(t *testing.T) { + response, err := runJSONCommand(t, bundleUnseenHandler(t, + map[string]string{"": `{"contact":{"id":5,"name":"GitHub"},"postings":[ + {"id":301,"summary":"CI failed","app_url":"https://app.hey.com/topics/881"}, + {"id":302,"summary":"CI fixed","app_url":"https://app.hey.com/topics/882"}]}`}, + map[string]string{"": "cursor-2"}, + ), "bundle", "view", "9") + if err != nil { + t.Fatalf("execute bundle view: %v", err) + } + + if response.Summary != "2 unseen threads bundled from GitHub" { + t.Errorf("summary = %q", response.Summary) + } + data, ok := response.Data.(map[string]any) + if !ok { + t.Fatalf("data = %T", response.Data) + } + contact, _ := data["contact"].(map[string]any) + if contact["id"] != float64(5) { + t.Errorf("contact = %v, want id 5 for hey contact threads", contact) + } + postings, _ := data["postings"].([]any) + if len(postings) != 2 { + t.Fatalf("postings = %v", postings) + } + first, _ := postings[0].(map[string]any) + if first["topic_id"] != float64(881) { + t.Errorf("first posting = %v, want topic_id 881", first) + } + if data["next_page"] != "cursor-2" { + t.Errorf("next_page = %v", data["next_page"]) + } +} + +func TestBundleViewFollowsTheCursorWithAll(t *testing.T) { + response, err := runJSONCommand(t, bundleUnseenHandler(t, + map[string]string{ + "": `{"contact":{"id":5,"name":"GitHub"},"postings":[{"id":301,"app_url":"https://app.hey.com/topics/881"}]}`, + "cursor-2": `{"contact":{"id":5,"name":"GitHub"},"postings":[{"id":302,"app_url":"https://app.hey.com/topics/882"}]}`, + }, + map[string]string{"": "cursor-2"}, + ), "bundle", "view", "9", "--all") + if err != nil { + t.Fatalf("execute bundle view --all: %v", err) + } + + if response.Summary != "2 unseen threads bundled from GitHub" { + t.Errorf("summary = %q", response.Summary) + } + data, _ := response.Data.(map[string]any) + if next, ok := data["next_page"]; ok { + t.Errorf("next_page = %v, want none after the last page", next) + } +} + +// A bundle with no unseen threads is not empty — it has been read — and the listing +// says where its mail lives instead of leaving zero rows to read as no mail. +func TestBundleViewSaysWhereAReadBundlesMailLives(t *testing.T) { + response, err := runJSONCommand(t, bundleUnseenHandler(t, + map[string]string{"": `{"contact":{"id":5,"name":"GitHub"},"postings":[]}`}, + nil, + ), "bundle", "view", "9") + if err != nil { + t.Fatalf("execute bundle view: %v", err) + } + if !strings.Contains(response.Notice, "no unseen threads") || !strings.Contains(response.Notice, "hey contact threads 5") { + t.Errorf("notice = %q, want the contact threads pointer", response.Notice) + } +} + +// The unseen route answers only for postings that are bundles, so a not-found means +// the ID was something else — and the error says what the route wants. +func TestBundleViewRefusesANonBundleID(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }), "bundle", "view", "9") + if err == nil || !strings.Contains(err.Error(), `bundle "9" not found`) { + t.Fatalf("error = %v, want a bundle not-found", err) + } +} + +func TestContactThreadsListsEveryThread(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/contacts/5.json" { + t.Errorf("request = %s %s, want GET /contacts/5.json", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + if cursor := r.URL.Query().Get("page"); cursor != "cursor-2" { + t.Errorf("cursor = %q, want cursor-2", cursor) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":5,"name":"GitHub","email_address":"noreply@example.com", + "entries_title":"All threads with GitHub", + "postings":[{"id":401,"summary":"CI failed","app_url":"https://app.hey.com/topics/881"}]}`) + }), "contact", "threads", "5", "--page", "cursor-2") + if err != nil { + t.Fatalf("execute contact threads: %v", err) + } + + if response.Summary != "1 thread with GitHub" { + t.Errorf("summary = %q", response.Summary) + } + data, ok := response.Data.(map[string]any) + if !ok { + t.Fatalf("data = %T", response.Data) + } + if data["entries_title"] != "All threads with GitHub" { + t.Errorf("entries_title = %v", data["entries_title"]) + } + postings, _ := data["postings"].([]any) + if len(postings) != 1 { + t.Fatalf("postings = %v", postings) + } + first, _ := postings[0].(map[string]any) + if first["topic_id"] != float64(881) { + t.Errorf("first posting = %v, want topic_id 881", first) + } +} + +// A thread read that 404s on a bundle's own id says what the id really is and where +// the mail lives, instead of leaving "not found" to read as "no content". +func TestThreadReadNamesABundleMisread(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/topics/9/entries.json": + http.NotFound(w, r) + case "/postings/9/bundles/unseen.json": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"contact":{"id":5,"name":"GitHub"},"postings":[]}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "thread", "read", "9") + if err == nil || !strings.Contains(err.Error(), "9 is a bundle, not a thread") { + t.Fatalf("error = %v, want the bundle named", err) + } + if !strings.Contains(err.Error(), "GitHub") { + t.Errorf("error = %v, want the bundled contact named", err) + } +} + +// An id that is neither a thread nor a bundle keeps its own not-found: the probe stays +// on the error path and changes nothing it cannot improve. +func TestThreadReadKeepsAPlainNotFound(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }), "thread", "read", "9") + if err == nil || strings.Contains(err.Error(), "bundle") { + t.Fatalf("error = %v, want the original not-found", err) + } +} diff --git a/internal/cmd/contacts.go b/internal/cmd/contacts.go index 61ed6d76..17fa6646 100644 --- a/internal/cmd/contacts.go +++ b/internal/cmd/contacts.go @@ -30,6 +30,7 @@ func newContactsCommand() *contactsCommand { contactsCommand.cmd.AddCommand(newContactsListCommand().cmd) contactsCommand.cmd.AddCommand(newContactsShowCommand().cmd) + contactsCommand.cmd.AddCommand(newContactsThreadsCommand().cmd) contactsCommand.cmd.AddCommand(newContactsAddCommand().cmd) contactsCommand.cmd.AddCommand(newContactsUpdateCommand().cmd) contactsCommand.cmd.AddCommand(newContactsHideCommand().cmd) diff --git a/internal/cmd/contacts_show.go b/internal/cmd/contacts_show.go index 298731ce..9eeeab6e 100644 --- a/internal/cmd/contacts_show.go +++ b/internal/cmd/contacts_show.go @@ -33,7 +33,7 @@ func newContactsShowCommand() *contactsShowCommand { Use: "show ", Short: "View a contact", Annotations: map[string]string{ - "agent_notes": "Returns contact details, aliases, screening status, and the private note.", + "agent_notes": "Returns contact details, aliases, screening status, and the private note. The embedded postings are one page of the contact's threads; hey contact threads pages through all of them.", }, Example: ` hey contact show 12345 hey contact show 12345 --json`, @@ -93,6 +93,7 @@ func (c *contactsShowCommand) run(cmd *cobra.Command, args []string) error { return writeOK(result, output.WithSummary(fmt.Sprintf("Contact %d", contactID)), output.WithBreadcrumbs( + output.Breadcrumb{Action: "threads", Command: fmt.Sprintf("hey contact threads %d", contactID), Description: "List every thread with this contact"}, output.Breadcrumb{Action: "edit", Command: fmt.Sprintf("hey contact update %d", contactID), Description: "Edit this contact"}, output.Breadcrumb{Action: "note", Command: fmt.Sprintf("hey contact note set %d", contactID), Description: "Edit the private note"}, ), diff --git a/internal/cmd/contacts_threads.go b/internal/cmd/contacts_threads.go new file mode 100644 index 00000000..2b7c2a26 --- /dev/null +++ b/internal/cmd/contacts_threads.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "fmt" + + "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/mail" + "github.com/basecamp/hey-cli/internal/output" +) + +type contactThreadsCommand struct { + cmd *cobra.Command + limit int + all bool + page string +} + +// contactThreadsOutput is what `hey contact threads --json` answers with: the contact +// by name, HEY's own heading for the list, and the page of threads. +type contactThreadsOutput struct { + ID int64 `json:"id"` + Name string `json:"name,omitempty"` + EmailAddress string `json:"email_address,omitempty"` + EntriesTitle string `json:"entries_title,omitempty"` + Postings []sourcePostingOutput `json:"postings"` + NextPage string `json:"next_page,omitempty"` +} + +var contactThreadsListing = postingsListing{ + heading: "Contact", + summary: func(count int, name string) string { + return fmt.Sprintf("%d %s with %s", count, threadNoun(count), name) + }, + cursorNotice: func(shown, total int) string { + return fmt.Sprintf("Showing %d remaining results from this cursor (%d threads read).", shown, total) + }, + breadcrumbs: []output.Breadcrumb{ + {Action: "read", Command: "hey thread read ", Description: "Read an email thread"}, + {Action: "show", Command: "hey contact show ", Description: "View the contact"}, + }, +} + +func newContactsThreadsCommand() *contactThreadsCommand { + command := &contactThreadsCommand{} + command.cmd = &cobra.Command{ + Use: "threads ", + Short: "List every thread with a contact", + Long: "List every email thread a contact is on, seen and unseen — the list HEY heads \"All threads with …\". This is also where a bundle's mail lives once it has been read through.", + Annotations: map[string]string{ + "agent_notes": "The ID comes from hey contact list, a posting's creator, or hey bundle view's contact. Returns threads newest first with topic_id for hey thread read, and answers --json, --styled, --markdown, --ids-only and --count.", + }, + Example: ` hey contact threads 12345 + hey contact threads 12345 --all + hey contact threads 12345 --json`, + RunE: command.run, + Args: usageExactOneArg(), + } + + 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 *contactThreadsCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + contactID, err := parseContactID(args[0]) + if err != nil { + return err + } + + first, err := sdk.Contacts().ThreadsPage(cmd.Context(), contactID, c.page) + if err != nil { + return apierr.FromSDK(err) + } + if first == nil || first.Contact == nil { + return apierr.ErrNotFound("contact", args[0]) + } + + contact := first.Contact + seed := pageResult[generated.Posting]{Items: contact.Postings, Cursor: first.NextPage} + request := pageRequest{Limit: c.limit, All: c.all, MaxPages: maxPostingPages} + + listing := contactThreadsListing + listing.payload = func(_ mail.Source, postings []sourcePostingOutput, nextPage string, _ int) any { + return contactThreadsOutput{ + ID: contact.Id, + Name: contact.Name, + EmailAddress: contact.EmailAddress, + EntriesTitle: contact.EntriesTitle, + Postings: postings, + NextPage: nextPage, + } + } + return listing.write(cmd, mail.ContactSource(contact), seed, request, c.page != "") +} diff --git a/internal/cmd/help.go b/internal/cmd/help.go index bcf6bf29..ce4fb1f1 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -21,7 +21,7 @@ var curatedCategories = []struct { }, { heading: "MAIL", - names: []string{"screener", "attachment", "draft", "watch"}, + names: []string{"bundle", "screener", "attachment", "draft", "watch"}, }, { heading: "WRITE & SHARE", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index a4f47d0f..0fe82950 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -116,6 +116,7 @@ CORE COMMANDS journal Read and write journal entries MAIL + bundle List the unseen threads a bundle groups screener Decide who gets to email you attachment List and save files from a thread draft Manage unsent drafts diff --git a/internal/cmd/postings_listing.go b/internal/cmd/postings_listing.go index 0ab517d2..64ec347a 100644 --- a/internal/cmd/postings_listing.go +++ b/internal/cmd/postings_listing.go @@ -57,6 +57,11 @@ type postingsListing struct { cursorNotice func(shown, total int) string breadcrumbs []output.Breadcrumb payload func(source mail.Source, postings []sourcePostingOutput, nextPage string, total int) any + + // emptyNotice speaks for a first page with nothing on it, where the counts alone + // would say nothing at all. Only the bundle sets one: a bundle with no unseen + // threads is not empty, it has been read, and its mail lives on its contact's list. + emptyNotice string } func (l postingsListing) write(cmd *cobra.Command, source mail.Source, first pageResult[generated.Posting], request pageRequest, fromCursor bool) error { @@ -107,6 +112,9 @@ func (l postingsListing) sourcePayload(source mail.Source, postings []generated. } func (l postingsListing) notice(shown, total int, hasMore, all, fromCursor bool) string { + if shown == 0 && !hasMore && !fromCursor && l.emptyNotice != "" { + return l.emptyNotice + } if all { if hasMore { return fmt.Sprintf("Showing %d results. Pagination limit reached; continue with --page using next_page.", shown) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 06182148..b0bdaa53 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -185,6 +185,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newLogoutCommand()) root.AddCommand(newAccountsCommand().cmd) root.AddCommand(newBoxCommand().cmd) + root.AddCommand(newBundleCommand().cmd) root.AddCommand(newLabelCommand().cmd) root.AddCommand(newCollectionCommand().cmd) root.AddCommand(newWorkflowCommand().cmd) diff --git a/internal/cmd/thread_source.go b/internal/cmd/thread_source.go index 230ff449..a0795e48 100644 --- a/internal/cmd/thread_source.go +++ b/internal/cmd/thread_source.go @@ -2,9 +2,11 @@ package cmd import ( "context" + "errors" "fmt" "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/terminal" "github.com/basecamp/hey-cli/internal/threadload" ) @@ -19,7 +21,7 @@ func loadThread(ctx context.Context, threadID int64, hydrate bool) (*threadload. Limits: threadLimits, }) if err != nil { - return nil, err + return nil, describeBundleMisread(ctx, threadID, err) } if len(thread.Entries) == 0 { return nil, apierr.ErrNotFound("entries for thread", fmt.Sprint(threadID)) @@ -27,6 +29,32 @@ func loadThread(ctx context.Context, threadID int64, hydrate bool) (*threadload. return thread, nil } +// describeBundleMisread names the mistake behind a thread read that 404s on a bundle's +// own id. A bundle row has no topic_id, and the likeliest id to be tried in its place is +// the row's own — which is not a topic, so the topic route answers not-found and a caller +// reads "no content" where the truth is "not a thread". A not-found is therefore checked +// against the bundle route, which answers exactly for postings that are bundles, and the +// error then says what the id really is and where its mail lives. Any other id 404s on +// the probe too and keeps its own error; the probe costs one request, on the error path +// alone. +func describeBundleMisread(ctx context.Context, threadID int64, err error) error { + var apiErr *apierr.Error + if !errors.As(err, &apiErr) || apiErr.Code != apierr.CodeNotFound { + return err + } + bundle, probeErr := sdk.Postings().BundleUnseenPage(ctx, threadID, "") + if probeErr != nil || bundle == nil { + return err + } + name := terminal.SanitizeLine(bundle.Contact.Name) + return &apierr.Error{ + Code: apierr.CodeNotFound, + Message: fmt.Sprintf("%d is a bundle, not a thread: it groups mail from %s and names no topic", threadID, name), + Hint: fmt.Sprintf("List its unseen threads with hey bundle view %d, or every thread with %s via hey contact threads %d.", threadID, name, bundle.Contact.Id), + HTTPStatus: 404, + } +} + // threadNotice is the thread's notice in terms of the CLI's limits. func threadNotice(thread *threadload.Thread) string { return thread.Notice(threadLimits) diff --git a/internal/mail/page.go b/internal/mail/page.go index 6be9dd60..7e3e649d 100644 --- a/internal/mail/page.go +++ b/internal/mail/page.go @@ -29,6 +29,10 @@ func ReadPage(ctx context.Context, client *hey.Client, source Source, cursor str return readFolderPage(ctx, client, source, cursor) case KindCollection: return readCollectionPage(ctx, client, source, cursor) + case KindBundle: + return readBundlePage(ctx, client, source, cursor) + case KindContact: + return readContactPage(ctx, client, source, cursor) default: return Page{}, fmt.Errorf("mail: source %d has no readable kind %q", source.ID, source.Kind) } @@ -136,6 +140,31 @@ func readFolderPage(ctx context.Context, client *hey.Client, source Source, curs return Page{Postings: result.Folder.Postings, Cursor: result.NextPage, Total: result.TotalCount}, nil } +// readBundlePage reads one page of the unseen postings a bundle posting groups. The +// cursor is a geared_pagination cursor, as a label's is. +func readBundlePage(ctx context.Context, client *hey.Client, source Source, cursor string) (Page, error) { + result, err := client.Postings().BundleUnseenPage(ctx, source.ID, cursor) + if err != nil { + return Page{}, err + } + if result == nil { + return Page{}, fmt.Errorf("mail: bundle %d answered no page at cursor %q", source.ID, cursor) + } + return Page{Postings: result.Postings, Cursor: result.NextPage}, nil +} + +// readContactPage reads one page of the threads a contact is on. +func readContactPage(ctx context.Context, client *hey.Client, source Source, cursor string) (Page, error) { + result, err := client.Contacts().ThreadsPage(ctx, source.ID, cursor) + if err != nil { + return Page{}, err + } + if result == nil || result.Contact == nil { + return Page{}, fmt.Errorf("mail: contact %d answered no page at cursor %q", source.ID, cursor) + } + return Page{Postings: result.Contact.Postings, Cursor: result.NextPage}, nil +} + func readCollectionPage(ctx context.Context, client *hey.Client, source Source, cursor string) (Page, error) { var params *generated.GetCollectionParams if cursor != "" { diff --git a/internal/mail/page_test.go b/internal/mail/page_test.go index 1b43fe8a..23841b68 100644 --- a/internal/mail/page_test.go +++ b/internal/mail/page_test.go @@ -218,3 +218,44 @@ func TestReadPageReportsAFailedRead(t *testing.T) { t.Fatal("expected the server failure to surface") } } + +func TestReadPageReadsABundlesUnseenPostings(t *testing.T) { + var path, cursor string + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + path, cursor = r.URL.Path, r.URL.Query().Get("page") + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Link", fmt.Sprintf("; rel=\"next\"", r.Host)) + _, _ = io.WriteString(w, `{"contact":{"id":5,"name":"GitHub"},"postings":[{"id":301},{"id":302}]}`) + }) + + page, err := ReadPage(context.Background(), client, Source{Kind: KindBundle, ID: 9}, "cursor-2") + if err != nil { + t.Fatalf("read page: %v", err) + } + if path != "/postings/9/bundles/unseen.json" || cursor != "cursor-2" { + t.Errorf("request = %s?page=%s", path, cursor) + } + if len(page.Postings) != 2 || page.Cursor != "cursor-3" { + t.Errorf("page = %+v", page) + } +} + +func TestReadPageReadsAContactsThreads(t *testing.T) { + var path, cursor string + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + path, cursor = r.URL.Path, r.URL.Query().Get("page") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":5,"name":"GitHub","entries_title":"All threads with GitHub","postings":[{"id":401}]}`) + }) + + page, err := ReadPage(context.Background(), client, Source{Kind: KindContact, ID: 5}, "cursor-2") + if err != nil { + t.Fatalf("read page: %v", err) + } + if path != "/contacts/5.json" || cursor != "cursor-2" { + t.Errorf("request = %s?page=%s", path, cursor) + } + if len(page.Postings) != 1 || page.Cursor != "" { + t.Errorf("page = %+v", page) + } +} diff --git a/internal/mail/source.go b/internal/mail/source.go index 6dfde2d6..64f0e1f2 100644 --- a/internal/mail/source.go +++ b/internal/mail/source.go @@ -1,6 +1,6 @@ -// Package mail reads the places HEY keeps postings — a box, a label, a collection — -// through one Source type and one page read, so a caller never has to know which -// endpoint a source is served by. +// Package mail reads the places HEY keeps postings — a box, a label, a collection, a +// bundle's unseen threads, a contact's threads — through one Source type and one page +// read, so a caller never has to know which endpoint a source is served by. package mail import ( @@ -17,6 +17,8 @@ const ( KindBox Kind = "box" KindFolder Kind = "folder" KindCollection Kind = "collection" + KindBundle Kind = "bundle" + KindContact Kind = "contact" ) // Source is a place postings are read from. BoxKind carries HEY's own kind for a box @@ -73,6 +75,28 @@ func FolderSource(folder *generated.FolderWithPostings) Source { } } +// BundleSource describes a bundle posting as a source: the unseen threads it groups, +// named for the bundled contact. The ID is the bundle row's own box item id, which is +// what the unseen route is addressed by. +func BundleSource(postingID int64, contact generated.Contact) Source { + return Source{ + Kind: KindBundle, + ID: postingID, + Name: contact.Name, + } +} + +// ContactSource describes a contact as a source: every thread they are on, seen and +// unseen — the list HEY heads with the contact's entries_title. +func ContactSource(contact *generated.ContactDetail) Source { + return Source{ + Kind: KindContact, + ID: contact.Id, + Name: contact.Name, + UpdatedAt: contact.UpdatedAt, + } +} + // CollectionSource describes the collection HEY answered with. func CollectionSource(collection *generated.CollectionWithPostings) Source { return Source{ diff --git a/internal/mail/source_test.go b/internal/mail/source_test.go index ba067ee0..073d6f62 100644 --- a/internal/mail/source_test.go +++ b/internal/mail/source_test.go @@ -54,3 +54,16 @@ func TestFolderAndCollectionSourcesCarryTheirTimestamps(t *testing.T) { t.Errorf("collection source = %+v", collection) } } + +func TestBundleAndContactSourcesNameTheContact(t *testing.T) { + bundle := BundleSource(9, generated.Contact{Id: 5, Name: "GitHub"}) + if bundle.Kind != KindBundle || bundle.ID != 9 || bundle.Name != "GitHub" { + t.Errorf("bundle source = %+v", bundle) + } + + updated := time.Date(2026, 3, 4, 9, 30, 0, 0, time.UTC) + contact := ContactSource(&generated.ContactDetail{Id: 5, Name: "GitHub", UpdatedAt: updated}) + if contact.Kind != KindContact || contact.ID != 5 || contact.Name != "GitHub" || !contact.UpdatedAt.Equal(updated) { + t.Errorf("contact source = %+v", contact) + } +} diff --git a/internal/tui/mail.go b/internal/tui/mail.go index a2d08ab3..6f553bbb 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -1195,6 +1195,9 @@ func (v *mailView) SubnavLeft() tea.Cmd { return v.switchBox(tabIndexes[i-1]) } } + case mail.KindBundle, mail.KindContact: + // Never the current source here: a bundle opens in its own lane over the box, + // which the bundleActive guard above already handled. } return nil } @@ -1233,6 +1236,9 @@ func (v *mailView) SubnavRight() tea.Cmd { } return v.openPreviouslySeen() } + case mail.KindBundle, mail.KindContact: + // Never the current source here: a bundle opens in its own lane over the box, + // which the bundleActive guard above already handled. } return nil } diff --git a/tests/smoke/bundle_test.go b/tests/smoke/bundle_test.go new file mode 100644 index 00000000..73c1d84e --- /dev/null +++ b/tests/smoke/bundle_test.go @@ -0,0 +1,126 @@ +package smoke_test + +import ( + "testing" +) + +type smokeBundleRow struct { + ID int `json:"id"` + Kind string `json:"kind"` + Creator struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"creator"` +} + +type smokeBundle struct { + ID int `json:"id"` + Contact struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"contact"` + Postings []struct { + ID int `json:"id"` + TopicID int `json:"topic_id"` + } `json:"postings"` + NextPage string `json:"next_page"` +} + +func imboxRows(t *testing.T) []smokeBundleRow { + t.Helper() + data := dataAs[struct { + Postings []smokeBundleRow `json:"postings"` + }](t, heyJSON(t, "box", "imbox", "--all")) + return data.Postings +} + +func scanForBundleRow(t *testing.T) (smokeBundleRow, bool) { + t.Helper() + for _, row := range imboxRows(t) { + if row.Kind == "bundle" { + return row, true + } + } + return smokeBundleRow{}, false +} + +// findBundleRow answers with an Imbox row of kind "bundle" — one sender's mail grouped +// into a single row. When the seed data has none, it bundles the sender of an Imbox +// thread to make one, and unbundles them again in cleanup. +func findBundleRow(t *testing.T) smokeBundleRow { + t.Helper() + if row, ok := scanForBundleRow(t); ok { + return row + } + + contactID := 0 + for _, row := range imboxRows(t) { + if row.Kind != "bundle" && row.Creator.ID != 0 { + contactID = row.Creator.ID + break + } + } + if contactID == 0 { + skipf(t, "no imbox sender available to bundle") + } + id := intStr(contactID) + _, stderr, code := hey(t, "contact", "bundle", id, "--json") + if code != 0 { + skipf(t, "contact bundle unavailable (exit %d): %s", code, stderr) + } + t.Cleanup(func() { + _, cleanupStderr, cleanupCode := hey(t, "contact", "unbundle", id) + if cleanupCode != 0 { + t.Logf("could not unbundle contact %s: %s", id, cleanupStderr) + } + }) + if row, ok := scanForBundleRow(t); ok { + return row + } + skipf(t, "bundling contact %d produced no bundle row in the Imbox", contactID) + return smokeBundleRow{} +} + +func TestBundleView(t *testing.T) { + row := findBundleRow(t) + bundle := dataAs[smokeBundle](t, heyJSON(t, "bundle", "view", intStr(row.ID))) + if bundle.ID != row.ID || bundle.Contact.ID == 0 { + t.Errorf("bundle view returned id=%d contact=%d, want row %d with its contact", bundle.ID, bundle.Contact.ID, row.ID) + } + for _, posting := range bundle.Postings { + if posting.TopicID == 0 { + t.Errorf("posting %d has no topic_id for hey thread read", posting.ID) + } + } + + limited := dataAs[smokeBundle](t, heyJSON(t, "bundle", "view", intStr(row.ID), "--limit", "1")) + if len(limited.Postings) > 1 { + t.Errorf("expected at most 1 posting with --limit 1, got %d", len(limited.Postings)) + } + + // The bundled contact's own list pages every thread, seen and unseen. + threads := dataAs[struct { + ID int `json:"id"` + }](t, heyJSON(t, "contact", "threads", intStr(bundle.Contact.ID))) + if threads.ID != bundle.Contact.ID { + t.Errorf("contact threads returned id %d, want the bundled contact %d", threads.ID, bundle.Contact.ID) + } +} + +// A bundle row's own id names no topic, and hey thread read says so instead of +// answering "not found". +func TestThreadReadNamesABundleRowID(t *testing.T) { + row := findBundleRow(t) + _, stderr := heyFail(t, "thread", "read", intStr(row.ID), "--json") + assertContains(t, stderr, "bundle") +} + +func TestBundleNoArgumentShowsHelp(t *testing.T) { + stdout := heyOK(t, "bundle") + assertContains(t, stdout, "hey bundle view") +} + +func TestBundleViewValidatesInput(t *testing.T) { + heyFail(t, "bundle", "view", "not-an-id") + heyFail(t, "bundle", "view", "0") +} diff --git a/tests/smoke/contacts_test.go b/tests/smoke/contacts_test.go index bfa681dc..e9544cb0 100644 --- a/tests/smoke/contacts_test.go +++ b/tests/smoke/contacts_test.go @@ -94,6 +94,39 @@ func TestContactLifecycleAndPrivateNote(t *testing.T) { contactWriteJSON(t, "contact", "hide", id) } +func TestContactThreads(t *testing.T) { + // An Imbox sender is a contact with at least one thread on record, so the + // listing has something real to answer with. + id := 0 + for _, row := range imboxRows(t) { + if row.Creator.ID != 0 { + id = row.Creator.ID + break + } + } + if id == 0 { + skipf(t, "no imbox sender available to list threads for") + } + + threads := dataAs[struct { + ID int `json:"id"` + Postings []json.RawMessage `json:"postings"` + }](t, heyJSON(t, "contact", "threads", intStr(id))) + if threads.ID != id { + t.Errorf("contact threads returned id %d, want %d", threads.ID, id) + } + if len(threads.Postings) == 0 { + t.Error("expected at least one thread with an imbox sender") + } + + limited := dataAs[struct { + Postings []json.RawMessage `json:"postings"` + }](t, heyJSON(t, "contact", "threads", intStr(id), "--limit", "1")) + if len(limited.Postings) > 1 { + t.Errorf("expected at most 1 posting with --limit 1, got %d", len(limited.Postings)) + } +} + func contactWriteJSON(t *testing.T, args ...string) Response { t.Helper() args = append(args, "--json") @@ -115,6 +148,7 @@ func TestContactCommandsValidateInput(t *testing.T) { heyFail(t, "contact", "add", "--name", "Sam Rivera") heyFail(t, "contact", "update", "12345") heyFail(t, "contact", "show", "not-an-id") + heyFail(t, "contact", "threads", "not-an-id") heyFail(t, "contact", "bundle", "not-an-id") heyFail(t, "contact", "unbundle", "0") }