Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?"
Expand All @@ -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
```

Expand Down
4 changes: 3 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <thread-id>` 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 <next_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 <next_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.
Expand Down
79 changes: 71 additions & 8 deletions internal/cmd/box.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand All @@ -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
Expand All @@ -65,8 +68,8 @@ func newBoxCommand() *boxCommand {
func newBoxViewCommand() *boxCommand {
return newBoxReaderCommand(
"view <name|id>",
"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
Expand All @@ -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 <id>, and every thread with that sender via hey contact threads <contact-id>. --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 <id>, and every thread with that sender via hey contact threads <contact-id>. --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")

Expand Down Expand Up @@ -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.
Expand Down
108 changes: 94 additions & 14 deletions internal/cmd/box_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"sync/atomic"
"testing"

"github.com/basecamp/hey-sdk/go/pkg/generated"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
})
Expand All @@ -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." {
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/canonical_commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading