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
3 changes: 3 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ hey habit edit --name
hey habit uncomplete
hey habit uncomplete --date
hey ignore
hey ignore --kind
hey journal
hey journal list
hey journal list --all
Expand Down Expand Up @@ -178,6 +179,7 @@ hey login --no-browser
hey login --token
hey logout
hey move
hey move --kind
hey move --to
hey recordings
hey recordings --all
Expand Down Expand Up @@ -275,6 +277,7 @@ hey todo list --all
hey todo list --limit
hey todo uncomplete
hey trash
hey trash --kind
hey tui
hey tui --instance
hey tui --remote
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,11 +400,11 @@ hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org
hey drafts # list drafts
hey seen 12345 # mark a thread as seen
hey unseen 12345 67890 # mark threads as unseen
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
```

Expand All @@ -424,7 +424,7 @@ 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 attachments <topic_id>` 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 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.
Organization actions take the `id` values returned by `hey box view --json`, `hey label view --json`, or `hey search --json`. For box and label results, select a record whose `kind` is exactly `topic`; search returns only email threads and does not include a `kind` field. `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. 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
2 changes: 1 addition & 1 deletion internal/cmd/box.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ var boxListing = postingsListing{
},
breadcrumbs: []output.Breadcrumb{
{Action: "read", Command: "hey threads <topic-id>", Description: "Read an email thread"},
{Action: "move", Command: "hey move <id> --to <box>", Description: "Move an email thread to another box"},
{Action: "move", Command: "hey move <id> --to <box> --kind topic", Description: "Move an email thread to another box"},
{Action: "compose", Command: "hey compose --to <email> --subject <subject>", Description: "Compose a new message"},
},
}
Expand Down
12 changes: 12 additions & 0 deletions internal/cmd/box_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ func TestValidateBoxArgs(t *testing.T) {
}
}

func TestBoxListingMoveBreadcrumbIncludesEmailKind(t *testing.T) {
for _, breadcrumb := range boxListing.breadcrumbs {
if breadcrumb.Action == "move" {
if breadcrumb.Command != "hey move <id> --to <box> --kind topic" {
t.Fatalf("move breadcrumb = %q", breadcrumb.Command)
}
return
}
}
t.Fatal("move breadcrumb not found")
}

func TestBoxCommandNamedRoutes(t *testing.T) {
tests := []struct {
name string
Expand Down
29 changes: 29 additions & 0 deletions internal/cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,35 @@ 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)",
"hey box view <box> --json",
"hey label view <label> --json",
"kind=topic",
"hey search --json returns email threads",
"--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"})
Expand Down
12 changes: 7 additions & 5 deletions internal/cmd/ignore.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import (
)

type ignoreCommand struct {
cmd *cobra.Command
cmd *cobra.Command
kind string
}

func newIgnoreCommand() *ignoreCommand {
Expand All @@ -18,14 +19,15 @@ func newIgnoreCommand() *ignoreCommand {
Use: "ignore <id>...",
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 view 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). In hey box view <box> --json or hey label view <label> --json, select records with kind=topic. hey search --json returns email threads; pass each result's id. Always 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
}
Expand Down
27 changes: 18 additions & 9 deletions internal/cmd/ignore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
Expand All @@ -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")
}
})
Expand Down
16 changes: 9 additions & 7 deletions internal/cmd/move.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import (
)

type moveCommand struct {
cmd *cobra.Command
to string
cmd *cobra.Command
to string
kind string
}

func newMoveCommand() *moveCommand {
Expand All @@ -26,17 +27,18 @@ func newMoveCommand() *moveCommand {
Use: "move <id>...",
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 view 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). In hey box view <box> --json or hey label view <label> --json, select records with kind=topic. hey search --json returns email threads; pass each result's id. Always 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
}
Expand Down
14 changes: 7 additions & 7 deletions internal/cmd/move_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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")
}
Expand Down
37 changes: 37 additions & 0 deletions internal/cmd/posting_kind.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

"github.com/basecamp/hey-cli/internal/apierr"
)

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 apierr.ErrUsageHint(
"--kind is required for email thread actions",
"Use a kind=topic record from `hey box view <box> --json` or `hey label view <label> --json`, or an email result from `hey search --json`; pass `--kind topic`.",
)
case "topic":
return nil
case "world/post":
return apierr.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 apierr.ErrUsageHint(
fmt.Sprintf("hey %s only manages email threads; unsupported kind %q", cmd.Name(), *kind),
"Use a kind=topic record from `hey box view <box> --json` or `hey label view <label> --json`, or an email result from `hey search --json`; pass `--kind topic`.",
)
}
}
}
52 changes: 52 additions & 0 deletions internal/cmd/posting_kind_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading