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 @@ -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
Expand 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
Expand Down Expand Up @@ -154,5 +156,6 @@ hey todo list --all
hey todo list --limit
hey todo uncomplete
hey trash
hey trash --kind
hey tui
hey unseen
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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 <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 --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

Expand Down
22 changes: 22 additions & 0 deletions internal/cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
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 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
}
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 @@ -15,8 +15,9 @@ import (
)

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

func newMoveCommand() *moveCommand {
Expand All @@ -25,17 +26,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 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
}
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/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 <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 <box> --json` or `hey search --json`.",
)
}
}
}
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)
}
})
}
}
12 changes: 7 additions & 5 deletions internal/cmd/trash.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import (
)

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

func newTrashCommand() *trashCommand {
Expand All @@ -18,14 +19,15 @@ func newTrashCommand() *trashCommand {
Use: "trash <id>...",
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
}
Expand Down
Loading