From 8285afa8051b88d10ab7fb96c4e1fe0a553796ab Mon Sep 17 00:00:00 2001 From: ivankuznetsov Date: Thu, 20 Aug 2026 17:29:10 +0100 Subject: [PATCH] fix: restrict posting actions to email threads --- .surface | 3 ++ README.md | 10 +++--- internal/cmd/help_test.go | 22 +++++++++++++ internal/cmd/ignore.go | 12 ++++--- internal/cmd/ignore_test.go | 27 ++++++++++------ internal/cmd/move.go | 16 +++++----- internal/cmd/move_test.go | 14 ++++----- internal/cmd/posting_kind.go | 37 ++++++++++++++++++++++ internal/cmd/posting_kind_test.go | 52 +++++++++++++++++++++++++++++++ internal/cmd/trash.go | 12 ++++--- internal/cmd/trash_test.go | 27 ++++++++++------ skills/hey/SKILL.md | 32 +++++++++---------- 12 files changed, 201 insertions(+), 63 deletions(-) create mode 100644 internal/cmd/posting_kind.go create mode 100644 internal/cmd/posting_kind_test.go diff --git a/.surface b/.surface index 00e6c63f..9cc3db52 100644 --- a/.surface +++ b/.surface @@ -97,6 +97,7 @@ hey habit complete --date hey habit uncomplete hey habit uncomplete --date hey ignore +hey ignore --kind hey journal hey journal list hey journal list --all @@ -105,6 +106,7 @@ hey journal read hey journal write hey journal write --content hey move +hey move --kind hey move --to hey recordings hey recordings --all @@ -154,5 +156,6 @@ hey todo list --all hey todo list --limit hey todo uncomplete hey trash +hey trash --kind hey tui hey unseen diff --git a/README.md b/README.md index 75d2c7ca..fd5e4317 100644 --- a/README.md +++ b/README.md @@ -143,11 +143,11 @@ hey compose --to user@example.com --subject "Hello" # compose a new message hey compose --to user@example.com --subject "Report" -m "Attached." --attach ./report.pdf hey compose --to user@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello" # with CC/BCC hey drafts # list drafts -hey move 12345 --to feed # move a thread to another box -hey move 12345 67890 --to "paper trail" # move multiple threads -hey trash 12345 # move a thread to Trash +hey move 12345 --to feed --kind topic # move an email thread to another box +hey move 12345 67890 --to "paper trail" --kind topic # move multiple email threads +hey trash 12345 --kind topic # move an email thread to Trash hey spam 12345 # mark a thread as spam -hey ignore 12345 # ignore future activity on a thread +hey ignore 12345 --kind topic # ignore future activity on an email thread hey stop-ignoring 12345 # resume attention for a thread ``` @@ -159,7 +159,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` or `hey search --json`. `move`, `trash`, and `ignore` only manage email threads and require the exact flag `--kind topic`; missing and non-email kinds are rejected before setup or any request. The CLI does not manage HEY World posts. 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/internal/cmd/help_test.go b/internal/cmd/help_test.go index 045d5019..039b2acc 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -51,6 +51,28 @@ func TestEmailCommandHelpKeepsPostingAsAnInternalTerm(t *testing.T) { } } +func TestEmailActionAgentNotesUseBoxItemIDs(t *testing.T) { + root := newRootCmd() + for _, name := range []string{"move", "trash", "ignore"} { + t.Run(name, func(t *testing.T) { + command, _, err := root.Find([]string{name}) + if err != nil { + t.Fatal(err) + } + + notes := command.Annotations["agent_notes"] + for _, want := range []string{"box item IDs (id)", "kind=topic", "--kind topic"} { + if !strings.Contains(notes, want) { + t.Errorf("%s agent notes missing %q: %s", name, want, notes) + } + } + if strings.Contains(notes, "topic IDs") { + t.Errorf("%s agent notes confuse topic_id with id: %s", name, notes) + } + }) + } +} + func TestContactCommandHelpUsesHEYTerminology(t *testing.T) { root := newRootCmd() contacts, _, err := root.Find([]string{"contacts"}) diff --git a/internal/cmd/ignore.go b/internal/cmd/ignore.go index 262da5a4..581e1969 100644 --- a/internal/cmd/ignore.go +++ b/internal/cmd/ignore.go @@ -9,7 +9,8 @@ import ( ) type ignoreCommand struct { - cmd *cobra.Command + cmd *cobra.Command + kind string } func newIgnoreCommand() *ignoreCommand { @@ -18,14 +19,15 @@ func newIgnoreCommand() *ignoreCommand { Use: "ignore ...", Short: "Ignore email threads", Long: "Ignore one or more email threads so new replies do not bring them back to your attention.", - Example: ` hey ignore 12345 - hey ignore 12345 67890`, + Example: ` hey ignore 12345 --kind topic + hey ignore 12345 67890 --kind topic`, Annotations: map[string]string{ - "agent_notes": "Accepts one or more box item IDs from hey box output. Ignored threads remain in their box and can be restored with hey stop-ignoring.", + "agent_notes": "Accepts one or more box item IDs (id) from hey box or hey search output. For hey box, select kind=topic records. Pass --kind topic. Ignored threads remain in their box and can be restored with hey stop-ignoring.", }, RunE: ignoreCommand.run, - Args: usageMinOneArg(), + Args: emailPostingArgs(&ignoreCommand.kind, usageMinOneArg()), } + ignoreCommand.cmd.Flags().StringVar(&ignoreCommand.kind, "kind", "", "Email thread kind; must be topic (required)") return ignoreCommand } diff --git a/internal/cmd/ignore_test.go b/internal/cmd/ignore_test.go index 03eacc78..7e4683db 100644 --- a/internal/cmd/ignore_test.go +++ b/internal/cmd/ignore_test.go @@ -87,12 +87,13 @@ func TestIgnoreAndStopIgnoring(t *testing.T) { command string method string args []string + wantIDs []int64 summary string }{ - {"ignore one", "ignore", http.MethodPost, []string{"12345"}, "1 thread ignored"}, - {"ignore multiple", "ignore", http.MethodPost, []string{"12345", "67890"}, "2 threads ignored"}, - {"stop ignoring one", "stop-ignoring", http.MethodDelete, []string{"12345"}, "Stopped ignoring 1 thread"}, - {"stop ignoring multiple", "stop-ignoring", http.MethodDelete, []string{"12345", "67890"}, "Stopped ignoring 2 threads"}, + {"ignore one", "ignore", http.MethodPost, []string{"12345", "--kind", "topic"}, []int64{12345}, "1 thread ignored"}, + {"ignore multiple", "ignore", http.MethodPost, []string{"12345", "67890", "--kind", "topic"}, []int64{12345, 67890}, "2 threads ignored"}, + {"stop ignoring one", "stop-ignoring", http.MethodDelete, []string{"12345"}, []int64{12345}, "Stopped ignoring 1 thread"}, + {"stop ignoring multiple", "stop-ignoring", http.MethodDelete, []string{"12345", "67890"}, []int64{12345, 67890}, "Stopped ignoring 2 threads"}, } for _, tt := range tests { @@ -105,10 +106,10 @@ func TestIgnoreAndStopIgnoring(t *testing.T) { if recorded.method != tt.method || recorded.path != "/postings/mutings.json" { t.Errorf("request = %s %s, want %s /postings/mutings.json", recorded.method, recorded.path, tt.method) } - if len(recorded.postingIDs) != len(tt.args) { - t.Fatalf("posting_ids = %v, want %d IDs", recorded.postingIDs, len(tt.args)) + if len(recorded.postingIDs) != len(tt.wantIDs) { + t.Fatalf("posting_ids = %v, want %d IDs", recorded.postingIDs, len(tt.wantIDs)) } - for i, want := range []int64{12345, 67890}[:len(tt.args)] { + for i, want := range tt.wantIDs { if recorded.postingIDs[i] != want { t.Errorf("posting_ids[%d] = %d, want %d", i, recorded.postingIDs[i], want) } @@ -139,7 +140,11 @@ func TestIgnoreAndStopIgnoringRejectInvalidIDsBeforeRequest(t *testing.T) { for _, command := range []string{"ignore", "stop-ignoring"} { t.Run(command, func(t *testing.T) { server, recorded := ignoringServer(t) - _, err := runIgnoring(t, server, command, "not-an-id") + args := []string{"not-an-id"} + if command == "ignore" { + args = append(args, "--kind", "topic") + } + _, err := runIgnoring(t, server, command, args...) var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != "usage" { t.Fatalf("invalid ID should produce a usage error, got %v", err) @@ -156,7 +161,11 @@ func TestIgnoreAndStopIgnoringReportServerFailures(t *testing.T) { t.Run(command, func(t *testing.T) { server, recorded := ignoringServer(t) recorded.status = http.StatusUnprocessableEntity - if _, err := runIgnoring(t, server, command, "12345"); err == nil { + args := []string{"12345"} + if command == "ignore" { + args = append(args, "--kind", "topic") + } + if _, err := runIgnoring(t, server, command, args...); err == nil { t.Fatal("server failure should be reported") } }) diff --git a/internal/cmd/move.go b/internal/cmd/move.go index 9661852a..a03e0432 100644 --- a/internal/cmd/move.go +++ b/internal/cmd/move.go @@ -15,8 +15,9 @@ import ( ) type moveCommand struct { - cmd *cobra.Command - to string + cmd *cobra.Command + to string + kind string } func newMoveCommand() *moveCommand { @@ -25,17 +26,18 @@ func newMoveCommand() *moveCommand { Use: "move ...", Short: "Move email threads to another box", Long: "Move one or more email threads to Imbox, The Feed, Set Aside, Reply Later, or Paper Trail.", - Example: ` hey move 12345 --to feed - hey move 12345 67890 --to "paper trail" - hey move 12345 --to 987`, + Example: ` hey move 12345 --to feed --kind topic + hey move 12345 67890 --to "paper trail" --kind topic + hey move 12345 --to 987 --kind topic`, Annotations: map[string]string{ - "agent_notes": "Accepts box item IDs from hey box output. --to accepts a box name, kind, or ID. Use HEY's scheduled Bubble Up flow for Bubble Up.", + "agent_notes": "Accepts one or more box item IDs (id) from hey box or hey search output. For hey box, select kind=topic records. Pass --kind topic. --to accepts a box name, kind, or ID. Use HEY's scheduled Bubble Up flow for Bubble Up.", }, RunE: moveCommand.run, - Args: usageMinOneArg(), + Args: emailPostingArgs(&moveCommand.kind, usageMinOneArg()), } moveCommand.cmd.Flags().StringVar(&moveCommand.to, "to", "", "Destination box name, kind, or ID (required)") + moveCommand.cmd.Flags().StringVar(&moveCommand.kind, "kind", "", "Email thread kind; must be topic (required)") return moveCommand } diff --git a/internal/cmd/move_test.go b/internal/cmd/move_test.go index 010cca2f..ad7b0772 100644 --- a/internal/cmd/move_test.go +++ b/internal/cmd/move_test.go @@ -80,7 +80,7 @@ func runMove(t *testing.T, server *httptest.Server, args ...string) (output.Resp func TestMovePostingsToNamedBox(t *testing.T) { server, recorded := moveServer(t) - resp, err := runMove(t, server, "12345", "67890", "--to", "paper-trail") + resp, err := runMove(t, server, "12345", "67890", "--to", "paper-trail", "--kind", "topic") if err != nil { t.Fatalf("move failed: %v", err) } @@ -118,7 +118,7 @@ func TestMoveDestinationAliases(t *testing.T) { for _, tt := range tests { t.Run(tt.to, func(t *testing.T) { server, recorded := moveServer(t) - if _, err := runMove(t, server, "12345", "--to", tt.to); err != nil { + if _, err := runMove(t, server, "12345", "--to", tt.to, "--kind", "topic"); err != nil { t.Fatalf("move failed: %v", err) } if recorded.boxID != tt.boxID { @@ -132,7 +132,7 @@ func TestMoveRejectsBubbleUp(t *testing.T) { for _, to := range []string{"bubble", "Bubble Up", "bubblebox", "6"} { t.Run(to, func(t *testing.T) { server, recorded := moveServer(t) - _, err := runMove(t, server, "12345", "--to", to) + _, err := runMove(t, server, "12345", "--to", to, "--kind", "topic") var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != "usage" { t.Fatalf("Bubble Up should produce a usage error, got %v", err) @@ -147,7 +147,7 @@ func TestMoveRejectsBubbleUp(t *testing.T) { func TestMoveRequiresDestination(t *testing.T) { server, recorded := moveServer(t) - _, err := runMove(t, server, "12345") + _, err := runMove(t, server, "12345", "--kind", "topic") var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != "usage" { t.Fatalf("missing destination should produce a usage error, got %v", err) @@ -160,7 +160,7 @@ func TestMoveRequiresDestination(t *testing.T) { func TestMoveRejectsInvalidPostingIDBeforeRequests(t *testing.T) { server, recorded := moveServer(t) - _, err := runMove(t, server, "not-an-id", "--to", "feed") + _, err := runMove(t, server, "not-an-id", "--to", "feed", "--kind", "topic") var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != "usage" { t.Fatalf("invalid posting should produce a usage error, got %v", err) @@ -173,7 +173,7 @@ func TestMoveRejectsInvalidPostingIDBeforeRequests(t *testing.T) { func TestMoveRejectsUnknownDestination(t *testing.T) { server, recorded := moveServer(t) - _, err := runMove(t, server, "12345", "--to", "archive") + _, err := runMove(t, server, "12345", "--to", "archive", "--kind", "topic") var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != "not_found" { t.Fatalf("unknown destination should produce a not-found error, got %v", err) @@ -187,7 +187,7 @@ func TestMoveReportsServerFailure(t *testing.T) { server, recorded := moveServer(t) recorded.moveStatus = http.StatusUnprocessableEntity - _, err := runMove(t, server, "12345", "--to", "feed") + _, err := runMove(t, server, "12345", "--to", "feed", "--kind", "topic") if err == nil { t.Fatal("move should report the server failure") } diff --git a/internal/cmd/posting_kind.go b/internal/cmd/posting_kind.go new file mode 100644 index 00000000..50bf811c --- /dev/null +++ b/internal/cmd/posting_kind.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/output" +) + +func emailPostingArgs(kind *string, positional cobra.PositionalArgs) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if err := positional(cmd, args); err != nil { + return err + } + + switch *kind { + case "": + return output.ErrUsageHint( + "--kind is required for email thread actions", + "Pass `--kind topic` from `hey box --json` or `hey search --json`.", + ) + case "topic": + return nil + case "world/post": + return output.ErrUsageHint( + fmt.Sprintf("hey %s does not manage HEY World posts", cmd.Name()), + "hey-cli only manages email threads. Pass `--kind topic` for an email thread.", + ) + default: + return output.ErrUsageHint( + fmt.Sprintf("hey %s only manages email threads; unsupported kind %q", cmd.Name(), *kind), + "Pass `--kind topic` from `hey box --json` or `hey search --json`.", + ) + } + } +} diff --git a/internal/cmd/posting_kind_test.go b/internal/cmd/posting_kind_test.go new file mode 100644 index 00000000..94b8e295 --- /dev/null +++ b/internal/cmd/posting_kind_test.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEmailPostingActionsRejectNonEmailKindsBeforeSetup(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "move missing kind", args: []string{"move", "12345", "--to", "feed"}, want: "--kind is required"}, + {name: "move World post", args: []string{"move", "12345", "--to", "feed", "--kind", "world/post"}, want: "HEY World"}, + {name: "trash other kind", args: []string{"trash", "12345", "--kind", "calendar/event"}, want: "only manages email threads"}, + {name: "ignore World post", args: []string{"ignore", "12345", "--kind", "world/post"}, want: "HEY World"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HEY_TOKEN", "test-token") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + + configDir := filepath.Join(tmpDir, "hey-cli") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("create config directory: %v", err) + } + if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte("{"), 0600); err != nil { + t.Fatalf("write invalid config: %v", err) + } + + root := newRootCmd() + root.SetArgs(tt.args) + err := root.Execute() + if err == nil { + t.Fatal("expected email kind validation error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("error = %q, want %q", err, tt.want) + } + if strings.Contains(err.Error(), "parse config") { + t.Errorf("kind validation ran after root setup: %v", err) + } + }) + } +} diff --git a/internal/cmd/trash.go b/internal/cmd/trash.go index 52bb2271..a16bfd40 100644 --- a/internal/cmd/trash.go +++ b/internal/cmd/trash.go @@ -9,7 +9,8 @@ import ( ) type trashCommand struct { - cmd *cobra.Command + cmd *cobra.Command + kind string } func newTrashCommand() *trashCommand { @@ -18,14 +19,15 @@ func newTrashCommand() *trashCommand { Use: "trash ...", Short: "Move email threads to Trash", Long: "Move one or more email threads to Trash. For a shared thread, HEY removes your access instead of deleting it for everyone.", - Example: ` hey trash 12345 - hey trash 12345 67890`, + Example: ` hey trash 12345 --kind topic + hey trash 12345 67890 --kind topic`, Annotations: map[string]string{ - "agent_notes": "Accepts one or more box item IDs from hey box output. Shared threads lose your access rather than being deleted for everyone.", + "agent_notes": "Accepts one or more box item IDs (id) from hey box or hey search output. For hey box, select kind=topic records. Pass --kind topic. Shared threads lose your access rather than being deleted for everyone.", }, RunE: trashCommand.run, - Args: usageMinOneArg(), + Args: emailPostingArgs(&trashCommand.kind, usageMinOneArg()), } + trashCommand.cmd.Flags().StringVar(&trashCommand.kind, "kind", "", "Email thread kind; must be topic (required)") return trashCommand } diff --git a/internal/cmd/trash_test.go b/internal/cmd/trash_test.go index abc46c68..b632eb00 100644 --- a/internal/cmd/trash_test.go +++ b/internal/cmd/trash_test.go @@ -74,13 +74,14 @@ func TestTrashAndSpam(t *testing.T) { name string command string args []string + wantIDs []int64 path string summary string }{ - {"trash one", "trash", []string{"12345"}, "/postings/trash.json", "1 thread moved to Trash"}, - {"trash multiple", "trash", []string{"12345", "67890"}, "/postings/trash.json", "2 threads moved to Trash"}, - {"spam one", "spam", []string{"12345"}, "/postings/spam.json", "1 thread marked as spam"}, - {"spam multiple", "spam", []string{"12345", "67890"}, "/postings/spam.json", "2 threads marked as spam"}, + {"trash one", "trash", []string{"12345", "--kind", "topic"}, []int64{12345}, "/postings/trash.json", "1 thread moved to Trash"}, + {"trash multiple", "trash", []string{"12345", "67890", "--kind", "topic"}, []int64{12345, 67890}, "/postings/trash.json", "2 threads moved to Trash"}, + {"spam one", "spam", []string{"12345"}, []int64{12345}, "/postings/spam.json", "1 thread marked as spam"}, + {"spam multiple", "spam", []string{"12345", "67890"}, []int64{12345, 67890}, "/postings/spam.json", "2 threads marked as spam"}, } for _, tt := range tests { @@ -93,10 +94,10 @@ func TestTrashAndSpam(t *testing.T) { if recorded.method != http.MethodPost || recorded.path != tt.path { t.Errorf("request = %s %s, want POST %s", recorded.method, recorded.path, tt.path) } - if len(recorded.postingIDs) != len(tt.args) { - t.Fatalf("posting_ids = %v, want %d IDs", recorded.postingIDs, len(tt.args)) + if len(recorded.postingIDs) != len(tt.wantIDs) { + t.Fatalf("posting_ids = %v, want %d IDs", recorded.postingIDs, len(tt.wantIDs)) } - for i, want := range []int64{12345, 67890}[:len(tt.args)] { + for i, want := range tt.wantIDs { if recorded.postingIDs[i] != want { t.Errorf("posting_ids[%d] = %d, want %d", i, recorded.postingIDs[i], want) } @@ -127,7 +128,11 @@ func TestTrashAndSpamRejectInvalidIDsBeforeRequest(t *testing.T) { for _, command := range []string{"trash", "spam"} { t.Run(command, func(t *testing.T) { server, recorded := removalServer(t) - _, err := runRemoval(t, server, command, "not-an-id") + args := []string{"not-an-id"} + if command == "trash" { + args = append(args, "--kind", "topic") + } + _, err := runRemoval(t, server, command, args...) var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != "usage" { t.Fatalf("invalid ID should produce a usage error, got %v", err) @@ -144,7 +149,11 @@ func TestTrashAndSpamReportServerFailures(t *testing.T) { t.Run(command, func(t *testing.T) { server, recorded := removalServer(t) recorded.status = http.StatusUnprocessableEntity - if _, err := runRemoval(t, server, command, "12345"); err == nil { + args := []string{"12345"} + if command == "trash" { + args = append(args, "--kind", "topic") + } + if _, err := runRemoval(t, server, command, args...); err == nil { t.Fatal("server failure should be reported") } }) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index acdfa342..6f32d7ed 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -153,10 +153,10 @@ hey boxes --quiet --jq '.[].name' | Delete todo | `hey todo delete 123` | | Mark as seen | `hey seen 12345` | | Mark as unseen | `hey unseen 12345` | -| Move email threads | `hey move 12345 --to feed` | -| Move email threads to Trash | `hey trash 12345` | +| Move email threads | `hey move 12345 --to feed --kind topic` | +| Move email threads to Trash | `hey trash 12345 --kind topic` | | Mark email threads as spam | `hey spam 12345` | -| Ignore email threads | `hey ignore 12345` | +| Ignore email threads | `hey ignore 12345 --kind topic` | | Stop ignoring email threads | `hey stop-ignoring 12345` | | Complete habit | `hey habit complete 123` | | Uncomplete habit | `hey habit uncomplete 123` | @@ -185,10 +185,10 @@ Want to read email? ├── Read full thread? → hey threads --json ├── Mark as seen? → hey seen ├── Mark as unseen? → hey unseen -├── Move to another box? → hey move --to -├── Move to Trash? → hey trash +├── Move to another box? → select a kind=topic item, then hey move --to --kind topic +├── Move to Trash? → select a kind=topic item, then hey trash --kind topic ├── Mark as spam? → hey spam -├── Ignore future activity? → hey ignore +├── Ignore future activity? → select a kind=topic item, then hey ignore --kind topic ├── Stop ignoring? → hey stop-ignoring └── Launch interactive UI? → hey (no args, launches TUI) ``` @@ -235,7 +235,7 @@ 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), `kind`, `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`. Before `move`, `trash`, or `ignore`, select a record whose `kind` is exactly `topic` and pass `--kind topic`; the CLI does not manage HEY World posts. Use `topic_id` for `hey threads`, `hey reply`, and `hey forward`. ### Email - Search @@ -324,33 +324,33 @@ Takes box item IDs (the `id` field from `hey box` output). ### Email - Moving Threads ```bash -hey move 12345 --to imbox # Move one thread -hey move 12345 67890 --to "paper trail" # Move multiple threads +hey move 12345 --to imbox --kind topic # Move one email thread +hey move 12345 67890 --to "paper trail" --kind topic # Move multiple email threads ``` -Takes box item IDs (the `id` field from `hey box --json`). `--to` accepts a box name, kind, or ID. Supported destinations are Imbox, The Feed, Set Aside, Reply Later, and Paper Trail. Bubble Up requires a scheduled date and is not supported by this command. +Takes box item IDs from records whose `kind` is exactly `topic` (the `id` and `kind` fields from `hey box --json`). Pass `--kind topic`; missing and non-email kinds are rejected before setup. `--to` accepts a box name, kind, or ID. Supported destinations are Imbox, The Feed, Set Aside, Reply Later, and Paper Trail. Bubble Up requires a scheduled date and is not supported by this command. ### Email - Trash and Spam ```bash -hey trash 12345 # Move one thread to Trash -hey trash 12345 67890 # Move multiple threads to Trash +hey trash 12345 --kind topic # Move one email thread to Trash +hey trash 12345 67890 --kind topic # Move multiple email threads to Trash hey spam 12345 # Mark one thread as spam hey spam 12345 67890 # Mark multiple threads as spam ``` -Takes box item IDs (the `id` field from `hey box --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. +Takes box item IDs (the `id` field from `hey box --json`). `trash` requires `--kind topic` and rejects every other kind; `spam` is unchanged. 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. ### Email - Ignoring Threads ```bash -hey ignore 12345 # Ignore one thread -hey ignore 12345 67890 # Ignore multiple threads +hey ignore 12345 --kind topic # Ignore one email thread +hey ignore 12345 67890 --kind topic # Ignore multiple email threads hey stop-ignoring 12345 # Stop ignoring one thread hey stop-ignoring 12345 67890 # Stop ignoring multiple threads ``` -Takes box item IDs (the `id` field from `hey box --json`). Ignored threads remain in their box; new replies do not bring them back to your attention. `hey stop-ignoring` reverses the action. +Takes box item IDs (the `id` field from `hey box --json`). `ignore` requires `--kind topic` and rejects every other kind; `stop-ignoring` is unchanged. Ignored threads remain in their box; new replies do not bring them back to your attention. `hey stop-ignoring` reverses the action. ### Drafts