From 3b21789e40040e57bf59730af17b44faaeb776c6 Mon Sep 17 00:00:00 2001 From: Ellis Givens Date: Wed, 9 Sep 2026 19:26:41 -0500 Subject: [PATCH] Distinguish HEY World posts from email Preserve each box row's kind and report separate email and HEY World counts in box output. Carry the kind into the TUI so published World posts cannot be opened or passed to email filing actions.\n\nKeep the CLI action verbs ID-only, with no caller-supplied kind flags or World deletion surface. --- README.md | 4 +- docs/cli.md | 4 +- internal/cmd/box.go | 79 +++++++++++++++-- internal/cmd/box_test.go | 108 +++++++++++++++++++++--- internal/cmd/canonical_commands_test.go | 2 +- internal/cmd/help_test.go | 2 +- internal/cmd/postings_listing.go | 23 ++++- internal/mail/posting.go | 14 +++ internal/mail/posting_test.go | 14 ++- internal/tui/mail.go | 12 +++ internal/tui/mail_test.go | 31 +++++++ skills/hey/SKILL.md | 9 ++ tests/smoke/boxes_test.go | 24 ++++-- tests/smoke/ignore_test.go | 18 +++- tests/smoke/seen_test.go | 35 ++++++-- 15 files changed, 331 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 0c27d406..14c1888a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ screen a moment later. ## Using the CLI ```bash -hey box view imbox # threads in a box +hey box view imbox # email and HEY World items in a box hey thread read 12345 # a whole thread, as Markdown hey reply 12345 -m "Friday works for me." hey compose --to alice@example.com --subject "Lunch?" -m "Thursday at noon?" @@ -78,7 +78,7 @@ Piped, a command that returns data writes JSON, and `--jq` filters it without a `jq`: ```bash -hey box view imbox --jq '.data.postings[] | {topic_id, subject}' +hey box view imbox --jq '.data.postings[] | {id, kind, topic_id, subject}' hey label view 789 --ids-only # one ID per line, for xargs ``` diff --git a/docs/cli.md b/docs/cli.md index 830e78bf..057102f3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -154,7 +154,7 @@ or through the direct-form escape (`hey box -- list`). ```bash hey box list # list mailboxes -hey box view imbox # list email threads in a box (by name or ID) +hey box view imbox # list email and HEY World items (by box name or ID) hey bundle view 456 # list the unseen threads a bundle row groups hey label list # list labels and their IDs hey label view 789 --all # list all email threads with a label @@ -275,6 +275,8 @@ The Screener is where first-time senders wait. `hey screener list` returns clear `--attach` is repeatable on `hey compose`, `hey reply`, and `hey bulk-reply send`, and attachment-only messages are supported. The CLI validates and uploads every file before sending the email. `hey attachment list ` returns every named downloadable file, including named inline images. Direct files keep stable message-and-position IDs such as `456:1`; files inside embedded HTML receive opaque IDs scoped to their message. Pass either returned ID to `hey attachment save`. Saving uses the original filename by default, accepts `--output` for a file or directory, and preserves existing files unless `--force` is set. +`hey box view --json` preserves every row's `kind`. A `world/post` row is published HEY World content, not email; preserve that kind while selecting IDs and never pass its `id` to email organization actions, because those commands receive bare IDs and cannot infer the kind. The response metadata reports `posting_count`, `email_count`, and `world_post_count`, and the summary names email and World counts separately when both are present. + Organization actions take the `id` values returned by `hey box view --json`, `hey label view --json`, or `hey search --json`. Reading, replying to, and forwarding a thread take its `topic_id` instead, which `hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry alongside `id`. `hey box view` also returns `next_page` and accepts `--page ` to continue a box listing; it keeps `next_history_url` for the sync clients that read it, and `--page` accepts that URL as readily as the cursor inside it. Label IDs come from `hey label list`; `hey label view` returns `next_page` and `total_count`, accepts `--page ` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs. Collection IDs come from `hey collection list`. `hey collection view` returns both each posting `id` and its `topic_id`, plus `next_page` and `total_count`. Collection membership commands take `topic_id`; posting organization commands continue to take `id`. Creating a collection returns a confirmed mutation, and `hey collection list` provides its ID for subsequent commands. Collection updates accept a non-empty name, summary, or both. diff --git a/internal/cmd/box.go b/internal/cmd/box.go index db7ffd7c..e1d27965 100644 --- a/internal/cmd/box.go +++ b/internal/cmd/box.go @@ -33,8 +33,11 @@ type boxOutput struct { } var boxListing = postingsListing{ - heading: "Box", - summary: boxSummary, + heading: "Box", + summary: boxSummary, + summarize: boxPostingSummary, + metadata: boxPostingMetadata, + showSummary: true, cursorNotice: func(shown, total int) string { return fmt.Sprintf("Showing %d remaining results from this cursor (%d threads read).", shown, total) }, @@ -49,8 +52,8 @@ var boxListing = postingsListing{ func newBoxCommand() *boxCommand { command := newBoxReaderCommand( "box", - "List HEY boxes and their email threads", - "List HEY boxes or list email threads in one box.", + "List HEY boxes and their items", + "List HEY boxes, or list email threads and HEY World posts in one box.", ` hey box list hey box view imbox hey box view imbox --limit 10 @@ -65,8 +68,8 @@ func newBoxCommand() *boxCommand { func newBoxViewCommand() *boxCommand { return newBoxReaderCommand( "view ", - "List email threads in a box", - "List email threads in a HEY box. Accepts a box name (imbox, feedbox, etc.) or numeric ID.", + "List email and HEY World items in a box", + "List email threads and HEY World posts in a box. Accepts a box name (imbox, feedbox, etc.) or numeric ID.", ` hey box view imbox hey box view imbox --limit 10 hey box view imbox --page next-cursor @@ -81,14 +84,14 @@ func newBoxReaderCommand(use, short, long, example string) *boxCommand { Short: short, Long: long, Annotations: map[string]string{ - "agent_notes": "Accepts a box name or numeric ID. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view , and every thread with that sender via hey contact threads . --page continues from the next_page cursor of an earlier listing of the same box.", + "agent_notes": "Accepts a box name or numeric ID. Returns email threads and HEY World posts; preserve each row's kind and never pass a world/post ID to email actions. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view , and every thread with that sender via hey contact threads . --page continues from the next_page cursor of an earlier listing of the same box.", }, Example: example, RunE: command.run, Args: validateBoxArgs, } - command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of threads to show") + command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of items to show") command.cmd.Flags().BoolVar(&command.all, "all", false, "Fetch all results (override --limit)") command.cmd.Flags().StringVar(&command.page, "page", "", "Continue from a next_page cursor") @@ -129,6 +132,66 @@ func boxSummary(count int, name string) string { return fmt.Sprintf("%d %s in %s", count, threadNoun(count), name) } +type boxPostingCounts struct { + postings int + emails int + worldPosts int +} + +func countBoxPostings(postings []generated.Posting) boxPostingCounts { + counts := boxPostingCounts{postings: len(postings)} + for _, posting := range postings { + if mail.IsWorldPostKind(posting.Kind) { + counts.worldPosts++ + continue + } + counts.emails++ + } + return counts +} + +func boxPostingSummary(postings []generated.Posting, boxName string) string { + return countBoxPostings(postings).summary(boxName) +} + +func boxPostingMetadata(postings []generated.Posting) []output.ResponseOption { + counts := countBoxPostings(postings) + return []output.ResponseOption{ + output.WithMeta("posting_count", counts.postings), + output.WithMeta("email_count", counts.emails), + output.WithMeta("world_post_count", counts.worldPosts), + } +} + +func (c boxPostingCounts) summary(boxName string) string { + emails := countPhrase(c.emails, "email", "emails") + if c.worldPosts == 0 { + return fmt.Sprintf("%s in %s", emails, boxName) + } + + worldPosts := countPhrase(c.worldPosts, "HEY World post", "HEY World posts") + if c.emails == 0 { + return fmt.Sprintf("%s in %s", worldPosts, boxName) + } + return fmt.Sprintf("%s and %s in %s", emails, worldPosts, boxName) +} + +func countPhrase(count int, singular, plural string) string { + noun := plural + if count == 1 { + noun = singular + } + return fmt.Sprintf("%s %s", formatCount(count), noun) +} + +func formatCount(count int) string { + digits := strconv.Itoa(count) + for i := len(digits) - 3; i > 0; i -= 3 { + digits = digits[:i] + "," + digits[i:] + } + return digits +} + // boxPayload answers with the box HEY served, its postings replaced by the ones the // listing read and its cursor by the one the next read carries on from. next_page is that // cursor on its own, which is what --page takes; next_history_url keeps the whole URL. diff --git a/internal/cmd/box_test.go b/internal/cmd/box_test.go index d64c8ddc..cdcc5db0 100644 --- a/internal/cmd/box_test.go +++ b/internal/cmd/box_test.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "testing" + "github.com/basecamp/hey-sdk/go/pkg/generated" "github.com/spf13/cobra" ) @@ -59,6 +60,19 @@ func TestValidateBoxArgs(t *testing.T) { } } +func TestBoxViewHelpUsesMixedItemTerminology(t *testing.T) { + command := newBoxViewCommand().cmd + if command.Short != "List email and HEY World items in a box" { + t.Errorf("short help = %q", command.Short) + } + if usage := command.Flags().Lookup("limit").Usage; usage != "Maximum number of items to show" { + t.Errorf("--limit help = %q", usage) + } + if notes := command.Annotations["agent_notes"]; !strings.Contains(notes, "world/post") { + t.Errorf("agent notes omit the World-post boundary: %q", notes) + } +} + func TestBoxCommandNamedRoutes(t *testing.T) { tests := []struct { name string @@ -91,7 +105,7 @@ func TestBoxCommandNamedRoutes(t *testing.T) { if requests.Load() != 1 { t.Errorf("requests = %d, want one named lookup", requests.Load()) } - if response.Summary != "0 threads in "+tt.name { + if response.Summary != "0 emails in "+tt.name { t.Errorf("summary = %q", response.Summary) } }) @@ -111,7 +125,7 @@ func TestBoxCommandNumericIDAndLimit(t *testing.T) { if err != nil { t.Fatalf("execute box: %v", err) } - if response.Summary != "1 thread in Receipts" { + if response.Summary != "1 email in Receipts" { t.Errorf("summary = %q", response.Summary) } if response.Notice != "Showing 1 of 2 results. Use --all to see everything." { @@ -149,7 +163,7 @@ func TestBoxCommandUnknownNameFallsBackToList(t *testing.T) { if got, want := fmt.Sprint(requests), "[GET /boxes.json GET /boxes/17.json]"; got != want { t.Errorf("requests = %s, want %s", got, want) } - if response.Summary != "0 threads in Receipts" { + if response.Summary != "0 emails in Receipts" { t.Errorf("summary = %q", response.Summary) } } @@ -195,7 +209,7 @@ func TestBoxCommandFollowsPagesOnTheNamedRoute(t *testing.T) { if got := fmt.Sprint(requests); got != want { t.Errorf("requests = %s, want %s", got, want) } - if response.Summary != "2 threads in The Feed" { + if response.Summary != "2 emails in The Feed" { t.Errorf("summary = %q", response.Summary) } } @@ -241,7 +255,7 @@ func TestBoxCommandFollowsPagesForACustomBox(t *testing.T) { if got := fmt.Sprint(requests); got != want { t.Errorf("requests = %s, want %s", got, want) } - if response.Summary != "2 threads in Receipts" { + if response.Summary != "2 emails in Receipts" { t.Errorf("summary = %q", response.Summary) } data, _ := response.Data.(map[string]any) @@ -288,7 +302,7 @@ func TestBoxCommandStopsAtAnEmptyPage(t *testing.T) { if requests.Load() != 2 { t.Errorf("requests = %d, want two", requests.Load()) } - if response.Summary != "1 thread in Imbox" || response.Notice != "" { + if response.Summary != "1 email in Imbox" || response.Notice != "" { t.Errorf("summary = %q notice = %q", response.Summary, response.Notice) } } @@ -309,19 +323,85 @@ func TestBoxSummaryUsesThreadTerminology(t *testing.T) { count int want string }{ - {"one thread", 1, "1 thread in Imbox"}, - {"multiple threads", 2, "2 threads in Imbox"}, + {name: "one thread", count: 1, want: "1 thread in Imbox"}, + {name: "multiple threads", count: 2, want: "2 threads in Imbox"}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := boxSummary(tt.count, "Imbox"); got != tt.want { - t.Errorf("boxSummary(%d) = %q, want %q", tt.count, got, tt.want) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := boxSummary(test.count, "Imbox"); got != test.want { + t.Errorf("boxSummary(%d) = %q, want %q", test.count, got, test.want) } }) } } +func TestBoxPostingCountsAndSummary(t *testing.T) { + postings := make([]generated.Posting, 2944) + for i := range postings { + postings[i] = generated.Posting{Id: int64(i + 1), Kind: "topic"} + } + for i := 0; i < 21; i++ { + postings = append(postings, generated.Posting{Id: int64(3000 + i), Kind: "world/post"}) + } + + counts := countBoxPostings(postings) + if counts.postings != 2965 || counts.emails != 2944 || counts.worldPosts != 21 { + t.Fatalf("counts = %+v", counts) + } + if got := counts.summary("Imbox"); got != "2,944 emails and 21 HEY World posts in Imbox" { + t.Errorf("summary = %q", got) + } +} + +func TestBoxMixedPostingKindsJSONContract(t *testing.T) { + resp, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/imbox.json" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id": 1, + "kind": "imbox", + "name": "Imbox", + "postings": [ + {"id": 101, "kind": "topic", "summary": "Project update"}, + {"id": 102, "kind": "world/post", "summary": "Published note"} + ] + }`)) + }), "box", "view", "imbox") + if err != nil { + t.Fatalf("execute: %v", err) + } + if resp.Summary != "1 email and 1 HEY World post in Imbox" { + t.Errorf("summary = %q", resp.Summary) + } + if got := resp.Meta["posting_count"]; got != float64(2) { + t.Errorf("posting_count = %v, want 2", got) + } + if got := resp.Meta["email_count"]; got != float64(1) { + t.Errorf("email_count = %v, want 1", got) + } + if got := resp.Meta["world_post_count"]; got != float64(1) { + t.Errorf("world_post_count = %v, want 1", got) + } + + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("data type = %T, want map[string]any", resp.Data) + } + postings, ok := data["postings"].([]any) + if !ok || len(postings) != 2 { + t.Fatalf("postings = %#v, want 2 entries", data["postings"]) + } + first, _ := postings[0].(map[string]any) + second, _ := postings[1].(map[string]any) + if first["kind"] != "topic" || second["kind"] != "world/post" { + t.Errorf("posting kinds = %q, %q", first["kind"], second["kind"]) + } +} + // The thread ID is the point of a listing: whatever `hey box --json` calls topic_id is // what `hey thread read` reads, and the box item ID is not. func TestBoxCommandCarriesAThreadIDThatThreadsReads(t *testing.T) { @@ -416,7 +496,7 @@ func TestBoxCommandContinuesFromAPageCursor(t *testing.T) { if err != nil { t.Fatalf("execute box --page %s: %v", page, err) } - if response.Summary != "1 thread in Imbox" { + if response.Summary != "1 email in Imbox" { t.Errorf("summary = %q", response.Summary) } } @@ -452,7 +532,7 @@ func TestBoxCommandOutputFormats(t *testing.T) { if err != nil { t.Fatalf("styled box: %v", err) } - for _, want := range []string{"Box: Imbox (imbox)", "Thread", "Jane Doe", "Studio invoice", "101", "501"} { + for _, want := range []string{"Box: Imbox (imbox)", "Thread", "Jane Doe", "Studio invoice", "101", "501", "2 emails in Imbox."} { if !strings.Contains(styled, want) { t.Errorf("styled output %q does not contain %q", styled, want) } diff --git a/internal/cmd/canonical_commands_test.go b/internal/cmd/canonical_commands_test.go index 41d5d0c7..3061e7b9 100644 --- a/internal/cmd/canonical_commands_test.go +++ b/internal/cmd/canonical_commands_test.go @@ -116,7 +116,7 @@ func TestBoxListIsReservedAndViewStillOpensABoxNamedList(t *testing.T) { if err != nil { t.Fatal(err) } - if viewed.Summary != "0 threads in list" { + if viewed.Summary != "0 emails in list" { t.Errorf("box view list summary = %q", viewed.Summary) } diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index a09d6a83..a56654aa 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -106,7 +106,7 @@ USAGE CORE COMMANDS tui Launch the interactive terminal UI - box List HEY boxes and their email threads + box List HEY boxes and their items thread Read email threads reply Reply to a thread compose Write and send a new email diff --git a/internal/cmd/postings_listing.go b/internal/cmd/postings_listing.go index 61a547cd..4dbebacb 100644 --- a/internal/cmd/postings_listing.go +++ b/internal/cmd/postings_listing.go @@ -68,6 +68,9 @@ type groupedPostingRow struct { type postingsListing struct { heading string summary func(count int, name string) string + summarize func(postings []generated.Posting, name string) string + metadata func(postings []generated.Posting) []output.ResponseOption + showSummary bool cursorNotice func(shown, total int) string breadcrumbs []output.Breadcrumb payload func(source mail.Source, postings []sourcePostingOutput, nextPage string, total int) any @@ -113,12 +116,23 @@ func (l postingsListing) writePages(cmd *cobra.Command, source mail.Source, firs case output.FormatMarkdown: return l.writeMarkdown(cmd, source, postings, nextPage, collected.Total, notice) default: - return writeOK(l.sourcePayload(source, postings, nextPage, collected.Total), - output.WithSummary(l.summary(len(postings), source.Name)), + options := []output.ResponseOption{ + output.WithSummary(l.summaryFor(postings, source.Name)), output.WithNotice(notice), output.WithBreadcrumbs(l.breadcrumbs...), - ) + } + if l.metadata != nil { + options = append(options, l.metadata(postings)...) + } + return writeOK(l.sourcePayload(source, postings, nextPage, collected.Total), options...) + } +} + +func (l postingsListing) summaryFor(postings []generated.Posting, name string) string { + if l.summarize != nil { + return l.summarize(postings, name) } + return l.summary(len(postings), name) } // sourcePayload is what `--json` answers with: the listing's own payload where it has one, @@ -181,6 +195,9 @@ func (l postingsListing) writeStyled(cmd *cobra.Command, source mail.Source, pos )) } table.print() + if l.showSummary { + fmt.Fprintln(cmd.OutOrStdout(), terminal.SanitizeLine(l.summaryFor(postings, source.Name))+".") + } if notice != "" { fmt.Fprintln(cmd.OutOrStdout(), notice) } diff --git a/internal/mail/posting.go b/internal/mail/posting.go index 6e31b949..0fea06e0 100644 --- a/internal/mail/posting.go +++ b/internal/mail/posting.go @@ -21,6 +21,7 @@ type Posting struct { // so the box a thread files out of is the row's own rather than the list's. BoxID int64 TopicID int64 + Kind string CreatedAt time.Time Name string Summary string @@ -79,6 +80,7 @@ func NewPosting(posting generated.Posting) Posting { ID: posting.Id, BoxID: posting.BoxId, TopicID: TopicIDOf(posting), + Kind: terminal.SanitizeLine(posting.Kind), CreatedAt: posting.CreatedAt, Name: terminal.SanitizeLine(posting.Name), Summary: terminal.SanitizeLine(posting.Summary), @@ -95,6 +97,18 @@ func NewPosting(posting generated.Posting) Posting { } } +// IsWorldPost reports whether this row is published HEY World content rather than +// an email thread. +func (p Posting) IsWorldPost() bool { + return IsWorldPostKind(p.Kind) +} + +// IsWorldPostKind reports whether HEY's posting discriminator names published +// HEY World content. +func IsWorldPostKind(kind string) bool { + return strings.EqualFold(strings.TrimSpace(kind), "world/post") +} + func contactOf(contact generated.Contact) Contact { return Contact{ ID: contact.Id, diff --git a/internal/mail/posting_test.go b/internal/mail/posting_test.go index f1a3e4e6..c14fac8b 100644 --- a/internal/mail/posting_test.go +++ b/internal/mail/posting_test.go @@ -33,7 +33,7 @@ func TestNewPostingKeepsWhatARowShows(t *testing.T) { if !posting.CreatedAt.Equal(created) { t.Errorf("created at = %s, want %s", posting.CreatedAt, created) } - if posting.ID != 4471829 || posting.TopicID != 501 || posting.Name != "Kitchen remodel quote" { + if posting.ID != 4471829 || posting.TopicID != 501 || posting.Kind != "topic" || posting.Name != "Kitchen remodel quote" { t.Errorf("posting = %+v", posting) } // The box is the row's own, not the list's: it is what a thread found through a @@ -62,6 +62,18 @@ func TestNewPostingKeepsWhatARowShows(t *testing.T) { } } +func TestPostingIdentifiesWorldContentByKind(t *testing.T) { + for _, kind := range []string{"world/post", " WORLD/POST "} { + posting := NewPosting(generated.Posting{Kind: kind}) + if !posting.IsWorldPost() { + t.Errorf("kind %q was not identified as HEY World content", kind) + } + } + if NewPosting(generated.Posting{Kind: "topic"}).IsWorldPost() { + t.Error("an email topic was identified as HEY World content") + } +} + // The zone HEY served is the reader's own, and it decides which day a late-evening thread // falls on. Normalizing it away is the bug that made `hey journal list` print yesterday. func TestNewPostingKeepsTheZoneHEYServed(t *testing.T) { diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 2a634ade..47eff9f0 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -2090,6 +2090,10 @@ func (v *mailView) openSelected() tea.Cmd { if selected == nil { return nil } + if selected.IsWorldPost() { + v.notice = "HEY World posts are published content; email thread actions are unavailable" + return nil + } // A bundle names a topic only when it holds one unseen thread — otherwise its row // opens the bundle itself: the unseen threads while there are any, or every thread // with its contact once it has been read through, which is where the web app sends @@ -2159,6 +2163,10 @@ func (v *mailView) startMove() { if selected == nil || currentSource == nil { return } + if selected.IsWorldPost() { + v.notice = "HEY World posts cannot be moved with email actions" + return + } picker := newMovePicker(*selected, v.boxes, *currentSource) if len(picker.destinations) == 0 { v.notice = "No other boxes available" @@ -2432,6 +2440,10 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { if selected == nil { return nil } + if selected.IsWorldPost() { + v.notice = "HEY World posts are published content; email actions are unavailable" + return nil + } return v.postingAction(key, *selected, v.postingBoxKind(*selected)) } diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 8da197f8..84abc11f 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -1901,6 +1901,37 @@ func TestMailViewPostingKeyFailureKeepsPosting(t *testing.T) { } } +func TestMailViewRejectsWorldPostEmailActionsWithoutRequests(t *testing.T) { + tests := []struct { + name string + key string + }{ + {name: "open", key: "enter"}, + {name: "move picker", key: "v"}, + {name: "move shortcut", key: "d"}, + {name: "trash", key: "t"}, + {name: "spam", key: "!"}, + {name: "ignore", key: "-"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.postingList.postings = []mail.Posting{{ID: 900, Kind: "world/post", Summary: "Published note"}} + v.postingList.cursor = 0 + + if cmd := v.HandleContentKey(keyPress(test.key)); cmd != nil { + t.Fatalf("world post action %q returned a command", test.key) + } + if len(recorded.requests) != 0 { + t.Fatalf("world post action %q made requests: %v", test.key, recorded.requests) + } + if !strings.Contains(v.notice, "HEY World") { + t.Fatalf("world post action %q notice = %q", test.key, v.notice) + } + }) + } +} + func TestMailViewPostingActionCopiesSelectedPostingBeforeAsyncRequest(t *testing.T) { v, recorded := mailWithTestServer(t, http.StatusNoContent) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 747ffe67..6a87b20a 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -123,6 +123,7 @@ CLI for HEY: mailboxes, labels, collections, email threads, contacts, replies, c 3. **HTML output** is available via `--html` for commands that return HTML content 4. **Linked mail accounts share one login** — use `hey account list --json`, then `--account ` when a task must target one account 5. **Local HEY configuration requires human trust** — never run `hey config trust-local` without the user's explicit approval +6. **Preserve item kind** from `hey box view --json` — `world/post` is published HEY World content, not email, and must not be passed to email-only actions ## Output Filtering @@ -580,6 +581,14 @@ hey spam 12345 67890 # Mark multiple threads as spam Takes box item IDs (the `id` field from `hey box view --json`). Trashing a shared thread removes your access instead of deleting it for everyone. Marking a thread as spam moves it to Spam and trains HEY's filters. +### HEY World + +`hey box view --json` can return `world/post` items beside email topics. These are +published content, not email. The response metadata reports `posting_count`, +`email_count`, and `world_post_count`. Preserve each row's `kind` and exclude +`world/post` rows before passing box item IDs to email organization commands; those +commands receive bare IDs and cannot validate the kind themselves. + ### Email - Ignoring Threads ```bash diff --git a/tests/smoke/boxes_test.go b/tests/smoke/boxes_test.go index f177021c..e4eec2ce 100644 --- a/tests/smoke/boxes_test.go +++ b/tests/smoke/boxes_test.go @@ -58,6 +58,7 @@ func TestBoxImbox(t *testing.T) { type Posting struct { ID int `json:"id"` AppURL string `json:"app_url"` + Kind string `json:"kind"` Summary string `json:"summary"` } type BoxResponse struct { @@ -70,6 +71,11 @@ func TestBoxImbox(t *testing.T) { if data.Kind != "imbox" { t.Errorf("expected kind=imbox, got %s", data.Kind) } + for _, posting := range data.Postings { + if posting.Kind == "" { + t.Errorf("posting %d has no kind", posting.ID) + } + } // Cross-verify: pick a posting and verify its topic page exists on the server. if len(data.Postings) > 0 { @@ -174,8 +180,9 @@ func TestBoxesAll(t *testing.T) { func TestMovePosting(t *testing.T) { resp := heyJSON(t, "box", "imbox", "--limit", "10") type Posting struct { - ID int `json:"id"` - Seen bool `json:"seen"` + ID int `json:"id"` + Kind string `json:"kind"` + Seen bool `json:"seen"` } type BoxResponse struct { Postings []Posting `json:"postings"` @@ -183,19 +190,24 @@ func TestMovePosting(t *testing.T) { imbox := dataAs[BoxResponse](t, resp) postingID := 0 for _, posting := range imbox.Postings { - if posting.Seen { + if posting.Kind == "topic" && posting.Seen { postingID = posting.ID break } } if postingID == 0 { - if len(imbox.Postings) == 0 { - skipf(t, "no postings in Imbox to move") + for _, posting := range imbox.Postings { + if posting.Kind == "topic" { + postingID = posting.ID + break + } + } + if postingID == 0 { + skipf(t, "no email topics in Imbox to move") } // No seen posting to borrow: mark one seen and put its unread // state back afterwards. The restore runs after the move-back // cleanup below, so the posting is home before it goes unseen. - postingID = imbox.Postings[0].ID heyOK(t, "seen", intStr(postingID), "--json") t.Cleanup(func() { _, cleanupStderr, cleanupCode := hey(t, "unseen", intStr(postingID), "--json") diff --git a/tests/smoke/ignore_test.go b/tests/smoke/ignore_test.go index aadc0a16..f2501ca5 100644 --- a/tests/smoke/ignore_test.go +++ b/tests/smoke/ignore_test.go @@ -6,8 +6,9 @@ import ( ) type ignorePosting struct { - ID int `json:"id"` - Muted bool `json:"muted"` + ID int `json:"id"` + Kind string `json:"kind"` + Muted bool `json:"muted"` } func TestIgnoreAndStopIgnoring(t *testing.T) { @@ -19,7 +20,18 @@ func TestIgnoreAndStopIgnoring(t *testing.T) { if len(box.Postings) == 0 { skipf(t, "no threads in Imbox to ignore") } - posting := box.Postings[0] + var posting ignorePosting + found := false + for _, candidate := range box.Postings { + if candidate.Kind == "topic" { + posting = candidate + found = true + break + } + } + if !found { + t.Skip("no email topics in Imbox to ignore") + } postingID := intStr(posting.ID) if posting.Muted { diff --git a/tests/smoke/seen_test.go b/tests/smoke/seen_test.go index 421ac416..5c5abc29 100644 --- a/tests/smoke/seen_test.go +++ b/tests/smoke/seen_test.go @@ -12,6 +12,7 @@ func TestSeenUnseen(t *testing.T) { type Posting struct { ID int `json:"id"` AppURL string `json:"app_url"` + Kind string `json:"kind"` } type BoxResp struct { Postings []Posting `json:"postings"` @@ -21,7 +22,18 @@ func TestSeenUnseen(t *testing.T) { t.Fatal("no postings in imbox to test seen/unseen") } - posting := data.Postings[0] + var posting Posting + found := false + for _, candidate := range data.Postings { + if candidate.Kind == "topic" { + posting = candidate + found = true + break + } + } + if !found { + t.Skip("no email topics in Imbox to test seen/unseen") + } postingID := intStr(posting.ID) // Mark as unseen. @@ -53,20 +65,27 @@ func TestSeenUnseen(t *testing.T) { func TestSeenMultiple(t *testing.T) { resp := heyJSON(t, "box", "imbox") type Posting struct { - ID int `json:"id"` + ID int `json:"id"` + Kind string `json:"kind"` } type BoxResp struct { Postings []Posting `json:"postings"` } data := dataAs[BoxResp](t, resp) - if len(data.Postings) < 2 { - t.Fatal("need at least 2 postings to test multi-seen") + var topicIDs []string + for _, posting := range data.Postings { + if posting.Kind == "topic" { + topicIDs = append(topicIDs, intStr(posting.ID)) + if len(topicIDs) == 2 { + break + } + } + } + if len(topicIDs) < 2 { + t.Fatal("need at least 2 email topics to test multi-seen") } - id1 := intStr(data.Postings[0].ID) - id2 := intStr(data.Postings[1].ID) - - stdout := heyOK(t, "seen", id1, id2, "--json") + stdout := heyOK(t, "seen", topicIDs[0], topicIDs[1], "--json") var resp2 Response if err := json.Unmarshal([]byte(stdout), &resp2); err != nil { t.Fatalf("failed to parse response: %v", err)