From 630181971fd76bd158a8bc5a019591b35b2f865c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 20 Aug 2026 11:40:35 -0400 Subject: [PATCH 1/4] Add label workflows to the CLI and TUI --- .surface | 12 + API-COVERAGE.md | 5 + README.md | 10 +- go.mod | 2 +- go.sum | 4 +- internal/cmd/drafts_test.go | 19 +- internal/cmd/help.go | 2 +- internal/cmd/help_test.go | 4 +- internal/cmd/label.go | 427 +++++++++++++++++++++++++ internal/cmd/label_test.go | 367 ++++++++++++++++++++++ internal/cmd/root.go | 2 + internal/folders/navigation.go | 52 +++ internal/folders/navigation_test.go | 69 ++++ internal/models/box.go | 7 + internal/output/writer.go | 13 +- internal/output/writer_test.go | 18 ++ internal/tui/folders.go | 215 +++++++++++++ internal/tui/mail.go | 469 ++++++++++++++++++++++++---- internal/tui/mail_test.go | 395 ++++++++++++++++++++++- internal/tui/move.go | 2 +- internal/tui/nav.go | 24 +- internal/tui/tui.go | 17 + internal/tui/tui_test.go | 42 +++ skills/hey/SKILL.md | 30 +- tests/smoke/labels_test.go | 50 +++ 25 files changed, 2163 insertions(+), 94 deletions(-) create mode 100644 internal/cmd/label.go create mode 100644 internal/cmd/label_test.go create mode 100644 internal/folders/navigation.go create mode 100644 internal/folders/navigation_test.go create mode 100644 internal/tui/folders.go create mode 100644 tests/smoke/labels_test.go diff --git a/.surface b/.surface index 00e6c63f..cbc952dd 100644 --- a/.surface +++ b/.surface @@ -104,6 +104,18 @@ hey journal list --limit hey journal read hey journal write hey journal write --content +hey label +hey label --all +hey label --limit +hey label --page +hey label add +hey label add --to +hey label create +hey label remove +hey label remove --from +hey labels +hey labels --all +hey labels --limit hey move hey move --to hey recordings diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 81ef48c7..d921606d 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -13,6 +13,11 @@ 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 | +| `/my/navigation.json` | GET | SDK `Identity().GetNavigation` | `hey labels`, Mail TUI navigation | covered | +| `/folders/{id}.json` | GET | SDK `Folders().GetPage` | `hey label `, Mail TUI labels | covered | +| `/postings/filings.json` | POST | SDK `Postings().File` | `hey label add`, TUI `g` | covered | +| `/postings/filings.json` | DELETE | SDK `Postings().Unfile` | `hey label remove`, TUI `g` | covered | +| `/postings/folders.json` | POST | SDK `Postings().CreateFolder` | `hey label create`, TUI `g` | 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 75d2c7ca..986b3f08 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ accounts, press Ctrl+A to switch between All Accounts and individual email addre Switching cancels requests from the previous account and reloads the active section; Calendar and Journal remain identity-wide. -Navigate between Mail, Contacts, Calendar, and Journal. In Mail, use `/` to search, Enter to open a thread, `r` to reply, `f` to forward, `m` to move, `t` to trash, `s` to mark as spam, `-` to ignore, and `+` to stop ignoring. Select threads with Space and press `b` to preview every bulk-reply recipient before writing and sending one reply to all selected threads. A delayed bulk reply can be recalled with `u` while HEY's undo window remains open. Search results retain the matching-message summary; use `n` and `p` to move between result pages. +Navigate between Mail, Contacts, Calendar, and Journal. Mail navigation includes HEY boxes followed by your labels. Use `n` and `p` to page through a label. Use `/` to search, Enter to open a thread, `r` to reply, `f` to forward, `m` to move, `g` to add, create, or remove labels, `t` to trash, `s` to mark as spam, `-` to ignore, and `+` to stop ignoring. Select threads with Space and press `b` to preview every bulk-reply recipient before writing and sending one reply to all selected threads. A delayed bulk reply can be recalled with `u` while HEY's undo window remains open. Search results retain the matching-message summary; use `n` and `p` to move between result pages. Thread attachments always appear with their filename, media type, and size. Use `[` and `]` to select an attachment, `s` to save it without replacing an existing file, and `o` to download and open it in an external application. Attachments never open automatically. Kitty and Ghostty can show inline images. Foot and other terminals use visible text markers. @@ -117,6 +117,12 @@ hey boxes --quiet --jq '.[].id' ```bash hey boxes # list mailboxes hey box imbox # list email threads in a box (by name or ID) +hey labels # list labels and their IDs +hey label 789 --all # list all email threads with a label +hey label add 12345 --to 789 # add a label to a thread +hey label create "Travel receipts" 12345 # create and add a label +hey label remove 12345 --from 789 # remove one label +hey label remove 12345 --from all # remove every label 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 @@ -159,7 +165,7 @@ Contact updates preserve omitted name, email, and alias fields. Supplying `--ali `--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 attachments ` returns stable message-and-position IDs such as `456:1`; pass an ID to `hey attachments 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 --json` or `hey search --json`. Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Bubble Up requires a scheduled date and is not available through `hey move`. Trashing a shared thread removes your access instead of deleting it for everyone. Ignored threads remain in their box and can be restored with `hey stop-ignoring`. +Organization actions take the `id` values returned by `hey box --json`, `hey label --json`, or `hey search --json`. Label IDs come from `hey labels`; `hey label` 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. Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Bubble Up requires a scheduled date and is not available through `hey move`. Trashing a shared thread removes your access instead of deleting it for everyone. Ignored threads remain in their box and can be restored with `hey stop-ignoring`. ### Calendars diff --git a/go.mod b/go.mod index 2ca522dc..55f092be 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( charm.land/bubbles/v2 v2.1.1 charm.land/bubbletea/v2 v2.0.8 charm.land/lipgloss/v2 v2.0.6 - github.com/basecamp/hey-sdk/go v0.6.0 + github.com/basecamp/hey-sdk/go v0.6.1 github.com/charmbracelet/x/ansi v0.11.8 github.com/itchyny/gojq v0.12.19 github.com/mattn/go-runewidth v0.0.27 diff --git a/go.sum b/go.sum index 497a1d34..1da38e8b 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= -github.com/basecamp/hey-sdk/go v0.6.0 h1:maoTjI41fb25L7d3KUbZq/CdzNTH96JGu8/oOukUnTs= -github.com/basecamp/hey-sdk/go v0.6.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= +github.com/basecamp/hey-sdk/go v0.6.1 h1:NlruAUq1GOk+VCkc1HyxAsvLCbNf54n0kZ6kNfhZkyc= +github.com/basecamp/hey-sdk/go v0.6.1/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= diff --git a/internal/cmd/drafts_test.go b/internal/cmd/drafts_test.go index a9a3406c..aa505c60 100644 --- a/internal/cmd/drafts_test.go +++ b/internal/cmd/drafts_test.go @@ -9,7 +9,12 @@ import ( "testing" ) -func runStyledDraftsCommand(t *testing.T, handler http.Handler, args ...string) (string, error) { +func runStyledCommand(t *testing.T, handler http.Handler, args ...string) (string, error) { + t.Helper() + return runFormattedCommand(t, handler, []string{"--styled"}, args...) +} + +func runFormattedCommand(t *testing.T, handler http.Handler, formatArgs []string, args ...string) (string, error) { t.Helper() previousColorDisabled := colorDisabled colorDisabled = false @@ -30,7 +35,9 @@ func runStyledDraftsCommand(t *testing.T, handler http.Handler, args ...string) var stdout, stderr bytes.Buffer root.SetOut(&stdout) root.SetErr(&stderr) - root.SetArgs(append([]string{"--styled", "--base-url", server.URL, "drafts"}, args...)) + rootArgs := append([]string{}, formatArgs...) + rootArgs = append(rootArgs, "--base-url", server.URL) + root.SetArgs(append(rootArgs, args...)) err := root.Execute() return stdout.String(), err @@ -90,13 +97,13 @@ func TestDraftsCommandAllOverridesLimit(t *testing.T) { func TestDraftsCommandStyledTable(t *testing.T) { const fullSummary = "Notes from the quarterly planning meeting including decisions and follow-up assignments" - stdout, err := runStyledDraftsCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stdout, err := runStyledCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `[ {"id":101,"summary":"`+fullSummary+`","subject":"Quarterly planning follow-up","updated_at":"2026-08-20T09:30:00Z"}, {"id":102,"summary":"Travel details","subject":"Team retreat itinerary","updated_at":"2026-08-19T14:00:00Z"} ]`) - }), "--limit", "1") + }), "drafts", "--limit", "1") if err != nil { t.Fatalf("execute styled drafts: %v", err) } @@ -111,9 +118,9 @@ func TestDraftsCommandStyledTable(t *testing.T) { } func TestDraftsCommandStyledEmpty(t *testing.T) { - stdout, err := runStyledDraftsCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stdout, err := runStyledCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) - })) + }), "drafts") if err != nil { t.Fatalf("execute styled drafts: %v", err) } diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 732ad4b9..48bd10a8 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", "bulk-reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"}, + names: []string{"boxes", "box", "labels", "label", "search", "contacts", "threads", "attachments", "compose", "reply", "bulk-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 045d5019..047608b8 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -30,7 +30,7 @@ func TestCuratedCommandHelpUsesUserFacingLanguage(t *testing.T) { func TestEmailCommandHelpKeepsPostingAsAnInternalTerm(t *testing.T) { root := newRootCmd() - for _, name := range []string{"boxes", "box", "search", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"} { + for _, name := range []string{"boxes", "box", "labels", "label", "search", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"} { t.Run(name, func(t *testing.T) { command, _, err := root.Find([]string{name}) if err != nil { @@ -94,6 +94,8 @@ USAGE EMAIL boxes List your HEY boxes box List email threads in a box + labels List your email labels + label View and manage an email label search Search email threads and messages contacts Manage contacts threads Read a thread diff --git a/internal/cmd/label.go b/internal/cmd/label.go new file mode 100644 index 00000000..397e1563 --- /dev/null +++ b/internal/cmd/label.go @@ -0,0 +1,427 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" + + internalfolders "github.com/basecamp/hey-cli/internal/folders" + "github.com/basecamp/hey-cli/internal/output" +) + +type labelsCommand struct { + cmd *cobra.Command + limit int + all bool +} + +func newLabelsCommand() *labelsCommand { + labelsCommand := &labelsCommand{} + labelsCommand.cmd = &cobra.Command{ + Use: "labels", + Short: "List your email labels", + Annotations: map[string]string{ + "agent_notes": "Returns label IDs and names. Use an ID with hey label and hey label add/remove.", + }, + Example: ` hey labels + hey labels --limit 10 + hey labels --json`, + RunE: labelsCommand.run, + } + + labelsCommand.cmd.Flags().IntVar(&labelsCommand.limit, "limit", 0, "Maximum number of labels to show") + labelsCommand.cmd.Flags().BoolVar(&labelsCommand.all, "all", false, "Show all results (override --limit)") + + return labelsCommand +} + +func (c *labelsCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + folders, err := internalfolders.List(cmd.Context(), sdk) + if err != nil { + return convertSDKError(err) + } + + total := len(folders) + if c.limit > 0 && !c.all && len(folders) > c.limit { + folders = folders[:c.limit] + } + notice := output.TruncationNotice(len(folders), total) + + if writer.IsStyled() { + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"ID", "Name"}) + for _, folder := range folders { + table.addRow([]string{fmt.Sprintf("%d", folder.Id), terminalSafeText(folder.Name)}) + } + table.print() + if notice != "" { + fmt.Fprintln(cmd.OutOrStdout(), notice) + } + return nil + } + + return writeOK(folders, + output.WithSummary(fmt.Sprintf("%d %s", len(folders), labelNoun(len(folders)))), + output.WithNotice(notice), + output.WithBreadcrumbs(output.Breadcrumb{ + Action: "view", + Command: "hey label ", + Description: "View email threads with a label", + }), + ) +} + +type labelCommand struct { + cmd *cobra.Command + limit int + all bool + page string +} + +type folderOutput struct { + generated.Folder + Postings []folderPostingOutput `json:"postings,omitempty"` + NextPage string `json:"next_page,omitempty"` + TotalCount int `json:"total_count,omitempty"` +} + +type folderPostingOutput struct { + generated.Posting + TopicID int64 `json:"topic_id,omitempty"` +} + +func newLabelCommand() *labelCommand { + labelCommand := &labelCommand{} + labelCommand.cmd = &cobra.Command{ + Use: "label ", + Short: "View and manage an email label", + Annotations: map[string]string{ + "agent_notes": "The ID comes from hey labels. Returns labeled email threads; subcommands add, create, and remove labels.", + }, + Example: ` hey label 123 + hey label 123 --limit 10 + hey label 123 --json`, + RunE: labelCommand.run, + Args: usageExactOneArg(), + } + + labelCommand.cmd.Flags().IntVar(&labelCommand.limit, "limit", 0, "Maximum number of threads to show") + labelCommand.cmd.Flags().BoolVar(&labelCommand.all, "all", false, "Fetch all results (override --limit)") + labelCommand.cmd.Flags().StringVar(&labelCommand.page, "page", "", "Continue from a next_page cursor") + labelCommand.cmd.AddCommand(newLabelAddCommand().cmd) + labelCommand.cmd.AddCommand(newLabelCreateCommand().cmd) + labelCommand.cmd.AddCommand(newLabelRemoveCommand().cmd) + + return labelCommand +} + +func (c *labelCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + folderID, err := parsePositiveID(args[0], "label") + if err != nil { + return err + } + + var params *generated.GetFolderParams + if c.page != "" { + params = &generated.GetFolderParams{Page: &c.page} + } + page, err := sdk.Folders().GetPage(cmd.Context(), folderID, params) + if err != nil { + return convertSDKError(err) + } + if page == nil || page.Folder == nil { + return output.ErrNotFound("label", args[0]) + } + + folder, nextPage, total, err := paginateFolder(cmd.Context(), folderID, page, c.limit, c.all) + if err != nil { + return err + } + notice := folderTruncationNotice(len(folder.Postings), total, nextPage != "", c.all, c.page != "") + + if writer.IsStyled() { + fmt.Fprintf(cmd.OutOrStdout(), "Label: %s\n\n", terminalSafeText(folder.Name)) + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"ID", "Thread", "From", "Summary", "Date"}) + for _, posting := range folder.Postings { + topicID := resolvePostingTopicID(posting) + table.addRow([]string{fmt.Sprintf("%d", posting.Id), fmt.Sprintf("%d", topicID), posting.Creator.Name, truncate(posting.Summary, 60), formatDate(posting.CreatedAt)}) + } + table.print() + if notice != "" { + fmt.Fprintln(cmd.OutOrStdout(), notice) + } + return nil + } + + return writeOK(makeFolderOutput(folder, nextPage, total), + output.WithSummary(fmt.Sprintf("%d %s labeled %s", len(folder.Postings), threadNoun(len(folder.Postings)), folder.Name)), + output.WithNotice(notice), + output.WithBreadcrumbs( + output.Breadcrumb{Action: "read", Command: "hey threads ", Description: "Read an email thread"}, + output.Breadcrumb{Action: "add_label", Command: "hey label add --to ", Description: "Add another label to a thread"}, + output.Breadcrumb{Action: "remove_label", Command: "hey label remove --from ", Description: "Remove labels from a thread"}, + ), + ) +} + +type labelAddCommand struct { + cmd *cobra.Command + to string +} + +func newLabelAddCommand() *labelAddCommand { + labelAddCommand := &labelAddCommand{} + labelAddCommand.cmd = &cobra.Command{ + Use: "add ...", + Short: "Add a label to email threads", + Example: ` hey label add 12345 --to 789 + hey label add 12345 67890 --to 789`, + RunE: labelAddCommand.run, + Args: usageMinOneArg(), + } + labelAddCommand.cmd.Flags().StringVar(&labelAddCommand.to, "to", "", "Label ID (required)") + return labelAddCommand +} + +func (c *labelAddCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + if strings.TrimSpace(c.to) == "" { + return output.ErrUsage("label is required (use --to )") + } + + folderID, err := parsePositiveID(c.to, "label") + if err != nil { + return err + } + postingIDs, err := parsePositivePostingIDs(args) + if err != nil { + return err + } + if err := sdk.Postings().File(cmd.Context(), folderID, postingIDs...); err != nil { + return convertSDKError(err) + } + + summary := fmt.Sprintf("Label %d added to %d %s", folderID, len(postingIDs), threadNoun(len(postingIDs))) + return writeLabelMutation(cmd, summary) +} + +type labelCreateCommand struct { + cmd *cobra.Command +} + +func newLabelCreateCommand() *labelCreateCommand { + labelCreateCommand := &labelCreateCommand{} + labelCreateCommand.cmd = &cobra.Command{ + Use: "create ...", + Short: "Create a label and add it to email threads", + Example: ` hey label create "Travel receipts" 12345 + hey label create "Project Apollo" 12345 67890`, + RunE: labelCreateCommand.run, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) < 2 { + return usageErrorf("%s ...", cmd.CommandPath()) + } + return nil + }, + } + return labelCreateCommand +} + +func (c *labelCreateCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + name := strings.TrimSpace(args[0]) + if name == "" { + return output.ErrUsage("label name is required") + } + postingIDs, err := parsePositivePostingIDs(args[1:]) + if err != nil { + return err + } + if err := sdk.Postings().CreateFolder(cmd.Context(), name, postingIDs...); err != nil { + return convertSDKError(err) + } + + summary := fmt.Sprintf("Label %q created and added to %d %s", name, len(postingIDs), threadNoun(len(postingIDs))) + return writeLabelMutation(cmd, summary) +} + +type labelRemoveCommand struct { + cmd *cobra.Command + from string +} + +func newLabelRemoveCommand() *labelRemoveCommand { + labelRemoveCommand := &labelRemoveCommand{} + labelRemoveCommand.cmd = &cobra.Command{ + Use: "remove ...", + Short: "Remove labels from email threads", + Example: ` hey label remove 12345 --from 789 + hey label remove 12345 67890 --from all`, + RunE: labelRemoveCommand.run, + Args: usageMinOneArg(), + } + labelRemoveCommand.cmd.Flags().StringVar(&labelRemoveCommand.from, "from", "", "Label ID, or all to remove every label (required)") + return labelRemoveCommand +} + +func (c *labelRemoveCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + from := strings.TrimSpace(c.from) + if from == "" { + return output.ErrUsage("label is required (use --from )") + } + folderID := int64(0) + if !strings.EqualFold(from, "all") { + var err error + folderID, err = parsePositiveID(from, "label") + if err != nil { + return err + } + } + postingIDs, err := parsePositivePostingIDs(args) + if err != nil { + return err + } + if err := sdk.Postings().Unfile(cmd.Context(), folderID, postingIDs...); err != nil { + return convertSDKError(err) + } + + summary := fmt.Sprintf("Label %d removed from %d %s", folderID, len(postingIDs), threadNoun(len(postingIDs))) + if folderID == 0 { + summary = fmt.Sprintf("All labels removed from %d %s", len(postingIDs), threadNoun(len(postingIDs))) + } + return writeLabelMutation(cmd, summary) +} + +func paginateFolder(ctx context.Context, folderID int64, first *hey.FolderPage, limit int, all bool) (*generated.FolderWithPostings, string, int, error) { + folder := *first.Folder + folder.Postings = append([]generated.Posting(nil), first.Folder.Postings...) + nextPage := first.NextPage + total := max(first.TotalCount, len(folder.Postings)) + + needMore := all || (limit > 0 && len(folder.Postings) < limit) + for page := 1; page <= maxAdditionalPages && needMore && nextPage != ""; page++ { + cursor := nextPage + result, err := sdk.Folders().GetPage(ctx, folderID, &generated.GetFolderParams{Page: &cursor}) + if err != nil { + return nil, "", 0, convertSDKError(err) + } + if result == nil || result.Folder == nil { + return nil, "", 0, fmt.Errorf("label %d page %q returned no data", folderID, cursor) + } + folder.Postings = append(folder.Postings, result.Folder.Postings...) + nextPage = result.NextPage + total = max(total, result.TotalCount, len(folder.Postings)) + needMore = all || (limit > 0 && len(folder.Postings) < limit) + } + + if limit > 0 && !all && len(folder.Postings) > limit { + folder.Postings = folder.Postings[:limit] + nextPage = "" + } + return &folder, nextPage, total, nil +} + +func folderTruncationNotice(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 with the label).", shown, total) + } + return fmt.Sprintf("Showing %d of %d results; HEY returned no additional page cursor.", shown, total) + } + return "" + } + if shown < total { + return fmt.Sprintf("Showing %d of %d results. Use --all to see everything.", shown, total) + } + if hasMore { + return fmt.Sprintf("Showing %d results. More available; use --all to fetch all.", shown) + } + return "" +} + +func makeFolderOutput(folder *generated.FolderWithPostings, nextPage string, total int) folderOutput { + postings := make([]folderPostingOutput, len(folder.Postings)) + for i, posting := range folder.Postings { + postings[i] = folderPostingOutput{Posting: posting, TopicID: resolvePostingTopicID(posting)} + } + return folderOutput{ + Folder: generated.Folder{ + Id: folder.Id, + Name: folder.Name, + AppUrl: folder.AppUrl, + CreatedAt: folder.CreatedAt, + UpdatedAt: folder.UpdatedAt, + }, + Postings: postings, + NextPage: nextPage, + TotalCount: total, + } +} + +func writeLabelMutation(cmd *cobra.Command, summary string) error { + if writer.IsStyled() { + fmt.Fprintln(cmd.OutOrStdout(), terminalSafeText(summary)+".") + return nil + } + return writeOK(nil, output.WithSummary(summary)) +} + +func parsePositiveID(value, kind string) (int64, error) { + id, err := strconv.ParseInt(value, 10, 64) + if err != nil || id <= 0 { + return 0, output.ErrUsage(fmt.Sprintf("invalid %s ID: %s", kind, value)) + } + return id, nil +} + +func parsePositivePostingIDs(values []string) ([]int64, error) { + ids := make([]int64, len(values)) + seen := make(map[int64]bool, len(values)) + for i, value := range values { + id, err := parsePositiveID(value, "thread") + if err != nil { + return nil, err + } + if seen[id] { + return nil, output.ErrUsage(fmt.Sprintf("duplicate thread ID: %d", id)) + } + seen[id] = true + ids[i] = id + } + return ids, nil +} + +func labelNoun(count int) string { + if count == 1 { + return "label" + } + return "labels" +} diff --git a/internal/cmd/label_test.go b/internal/cmd/label_test.go new file mode 100644 index 00000000..eea08cfb --- /dev/null +++ b/internal/cmd/label_test.go @@ -0,0 +1,367 @@ +package cmd + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" +) + +func TestLabelsCommand(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/my/navigation.json" { + t.Errorf("request = %s %s, want GET /my/navigation.json", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"items":[{"title":"Labels","menu_items":[{"title":"All Labels","app_url":"/folders"},{"title":"Receipts","app_url":"/folders/12"},{"title":"Travel","app_url":"/folders/34"}]}]}`) + }), "labels", "--limit", "1") + if err != nil { + t.Fatalf("execute labels: %v", err) + } + if response.Summary != "1 label" { + t.Errorf("summary = %q, want 1 label", response.Summary) + } + if response.Notice != "Showing 1 of 2 results. Use --all to see everything." { + t.Errorf("notice = %q", response.Notice) + } + folders, ok := response.Data.([]any) + if !ok || len(folders) != 1 { + t.Fatalf("data = %#v, want one folder", response.Data) + } + folder, ok := folders[0].(map[string]any) + if !ok || folder["id"] != float64(12) || folder["name"] != "Receipts" { + t.Errorf("folder = %#v", folders[0]) + } +} + +func TestLabelsCommandAllOverridesLimit(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"items":[{"title":"Labels","menu_items":[{"title":"Receipts","app_url":"/folders/12"},{"title":"Travel","app_url":"/folders/34"}]}]}`) + }), "labels", "--limit", "1", "--all") + if err != nil { + t.Fatalf("execute labels: %v", err) + } + folders, ok := response.Data.([]any) + if !ok || len(folders) != 2 { + t.Fatalf("data = %#v, want two folders", response.Data) + } + if response.Summary != "2 labels" || response.Notice != "" { + t.Errorf("response = %#v, want complete result", response) + } +} + +func TestLabelCommand(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/folders/12.json" { + t.Errorf("request = %s %s, want GET /folders/12.json", r.Method, r.URL.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") + _, _ = io.WriteString(w, `{"id":12,"name":"Receipts","postings":[{"id":101,"kind":"topic","summary":"Hotel receipt","app_url":"https://app.hey.com/topics/501","creator":{"name":"Jane Doe"},"created_at":"2026-08-20T09:30:00Z"},{"id":102,"kind":"topic","summary":"Train receipt"}]}`) + }), "label", "12", "--page", "current-cursor", "--limit", "1") + if err != nil { + t.Fatalf("execute label: %v", err) + } + if response.Summary != "1 thread labeled Receipts" { + 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) + } + folder, ok := response.Data.(map[string]any) + if !ok || folder["id"] != float64(12) || folder["name"] != "Receipts" { + t.Fatalf("data = %#v, want Receipts folder", response.Data) + } + postings, ok := folder["postings"].([]any) + if !ok || len(postings) != 1 { + t.Fatalf("postings = %#v, want one posting", folder["postings"]) + } + posting, ok := postings[0].(map[string]any) + if !ok || posting["id"] != float64(101) || posting["topic_id"] != float64(501) { + t.Errorf("posting IDs = %#v, want id 101 and topic_id 501", postings[0]) + } +} + +func TestLabelCommandReturnsContinuation(t *testing.T) { + var requests atomic.Int32 + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Total-Count", "3") + w.Header().Set("Link", "; rel=\"next\"") + _, _ = io.WriteString(w, `{"id":12,"name":"Receipts","postings":[{"id":101,"kind":"topic"},{"id":102,"kind":"topic"}]}`) + }), "label", "12") + if err != nil { + t.Fatalf("execute label: %v", err) + } + if requests.Load() != 1 { + t.Errorf("requests = %d, want first page only", requests.Load()) + } + folder, ok := response.Data.(map[string]any) + if !ok || folder["next_page"] != "next-cursor" || folder["total_count"] != float64(3) { + t.Fatalf("pagination metadata = %#v", response.Data) + } + if response.Notice != "Showing 2 of 3 results. Use --all to see everything." { + t.Errorf("notice = %q", response.Notice) + } +} + +func TestLabelCommandFetchesAllPages(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, `{"id":12,"name":"Receipts","postings":[{"id":101,"kind":"topic"},{"id":102,"kind":"topic"}]}`) + return + } + if got := r.URL.Query().Get("page"); got != "next-cursor" { + t.Errorf("page = %q, want next-cursor", got) + } + _, _ = io.WriteString(w, `{"id":12,"name":"Receipts","postings":[{"id":103,"kind":"topic"}]}`) + }), "label", "12", "--all") + if err != nil { + t.Fatalf("execute label --all: %v", err) + } + if requests.Load() != 2 { + t.Errorf("requests = %d, want 2", requests.Load()) + } + folder, ok := response.Data.(map[string]any) + if !ok { + t.Fatalf("data = %#v", response.Data) + } + postings, ok := folder["postings"].([]any) + if !ok || len(postings) != 3 { + t.Fatalf("postings = %#v, want three", folder["postings"]) + } + if folder["total_count"] != float64(3) || folder["next_page"] != nil { + t.Errorf("pagination metadata = %#v", folder) + } + if response.Summary != "3 threads labeled Receipts" || response.Notice != "" { + t.Errorf("response = %#v", response) + } +} + +func TestLabelCommandStyledTable(t *testing.T) { + stdout, err := runStyledCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":12,"name":"Receipts","postings":[{"id":101,"kind":"topic","summary":"Hotel receipt","app_url":"https://app.hey.com/topics/501","creator":{"name":"Jane Doe"},"created_at":"2026-08-20T09:30:00Z"}]}`) + }), "label", "12") + if err != nil { + t.Fatalf("execute styled folder: %v", err) + } + for _, want := range []string{"Label: Receipts", "ID", "Thread", "From", "Summary", "Date", "101", "501", "Jane Doe", "Hotel receipt", "2026-08-20"} { + if !strings.Contains(stdout, want) { + t.Errorf("output %q does not contain %q", stdout, want) + } + } +} + +func TestLabelStyledOutputSanitizesFolderNames(t *testing.T) { + const unsafeName = "Receipts\x1b]2;owned\a\nArchive" + stdout, err := runStyledCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": 12, "name": unsafeName}) + }), "label", "12") + if err != nil { + t.Fatalf("execute styled folder: %v", err) + } + if strings.Contains(stdout, "\x1b]2;owned") || strings.Contains(stdout, "\nArchive\n") { + t.Errorf("unsafe label name reached terminal output: %q", stdout) + } + if !strings.Contains(stdout, "Receipts�]2;owned��Archive") { + t.Errorf("sanitized label name missing from %q", stdout) + } +} + +func TestLabelMarkdownOutputSanitizesFolderNames(t *testing.T) { + const unsafeName = "Receipts\x1b]2;owned\a\nArchive" + server := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{map[string]any{ + "title": "Labels", + "menu_items": []any{map[string]any{"title": unsafeName, "app_url": "/folders/12"}}, + }}}) + }) + + stdout, err := runFormattedCommand(t, server, []string{"--markdown"}, "labels") + if err != nil { + t.Fatalf("execute markdown folders: %v", err) + } + if strings.Contains(stdout, "\x1b") || strings.Contains(stdout, "\a") || strings.Contains(stdout, "\nArchive") { + t.Errorf("unsafe label name reached markdown output: %q", stdout) + } + if !strings.Contains(stdout, "
Archive") { + t.Errorf("sanitized label name missing from %q", stdout) + } +} + +func TestLabelTruncationNotice(t *testing.T) { + tests := []struct { + name string + shown int + total int + hasMore bool + all bool + fromCursor bool + want string + }{ + {name: "limited", shown: 2, total: 5, want: "Showing 2 of 5 results. Use --all to see everything."}, + {name: "all capped", shown: 100, total: 200, hasMore: true, all: true, want: "Showing 100 results. Pagination limit reached; continue with --page using next_page."}, + {name: "all from cursor", shown: 3, total: 8, all: true, fromCursor: true, want: "Showing 3 remaining results from this cursor (8 threads with the label)."}, + {name: "missing cursor", shown: 3, total: 8, all: true, want: "Showing 3 of 8 results; HEY returned no additional page cursor."}, + {name: "complete", shown: 8, total: 8, all: true, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := folderTruncationNotice(tt.shown, tt.total, tt.hasMore, tt.all, tt.fromCursor); got != tt.want { + t.Errorf("notice = %q, want %q", got, tt.want) + } + }) + } +} + +func TestLabelMutationCommands(t *testing.T) { + tests := []struct { + name string + args []string + method string + path string + wantSummary string + check func(*testing.T, *http.Request) + }{ + { + name: "add", args: []string{"label", "add", "101", "102", "--to", "12"}, + method: http.MethodPost, path: "/postings/filings.json", wantSummary: "Label 12 added to 2 threads", + check: func(t *testing.T, r *http.Request) { + var body struct { + FolderID int64 `json:"folder_id"` + PostingIDs []int64 `json:"posting_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.FolderID != 12 || len(body.PostingIDs) != 2 || body.PostingIDs[0] != 101 || body.PostingIDs[1] != 102 { + t.Errorf("body = %#v", body) + } + }, + }, + { + name: "create", args: []string{"label", "create", "Travel receipts", "101"}, + method: http.MethodPost, path: "/postings/folders.json", wantSummary: `Label "Travel receipts" created and added to 1 thread`, + check: func(t *testing.T, r *http.Request) { + var body struct { + Folder struct { + Name string `json:"name"` + } `json:"folder"` + PostingIDs []int64 `json:"posting_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Folder.Name != "Travel receipts" || len(body.PostingIDs) != 1 || body.PostingIDs[0] != 101 { + t.Errorf("body = %#v", body) + } + }, + }, + { + name: "remove one", args: []string{"label", "remove", "101", "--from", "12"}, + method: http.MethodDelete, path: "/postings/filings.json", wantSummary: "Label 12 removed from 1 thread", + check: func(t *testing.T, r *http.Request) { + if got := r.URL.Query().Get("folder_id"); got != "12" { + t.Errorf("folder_id = %q", got) + } + if got := r.URL.Query().Get("posting_ids"); got != "101" { + t.Errorf("posting_ids = %q", got) + } + }, + }, + { + name: "remove all", args: []string{"label", "remove", "101", "102", "--from", "all"}, + method: http.MethodDelete, path: "/postings/filings.json", wantSummary: "All labels removed from 2 threads", + check: func(t *testing.T, r *http.Request) { + if r.URL.Query().Has("folder_id") { + t.Errorf("unexpected folder_id in %s", r.URL.RawQuery) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != tt.method || r.URL.Path != tt.path { + t.Errorf("request = %s %s, want %s %s", r.Method, r.URL.Path, tt.method, tt.path) + http.NotFound(w, r) + return + } + if tt.check != nil { + tt.check(t, r) + } + w.WriteHeader(http.StatusNoContent) + }), tt.args...) + if err != nil { + t.Fatalf("execute mutation: %v", err) + } + if response.Summary != tt.wantSummary { + t.Errorf("summary = %q, want %q", response.Summary, tt.wantSummary) + } + }) + } +} + +func TestLabelValidationMakesNoRequest(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "show label ID", args: []string{"label", "invalid"}, want: "invalid label ID"}, + {name: "show positive label ID", args: []string{"label", "0"}, want: "invalid label ID"}, + {name: "add label required", args: []string{"label", "add", "101"}, want: "label is required"}, + {name: "add label ID", args: []string{"label", "add", "101", "--to", "invalid"}, want: "invalid label ID"}, + {name: "add thread", args: []string{"label", "add", "invalid", "--to", "12"}, want: "invalid thread ID"}, + {name: "add duplicate", args: []string{"label", "add", "101", "101", "--to", "12"}, want: "duplicate thread ID"}, + {name: "create name", args: []string{"label", "create", " ", "101"}, want: "label name is required"}, + {name: "create posting", args: []string{"label", "create", "Receipts", "0"}, want: "invalid thread ID"}, + {name: "remove label required", args: []string{"label", "remove", "101"}, want: "label is required"}, + {name: "remove label ID", args: []string{"label", "remove", "101", "--from", "invalid"}, want: "invalid label ID"}, + {name: "remove thread", args: []string{"label", "remove", "invalid", "--from", "all"}, want: "invalid thread ID"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var requests atomic.Int32 + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + http.Error(w, "unexpected request", http.StatusInternalServerError) + }), tt.args...) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want substring %q", err, tt.want) + } + if requests.Load() != 0 { + t.Errorf("requests = %d, want 0", requests.Load()) + } + }) + } +} + +func TestLabelCommandAPIError(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "folder unavailable", http.StatusBadRequest) + }), "label", "12") + if err == nil || !strings.Contains(err.Error(), "400 Bad Request") { + t.Fatalf("error = %v, want HTTP failure", err) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6727742d..a710b0d5 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -153,6 +153,8 @@ func newRootCmd() *cobra.Command { root.AddCommand(newAccountsCommand().cmd) root.AddCommand(newBoxesCommand().cmd) root.AddCommand(newBoxCommand().cmd) + root.AddCommand(newLabelsCommand().cmd) + root.AddCommand(newLabelCommand().cmd) root.AddCommand(newSearchCommand().cmd) root.AddCommand(newContactsCommand().cmd) root.AddCommand(newThreadsCommand().cmd) diff --git a/internal/folders/navigation.go b/internal/folders/navigation.go new file mode 100644 index 00000000..f17dbe36 --- /dev/null +++ b/internal/folders/navigation.go @@ -0,0 +1,52 @@ +package folders + +import ( + "context" + "regexp" + "strconv" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" +) + +// List returns the folders carried by HEY's typed navigation payload. +func List(ctx context.Context, client *hey.Client) ([]generated.Folder, error) { + navigation, err := client.Identity().GetNavigation(ctx) + if err != nil { + return nil, err + } + return FromNavigation(navigation), nil +} + +// FromNavigation returns each concrete folder entry from HEY's Labels navigation group. +func FromNavigation(navigation *generated.NavigationResponse) []generated.Folder { + if navigation == nil { + return nil + } + + var result []generated.Folder + for _, item := range navigation.Items { + if item.Icon.Name != navigationIcon && item.Title != navigationTitle { + continue + } + for _, entry := range item.MenuItems { + match := folderPath.FindStringSubmatch(entry.AppUrl) + if match == nil { + continue + } + id, err := strconv.ParseInt(match[1], 10, 64) + if err != nil { + continue + } + result = append(result, generated.Folder{Id: id, Name: entry.Title, AppUrl: entry.AppUrl}) + } + } + return result +} + +var folderPath = regexp.MustCompile(`/folders/(\d+)(?:$|[/?#])`) + +const ( + navigationIcon = "folders" + navigationTitle = "Labels" +) diff --git a/internal/folders/navigation_test.go b/internal/folders/navigation_test.go new file mode 100644 index 00000000..75d54a20 --- /dev/null +++ b/internal/folders/navigation_test.go @@ -0,0 +1,69 @@ +package folders + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" +) + +func TestList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/my/navigation.json" { + t.Errorf("request = %s %s, want GET /my/navigation.json", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"items":[{"title":"Etiquetas","icon":{"name":"folders"},"menu_items":[{"title":"All Labels","app_url":"/folders"},{"title":"Receipts","app_url":"https://app.hey.com/folders/12"},{"title":"Travel","app_url":"/folders/34?from=navigation"}]},{"title":"Collections","menu_items":[{"title":"Planning","app_url":"/collections/56"}]}]}`) + })) + t.Cleanup(server.Close) + + client := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + folders, err := List(context.Background(), client) + if err != nil { + t.Fatalf("list folders: %v", err) + } + if len(folders) != 2 { + t.Fatalf("folders = %+v, want two", folders) + } + if folders[0].Id != 12 || folders[0].Name != "Receipts" || folders[0].AppUrl != "https://app.hey.com/folders/12" { + t.Errorf("first folder = %+v", folders[0]) + } + if folders[1].Id != 34 || folders[1].Name != "Travel" { + t.Errorf("second folder = %+v", folders[1]) + } +} + +func TestFromNavigationHandlesEmptyAndMalformedItems(t *testing.T) { + if folders := FromNavigation(nil); folders != nil { + t.Errorf("nil navigation returned %+v", folders) + } + + navigation := &generated.NavigationResponse{Items: []generated.NavigationItem{{ + Title: navigationTitle, + MenuItems: []generated.NavigationItem{ + {Title: "All Labels", AppUrl: "/folders"}, + {Title: "Missing ID", AppUrl: "/folders/not-an-id"}, + }, + }}} + if folders := FromNavigation(navigation); len(folders) != 0 { + t.Errorf("malformed navigation returned %+v", folders) + } +} + +func TestListReportsNavigationFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "navigation unavailable", http.StatusBadRequest) + })) + t.Cleanup(server.Close) + + client := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + if _, err := List(context.Background(), client); err == nil { + t.Fatal("expected navigation failure") + } +} diff --git a/internal/models/box.go b/internal/models/box.go index a5aacfe3..c62a74ef 100644 --- a/internal/models/box.go +++ b/internal/models/box.go @@ -14,6 +14,12 @@ type Box struct { PostingChangesURL string `json:"posting_changes_url"` } +type Folder struct { + ID int64 `json:"id"` + Name string `json:"name"` + AppURL string `json:"app_url"` +} + type Posting struct { ID int64 `json:"id"` CreatedAt string `json:"created_at"` @@ -33,6 +39,7 @@ type Posting struct { AlternativeSenderName string `json:"alternative_sender_name"` VisibleEntryCount int32 `json:"visible_entry_count"` Extenzions []Extenzion `json:"extenzions,omitempty"` + Folders []Folder `json:"folders,omitempty"` TopicID int64 `json:"topic_id"` Topic *Topic `json:"topic,omitempty"` } diff --git a/internal/output/writer.go b/internal/output/writer.go index e7795c07..e87a20c9 100644 --- a/internal/output/writer.go +++ b/internal/output/writer.go @@ -299,7 +299,7 @@ func (w *Writer) writeMarkdown(data any) error { } keys := sortedKeys(m) for _, k := range keys { - fmt.Fprintf(w.opts.Stdout, "**%s:** %v\n", k, m[k]) + fmt.Fprintf(w.opts.Stdout, "**%s:** %s\n", markdownCell(k), markdownCell(fmt.Sprintf("%v", m[k]))) } return nil } @@ -326,7 +326,7 @@ func (w *Writer) writeMarkdown(data any) error { sb.WriteString("|") for _, h := range headers { sb.WriteString(" ") - sb.WriteString(h) + sb.WriteString(markdownCell(h)) sb.WriteString(" |") } sb.WriteString("\n|") @@ -347,7 +347,7 @@ func (w *Writer) writeMarkdown(data any) error { } } sb.WriteString(" ") - sb.WriteString(v) + sb.WriteString(markdownCell(v)) sb.WriteString(" |") } sb.WriteString("\n") @@ -357,6 +357,13 @@ func (w *Writer) writeMarkdown(data any) error { return nil } +func markdownCell(value string) string { + value = sanitizeTerminal(value) + value = strings.ReplaceAll(value, "|", `\|`) + value = strings.ReplaceAll(value, "\t", " ") + return strings.ReplaceAll(value, "\n", "
") +} + func isTTY(w io.Writer) bool { if f, ok := w.(*os.File); ok { return term.IsTerminal(int(f.Fd())) //nolint:gosec // G115: fd fits in int on all supported platforms diff --git a/internal/output/writer_test.go b/internal/output/writer_test.go index 87b1b47b..6ab74a35 100644 --- a/internal/output/writer_test.go +++ b/internal/output/writer_test.go @@ -301,6 +301,24 @@ func TestWriterOK_MarkdownIncludesOptionalFieldsFromLaterRows(t *testing.T) { } } +func TestWriterOK_MarkdownSanitizesTerminalControlsAndLayout(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Format: FormatMarkdown, Stdout: &buf}) + + data := []map[string]any{{"name": "Receipts\x1b]2;owned\a\nArchive|2026\tQ3"}} + if err := w.OK(data); err != nil { + t.Fatal(err) + } + + output := buf.String() + if strings.Contains(output, "\x1b") || strings.Contains(output, "\a") || strings.Contains(output, "\nArchive") { + t.Errorf("unsafe controls reached markdown output: %q", output) + } + if !strings.Contains(output, "
Archive\\|2026 Q3") { + t.Errorf("sanitized markdown value missing from %q", output) + } +} + func TestWriterErr_JSON(t *testing.T) { var buf bytes.Buffer w := New(Options{Format: FormatJSON, Stderr: &buf}) diff --git a/internal/tui/folders.go b/internal/tui/folders.go new file mode 100644 index 00000000..7f7695c2 --- /dev/null +++ b/internal/tui/folders.go @@ -0,0 +1,215 @@ +package tui + +import ( + "fmt" + "strings" + "unicode" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/basecamp/hey-cli/internal/models" +) + +const mailSourceKindFolder = "folder" + +type folderPickerChoice int + +const ( + folderPickerExisting folderPickerChoice = iota + folderPickerCreate + folderPickerRemoveAll +) + +type folderPickerSelection struct { + kind folderPickerChoice + folder models.Box +} + +type folderPicker struct { + posting models.Posting + folders []models.Box + cursor int + offset int + height int + creating bool + input textinput.Model + status string +} + +func newFolderPicker(posting models.Posting, sources []models.Box) *folderPicker { + folders := make([]models.Box, 0, len(sources)) + for _, source := range sources { + if source.Kind == mailSourceKindFolder { + folders = append(folders, source) + } + } + input := textinput.New() + input.Prompt = "" + input.Placeholder = "Label name…" + return &folderPicker{posting: posting, folders: folders, input: input} +} + +func (p *folderPicker) choices() []folderPickerSelection { + choices := make([]folderPickerSelection, 0, len(p.folders)+2) + for _, folder := range p.folders { + choices = append(choices, folderPickerSelection{kind: folderPickerExisting, folder: folder}) + } + choices = append(choices, folderPickerSelection{kind: folderPickerCreate}) + if len(p.posting.Folders) > 0 { + choices = append(choices, folderPickerSelection{kind: folderPickerRemoveAll}) + } + return choices +} + +func (p *folderPicker) selected() *folderPickerSelection { + choices := p.choices() + if p.cursor < 0 || p.cursor >= len(choices) { + return nil + } + return &choices[p.cursor] +} + +func (p *folderPicker) postingHasFolder(folderID int64) bool { + for _, folder := range p.posting.Folders { + if folder.ID == folderID { + return true + } + } + return false +} + +func (p *folderPicker) startCreate() tea.Cmd { + p.creating = true + p.status = "" + p.input.SetValue("") + return p.input.Focus() +} + +func (p *folderPicker) cancelCreate() { + p.creating = false + p.status = "" + p.input.Blur() +} + +func (p *folderPicker) createName() (string, bool) { + name := strings.TrimSpace(p.input.Value()) + if name == "" { + p.status = "Enter a label name" + return "", false + } + return name, true +} + +func (p *folderPicker) update(msg tea.Msg) tea.Cmd { + if !p.creating { + return nil + } + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + return cmd +} + +func (p *folderPicker) handleKey(msg tea.KeyPressMsg) tea.Cmd { + if p.creating { + return p.update(msg) + } + switch msg.Key().Code { + case tea.KeyUp: + if p.cursor > 0 { + p.cursor-- + } + case tea.KeyDown: + if p.cursor < len(p.choices())-1 { + p.cursor++ + } + } + p.ensureVisible() + return nil +} + +func (p *folderPicker) resize(width, height int) { + p.height = height + p.input.SetWidth(max(width-16, 10)) + p.ensureVisible() +} + +func (p *folderPicker) visibleRows() int { + return max(p.height-5, 1) +} + +func (p *folderPicker) ensureVisible() { + if p.cursor < p.offset { + p.offset = p.cursor + } + rows := p.visibleRows() + if p.cursor >= p.offset+rows { + p.offset = p.cursor - rows + 1 + } +} + +func (p *folderPicker) view(styles styles, width int) string { + contentWidth := max(width-4, 1) + var b strings.Builder + if p.creating { + b.WriteString(styles.title.Render("Create label")) + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(colorMuted).Render("Name: ")) + b.WriteString(p.input.View()) + if p.status != "" { + b.WriteString("\n\n") + b.WriteString(lipgloss.NewStyle().Foreground(colorError).Render(p.status)) + } + return b.String() + } + + b.WriteString(styles.title.Render("Label thread")) + if p.posting.Summary != "" { + fmt.Fprintf(&b, "\n%s", truncateStr(p.posting.Summary, contentWidth)) + } + b.WriteString("\n\n") + choices := p.choices() + end := min(p.offset+p.visibleRows(), len(choices)) + for i := p.offset; i < end; i++ { + choice := choices[i] + prefix := " " + if i == p.cursor { + prefix = "› " + } + label := "" + switch choice.kind { + case folderPickerExisting: + mark := "[ ]" + if p.postingHasFolder(choice.folder.ID) { + mark = "[x]" + } + label = mark + " " + terminalSafeFolderText(choice.folder.Name) + case folderPickerCreate: + label = "+ Create a new label…" + case folderPickerRemoveAll: + label = "− Remove all labels" + } + if i == p.cursor { + label = styles.title.Render(label) + } + fmt.Fprintf(&b, "%s%s\n", prefix, label) + } + return b.String() +} + +func terminalSafeFolderText(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return '�' + } + return r + }, value) +} + +func (p *folderPicker) helpBindings() []helpBinding { + if p.creating { + return []helpBinding{{"enter", "create"}, {"esc", "back"}} + } + return []helpBinding{{"↑↓", "select"}, {"enter", "toggle"}, {"esc", "cancel"}} +} diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 350444e8..bc7be5fd 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -15,6 +15,7 @@ import ( "github.com/basecamp/hey-cli/internal/apierr" attachmentfiles "github.com/basecamp/hey-cli/internal/attachments" + internalfolders "github.com/basecamp/hey-cli/internal/folders" "github.com/basecamp/hey-cli/internal/htmlutil" "github.com/basecamp/hey-cli/internal/models" ) @@ -37,11 +38,22 @@ const ( type boxesLoadedMsg []models.Box -type postingsLoadedMsg struct { +type mailSourcesLoadedMsg struct { requestID uint64 - boxID int64 - postings []models.Posting - err error + sources []models.Box + folderErr error +} + +type postingsLoadedMsg struct { + requestID uint64 + boxID int64 + sourceKind string + page string + pageHistory []string + nextPage string + totalCount int + postings []models.Posting + err error } type topicLoadedMsg struct { @@ -88,11 +100,20 @@ const ( ) type postingActionDoneMsg struct { - action string - boxID int64 - postingID int64 - effect postingActionEffect - err error + action string + boxID int64 + sourceKind string + postingID int64 + effect postingActionEffect + err error +} + +type folderActionDoneMsg struct { + action string + sourceID int64 + sourceKind string + created bool + err error } // --- Mail section view --- @@ -100,8 +121,12 @@ type postingActionDoneMsg struct { type mailView struct { vc *viewContext - boxes []models.Box - boxIndex int + boxes []models.Box + boxIndex int + folderPage string + folderNextPage string + folderPageHistory []string + folderTotalCount int postingList contentList topicViewport viewport.Model @@ -115,20 +140,23 @@ type mailView struct { inThread bool loading bool - compose *composeForm // non-nil while a message, reply or forward is being written - bulkReply *bulkReplyForm // non-nil while a bulk reply is being previewed or written - movePicker *movePicker // non-nil while a destination box is being selected - searchForm *mailSearchForm // non-nil while a search query is being entered - searchList contentList - searchActive bool - searchQuery string - searchPage int - lastBulkReplyID int64 // delayed delivery currently available for undo - pendingMutations int // writes that must finish before changing the account context - notice string // one-shot confirmation shown above the posting list - activeRequestID uint64 // identifies the only mail read allowed to update the view - activeRequestKind mailRequestKind - requestCancel context.CancelFunc + compose *composeForm // non-nil while a message, reply or forward is being written + bulkReply *bulkReplyForm // non-nil while a bulk reply is being previewed or written + movePicker *movePicker // non-nil while a destination box is being selected + folderPicker *folderPicker // non-nil while folder labels are being managed + searchForm *mailSearchForm // non-nil while a search query is being entered + searchList contentList + searchActive bool + searchQuery string + searchPage int + lastBulkReplyID int64 // delayed delivery currently available for undo + pendingMutations int // writes that must finish before changing the account context + notice string // one-shot confirmation shown above the posting list + activeRequestID uint64 // identifies the only mail read allowed to update the view + activeRequestKind mailRequestKind + sourceRequestID uint64 + folderDiscoveryErr string + requestCancel context.CancelFunc } func newMailView(vc *viewContext) *mailView { @@ -141,28 +169,39 @@ func newMailView(vc *viewContext) *mailView { func (v *mailView) Init() tea.Cmd { if len(v.boxes) == 0 { - v.loading = true - return v.fetchBoxes() + return v.requestSources() } if v.boxIndex < len(v.boxes) { - return v.requestPostings(v.boxes[v.boxIndex].ID) + return v.requestPostings(v.boxes[v.boxIndex]) } return nil } func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { switch msg := msg.(type) { - case boxesLoadedMsg: - v.boxes = orderBoxes([]models.Box(msg)) - v.loading = false - if len(v.boxes) > 0 { - v.boxIndex = 0 - return v.requestPostings(v.boxes[0].ID), true + case mailSourcesLoadedMsg: + if msg.requestID != v.sourceRequestID { + return nil, true } - return nil, true + sources := msg.sources + if msg.folderErr != nil { + v.folderDiscoveryErr = msg.folderErr.Error() + for _, source := range v.boxes { + if source.Kind == mailSourceKindFolder { + sources = append(sources, source) + } + } + v.notice = "Could not load labels — press g to retry" + } else { + v.folderDiscoveryErr = "" + } + return v.applySources(sources), true + + case boxesLoadedMsg: + return v.applySources([]models.Box(msg)), true case postingsLoadedMsg: - if msg.requestID != v.activeRequestID || msg.boxID != v.currentBoxID() { + if msg.requestID != v.activeRequestID || msg.boxID != v.currentBoxID() || (msg.sourceKind != "" && msg.sourceKind != v.currentSourceKind()) { return nil, true } v.finishRequest(msg.requestID) @@ -170,6 +209,15 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { return func() tea.Msg { return errMsg{msg.err} }, true } v.postingList.setPostings(msg.postings) + if v.currentSourceKind() == mailSourceKindFolder { + v.folderPage = msg.page + v.folderPageHistory = msg.pageHistory + v.folderNextPage = msg.nextPage + v.folderTotalCount = msg.totalCount + if v.notice == "" { + v.notice = v.folderPageNotice() + } + } return nil, true case searchResultsLoadedMsg: @@ -357,7 +405,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { if msg.err != nil { return func() tea.Msg { return errMsg{msg.err} }, true } - if msg.boxID != v.currentBoxID() { + if msg.boxID != v.currentBoxID() || (msg.sourceKind != "" && msg.sourceKind != v.currentSourceKind()) { return nil, true } v.notice = msg.action @@ -383,7 +431,28 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } } if v.activeRequestKind == mailRequestPostings { - return v.requestPostings(v.currentBoxID()), true + if source := v.currentSource(); source != nil { + return v.requestPostings(*source), true + } + } + return nil, true + + case folderActionDoneMsg: + v.finishMutation() + if msg.err != nil { + if msg.sourceID == v.currentBoxID() && msg.sourceKind == v.currentSourceKind() { + v.notice = "Could not update labels: " + msg.err.Error() + } + return nil, true + } + if msg.sourceID == v.currentBoxID() && msg.sourceKind == v.currentSourceKind() { + v.notice = msg.action + } + if msg.created { + return v.requestSources(), true + } + if source := v.currentSource(); source != nil && msg.sourceID == source.ID && msg.sourceKind == source.Kind { + return v.requestPostings(*source), true } return nil, true } @@ -399,6 +468,9 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { if v.searchForm != nil { return v.searchForm.update(msg), true } + if v.folderPicker != nil && v.folderPicker.creating { + return v.folderPicker.update(msg), true + } // Pass through to viewport if in thread if v.inThread { @@ -423,6 +495,9 @@ func (v *mailView) View() string { if v.movePicker != nil { return v.movePicker.view(v.vc.styles, v.vc.width) } + if v.folderPicker != nil { + return v.folderPicker.view(v.vc.styles, v.vc.width) + } if v.inThread { if v.notice != "" { return v.vc.styles.title.Render(v.notice) + "\n" + v.topicViewport.View() @@ -443,7 +518,7 @@ func (v *mailView) View() string { // CapturingInput reports whether a form or picker is open and wants every key. func (v *mailView) CapturingInput() bool { - return v.compose != nil || v.bulkReply != nil || v.movePicker != nil || v.searchForm != nil + return v.compose != nil || v.bulkReply != nil || v.movePicker != nil || v.folderPicker != nil || v.searchForm != nil } func (v *mailView) AccountSwitchBlocked() bool { @@ -463,6 +538,9 @@ func (v *mailView) HelpBindings() []helpBinding { if v.movePicker != nil { return v.movePicker.helpBindings() } + if v.folderPicker != nil { + return v.folderPicker.helpBindings() + } if v.inThread { bindings := []helpBinding{{"r", "reply"}, {"f", "forward"}} if len(v.attachments) > 0 { @@ -482,6 +560,10 @@ func (v *mailView) HelpBindings() []helpBinding { if selected := v.postingList.selectedPosting(); selected != nil && selected.Muted { ignoreBinding = helpBinding{"+", "stop ignoring"} } + folderBinding := helpBinding{"g", "labels"} + if v.folderDiscoveryErr != "" { + folderBinding = helpBinding{"g", "retry labels"} + } bindings := []helpBinding{ {"/", "search"}, {"c", "compose"}, @@ -490,6 +572,7 @@ func (v *mailView) HelpBindings() []helpBinding { {"r", "reply"}, {"f", "forward"}, {"m", "move"}, + folderBinding, {"e", "seen"}, {"l", "reply later"}, {"a", "set aside"}, @@ -499,6 +582,14 @@ func (v *mailView) HelpBindings() []helpBinding { {"s", "spam"}, ignoreBinding, } + if v.currentSourceKind() == mailSourceKindFolder { + if v.folderNextPage != "" { + bindings = append(bindings, helpBinding{"n", "next page"}) + } + if len(v.folderPageHistory) > 0 { + bindings = append(bindings, helpBinding{"p", "previous page"}) + } + } if v.lastBulkReplyID != 0 { bindings = append(bindings, helpBinding{"u", "undo bulk reply"}) } @@ -516,6 +607,10 @@ func (v *mailView) SubnavItems() ([]navItem, int, string, bool) { label := "Mail" if v.boxIndex >= 0 && v.boxIndex < len(v.boxes) { label = v.boxes[v.boxIndex].Name + if v.boxes[v.boxIndex].Kind == mailSourceKindFolder { + label = terminalSafeFolderText(label) + label = fmt.Sprintf("%s (page %d)", label, len(v.folderPageHistory)+1) + } } return boxNavItems(v.boxes), v.boxIndex, label, true } @@ -592,6 +687,49 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return nil } + if v.folderPicker != nil { + picker := v.folderPicker + if msg.Key().Code == tea.KeyEscape { + if picker.creating { + picker.cancelCreate() + return nil + } + v.folderPicker = nil + return nil + } + if picker.creating { + if msg.Key().Code == tea.KeyEnter { + name, ok := picker.createName() + if !ok { + return nil + } + v.folderPicker = nil + return v.createFolderForPosting(picker.posting.ID, name) + } + return picker.handleKey(msg) + } + if msg.Key().Code == tea.KeyEnter { + selection := picker.selected() + if selection == nil { + return nil + } + switch selection.kind { + case folderPickerExisting: + v.folderPicker = nil + if picker.postingHasFolder(selection.folder.ID) { + return v.unfilePosting(picker.posting.ID, selection.folder.ID, selection.folder.Name) + } + return v.filePosting(picker.posting.ID, selection.folder.ID, selection.folder.Name) + case folderPickerCreate: + return picker.startCreate() + case folderPickerRemoveAll: + v.folderPicker = nil + return v.unfilePosting(picker.posting.ID, 0, "") + } + } + return picker.handleKey(msg) + } + if v.inThread { switch msg.String() { case "r": @@ -650,6 +788,14 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { case tea.KeyEnter: return v.openSelected() default: + if v.currentSourceKind() == mailSourceKindFolder { + switch msg.String() { + case "n": + return v.nextFolderPage() + case "p": + return v.previousFolderPage() + } + } switch msg.String() { case "/": return v.startSearch() @@ -665,6 +811,8 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { case "m": v.startMove() return nil + case "g": + return v.startFolderPicker() default: return v.handlePostingAction(msg.String()) } @@ -692,6 +840,7 @@ func (v *mailView) ExitThread() { v.inThread = false v.compose = nil v.movePicker = nil + v.folderPicker = nil v.cancelRequest() return } @@ -728,6 +877,9 @@ func (v *mailView) Resize(width, height int) { if v.searchForm != nil { v.searchForm.resize(width, height) } + if v.folderPicker != nil { + v.folderPicker.resize(width, height) + } v.postingList.setSize(width, height) v.searchList.setSize(width, height) v.topicViewport.SetWidth(width) @@ -749,14 +901,65 @@ func (v *mailView) switchBox(index int) tea.Cmd { v.notice = "" v.postingList.setPostings(nil) v.boxIndex = index - return v.requestPostings(v.boxes[index].ID) + v.resetFolderPagination() + return v.requestPostings(v.boxes[index]) } -func (v *mailView) currentBoxID() int64 { +func (v *mailView) currentSource() *models.Box { if v.boxIndex < 0 || v.boxIndex >= len(v.boxes) { - return 0 + return nil + } + return &v.boxes[v.boxIndex] +} + +func (v *mailView) currentBoxID() int64 { + if source := v.currentSource(); source != nil { + return source.ID + } + return 0 +} + +func (v *mailView) currentSourceKind() string { + if source := v.currentSource(); source != nil { + return source.Kind + } + return "" +} + +func (v *mailView) currentSourceIdentity() (int64, string) { + if source := v.currentSource(); source != nil { + return source.ID, source.Kind + } + return 0, "" +} + +func sourceIndex(sources []models.Box, id int64, kind string) int { + for i, source := range sources { + if source.ID == id && source.Kind == kind { + return i + } } - return v.boxes[v.boxIndex].ID + return 0 +} + +func (v *mailView) applySources(sources []models.Box) tea.Cmd { + currentID, currentKind := v.currentSourceIdentity() + v.boxes = orderBoxes(sources) + v.loading = false + if len(v.boxes) == 0 { + return nil + } + v.boxIndex = sourceIndex(v.boxes, currentID, currentKind) + if v.boxes[v.boxIndex].ID != currentID || v.boxes[v.boxIndex].Kind != currentKind { + v.resetFolderPagination() + } + return v.requestPostings(v.boxes[v.boxIndex]) +} + +func (v *mailView) requestSources() tea.Cmd { + v.sourceRequestID++ + v.loading = true + return v.fetchSources(v.sourceRequestID) } func (v *mailView) beginRequest(kind mailRequestKind) (uint64, context.Context) { @@ -793,9 +996,48 @@ func (v *mailView) cancelRequest() { v.loading = false } -func (v *mailView) requestPostings(boxID int64) tea.Cmd { +func (v *mailView) requestPostings(source models.Box) tea.Cmd { + return v.requestPostingsPage(source, v.folderPage, v.folderPageHistory) +} + +func (v *mailView) requestPostingsPage(source models.Box, page string, history []string) tea.Cmd { requestID, ctx := v.beginRequest(mailRequestPostings) - return v.fetchPostings(ctx, requestID, boxID) + return v.fetchPostings(ctx, requestID, source, page, append([]string(nil), history...)) +} + +func (v *mailView) nextFolderPage() tea.Cmd { + source := v.currentSource() + if source == nil || source.Kind != mailSourceKindFolder || v.folderNextPage == "" { + return nil + } + history := append(append([]string(nil), v.folderPageHistory...), v.folderPage) + return v.requestPostingsPage(*source, v.folderNextPage, history) +} + +func (v *mailView) previousFolderPage() tea.Cmd { + source := v.currentSource() + if source == nil || source.Kind != mailSourceKindFolder || len(v.folderPageHistory) == 0 { + return nil + } + last := len(v.folderPageHistory) - 1 + page := v.folderPageHistory[last] + history := append([]string(nil), v.folderPageHistory[:last]...) + return v.requestPostingsPage(*source, page, history) +} + +func (v *mailView) resetFolderPagination() { + v.folderPage = "" + v.folderNextPage = "" + v.folderPageHistory = nil + v.folderTotalCount = 0 +} + +func (v *mailView) folderPageNotice() string { + page := len(v.folderPageHistory) + 1 + if v.folderTotalCount > 0 { + return fmt.Sprintf("Label page %d — %d threads total", page, v.folderTotalCount) + } + return fmt.Sprintf("Label page %d", page) } func (v *mailView) startSearch() tea.Cmd { @@ -935,8 +1177,58 @@ func (v *mailView) startMove() { v.movePicker = picker } +func (v *mailView) startFolderPicker() tea.Cmd { + if v.folderDiscoveryErr != "" { + v.notice = "Retrying labels…" + return v.requestSources() + } + selected := v.postingList.selectedPosting() + if selected == nil { + return nil + } + v.folderPicker = newFolderPicker(*selected, v.boxes) + v.folderPicker.resize(v.vc.width, v.vc.height) + return nil +} + +func (v *mailView) filePosting(postingID, folderID int64, folderName string) tea.Cmd { + return v.doFolderAction("Label "+terminalSafeFolderText(folderName)+" added", false, func() error { + return v.vc.sdk.Postings().File(v.vc.ctx, folderID, postingID) + }) +} + +func (v *mailView) createFolderForPosting(postingID int64, folderName string) tea.Cmd { + return v.doFolderAction("Label "+terminalSafeFolderText(folderName)+" created", true, func() error { + return v.vc.sdk.Postings().CreateFolder(v.vc.ctx, folderName, postingID) + }) +} + +func (v *mailView) unfilePosting(postingID, folderID int64, folderName string) tea.Cmd { + label := "All labels removed" + if folderID != 0 { + label = "Label " + terminalSafeFolderText(folderName) + " removed" + } + return v.doFolderAction(label, false, func() error { + return v.vc.sdk.Postings().Unfile(v.vc.ctx, folderID, postingID) + }) +} + +func (v *mailView) doFolderAction(label string, created bool, fn func() error) tea.Cmd { + sourceID, sourceKind := v.currentSourceIdentity() + v.pendingMutations++ + return func() tea.Msg { + return folderActionDoneMsg{ + action: label, + sourceID: sourceID, + sourceKind: sourceKind, + created: created, + err: fn(), + } + } +} + func (v *mailView) movePostingToBox(postingID int64, destination models.Box) tea.Cmd { - return v.doPostingAction("Thread moved to "+destination.Name, postingActionRemove, v.currentBoxID(), postingID, func() error { + return v.doPostingAction("Thread moved to "+destination.Name, v.boxMoveEffect(), v.currentBoxID(), postingID, func() error { return v.vc.sdk.Postings().Move(v.vc.ctx, destination.ID, postingID) }) } @@ -1015,7 +1307,14 @@ func (v *mailView) moveSelectedToKnownBox(name, kind string, boxID, postingID in v.notice = "Already in " + name return nil } - return v.doPostingAction("Thread moved to "+name, postingActionRemove, boxID, postingID, fn) + return v.doPostingAction("Thread moved to "+name, v.boxMoveEffect(), boxID, postingID, fn) +} + +func (v *mailView) boxMoveEffect() postingActionEffect { + if v.currentSourceKind() == mailSourceKindFolder { + return postingActionNone + } + return postingActionRemove } func (v *mailView) movesOutOfCurrentBox(destinationKind string) bool { @@ -1026,15 +1325,17 @@ func (v *mailView) movesOutOfCurrentBox(destinationKind string) bool { } func (v *mailView) doPostingAction(label string, effect postingActionEffect, boxID, postingID int64, fn func() error) tea.Cmd { + sourceKind := v.currentSourceKind() v.pendingMutations++ return func() tea.Msg { err := fn() return postingActionDoneMsg{ - action: label, - boxID: boxID, - postingID: postingID, - effect: effect, - err: err, + action: label, + boxID: boxID, + sourceKind: sourceKind, + postingID: postingID, + effect: effect, + err: err, } } } @@ -1052,6 +1353,10 @@ func sdkBoxToModel(b generated.Box) models.Box { } func sdkPostingToModel(p generated.Posting) models.Posting { + folders := make([]models.Folder, len(p.Folders)) + for i, folder := range p.Folders { + folders[i] = models.Folder{ID: folder.Id, Name: folder.Name, AppURL: folder.AppUrl} + } return models.Posting{ ID: p.Id, CreatedAt: formatTimestamp(p.CreatedAt), @@ -1067,6 +1372,7 @@ func sdkPostingToModel(p generated.Posting) models.Posting { AlternativeSenderName: p.AlternativeSenderName, VisibleEntryCount: p.VisibleEntryCount, Extenzions: sdkExtenzionsToModel(p.Extenzions), + Folders: folders, Creator: models.Contact{ ID: p.Creator.Id, Name: p.Creator.Name, @@ -1156,7 +1462,7 @@ func sdkMessageToEntry(entry generated.Entry, message generated.Message) models. // --- Fetch commands --- -func (v *mailView) fetchBoxes() tea.Cmd { +func (v *mailView) fetchSources(requestID uint64) tea.Cmd { return func() tea.Msg { result, err := v.vc.sdk.Boxes().List(v.vc.ctx) if err != nil { @@ -1166,25 +1472,58 @@ func (v *mailView) fetchBoxes() tea.Cmd { if result != nil { sdkBoxes = *result } - boxes := make([]models.Box, len(sdkBoxes)) - for i, b := range sdkBoxes { - boxes[i] = sdkBoxToModel(b) + boxes := make([]models.Box, 0, len(sdkBoxes)) + for _, box := range sdkBoxes { + boxes = append(boxes, sdkBoxToModel(box)) + } + + folders, folderErr := internalfolders.List(v.vc.ctx, v.vc.sdk) + if folderErr == nil { + for _, folder := range folders { + boxes = append(boxes, models.Box{ID: folder.Id, Kind: mailSourceKindFolder, Name: folder.Name, AppURL: folder.AppUrl}) + } } - return boxesLoadedMsg(boxes) + return mailSourcesLoadedMsg{requestID: requestID, sources: boxes, folderErr: folderErr} } } -func (v *mailView) fetchPostings(ctx context.Context, requestID uint64, boxID int64) tea.Cmd { +func (v *mailView) fetchPostings(ctx context.Context, requestID uint64, source models.Box, page string, history []string) tea.Cmd { return func() tea.Msg { - resp, err := v.vc.sdk.Boxes().Get(ctx, boxID, nil) - if err != nil { - return postingsLoadedMsg{requestID: requestID, boxID: boxID, err: err} + var sdkPostings []generated.Posting + message := postingsLoadedMsg{requestID: requestID, boxID: source.ID, sourceKind: source.Kind, page: page, pageHistory: history} + if source.Kind == mailSourceKindFolder { + var params *generated.GetFolderParams + if page != "" { + params = &generated.GetFolderParams{Page: &page} + } + result, err := v.vc.sdk.Folders().GetPage(ctx, source.ID, params) + if err != nil { + message.err = err + return message + } + if result != nil { + message.nextPage = result.NextPage + message.totalCount = result.TotalCount + if result.Folder != nil { + sdkPostings = result.Folder.Postings + } + } + } else { + box, err := v.vc.sdk.Boxes().Get(ctx, source.ID, nil) + if err != nil { + message.err = err + return message + } + if box != nil { + sdkPostings = box.Postings + } } - postings := make([]models.Posting, 0, len(resp.Postings)) - for _, p := range resp.Postings { - postings = append(postings, sdkPostingToModel(p)) + postings := make([]models.Posting, 0, len(sdkPostings)) + for _, posting := range sdkPostings { + postings = append(postings, sdkPostingToModel(posting)) } - return postingsLoadedMsg{requestID: requestID, boxID: boxID, postings: postings} + message.postings = postings + return message } } diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 209ad728..a27ba088 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -62,6 +62,15 @@ func currentPostingsLoaded(v *mailView, postings []models.Posting) postingsLoade } } +func hasHelpBinding(bindings []helpBinding, key string) bool { + for _, binding := range bindings { + if binding.key == key { + return true + } + } + return false +} + type recordedMailRequest struct { method string path string @@ -70,6 +79,10 @@ type recordedMailRequest struct { body struct { PostingIDs []int64 `json:"posting_ids"` BoxID *int64 `json:"box_id"` + FolderID *int64 `json:"folder_id"` + Folder struct { + Name string `json:"name"` + } `json:"folder"` } } @@ -494,6 +507,376 @@ func TestMailViewMovePickerCancelsWithoutRequest(t *testing.T) { } } +func TestMailViewLoadsFolderSourcesAndPostings(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/boxes.json": + _, _ = w.Write([]byte(`[{"id":1,"kind":"imbox","name":"Imbox"}]`)) + case "/my/navigation.json": + _, _ = w.Write([]byte(`{"items":[{"title":"Labels","menu_items":[{"title":"All Labels","app_url":"/folders"},{"title":"Receipts","app_url":"/folders/12"}]}]}`)) + case "/folders/12.json": + _, _ = w.Write([]byte(`{"id":12,"name":"Receipts","postings":[{"id":100,"kind":"topic","summary":"Hotel receipt","folders":[{"id":12,"name":"Receipts"}]}]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + vc := testVC() + vc.sdk = client + v := newMailView(vc) + + loaded, ok := runCmd(v.Init()).(mailSourcesLoadedMsg) + if !ok { + t.Fatalf("initial command returned %T, want mailSourcesLoadedMsg", loaded) + } + postingsCmd, consumed := v.Update(loaded) + if !consumed || postingsCmd == nil { + t.Fatal("folder sources should be loaded and start the first mailbox request") + } + if len(v.boxes) != 2 || v.boxes[1].Kind != mailSourceKindFolder || v.boxes[1].Name != "Receipts" { + t.Fatalf("mail sources = %+v", v.boxes) + } + + folderCmd := v.SubnavRight() + folderLoaded, ok := runCmd(folderCmd).(postingsLoadedMsg) + if !ok || folderLoaded.err != nil { + t.Fatalf("folder command returned %#v", folderLoaded) + } + v.Update(folderLoaded) + if len(v.postingList.postings) != 1 || v.postingList.postings[0].Summary != "Hotel receipt" { + t.Errorf("folder postings = %+v", v.postingList.postings) + } + if len(v.postingList.postings[0].Folders) != 1 || v.postingList.postings[0].Folders[0].ID != 12 { + t.Errorf("posting folders = %+v", v.postingList.postings[0].Folders) + } + if v.notice != "Label page 1" { + t.Errorf("folder pagination notice = %q", v.notice) + } +} + +func TestMailViewFolderPagination(t *testing.T) { + var folderQueries []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/boxes.json": + _, _ = w.Write([]byte(`[{"id":1,"kind":"imbox","name":"Imbox"}]`)) + case "/my/navigation.json": + _, _ = w.Write([]byte(`{"items":[{"title":"Labels","menu_items":[{"title":"Receipts","app_url":"/folders/12"}]}]}`)) + case "/folders/12.json": + folderQueries = append(folderQueries, r.URL.Query().Get("page")) + w.Header().Set("X-Total-Count", "2") + if r.URL.Query().Get("page") == "next-cursor" { + _, _ = w.Write([]byte(`{"id":12,"name":"Receipts","postings":[{"id":101,"kind":"topic","summary":"Second page"}]}`)) + return + } + w.Header().Set("Link", "; rel=\"next\"") + _, _ = w.Write([]byte(`{"id":12,"name":"Receipts","postings":[{"id":100,"kind":"topic","summary":"First page"}]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + vc := testVC() + vc.sdk = client + v := newMailView(vc) + v.Update(runCmd(v.Init())) + first := runCmd(v.SubnavRight()).(postingsLoadedMsg) + v.Update(first) + if v.folderNextPage != "next-cursor" || v.notice != "Label page 1 — 2 threads total" { + t.Errorf("first page state = next:%q notice:%q", v.folderNextPage, v.notice) + } + if !hasHelpBinding(v.HelpBindings(), "n") { + t.Error("first folder page should offer next-page navigation") + } + + second := runCmd(v.HandleContentKey(keyPress("n"))).(postingsLoadedMsg) + v.Update(second) + if len(v.postingList.postings) != 1 || v.postingList.postings[0].Summary != "Second page" || len(v.folderPageHistory) != 1 { + t.Errorf("second page state = postings:%+v history:%v", v.postingList.postings, v.folderPageHistory) + } + _, _, label, _ := v.SubnavItems() + if !strings.Contains(label, "page 2") || v.notice != "Label page 2 — 2 threads total" { + t.Errorf("second page label=%q notice=%q", label, v.notice) + } + if !hasHelpBinding(v.HelpBindings(), "p") { + t.Error("second folder page should offer previous-page navigation") + } + + previous := runCmd(v.HandleContentKey(keyPress("p"))).(postingsLoadedMsg) + v.Update(previous) + if len(v.folderPageHistory) != 0 || len(v.postingList.postings) != 1 || v.postingList.postings[0].Summary != "First page" { + t.Errorf("previous page state = postings:%+v history:%v", v.postingList.postings, v.folderPageHistory) + } + if fmt.Sprint(folderQueries) != "[ next-cursor ]" { + t.Errorf("folder page queries = %q", folderQueries) + } +} + +func TestMailViewFolderDiscoveryFailurePreservesMailAndRetries(t *testing.T) { + var navigationRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/boxes.json": + _, _ = w.Write([]byte(`[{"id":1,"kind":"imbox","name":"Imbox"}]`)) + case "/my/navigation.json": + if navigationRequests.Add(1) == 1 { + http.Error(w, "navigation unavailable", http.StatusBadRequest) + return + } + _, _ = w.Write([]byte(`{"items":[{"title":"Labels","menu_items":[{"title":"Receipts","app_url":"/folders/12"}]}]}`)) + case "/boxes/1.json": + _, _ = w.Write([]byte(`{"id":1,"kind":"imbox","name":"Imbox","postings":[]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + + client := hey.NewClient(&hey.Config{BaseURL: server.URL}, &hey.StaticTokenProvider{Token: "test-token"}, hey.WithMaxRetries(0)) + vc := testVC() + vc.sdk = client + v := newMailView(vc) + + failed := runCmd(v.Init()).(mailSourcesLoadedMsg) + firstPostings, consumed := v.Update(failed) + if !consumed || firstPostings == nil || len(v.boxes) != 1 || v.folderDiscoveryErr == "" { + t.Fatalf("folder failure state = consumed:%v command:%v sources:%+v error:%q", consumed, firstPostings != nil, v.boxes, v.folderDiscoveryErr) + } + if !strings.Contains(v.notice, "press g to retry") { + t.Errorf("notice = %q", v.notice) + } + v.Update(runCmd(firstPostings)) + + retry := v.HandleContentKey(keyPress("g")) + if retry == nil || !v.loading { + t.Fatal("g should retry failed folder discovery") + } + recovered := runCmd(retry).(mailSourcesLoadedMsg) + v.Update(recovered) + if v.folderDiscoveryErr != "" || len(v.boxes) != 2 || v.boxes[1].Name != "Receipts" { + t.Errorf("recovered sources = %+v error=%q", v.boxes, v.folderDiscoveryErr) + } +} + +func TestMailViewFolderDiscoveryFailurePreservesKnownFolders(t *testing.T) { + v := mailWithPostings() + v.boxes = append(v.boxes, models.Box{ID: 12, Kind: mailSourceKindFolder, Name: "Receipts"}) + v.sourceRequestID = 1 + + v.Update(mailSourcesLoadedMsg{ + requestID: 1, + sources: testBoxes(), + folderErr: fmt.Errorf("navigation unavailable"), + }) + if sourceIndex(v.boxes, 12, mailSourceKindFolder) == 0 || v.folderDiscoveryErr == "" { + t.Errorf("sources = %+v error=%q", v.boxes, v.folderDiscoveryErr) + } +} + +func TestMailViewIgnoresStaleFolderDiscovery(t *testing.T) { + v := mailWithPostings() + v.sourceRequestID = 2 + v.Update(mailSourcesLoadedMsg{requestID: 1, sources: []models.Box{{ID: 99, Kind: mailSourceKindFolder, Name: "Stale"}}}) + if len(v.boxes) != len(testBoxes()) { + t.Errorf("stale discovery replaced sources: %+v", v.boxes) + } +} + +func TestMailViewFolderPickerFilesAndUnfilesThread(t *testing.T) { + t.Run("file", func(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.boxes = append(v.boxes, models.Box{ID: 12, Kind: mailSourceKindFolder, Name: "Receipts"}) + + v.HandleContentKey(keyPress("g")) + if v.folderPicker == nil || !v.CapturingInput() { + t.Fatal("folder picker should capture input") + } + if view := v.View(); !strings.Contains(view, "Label thread") || !strings.Contains(view, "[ ] Receipts") || !strings.Contains(view, "Create a new label") { + t.Errorf("folder picker view = %q", view) + } + + done, ok := runCmd(v.HandleContentKey(keyPress("enter"))).(folderActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("folder action returned %#v", done) + } + if recorded.path != "/postings/filings.json" || recorded.body.FolderID == nil || *recorded.body.FolderID != 12 { + t.Errorf("folder request = %s body=%+v", recorded.path, recorded.body) + } + if len(recorded.body.PostingIDs) != 1 || recorded.body.PostingIDs[0] != 100 { + t.Errorf("posting_ids = %v", recorded.body.PostingIDs) + } + refresh, consumed := v.Update(done) + if !consumed || refresh == nil || v.notice != "Label Receipts added" { + t.Errorf("completion = consumed:%v refresh:%v notice:%q", consumed, refresh != nil, v.notice) + } + }) + + t.Run("unfile", func(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.boxes = append(v.boxes, models.Box{ID: 12, Kind: mailSourceKindFolder, Name: "Receipts"}) + v.postingList.postings[0].Folders = []models.Folder{{ID: 12, Name: "Receipts"}} + + v.HandleContentKey(keyPress("g")) + if view := v.View(); !strings.Contains(view, "[x] Receipts") || !strings.Contains(view, "Remove all labels") { + t.Errorf("folder picker view = %q", view) + } + done, ok := runCmd(v.HandleContentKey(keyPress("enter"))).(folderActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("folder action returned %#v", done) + } + if recorded.method != http.MethodDelete || recorded.path != "/postings/filings.json" { + t.Errorf("request = %s %s", recorded.method, recorded.path) + } + if len(recorded.rawQueries) == 0 || !strings.Contains(recorded.rawQueries[len(recorded.rawQueries)-1], "folder_id=12") { + t.Errorf("queries = %v", recorded.rawQueries) + } + }) + + t.Run("remove all", func(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.boxes = append(v.boxes, models.Box{ID: 12, Kind: mailSourceKindFolder, Name: "Receipts"}) + v.postingList.postings[0].Folders = []models.Folder{{ID: 12, Name: "Receipts"}} + + v.HandleContentKey(keyPress("g")) + v.HandleContentKey(keyPress("down")) + v.HandleContentKey(keyPress("down")) + done, ok := runCmd(v.HandleContentKey(keyPress("enter"))).(folderActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("folder action returned %#v", done) + } + if len(recorded.rawQueries) == 0 || strings.Contains(recorded.rawQueries[len(recorded.rawQueries)-1], "folder_id=") { + t.Errorf("queries = %v, want no folder_id", recorded.rawQueries) + } + v.Update(done) + if v.notice != "All labels removed" { + t.Errorf("notice = %q", v.notice) + } + }) +} + +func TestMailViewFolderPickerCreatesFolder(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + + v.HandleContentKey(keyPress("g")) + if cmd := v.HandleContentKey(keyPress("enter")); cmd == nil || v.folderPicker == nil || !v.folderPicker.creating { + t.Fatal("selecting create should focus the folder name input") + } + v.folderPicker.input.SetValue("Travel receipts") + done, ok := runCmd(v.HandleContentKey(keyPress("enter"))).(folderActionDoneMsg) + if !ok || done.err != nil || !done.created { + t.Fatalf("create command returned %#v", done) + } + if recorded.path != "/postings/folders.json" || recorded.body.Folder.Name != "Travel receipts" { + t.Errorf("create request = %s body=%+v", recorded.path, recorded.body) + } + if len(recorded.body.PostingIDs) != 1 || recorded.body.PostingIDs[0] != 100 { + t.Errorf("posting_ids = %v", recorded.body.PostingIDs) + } + refresh, consumed := v.Update(done) + if !consumed || refresh == nil || v.notice != "Label Travel receipts created" { + t.Errorf("completion = consumed:%v refresh:%v notice:%q", consumed, refresh != nil, v.notice) + } +} + +func TestMailViewFolderPickerScrollsAndSanitizesNames(t *testing.T) { + v := mailWithPostings() + v.vc.height = 8 + for id := int64(1); id <= 20; id++ { + name := fmt.Sprintf("Label %02d", id) + if id == 20 { + name = "Archive\x1b]2;owned\a\n2026" + } + v.boxes = append(v.boxes, models.Box{ID: id + 100, Kind: mailSourceKindFolder, Name: name}) + } + + v.HandleContentKey(keyPress("g")) + for range 19 { + v.HandleContentKey(keyPress("down")) + } + view := v.View() + if strings.Contains(view, "Label 01") || !strings.Contains(view, "Archive�]2;owned��2026") { + t.Errorf("scrolled folder picker = %q", view) + } + if strings.Contains(view, "\x1b]2;owned") { + t.Errorf("unsafe folder name reached picker: %q", view) + } + + v.boxIndex = len(v.boxes) - 1 + _, _, label, _ := v.SubnavItems() + if strings.Contains(label, "\x1b]2;owned") || !strings.Contains(label, "Archive�]2;owned��2026") { + t.Errorf("folder navigation label = %q", label) + } +} + +func TestMailViewFolderActionFailureKeepsThread(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusInternalServerError) + v.boxes = append(v.boxes, models.Box{ID: 12, Kind: mailSourceKindFolder, Name: "Receipts"}) + + v.HandleContentKey(keyPress("g")) + done, ok := runCmd(v.HandleContentKey(keyPress("enter"))).(folderActionDoneMsg) + if !ok || done.err == nil { + t.Fatalf("folder action returned %#v, want an error", done) + } + cmd, consumed := v.Update(done) + if !consumed || cmd != nil { + t.Error("failed folder action should stay in the current view") + } + if v.postingIndex(100) < 0 || !strings.Contains(v.notice, "Could not update labels") { + t.Errorf("posting present=%v notice=%q", v.postingIndex(100) >= 0, v.notice) + } + if v.pendingMutations != 0 { + t.Errorf("pending mutations = %d, want 0", v.pendingMutations) + } +} + +func TestMailViewFolderPickerRequiresNameAndCancels(t *testing.T) { + v := mailWithPostings() + v.HandleContentKey(keyPress("g")) + v.HandleContentKey(keyPress("enter")) + if cmd := v.HandleContentKey(keyPress("enter")); cmd != nil { + t.Fatal("empty folder name should not submit") + } + if v.folderPicker == nil || !strings.Contains(v.View(), "Enter a label name") { + t.Error("empty folder name should keep the form open with guidance") + } + v.HandleContentKey(keyPress("esc")) + if v.folderPicker == nil || v.folderPicker.creating { + t.Error("first escape should return to the folder choices") + } + v.HandleContentKey(keyPress("esc")) + if v.folderPicker != nil || v.CapturingInput() { + t.Error("second escape should close the folder picker") + } +} + +func TestMailViewMoveFromFolderKeepsFiledThreadVisible(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.boxes = []models.Box{ + {ID: 12, Kind: mailSourceKindFolder, Name: "Receipts"}, + {ID: 2, Kind: hey.BoxKindFeed, Name: "The Feed"}, + } + v.boxIndex = 0 + + done, ok := runCmd(v.HandleContentKey(keyPress("d"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("move command returned %#v", done) + } + if done.effect != postingActionNone { + t.Errorf("folder move effect = %v, want postingActionNone", done.effect) + } + v.Update(done) + if v.postingIndex(100) < 0 { + t.Error("moving boxes should preserve a thread's folder label") + } +} + func TestMailViewMoveWithinCurrentBoxSkipsRequest(t *testing.T) { tests := []struct { name string @@ -1715,7 +2098,7 @@ func TestMailViewHelpBindings(t *testing.T) { for _, b := range bindings { keys[b.key] = true } - for _, expected := range []string{"/", "r", "f", "m", "e", "l", "a", "t", "s", "-"} { + for _, expected := range []string{"/", "r", "f", "m", "g", "e", "l", "a", "t", "s", "-"} { if !keys[expected] { t.Errorf("missing help binding for key %q", expected) } @@ -1745,6 +2128,16 @@ func TestMailViewHelpBindingsInMovePicker(t *testing.T) { } } +func TestMailViewHelpBindingsInFolderPicker(t *testing.T) { + v := mailWithPostings() + v.HandleContentKey(keyPress("g")) + + bindings := v.HelpBindings() + if len(bindings) != 3 || bindings[0].key != "↑↓" || bindings[1].key != "enter" || bindings[2].key != "esc" { + t.Errorf("folder picker help = %v", bindings) + } +} + func TestMailViewHelpBindingsInThread(t *testing.T) { v := mailWithPostings() v.inThread = true diff --git a/internal/tui/move.go b/internal/tui/move.go index ed2098c7..b3fbb028 100644 --- a/internal/tui/move.go +++ b/internal/tui/move.go @@ -21,7 +21,7 @@ type movePicker struct { func newMovePicker(posting models.Posting, boxes []models.Box, currentBoxID int64) *movePicker { destinations := make([]models.Box, 0, len(boxes)) for _, box := range boxes { - if box.ID == currentBoxID || strings.EqualFold(box.Kind, hey.BoxKindBubbleUp) || strings.EqualFold(box.Name, "Bubble Up") { + if box.Kind == mailSourceKindFolder || box.ID == currentBoxID || strings.EqualFold(box.Kind, hey.BoxKindBubbleUp) || strings.EqualFold(box.Name, "Bubble Up") { continue } destinations = append(destinations, box) diff --git a/internal/tui/nav.go b/internal/tui/nav.go index c11a2dd0..c1b59834 100644 --- a/internal/tui/nav.go +++ b/internal/tui/nav.go @@ -78,21 +78,27 @@ var knownBoxes = []boxSpec{ // in their predefined order; unknown boxes are appended at the end. func orderBoxes(boxes []models.Box) []models.Box { ordered := make([]models.Box, 0, len(boxes)) - used := make(map[int64]bool) + type sourceKey struct { + id int64 + kind string + } + used := make(map[sourceKey]bool) // Add known boxes in preferred order for _, spec := range knownBoxes { for _, b := range boxes { - if strings.EqualFold(b.Name, spec.name) && !used[b.ID] { + key := sourceKey{id: b.ID, kind: b.Kind} + if b.Kind != mailSourceKindFolder && strings.EqualFold(b.Name, spec.name) && !used[key] { ordered = append(ordered, b) - used[b.ID] = true + used[key] = true break } } } // Append any remaining boxes for _, b := range boxes { - if !used[b.ID] { + key := sourceKey{id: b.ID, kind: b.Kind} + if !used[key] { ordered = append(ordered, b) } } @@ -105,12 +111,16 @@ func boxNavItems(boxes []models.Box) []navItem { for i, b := range boxes { icon := "" for _, spec := range knownBoxes { - if strings.EqualFold(b.Name, spec.name) { + if b.Kind != mailSourceKindFolder && strings.EqualFold(b.Name, spec.name) { icon = spec.icon break } } - items[i] = navItem{icon: icon, label: b.Name} + label := b.Name + if b.Kind == mailSourceKindFolder { + label = terminalSafeFolderText(label) + } + items[i] = navItem{icon: icon, label: label} } return items } @@ -120,7 +130,7 @@ func boxForShortcut(key string, boxes []models.Box) int { for _, spec := range knownBoxes { if spec.key == key { for i, b := range boxes { - if strings.EqualFold(b.Name, spec.name) { + if b.Kind != mailSourceKindFolder && strings.EqualFold(b.Name, spec.name) { return i } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4b7e815a..bd0decde 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -199,6 +199,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateHelpBindings() return m, nil + case mailSourcesLoadedMsg: + if m.activeView != m.mailView { + cmd, _ := m.mailView.Update(msg) + return m, m.stampViewCmd(cmd) + } + + case postingsLoadedMsg: + if m.activeView != m.mailView { + if msg.err != nil { + m.mailView.finishRequest(msg.requestID) + m.mailView.notice = "Could not load mail: " + msg.err.Error() + return m, nil + } + cmd, _ := m.mailView.Update(msg) + return m, m.stampViewCmd(cmd) + } + case errMsg: m.loading = false m.err = msg.err diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 5830fb10..6cfff139 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -9,6 +9,8 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" + "github.com/basecamp/hey-cli/internal/models" ) @@ -163,6 +165,46 @@ func TestOrderBoxes(t *testing.T) { // --- Navigation: Tab cycles focus rows --- +func TestOrderBoxesPreservesFolderWithCollidingIDAndName(t *testing.T) { + boxes := []models.Box{ + {ID: 1, Kind: hey.BoxKindImbox, Name: "Imbox"}, + {ID: 1, Kind: mailSourceKindFolder, Name: "Imbox"}, + } + ordered := orderBoxes(boxes) + if len(ordered) != 2 || ordered[0].Kind != hey.BoxKindImbox || ordered[1].Kind != mailSourceKindFolder { + t.Errorf("ordered sources = %+v", ordered) + } + if index := boxForShortcut("I", ordered); index != 0 { + t.Errorf("Imbox shortcut index = %d, want box index 0", index) + } + items := boxNavItems(ordered) + if items[0].icon == "" || items[1].icon != "" { + t.Errorf("navigation icons = %+v", items) + } +} + +func TestFolderDiscoveryCompletesWhileAnotherSectionIsActive(t *testing.T) { + m := newModel() + m.section = sectionCalendar + m.activeView = m.calendarView + m.mailView.sourceRequestID = 1 + + updated, cmd := m.Update(mailSourcesLoadedMsg{ + requestID: 1, + sources: []models.Box{ + {ID: 1, Kind: hey.BoxKindImbox, Name: "Imbox"}, + {ID: 12, Kind: mailSourceKindFolder, Name: "Receipts"}, + }, + }) + m = updated.(model) + if len(m.mailView.boxes) != 2 || m.mailView.boxes[1].Name != "Receipts" { + t.Errorf("mail sources = %+v", m.mailView.boxes) + } + if cmd == nil { + t.Error("inactive Mail view should continue loading its selected source") + } +} + func TestTabCyclesFocus(t *testing.T) { m := modelWithBoxes() m.focus = rowSection diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index acdfa342..c24007e5 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -2,7 +2,7 @@ name: hey description: | Interact with HEY via the HEY CLI. Read and send emails, manage contacts, - boxes, calendars, todos, habits, time tracking, and journal entries. Use for ANY + boxes, labels, calendars, todos, habits, time tracking, and journal entries. Use for ANY HEY-related question or action. triggers: # Direct invocations @@ -12,6 +12,8 @@ triggers: - hey accounts - hey boxes - hey box + - hey labels + - hey label - hey search - hey contacts - hey threads @@ -92,7 +94,7 @@ argument-hint: "[command] [args...]" # /hey - HEY Email Workflow Command -CLI for HEY: mailboxes, email threads, contacts, replies, compose, calendars, todos, habits, time tracking, and journal entries. +CLI for HEY: mailboxes, labels, email threads, contacts, replies, compose, calendars, todos, habits, time tracking, and journal entries. ## Agent Invariants @@ -125,6 +127,11 @@ hey boxes --quiet --jq '.[].name' | Trust this repository's settings | `hey config trust-local` (requires explicit user approval) | | List mailboxes | `hey boxes --json` | | List emails in a box | `hey box imbox --json` | +| List labels | `hey labels --json` | +| List emails with a label | `hey label --all --json` | +| Add a label to a thread | `hey label add --to ` | +| Create and add a label | `hey label create "Travel receipts" ` | +| Remove labels | `hey label remove --from ` | | Search email | `hey search "quarterly planning" --json` | | List search filters | `hey search filters --json` | | List contacts | `hey contacts list --json` | @@ -179,6 +186,8 @@ hey boxes --quiet --jq '.[].name' Want to read email? ├── Which mailbox? → hey boxes --json ├── List emails in box? → hey box --json +├── List labels or labeled email? → hey labels --json / hey label --json +├── Add, create, or remove a label? → hey label add|create|remove ├── 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 @@ -235,7 +244,20 @@ hey box 123 --json # List emails in box (by ID) 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`. +**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 label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring`. Use `topic_id` for `hey threads`, `hey reply`, and `hey forward`. + +### Email - Labels + +```bash +hey labels --json # List labels and stable IDs +hey label 789 --all --json # List every thread with a label +hey label add 12345 --to 789 # Add an existing label +hey label create "Travel receipts" 12345 # Create and add a label +hey label remove 12345 --from 789 # Remove one label +hey label remove 12345 --from all # Remove every label +``` + +Label mutations take box item IDs from `hey box`, `hey label`, or active `hey search` results. Label IDs come from `hey labels`. `hey label` returns `next_page` and `total_count`; pass `--page ` to continue or `--all` to fetch every page. HEY creates a label while adding it to at least one thread, so `label create` requires one or more thread item IDs. ### Email - Search @@ -281,7 +303,7 @@ hey threads --json # Read full email thread hey threads --html # Read with raw HTML content ``` -**ID note:** Every email thread returned by `hey box` has an `id` (its box item ID) and a `topic_id` (its thread ID). `hey seen`, `hey unseen`, `hey move`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring` expect `id`. `hey threads`, `hey attachments`, `hey reply`, and `hey forward` expect `topic_id`. The `app_url` field also contains the thread ID as a fallback (e.g. `https://app.hey.com/topics/123` → `123`). +**ID note:** Every email thread returned by `hey box` or `hey label` has 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 threads`, `hey attachments`, `hey reply`, and `hey forward` expect `topic_id`. The `app_url` field also contains the thread ID as a fallback (e.g. `https://app.hey.com/topics/123` → `123`). ### Email - Attachments diff --git a/tests/smoke/labels_test.go b/tests/smoke/labels_test.go new file mode 100644 index 00000000..95807937 --- /dev/null +++ b/tests/smoke/labels_test.go @@ -0,0 +1,50 @@ +package smoke_test + +import ( + "strconv" + "strings" + "testing" +) + +type smokeFolder struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +func TestLabelsAndLabel(t *testing.T) { + response := heyJSON(t, "labels") + folders := dataAs[[]smokeFolder](t, response) + if response.Summary == "" { + t.Error("labels response omitted summary") + } + if len(folders) == 0 { + t.Skip("no labels available for label detail validation") + } + + folder := folders[0] + detail := heyJSON(t, "label", strconv.FormatInt(folder.ID, 10), "--limit", "2") + data := dataAs[struct { + ID int64 `json:"id"` + Name string `json:"name"` + }](t, detail) + if data.ID != folder.ID || data.Name != folder.Name { + t.Errorf("label detail = %+v, want %+v", data, folder) + } + + html := fetchHTML(t, baseURL+"/folders") + if !strings.Contains(html, folder.Name) { + t.Errorf("folder page does not contain %q", folder.Name) + } +} + +func TestLabelMutationValidation(t *testing.T) { + for _, args := range [][]string{ + {"label", "add", "101"}, + {"label", "add", "invalid", "--to", "12"}, + {"label", "create", "Receipts", "invalid"}, + {"label", "remove", "101"}, + {"label", "remove", "invalid", "--from", "all"}, + } { + heyFail(t, args...) + } +} From bf3bcaae294ab28acc577add7a2adfb721e01e3a Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 20 Aug 2026 12:00:44 -0400 Subject: [PATCH 2/4] Address label workflow review findings --- internal/cmd/label.go | 38 ++++++++++------ internal/cmd/label_test.go | 59 +++++++++++++++++++++++-- internal/folders/navigation.go | 19 +++++--- internal/folders/navigation_test.go | 4 +- internal/tui/folders.go | 1 + internal/tui/mail.go | 24 +++++++---- internal/tui/mail_test.go | 67 +++++++++++++++++++++++++++++ internal/tui/move.go | 5 ++- 8 files changed, 183 insertions(+), 34 deletions(-) diff --git a/internal/cmd/label.go b/internal/cmd/label.go index 397e1563..5d14b58b 100644 --- a/internal/cmd/label.go +++ b/internal/cmd/label.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" "strings" + "time" "github.com/spf13/cobra" @@ -60,8 +61,8 @@ func (c *labelsCommand) run(cmd *cobra.Command, args []string) error { if writer.IsStyled() { table := newTable(cmd.OutOrStdout()) table.addRow([]string{"ID", "Name"}) - for _, folder := range folders { - table.addRow([]string{fmt.Sprintf("%d", folder.Id), terminalSafeText(folder.Name)}) + for _, label := range folders { + table.addRow([]string{fmt.Sprintf("%d", label.ID), terminalSafeText(label.Name)}) } table.print() if notice != "" { @@ -89,10 +90,14 @@ type labelCommand struct { } type folderOutput struct { - generated.Folder - Postings []folderPostingOutput `json:"postings,omitempty"` + ID int64 `json:"id"` + Name string `json:"name,omitempty"` + AppURL string `json:"app_url,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Postings []folderPostingOutput `json:"postings"` NextPage string `json:"next_page,omitempty"` - TotalCount int `json:"total_count,omitempty"` + TotalCount int `json:"total_count"` } type folderPostingOutput struct { @@ -159,7 +164,9 @@ func (c *labelCommand) run(cmd *cobra.Command, args []string) error { table.addRow([]string{"ID", "Thread", "From", "Summary", "Date"}) for _, posting := range folder.Postings { topicID := resolvePostingTopicID(posting) - table.addRow([]string{fmt.Sprintf("%d", posting.Id), fmt.Sprintf("%d", topicID), posting.Creator.Name, truncate(posting.Summary, 60), formatDate(posting.CreatedAt)}) + creator := terminalSafeText(posting.Creator.Name) + summary := truncate(terminalSafeText(posting.Summary), 60) + table.addRow([]string{fmt.Sprintf("%d", posting.Id), fmt.Sprintf("%d", topicID), creator, summary, formatDate(posting.CreatedAt)}) } table.print() if notice != "" { @@ -373,19 +380,24 @@ func makeFolderOutput(folder *generated.FolderWithPostings, nextPage string, tot postings[i] = folderPostingOutput{Posting: posting, TopicID: resolvePostingTopicID(posting)} } return folderOutput{ - Folder: generated.Folder{ - Id: folder.Id, - Name: folder.Name, - AppUrl: folder.AppUrl, - CreatedAt: folder.CreatedAt, - UpdatedAt: folder.UpdatedAt, - }, + ID: folder.Id, + Name: folder.Name, + AppURL: folder.AppUrl, + CreatedAt: nonZeroTime(folder.CreatedAt), + UpdatedAt: nonZeroTime(folder.UpdatedAt), Postings: postings, NextPage: nextPage, TotalCount: total, } } +func nonZeroTime(value time.Time) *time.Time { + if value.IsZero() { + return nil + } + return &value +} + func writeLabelMutation(cmd *cobra.Command, summary string) error { if writer.IsStyled() { fmt.Fprintln(cmd.OutOrStdout(), terminalSafeText(summary)+".") diff --git a/internal/cmd/label_test.go b/internal/cmd/label_test.go index eea08cfb..03dc4970 100644 --- a/internal/cmd/label_test.go +++ b/internal/cmd/label_test.go @@ -32,9 +32,12 @@ func TestLabelsCommand(t *testing.T) { if !ok || len(folders) != 1 { t.Fatalf("data = %#v, want one folder", response.Data) } - folder, ok := folders[0].(map[string]any) - if !ok || folder["id"] != float64(12) || folder["name"] != "Receipts" { - t.Errorf("folder = %#v", folders[0]) + label, ok := folders[0].(map[string]any) + if !ok || label["id"] != float64(12) || label["name"] != "Receipts" || label["app_url"] != "/folders/12" { + t.Errorf("label = %#v", folders[0]) + } + if len(label) != 3 || label["created_at"] != nil || label["updated_at"] != nil { + t.Errorf("navigation label exposed fields HEY did not return: %#v", label) } } @@ -91,6 +94,31 @@ func TestLabelCommand(t *testing.T) { } } +func TestLabelCommandEmptyPreservesCollectionAndCount(t *testing.T) { + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Total-Count", "0") + _, _ = io.WriteString(w, `{"id":12,"name":"Receipts","postings":[]}`) + }), "label", "12") + if err != nil { + t.Fatalf("execute empty label: %v", err) + } + label, ok := response.Data.(map[string]any) + if !ok { + t.Fatalf("data = %#v", response.Data) + } + postings, ok := label["postings"].([]any) + if !ok || len(postings) != 0 || label["total_count"] != float64(0) { + t.Errorf("empty label contract = %#v", label) + } + if _, ok := label["created_at"]; ok { + t.Errorf("empty label fabricated created_at: %#v", label) + } + if _, ok := label["updated_at"]; ok { + t.Errorf("empty label fabricated updated_at: %#v", label) + } +} + func TestLabelCommandReturnsContinuation(t *testing.T) { var requests atomic.Int32 response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -168,6 +196,31 @@ func TestLabelCommandStyledTable(t *testing.T) { } } +func TestLabelStyledOutputSanitizesPostingText(t *testing.T) { + const unsafeCreator = "Jane\x1b]2;owned\a\nDoe" + const unsafeSummary = "Receipt\x1b]2;owned\a\nArchive" + stdout, err := runStyledCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 12, "name": "Receipts", + "postings": []any{map[string]any{ + "id": 101, "kind": "topic", "creator": map[string]any{"name": unsafeCreator}, "summary": unsafeSummary, + }}, + }) + }), "label", "12") + if err != nil { + t.Fatalf("execute styled label: %v", err) + } + if strings.Contains(stdout, "\x1b]2;owned") || strings.Contains(stdout, "\nDoe") || strings.Contains(stdout, "\nArchive") { + t.Errorf("unsafe posting text reached terminal output: %q", stdout) + } + for _, want := range []string{"Jane�]2;owned��Doe", "Receipt�]2;owned��Archive"} { + if !strings.Contains(stdout, want) { + t.Errorf("sanitized output %q does not contain %q", stdout, want) + } + } +} + func TestLabelStyledOutputSanitizesFolderNames(t *testing.T) { const unsafeName = "Receipts\x1b]2;owned\a\nArchive" stdout, err := runStyledCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/folders/navigation.go b/internal/folders/navigation.go index f17dbe36..28bb8214 100644 --- a/internal/folders/navigation.go +++ b/internal/folders/navigation.go @@ -9,8 +9,15 @@ import ( hey "github.com/basecamp/hey-sdk/go/pkg/hey" ) -// List returns the folders carried by HEY's typed navigation payload. -func List(ctx context.Context, client *hey.Client) ([]generated.Folder, error) { +// Label identifies a user-visible HEY label discovered through navigation. +type Label struct { + ID int64 `json:"id"` + Name string `json:"name"` + AppURL string `json:"app_url,omitempty"` +} + +// List returns the labels carried by HEY's typed navigation payload. +func List(ctx context.Context, client *hey.Client) ([]Label, error) { navigation, err := client.Identity().GetNavigation(ctx) if err != nil { return nil, err @@ -18,13 +25,13 @@ func List(ctx context.Context, client *hey.Client) ([]generated.Folder, error) { return FromNavigation(navigation), nil } -// FromNavigation returns each concrete folder entry from HEY's Labels navigation group. -func FromNavigation(navigation *generated.NavigationResponse) []generated.Folder { +// FromNavigation returns each concrete label entry from HEY's Labels navigation group. +func FromNavigation(navigation *generated.NavigationResponse) []Label { if navigation == nil { return nil } - var result []generated.Folder + var result []Label for _, item := range navigation.Items { if item.Icon.Name != navigationIcon && item.Title != navigationTitle { continue @@ -38,7 +45,7 @@ func FromNavigation(navigation *generated.NavigationResponse) []generated.Folder if err != nil { continue } - result = append(result, generated.Folder{Id: id, Name: entry.Title, AppUrl: entry.AppUrl}) + result = append(result, Label{ID: id, Name: entry.Title, AppURL: entry.AppUrl}) } } return result diff --git a/internal/folders/navigation_test.go b/internal/folders/navigation_test.go index 75d54a20..237e97a0 100644 --- a/internal/folders/navigation_test.go +++ b/internal/folders/navigation_test.go @@ -31,10 +31,10 @@ func TestList(t *testing.T) { if len(folders) != 2 { t.Fatalf("folders = %+v, want two", folders) } - if folders[0].Id != 12 || folders[0].Name != "Receipts" || folders[0].AppUrl != "https://app.hey.com/folders/12" { + if folders[0].ID != 12 || folders[0].Name != "Receipts" || folders[0].AppURL != "https://app.hey.com/folders/12" { t.Errorf("first folder = %+v", folders[0]) } - if folders[1].Id != 34 || folders[1].Name != "Travel" { + if folders[1].ID != 34 || folders[1].Name != "Travel" { t.Errorf("second folder = %+v", folders[1]) } } diff --git a/internal/tui/folders.go b/internal/tui/folders.go index 7f7695c2..f44ceb6d 100644 --- a/internal/tui/folders.go +++ b/internal/tui/folders.go @@ -190,6 +190,7 @@ func (p *folderPicker) view(styles styles, width int) string { case folderPickerRemoveAll: label = "− Remove all labels" } + label = truncateStr(label, max(contentWidth-lipgloss.Width(prefix), 1)) if i == p.cursor { label = styles.title.Render(label) } diff --git a/internal/tui/mail.go b/internal/tui/mail.go index bc7be5fd..52d2b6e4 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -194,6 +194,9 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { v.notice = "Could not load labels — press g to retry" } else { v.folderDiscoveryErr = "" + if v.notice == "Retrying labels…" { + v.notice = "" + } } return v.applySources(sources), true @@ -577,11 +580,15 @@ func (v *mailView) HelpBindings() []helpBinding { {"l", "reply later"}, {"a", "set aside"}, {"d", "feed"}, - {"p", "paper trail"}, - {"t", "trash"}, - {"s", "spam"}, - ignoreBinding, } + if v.currentSourceKind() != mailSourceKindFolder { + bindings = append(bindings, helpBinding{"p", "paper trail"}) + } + bindings = append(bindings, + helpBinding{"t", "trash"}, + helpBinding{"s", "spam"}, + ignoreBinding, + ) if v.currentSourceKind() == mailSourceKindFolder { if v.folderNextPage != "" { bindings = append(bindings, helpBinding{"n", "next page"}) @@ -1166,10 +1173,11 @@ func (v *mailView) openSelected() tea.Cmd { func (v *mailView) startMove() { selected := v.postingList.selectedPosting() - if selected == nil { + currentSource := v.currentSource() + if selected == nil || currentSource == nil { return } - picker := newMovePicker(*selected, v.boxes, v.currentBoxID()) + picker := newMovePicker(*selected, v.boxes, *currentSource) if len(picker.destinations) == 0 { v.notice = "No other boxes available" return @@ -1479,8 +1487,8 @@ func (v *mailView) fetchSources(requestID uint64) tea.Cmd { folders, folderErr := internalfolders.List(v.vc.ctx, v.vc.sdk) if folderErr == nil { - for _, folder := range folders { - boxes = append(boxes, models.Box{ID: folder.Id, Kind: mailSourceKindFolder, Name: folder.Name, AppURL: folder.AppUrl}) + for _, label := range folders { + boxes = append(boxes, models.Box{ID: label.ID, Kind: mailSourceKindFolder, Name: label.Name, AppURL: label.AppURL}) } } return mailSourcesLoadedMsg{requestID: requestID, sources: boxes, folderErr: folderErr} diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index a27ba088..3e92feec 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -18,6 +18,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/basecamp/hey-sdk/go/pkg/generated" hey "github.com/basecamp/hey-sdk/go/pkg/hey" @@ -469,6 +470,24 @@ func TestMailViewMovePickerMovesToSelectedBox(t *testing.T) { } } +func TestMailViewMovePickerKeepsBoxWithCollidingLabelID(t *testing.T) { + v := mailWithPostings() + v.boxes = []models.Box{ + {ID: 12, Kind: hey.BoxKindImbox, Name: "Imbox"}, + {ID: 12, Kind: mailSourceKindFolder, Name: "Receipts"}, + } + v.boxIndex = 1 + + v.HandleContentKey(keyPress("m")) + if v.movePicker == nil || len(v.movePicker.destinations) != 1 { + t.Fatalf("move destinations = %+v", v.movePicker) + } + destination := v.movePicker.destinations[0] + if destination.ID != 12 || destination.Kind != hey.BoxKindImbox { + t.Errorf("destination = %+v, want colliding Imbox", destination) + } +} + func TestMailViewMovePickerSelectsWithArrowKeys(t *testing.T) { v, recorded := mailWithTestServer(t, http.StatusNoContent) v.boxes = []models.Box{ @@ -663,6 +682,9 @@ func TestMailViewFolderDiscoveryFailurePreservesMailAndRetries(t *testing.T) { if v.folderDiscoveryErr != "" || len(v.boxes) != 2 || v.boxes[1].Name != "Receipts" { t.Errorf("recovered sources = %+v error=%q", v.boxes, v.folderDiscoveryErr) } + if v.notice != "" { + t.Errorf("successful retry left notice %q", v.notice) + } } func TestMailViewFolderDiscoveryFailurePreservesKnownFolders(t *testing.T) { @@ -815,6 +837,25 @@ func TestMailViewFolderPickerScrollsAndSanitizesNames(t *testing.T) { } } +func TestMailViewFolderPickerTruncatesLongNamesToOneRow(t *testing.T) { + posting := models.Posting{ID: 100, Summary: "Receipt"} + picker := newFolderPicker(posting, []models.Box{{ + ID: 12, Kind: mailSourceKindFolder, + Name: "A label name that is much wider than the picker", + }}) + picker.resize(24, 8) + + view := picker.view(testVC().styles, 24) + if !strings.Contains(view, "…") || strings.Contains(view, "much wider than the picker") { + t.Errorf("long label was not truncated: %q", view) + } + for _, line := range strings.Split(view, "\n") { + if width := lipgloss.Width(line); width > 20 { + t.Errorf("picker line width = %d, want at most 20: %q", width, line) + } + } +} + func TestMailViewFolderActionFailureKeepsThread(t *testing.T) { v, _ := mailWithTestServer(t, http.StatusInternalServerError) v.boxes = append(v.boxes, models.Box{ID: 12, Kind: mailSourceKindFolder, Name: "Receipts"}) @@ -2105,6 +2146,32 @@ func TestMailViewHelpBindings(t *testing.T) { } } +func TestMailViewLabelHelpUsesPOnlyForPreviousPage(t *testing.T) { + v := mailWithPostings() + v.boxes = append(v.boxes, models.Box{ID: 12, Kind: mailSourceKindFolder, Name: "Receipts"}) + v.boxIndex = len(v.boxes) - 1 + + for _, binding := range v.HelpBindings() { + if binding.key == "p" { + t.Errorf("first label page advertised p binding: %+v", binding) + } + } + + v.folderPageHistory = []string{""} + var previous, paperTrail int + for _, binding := range v.HelpBindings() { + if binding.key == "p" && binding.desc == "previous page" { + previous++ + } + if binding.key == "p" && binding.desc == "paper trail" { + paperTrail++ + } + } + if previous != 1 || paperTrail != 0 { + t.Errorf("label p bindings = previous:%d paper-trail:%d; all=%v", previous, paperTrail, v.HelpBindings()) + } +} + func TestMailViewHelpBindingsStopIgnoringForIgnoredThread(t *testing.T) { v := mailWithPostings() v.postingList.postings[0].Muted = true diff --git a/internal/tui/move.go b/internal/tui/move.go index b3fbb028..318d43c7 100644 --- a/internal/tui/move.go +++ b/internal/tui/move.go @@ -18,10 +18,11 @@ type movePicker struct { cursor int } -func newMovePicker(posting models.Posting, boxes []models.Box, currentBoxID int64) *movePicker { +func newMovePicker(posting models.Posting, boxes []models.Box, currentSource models.Box) *movePicker { destinations := make([]models.Box, 0, len(boxes)) for _, box := range boxes { - if box.Kind == mailSourceKindFolder || box.ID == currentBoxID || strings.EqualFold(box.Kind, hey.BoxKindBubbleUp) || strings.EqualFold(box.Name, "Bubble Up") { + isCurrentSource := box.ID == currentSource.ID && box.Kind == currentSource.Kind + if box.Kind == mailSourceKindFolder || isCurrentSource || strings.EqualFold(box.Kind, hey.BoxKindBubbleUp) || strings.EqualFold(box.Name, "Bubble Up") { continue } destinations = append(destinations, box) From 2d0f6276c81b42f357b9353ddeb388c10f7dc34d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 20 Aug 2026 12:14:20 -0400 Subject: [PATCH 3/4] Address automated label review findings --- internal/cmd/box.go | 3 + internal/cmd/label.go | 7 +- internal/cmd/label_test.go | 10 ++ internal/cmd/sdk.go | 2 +- internal/tui/mail.go | 8 +- internal/tui/tui.go | 3 + internal/tui/tui_test.go | 35 ++++++ tests/smoke/helpers_test.go | 5 +- tests/smoke/labels_test.go | 220 ++++++++++++++++++++++++++++++++++++ 9 files changed, 287 insertions(+), 6 deletions(-) diff --git a/internal/cmd/box.go b/internal/cmd/box.go index 9721ae0e..fd284686 100644 --- a/internal/cmd/box.go +++ b/internal/cmd/box.go @@ -90,6 +90,9 @@ func (c *boxCommand) run(cmd *cobra.Command, args []string) error { table.addRow([]string{"Thread", "From", "Summary", "Date"}) for _, p := range postings { displayID := resolvePostingTopicID(p) + if displayID == 0 { + displayID = p.Id + } table.addRow([]string{fmt.Sprintf("%d", displayID), p.Creator.Name, truncate(p.Summary, 60), formatDate(p.CreatedAt)}) } table.print() diff --git a/internal/cmd/label.go b/internal/cmd/label.go index 5d14b58b..d864a195 100644 --- a/internal/cmd/label.go +++ b/internal/cmd/label.go @@ -163,10 +163,13 @@ func (c *labelCommand) run(cmd *cobra.Command, args []string) error { table := newTable(cmd.OutOrStdout()) table.addRow([]string{"ID", "Thread", "From", "Summary", "Date"}) for _, posting := range folder.Postings { - topicID := resolvePostingTopicID(posting) + topicID := "" + if id := resolvePostingTopicID(posting); id != 0 { + topicID = fmt.Sprintf("%d", id) + } creator := terminalSafeText(posting.Creator.Name) summary := truncate(terminalSafeText(posting.Summary), 60) - table.addRow([]string{fmt.Sprintf("%d", posting.Id), fmt.Sprintf("%d", topicID), creator, summary, formatDate(posting.CreatedAt)}) + table.addRow([]string{fmt.Sprintf("%d", posting.Id), topicID, creator, summary, formatDate(posting.CreatedAt)}) } table.print() if notice != "" { diff --git a/internal/cmd/label_test.go b/internal/cmd/label_test.go index 03dc4970..6d9ea7af 100644 --- a/internal/cmd/label_test.go +++ b/internal/cmd/label_test.go @@ -138,6 +138,16 @@ func TestLabelCommandReturnsContinuation(t *testing.T) { if !ok || folder["next_page"] != "next-cursor" || folder["total_count"] != float64(3) { t.Fatalf("pagination metadata = %#v", response.Data) } + postings, ok := folder["postings"].([]any) + if !ok || len(postings) != 2 { + t.Fatalf("postings = %#v", folder["postings"]) + } + for _, raw := range postings { + posting := raw.(map[string]any) + if _, exists := posting["topic_id"]; exists { + t.Errorf("posting without a topic URL exposed topic_id: %#v", posting) + } + } if response.Notice != "Showing 2 of 3 results. Use --all to see everything." { t.Errorf("notice = %q", response.Notice) } diff --git a/internal/cmd/sdk.go b/internal/cmd/sdk.go index 2f0621b2..9197f940 100644 --- a/internal/cmd/sdk.go +++ b/internal/cmd/sdk.go @@ -179,7 +179,7 @@ func resolvePostingTopicID(p generated.Posting) int64 { return id } } - return p.Id + return 0 } // --- Calendar helpers --- diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 52d2b6e4..918e2706 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -204,7 +204,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { return v.applySources([]models.Box(msg)), true case postingsLoadedMsg: - if msg.requestID != v.activeRequestID || msg.boxID != v.currentBoxID() || (msg.sourceKind != "" && msg.sourceKind != v.currentSourceKind()) { + if !v.acceptsPostingsLoaded(msg) { return nil, true } v.finishRequest(msg.requestID) @@ -981,6 +981,12 @@ func (v *mailView) beginRequest(kind mailRequestKind) (uint64, context.Context) return v.activeRequestID, ctx } +func (v *mailView) acceptsPostingsLoaded(msg postingsLoadedMsg) bool { + return msg.requestID == v.activeRequestID && + msg.boxID == v.currentBoxID() && + (msg.sourceKind == "" || msg.sourceKind == v.currentSourceKind()) +} + func (v *mailView) finishRequest(requestID uint64) { if requestID != v.activeRequestID { return diff --git a/internal/tui/tui.go b/internal/tui/tui.go index bd0decde..6708126a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -207,6 +207,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case postingsLoadedMsg: if m.activeView != m.mailView { + if !m.mailView.acceptsPostingsLoaded(msg) { + return m, nil + } if msg.err != nil { m.mailView.finishRequest(msg.requestID) m.mailView.notice = "Could not load mail: " + msg.err.Error() diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 6cfff139..d359254f 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strconv" "strings" "testing" @@ -205,6 +206,40 @@ func TestFolderDiscoveryCompletesWhileAnotherSectionIsActive(t *testing.T) { } } +func TestInactiveMailIgnoresStalePostingErrors(t *testing.T) { + m := newModel() + m.section = sectionCalendar + m.activeView = m.calendarView + m.mailView.boxes = []models.Box{{ID: 1, Kind: hey.BoxKindImbox, Name: "Imbox"}} + m.mailView.boxIndex = 0 + m.mailView.activeRequestID = 2 + m.mailView.activeRequestKind = mailRequestPostings + m.mailView.loading = true + m.mailView.notice = "Current mail state" + + updated, cmd := m.Update(postingsLoadedMsg{ + requestID: 1, + boxID: 1, + sourceKind: hey.BoxKindImbox, + err: fmt.Errorf("stale failure"), + }) + m = updated.(model) + if cmd != nil || m.mailView.notice != "Current mail state" || !m.mailView.loading || m.mailView.activeRequestID != 2 { + t.Errorf("stale error changed inactive Mail: notice=%q loading=%v request=%d", m.mailView.notice, m.mailView.loading, m.mailView.activeRequestID) + } + + updated, cmd = m.Update(postingsLoadedMsg{ + requestID: 2, + boxID: 1, + sourceKind: hey.BoxKindImbox, + err: fmt.Errorf("current failure"), + }) + m = updated.(model) + if cmd != nil || m.mailView.notice != "Could not load mail: current failure" || m.mailView.loading { + t.Errorf("current error state = notice:%q loading:%v", m.mailView.notice, m.mailView.loading) + } +} + func TestTabCyclesFocus(t *testing.T) { m := modelWithBoxes() m.focus = rowSection diff --git a/tests/smoke/helpers_test.go b/tests/smoke/helpers_test.go index 9001d91c..b2a55752 100644 --- a/tests/smoke/helpers_test.go +++ b/tests/smoke/helpers_test.go @@ -24,6 +24,7 @@ var ( baseURL string configDir string sessionCookie string + smokeEmail string ) // Response mirrors the CLI's JSON envelope. @@ -51,7 +52,7 @@ type Breadcrumb struct { func TestMain(m *testing.M) { baseURL = envOr("HEY_SMOKE_BASE_URL", "http://app.hey.localhost:3003") - email := envOr("HEY_SMOKE_EMAIL", "david@basecamp.com") + smokeEmail = envOr("HEY_SMOKE_EMAIL", "david@basecamp.com") password := envOr("HEY_SMOKE_PASSWORD", "secret123456") // Locate the pre-built binary. @@ -76,7 +77,7 @@ func TestMain(m *testing.M) { os.Exit(1) } // Launch headless Chrome browser and log in to obtain a session cookie. - sessionCookie, err = browserLogin(baseURL, email, password) + sessionCookie, err = browserLogin(baseURL, smokeEmail, password) if err != nil { fmt.Fprintf(os.Stderr, "Browser login failed: %v\n", err) os.Exit(1) diff --git a/tests/smoke/labels_test.go b/tests/smoke/labels_test.go index 95807937..f0fdd3ee 100644 --- a/tests/smoke/labels_test.go +++ b/tests/smoke/labels_test.go @@ -1,9 +1,17 @@ package smoke_test import ( + "context" + "encoding/json" + "fmt" + "net/url" "strconv" "strings" "testing" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" ) type smokeFolder struct { @@ -37,6 +45,218 @@ func TestLabelsAndLabel(t *testing.T) { } } +func TestLabelMutations(t *testing.T) { + uid := uniqueID() + subject := fmt.Sprintf("Disposable label test %s", uid) + _, stderr, code := hey(t, "compose", + "--to", smokeEmail, + "--subject", subject, + "-m", "This disposable thread verifies label mutations.", + "--json", + ) + if code != 0 { + t.Skipf("could not create a disposable thread (exit %d): %s", code, stderr) + } + t.Cleanup(func() { cleanupThreadBySubject(t, subject) }) + + postingID, err := waitForPostingIDBySubject(t, subject) + if err != nil { + t.Fatalf("could not find disposable thread: %v", err) + } + if postingID == 0 { + t.Skip("disposable thread did not appear in Imbox") + } + + labelName := "Smoke label " + uid + _, stderr, code = hey(t, "label", "create", labelName, strconv.FormatInt(postingID, 10), "--json") + if code != 0 { + t.Skipf("label creation unavailable (exit %d): %s", code, stderr) + } + t.Cleanup(func() { cleanupLabelByName(t, labelName, postingID) }) + + labelID, err := findLabelIDByName(t, labelName) + if err != nil { + t.Fatalf("could not list created label: %v", err) + } + if labelID == 0 { + t.Fatalf("created label %q was not listed", labelName) + } + + assertLabelContainsPosting(t, labelID, postingID, true) + labelWriteJSON(t, "label", "remove", strconv.FormatInt(postingID, 10), "--from", strconv.FormatInt(labelID, 10)) + assertLabelContainsPosting(t, labelID, postingID, false) + labelWriteJSON(t, "label", "add", strconv.FormatInt(postingID, 10), "--to", strconv.FormatInt(labelID, 10)) + assertLabelContainsPosting(t, labelID, postingID, true) +} + +func labelWriteJSON(t *testing.T, args ...string) Response { + t.Helper() + args = append(args, "--json") + stdout, stderr, code := hey(t, args...) + if code != 0 { + t.Skipf("label write unavailable (exit %d): %s", code, stderr) + } + var response Response + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("invalid label response: %v: %s", err, stdout) + } + return response +} + +func assertLabelContainsPosting(t *testing.T, labelID, postingID int64, want bool) { + t.Helper() + label := dataAs[struct { + Postings []struct { + ID int64 `json:"id"` + } `json:"postings"` + }](t, heyJSON(t, "label", strconv.FormatInt(labelID, 10), "--all")) + found := false + for _, item := range label.Postings { + if item.ID == postingID { + found = true + break + } + } + if found != want { + t.Errorf("label %d contains posting %d = %v, want %v", labelID, postingID, found, want) + } +} + +func waitForPostingIDBySubject(t *testing.T, subject string) (int64, error) { + t.Helper() + var lastErr error + for range 10 { + postingID, err := findPostingIDBySubject(t, subject) + if err == nil && postingID != 0 { + return postingID, nil + } + lastErr = err + time.Sleep(500 * time.Millisecond) + } + return 0, lastErr +} + +func findPostingIDBySubject(t *testing.T, subject string) (int64, error) { + t.Helper() + stdout, stderr, code := hey(t, "box", "imbox", "--all", "--json") + if code != 0 { + return 0, fmt.Errorf("list Imbox (exit %d): %s", code, stderr) + } + var response Response + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + return 0, fmt.Errorf("decode Imbox response: %w", err) + } + var box struct { + Postings []struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"postings"` + } + if err := json.Unmarshal(response.Data, &box); err != nil { + return 0, fmt.Errorf("decode Imbox data: %w", err) + } + for _, posting := range box.Postings { + if posting.Name == subject { + return posting.ID, nil + } + } + return 0, nil +} + +func findLabelIDByName(t *testing.T, name string) (int64, error) { + t.Helper() + stdout, stderr, code := hey(t, "labels", "--all", "--json") + if code != 0 { + return 0, fmt.Errorf("list labels (exit %d): %s", code, stderr) + } + var response Response + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + return 0, fmt.Errorf("decode labels response: %w", err) + } + var labels []smokeFolder + if err := json.Unmarshal(response.Data, &labels); err != nil { + return 0, fmt.Errorf("decode labels data: %w", err) + } + for _, label := range labels { + if label.Name == name { + return label.ID, nil + } + } + return 0, nil +} + +func cleanupThreadBySubject(t *testing.T, subject string) { + t.Helper() + postingID, err := waitForPostingIDBySubject(t, subject) + if err != nil { + t.Errorf("could not locate disposable thread %q for cleanup: %v", subject, err) + return + } + if postingID == 0 { + t.Errorf("disposable thread %q remained unresolved during cleanup", subject) + return + } + if _, stderr, code := hey(t, "trash", strconv.FormatInt(postingID, 10), "--json"); code != 0 { + t.Errorf("could not trash disposable thread %d (exit %d): %s", postingID, code, stderr) + } +} + +func cleanupLabelByName(t *testing.T, name string, postingID int64) { + t.Helper() + labelID, err := findLabelIDByName(t, name) + if err != nil { + t.Errorf("could not locate disposable label %q for cleanup: %v", name, err) + return + } + if labelID == 0 { + return + } + if _, stderr, code := hey(t, "label", "remove", strconv.FormatInt(postingID, 10), "--from", "all", "--json"); code != 0 { + t.Errorf("could not remove disposable label filings (exit %d): %s", code, stderr) + } + + var deleteErr error + for range 2 { + deleteErr = deleteLabelInBrowser(t, labelID) + remainingID, listErr := findLabelIDByName(t, name) + if listErr == nil && remainingID == 0 { + return + } + if listErr != nil { + deleteErr = listErr + } + } + t.Errorf("disposable label %q (%d) remains after cleanup: %v", name, labelID, deleteErr) +} + +func deleteLabelInBrowser(t *testing.T, labelID int64) error { + t.Helper() + ctx, ctxCancel, allocCancel := newBrowserContext() + defer ctxCancel() + defer allocCancel() + + tCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + parsed, err := url.Parse(baseURL) + if err != nil { + return fmt.Errorf("parse base URL: %w", err) + } + selector := fmt.Sprintf(`form[action$="/folders/%d"] button`, labelID) + if err := chromedp.Run(tCtx, + network.SetCookie("session_token", sessionCookie). + WithDomain(parsed.Hostname()). + WithPath("/"). + WithHTTPOnly(true), + chromedp.Navigate(baseURL+"/folders"), + chromedp.WaitVisible(selector, chromedp.ByQuery), + chromedp.Click(selector, chromedp.ByQuery), + chromedp.WaitNotPresent(selector, chromedp.ByQuery), + ); err != nil { + return fmt.Errorf("delete label %d in browser: %w", labelID, err) + } + return nil +} + func TestLabelMutationValidation(t *testing.T) { for _, args := range [][]string{ {"label", "add", "101"}, From 824032f3df00b0a32f226ed447450241d4f4a521 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 20 Aug 2026 12:28:19 -0400 Subject: [PATCH 4/4] Harden Markdown and label smoke checks --- internal/output/writer.go | 24 +++++++++++++++++++++++- internal/output/writer_test.go | 14 ++++++++++++-- tests/smoke/labels_test.go | 5 +++-- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/internal/output/writer.go b/internal/output/writer.go index e87a20c9..13fb2bf9 100644 --- a/internal/output/writer.go +++ b/internal/output/writer.go @@ -359,11 +359,33 @@ func (w *Writer) writeMarkdown(data any) error { func markdownCell(value string) string { value = sanitizeTerminal(value) - value = strings.ReplaceAll(value, "|", `\|`) + value = escapeMarkdownTablePipes(value) value = strings.ReplaceAll(value, "\t", " ") return strings.ReplaceAll(value, "\n", "
") } +func escapeMarkdownTablePipes(value string) string { + var escaped strings.Builder + for index := 0; index < len(value); { + start := index + for index < len(value) && value[index] == '\\' { + index++ + } + if index < len(value) && value[index] == '|' { + escaped.WriteString(strings.Repeat(`\`, 2*(index-start)+1)) + escaped.WriteByte('|') + index++ + continue + } + escaped.WriteString(value[start:index]) + if index < len(value) { + escaped.WriteByte(value[index]) + index++ + } + } + return escaped.String() +} + func isTTY(w io.Writer) bool { if f, ok := w.(*os.File); ok { return term.IsTerminal(int(f.Fd())) //nolint:gosec // G115: fd fits in int on all supported platforms diff --git a/internal/output/writer_test.go b/internal/output/writer_test.go index 6ab74a35..7967d495 100644 --- a/internal/output/writer_test.go +++ b/internal/output/writer_test.go @@ -305,7 +305,7 @@ func TestWriterOK_MarkdownSanitizesTerminalControlsAndLayout(t *testing.T) { var buf bytes.Buffer w := New(Options{Format: FormatMarkdown, Stdout: &buf}) - data := []map[string]any{{"name": "Receipts\x1b]2;owned\a\nArchive|2026\tQ3"}} + data := []map[string]any{{"name": "Receipts\x1b]2;owned\a\nArchive|2026\tQ3 " + `Path\|Receipts`}} if err := w.OK(data); err != nil { t.Fatal(err) } @@ -314,11 +314,21 @@ func TestWriterOK_MarkdownSanitizesTerminalControlsAndLayout(t *testing.T) { if strings.Contains(output, "\x1b") || strings.Contains(output, "\a") || strings.Contains(output, "\nArchive") { t.Errorf("unsafe controls reached markdown output: %q", output) } - if !strings.Contains(output, "
Archive\\|2026 Q3") { + if !strings.Contains(output, `
Archive\|2026 Q3 Path\\\|Receipts`) { t.Errorf("sanitized markdown value missing from %q", output) } } +func TestEscapeMarkdownTablePipesPreservesBackslashes(t *testing.T) { + for backslashes := range 4 { + input := strings.Repeat(`\`, backslashes) + "|" + want := strings.Repeat(`\`, 2*backslashes+1) + "|" + if got := escapeMarkdownTablePipes(input); got != want { + t.Errorf("escapeMarkdownTablePipes(%q) = %q, want %q", input, got, want) + } + } +} + func TestWriterErr_JSON(t *testing.T) { var buf bytes.Buffer w := New(Options{Format: FormatJSON, Stderr: &buf}) diff --git a/tests/smoke/labels_test.go b/tests/smoke/labels_test.go index f0fdd3ee..24a74089 100644 --- a/tests/smoke/labels_test.go +++ b/tests/smoke/labels_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "html" "net/url" "strconv" "strings" @@ -39,8 +40,8 @@ func TestLabelsAndLabel(t *testing.T) { t.Errorf("label detail = %+v, want %+v", data, folder) } - html := fetchHTML(t, baseURL+"/folders") - if !strings.Contains(html, folder.Name) { + page := html.UnescapeString(fetchHTML(t, baseURL+"/folders")) + if !strings.Contains(page, folder.Name) { t.Errorf("folder page does not contain %q", folder.Name) } }