Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .surface
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,6 +73,7 @@ hey seen
hey setup
hey skill
hey skill install
hey spam
hey threads
hey timetrack
hey timetrack current
Expand All@@ -91,5 +92,6 @@ hey todo list
hey todo list --all
hey todo list --limit
hey todo uncomplete
hey trash
hey tui
hey unseen
2 changes: 2 additions & 0 deletions API-COVERAGE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,8 @@ The legacy `internal/client/` is used only for HTML-scraping gap operations mark
| `/topics/{id}.json` | GET | SDK `Topics().Get` | `hey forward <topic-id>` | covered |
| `/entries/{id}/forwards/new.json` | GET | SDK `Entries().NewForward` | `hey forward <topic-id>` | covered |
| `/postings/moves.json` | POST | SDK `Postings().Move` | `hey move <id> --to <box>`, TUI `m` | covered |
| `/postings/trash.json` | POST | SDK `Postings().MoveToTrash` | `hey trash <id>`, TUI `t` | covered |
| `/postings/spam.json` | POST | SDK `Postings().MarkSpam` | `hey spam <id>`, TUI `s` | covered |
| `/calendar/days/{date}/habits/{id}/completions.json` | POST | SDK `Habits().Complete` | `hey habit complete <id>` | covered |
| `/calendar/days/{date}/habits/{id}/completions.json` | DELETE | SDK `Habits().Uncomplete` | `hey habit uncomplete <id>` | covered |
| `/calendar/days/{date}/journal_entry.json` | GET | SDK `Journal().Get` | `hey journal read [date]` | partial: falls back to legacy |
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ hey auth logout # clear credentials

Run `hey` to launch the interactive terminal UI.

Navigate between mailboxes and email threads. Use Enter to open a thread, `r` to reply, `f` to forward, `m` to move a thread, and Escape or `q` to go back.
Navigate between mailboxes and email threads. Use Enter to open a thread, `r` to reply, `f` to forward, `m` to move, `t` to trash, `s` to mark as spam, and Escape or `q` to go back.

## CLI Commands

Expand All@@ -77,9 +77,11 @@ hey compose --to user@example.com --cc bob@example.com --bcc carol@example.org -
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 spam 12345 # mark a thread as spam
```

`hey move` takes the `id` values returned by `hey box --json`. 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`.
`hey move`, `hey trash`, and `hey spam` take the `id` values returned by `hey box --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.

### Calendars

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/help.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,7 +17,7 @@ var curatedCategories = []struct {
}{
{
heading: "EMAIL",
names: []string{"boxes", "box", "threads", "compose", "reply", "forward", "drafts", "seen", "unseen", "move"},
names: []string{"boxes", "box", "threads", "compose", "reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam"},
},
{
heading: "CALENDAR & TASKS",
Expand Down
4 changes: 3 additions & 1 deletion internal/cmd/help_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@ func TestCuratedCommandHelpUsesUserFacingLanguage(t *testing.T) {

func TestEmailCommandHelpKeepsPostingAsAnInternalTerm(t *testing.T) {
root := newRootCmd()
for _, name := range []string{"boxes", "box", "seen", "unseen", "move"} {
for _, name := range []string{"boxes", "box", "seen", "unseen", "move", "trash", "spam"} {
t.Run(name, func(t *testing.T) {
command, _, err := root.Find([]string{name})
if err != nil {
Expand DownExpand Up@@ -73,6 +73,8 @@ EMAIL
seen Mark email threads as seen
unseen Mark email threads as unseen
move Move email threads to another box
trash Move email threads to Trash
spam Mark email threads as spam

CALENDAR & TASKS
calendars List calendars
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/root.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,6 +133,8 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newSeenCommand().cmd)
root.AddCommand(newUnseenCommand().cmd)
root.AddCommand(newMoveCommand().cmd)
root.AddCommand(newTrashCommand().cmd)
root.AddCommand(newSpamCommand().cmd)
root.AddCommand(newSetupCommand())
root.AddCommand(newTuiCommand().cmd)
root.AddCommand(newSkillCommand().cmd)
Expand Down
54 changes: 54 additions & 0 deletions internal/cmd/spam.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

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

type spamCommand struct {
cmd *cobra.Command
}

func newSpamCommand() *spamCommand {
spamCommand := &spamCommand{}
spamCommand.cmd = &cobra.Command{
Use: "spam <id>...",
Short: "Mark email threads as spam",
Long: "Mark one or more email threads as spam. HEY moves the threads to Spam and trains its filters.",
Example: ` hey spam 12345
hey spam 12345 67890`,
Annotations: map[string]string{
"agent_notes": "Accepts one or more box item IDs from hey box output. Marks each thread as spam and removes it from the current box.",
},
RunE: spamCommand.run,
Args: usageMinOneArg(),
}

return spamCommand
}

func (c *spamCommand) run(cmd *cobra.Command, args []string) error {
if err := requireAuth(); err != nil {
return err
}

ids, err := parseIntArgs(args)
if err != nil {
return err
}

if err := sdk.Postings().MarkSpam(cmd.Context(), ids...); err != nil {
return convertSDKError(err)
}

summary := fmt.Sprintf("%d %s marked as spam", len(ids), threadNoun(len(ids)))
if writer.IsStyled() {
fmt.Fprintln(cmd.OutOrStdout(), summary+".")
return nil
}

return writeOK(nil, output.WithSummary(summary))
}
54 changes: 54 additions & 0 deletions internal/cmd/trash.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

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

type trashCommand struct {
cmd *cobra.Command
}

func newTrashCommand() *trashCommand {
trashCommand := &trashCommand{}
trashCommand.cmd = &cobra.Command{
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`,
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.",
},
RunE: trashCommand.run,
Args: usageMinOneArg(),
}

return trashCommand
}

func (c *trashCommand) run(cmd *cobra.Command, args []string) error {
if err := requireAuth(); err != nil {
return err
}

ids, err := parseIntArgs(args)
if err != nil {
return err
}

if err := sdk.Postings().MoveToTrash(cmd.Context(), ids...); err != nil {
return convertSDKError(err)
}

summary := fmt.Sprintf("%d %s moved to Trash", len(ids), threadNoun(len(ids)))
if writer.IsStyled() {
fmt.Fprintln(cmd.OutOrStdout(), summary+".")
return nil
}

return writeOK(nil, output.WithSummary(summary))
}
152 changes: 152 additions & 0 deletions internal/cmd/trash_test.go
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
package cmd

import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"

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

type recordedRemoval struct {
method string
path string
postingIDs []int64
status int
requests int
}

func removalServer(t *testing.T) (*httptest.Server, *recordedRemoval) {
t.Helper()
recorded := &recordedRemoval{status: http.StatusNoContent}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
recorded.requests++
recorded.method = r.Method
recorded.path = r.URL.Path
var body struct {
PostingIDs []int64 `json:"posting_ids"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
recorded.postingIDs = body.PostingIDs

switch r.URL.Path {
case "/postings/trash.json", "/postings/spam.json":
w.WriteHeader(recorded.status)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
return server, recorded
}

func runRemoval(t *testing.T, server *httptest.Server, command string, args ...string) (output.Response, error) {
t.Helper()
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)
t.Setenv("XDG_STATE_HOME", tmpDir)
t.Setenv("XDG_CACHE_HOME", tmpDir)

root := newRootCmd()
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs(append([]string{command, "--json", "--base-url", server.URL}, args...))

err := root.Execute()
var resp output.Response
if buf.Len() > 0 {
_ = json.Unmarshal(buf.Bytes(), &resp)
}
return resp, err
}

func TestTrashAndSpam(t *testing.T) {
tests := []struct {
name string
command string
args []string
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"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server, recorded := removalServer(t)
resp, err := runRemoval(t, server, tt.command, tt.args...)
if err != nil {
t.Fatalf("%s failed: %v", tt.command, err)
}
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))
}
for i, want := range []int64{12345, 67890}[:len(tt.args)] {
if recorded.postingIDs[i] != want {
t.Errorf("posting_ids[%d] = %d, want %d", i, recorded.postingIDs[i], want)
}
}
if resp.Summary != tt.summary {
t.Errorf("summary = %q, want %q", resp.Summary, tt.summary)
}
})
}
}

func TestTrashAndSpamRequireIDs(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)
if err == nil || !strings.Contains(err.Error(), "Usage:") {
t.Fatalf("missing ID should produce a usage error, got %v", err)
}
if recorded.requests != 0 {
t.Errorf("missing ID made %d requests", recorded.requests)
}
})
}
}

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")
var cliErr *apierr.Error
if !errors.As(err, &cliErr) || cliErr.Code != "usage" {
t.Fatalf("invalid ID should produce a usage error, got %v", err)
}
if recorded.requests != 0 {
t.Errorf("invalid ID made %d requests", recorded.requests)
}
})
}
}

func TestTrashAndSpamReportServerFailures(t *testing.T) {
for _, command := range []string{"trash", "spam"} {
t.Run(command, func(t *testing.T) {
server, recorded := removalServer(t)
recorded.status = http.StatusUnprocessableEntity
if _, err := runRemoval(t, server, command, "12345"); err == nil {
t.Fatal("server failure should be reported")
}
})
}
}
5 changes: 5 additions & 0 deletions internal/tui/mail.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -271,6 +271,7 @@ func (v *mailView) HelpBindings() []helpBinding {
{"d", "feed"},
{"p", "paper trail"},
{"t", "trash"},
{"s", "spam"},
{"-", "mute"},
}
}
Expand DownExpand Up@@ -526,6 +527,10 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd {
return v.doPostingAction("Thread moved to Trash", true, boxID, p.ID, func() error {
return v.vc.sdk.Postings().MoveToTrash(v.vc.ctx, p.ID)
})
case "s":
return v.doPostingAction("Thread marked as spam", true, boxID, p.ID, func() error {
return v.vc.sdk.Postings().MarkSpam(v.vc.ctx, p.ID)
})
case "-":
return v.doPostingAction("Thread muted", true, boxID, p.ID, func() error {
return v.vc.sdk.Postings().Mute(v.vc.ctx, p.ID)
Expand Down
3 changes: 2 additions & 1 deletion internal/tui/mail_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -251,6 +251,7 @@ func TestMailViewPostingKeysCallExpectedEndpoints(t *testing.T) {
{"feed", "d", "/postings/moves.json", 2, true, "Thread moved to The Feed", false},
{"paper trail", "p", "/postings/moves.json", 5, true, "Thread moved to Paper Trail", false},
{"trash", "t", "/postings/trash.json", 0, true, "Thread moved to Trash", false},
{"spam", "s", "/postings/spam.json", 0, true, "Thread marked as spam", false},
{"mute", "-", "/postings/mutings.json", 0, true, "Thread muted", false},
}

Expand DownExpand Up@@ -1072,7 +1073,7 @@ func TestMailViewHelpBindings(t *testing.T) {
for _, b := range bindings {
keys[b.key] = true
}
for _, expected := range []string{"r", "f", "m", "e", "l", "a", "t"} {
for _, expected := range []string{"r", "f", "m", "e", "l", "a", "t", "s"} {
if !keys[expected] {
t.Errorf("missing help binding for key %q", expected)
}
Expand Down
Loading