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
2 changes: 2 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ hey journal list --limit
hey journal read
hey journal write
hey journal write --content
hey mark-spam
hey move
hey move --to
hey recordings
Expand All @@ -97,6 +98,7 @@ hey recordings --starts-on
hey reply
hey reply --attach
hey reply --message
hey restore
hey search
hey search --all
hey search --any
Expand Down
2 changes: 2 additions & 0 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ The remaining HTML-reading gaps use the SDK's authenticated HTML helper and are
| signed Active Storage blob URL | GET | SDK `DownloadBlob` | `hey attachments save <id>` | covered |
| `/messages.json` | POST | SDK `Messages().Create` | `hey compose`, `hey forward <topic-id>` | covered |
| `/entries/{id}/replies` | POST | SDK `Entries().CreateReply` | `hey reply <topic-id>` | covered |
| `/topics/{id}/status/active` | PUT | SDK `Topics().Restore` | `hey restore <topic-id>` | covered |
| `/entries/{id}/status/spam` | PUT | SDK `Entries().MarkSpam` | `hey mark-spam <entry-id>` | covered |
| `/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 |
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ 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 restore 123 # restore a topic from Trash
hey mark-spam 456 # mark one email entry as spam
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
Expand All @@ -110,6 +112,8 @@ Contact updates preserve omitted name, email, and alias fields. Supplying `--ali

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`.

`hey restore` takes a topic ID and returns a topic from Trash to active mail. `hey mark-spam` takes an entry ID and marks that one email entry as spam. Both commands change mailbox state, so confirm the exact ID before running them.

### Calendars

```bash
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ var curatedCategories = []struct {
}{
{
heading: "EMAIL",
names: []string{"boxes", "box", "search", "contacts", "threads", "attachments", "compose", "reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"},
names: []string{"boxes", "box", "search", "contacts", "threads", "attachments", "compose", "reply", "forward", "drafts", "restore", "mark-spam", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"},
},
{
heading: "CALENDAR & TASKS",
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ EMAIL
reply Reply to a thread
forward Forward the latest message in a thread
drafts List draft emails
restore Restore a topic to active mail
mark-spam Mark an email entry as spam
seen Mark email threads as seen
unseen Mark email threads as unseen
move Move email threads to another box
Expand Down
46 changes: 46 additions & 0 deletions internal/cmd/mark_spam.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"
)

type markSpamCommand struct {
cmd *cobra.Command
}

func newMarkSpamCommand() *markSpamCommand {
command := &markSpamCommand{}
command.cmd = &cobra.Command{
Use: "mark-spam <entry-id>",
Short: "Mark an email entry as spam",
Long: "Mark one email entry as spam. This changes mailbox state, so confirm the exact entry ID before running it.",
Example: " hey mark-spam 12345",
Annotations: map[string]string{
"agent_notes": "State-changing action. Confirm the exact entry ID before marking it as spam.",
},
Args: usageExactOneArg(),
RunE: command.run,
}
return command
}

func (c *markSpamCommand) run(cmd *cobra.Command, args []string) error {
entryID, err := parsePositiveControlID(args[0], "entry")
if err != nil {
return err
}
if err := rejectControlListFormats(); err != nil {
return err
}
if err := requireAuth(); err != nil {
return err
}
if err := sdk.Entries().MarkSpam(cmd.Context(), entryID); err != nil {
return convertSDKError(err)
}

result := topicControlResult{EntryID: entryID, Action: "marked_spam"}
return writeTopicControlResult(cmd, result, fmt.Sprintf("Entry %d marked as spam", entryID))
}
46 changes: 46 additions & 0 deletions internal/cmd/restore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"
)

type restoreCommand struct {
cmd *cobra.Command
}

func newRestoreCommand() *restoreCommand {
command := &restoreCommand{}
command.cmd = &cobra.Command{
Use: "restore <topic-id>",
Short: "Restore a topic to active mail",
Long: "Restore a topic from Trash to active mail. This changes mailbox state, so confirm the exact topic ID before running it.",
Example: " hey restore 12345",
Annotations: map[string]string{
"agent_notes": "State-changing action. Confirm the exact topic ID before restoring it from Trash.",
},
Args: usageExactOneArg(),
RunE: command.run,
}
return command
}

func (c *restoreCommand) run(cmd *cobra.Command, args []string) error {
topicID, err := parsePositiveControlID(args[0], "topic")
if err != nil {
return err
}
if err := rejectControlListFormats(); err != nil {
return err
}
if err := requireAuth(); err != nil {
return err
}
if err := sdk.Topics().Restore(cmd.Context(), topicID); err != nil {
return convertSDKError(err)
}

result := topicControlResult{TopicID: topicID, Action: "restored"}
return writeTopicControlResult(cmd, result, fmt.Sprintf("Topic %d restored to active mail", topicID))
}
2 changes: 2 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newForwardCommand().cmd)
root.AddCommand(newComposeCommand().cmd)
root.AddCommand(newDraftsCommand().cmd)
root.AddCommand(newRestoreCommand().cmd)
root.AddCommand(newMarkSpamCommand().cmd)
Comment thread
code-monger-givenall marked this conversation as resolved.
root.AddCommand(newCalendarsCommand().cmd)
root.AddCommand(newRecordingsCommand().cmd)
root.AddCommand(newTodoCommand().cmd)
Expand Down
43 changes: 43 additions & 0 deletions internal/cmd/topic_controls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package cmd

import (
"fmt"
"strconv"

"github.com/spf13/cobra"

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

type topicControlResult struct {
TopicID int64 `json:"topic_id,omitempty"`
EntryID int64 `json:"entry_id,omitempty"`
Action string `json:"action"`
}

func parsePositiveControlID(value, kind string) (int64, error) {
id, err := strconv.ParseInt(value, 10, 64)
if err != nil || id <= 0 {
return 0, output.ErrUsage(fmt.Sprintf("invalid %s ID: %s", kind, value))
}
return id, nil
}

func rejectControlListFormats() error {
switch writer.EffectiveFormat() {
case output.FormatIDs:
return output.ErrUsage("--ids-only requires list data")
case output.FormatCount:
return output.ErrUsage("--count requires list data")
default:
return nil
}
}

func writeTopicControlResult(cmd *cobra.Command, result topicControlResult, summary string) error {
if writer.IsStyled() {
fmt.Fprintln(cmd.OutOrStdout(), summary+".")
return nil
}
return writeOK(result, output.WithSummary(summary))
}
134 changes: 134 additions & 0 deletions internal/cmd/topic_controls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package cmd

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

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

func runTopicControl(t *testing.T, server *httptest.Server, 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{"--json", "--base-url", server.URL}, args...))

err := root.Execute()
var resp output.Response
if buf.Len() > 0 {
if decodeErr := json.Unmarshal(buf.Bytes(), &resp); decodeErr != nil {
t.Fatalf("decode response: %v\n%s", decodeErr, buf.String())
}
}
return resp, err
}

func TestTopicControlActions(t *testing.T) {
tests := []struct {
name string
args []string
method string
path string
summary string
}{
{name: "restore", args: []string{"restore", "123"}, method: http.MethodPut, path: "/topics/123/status/active.json", summary: "Topic 123 restored to active mail"},
{name: "mark spam", args: []string{"mark-spam", "456"}, method: http.MethodPut, path: "/entries/456/status/spam.json", summary: "Entry 456 marked as spam"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != test.method || r.URL.Path != test.path {
t.Errorf("request = %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

resp, err := runTopicControl(t, server, test.args...)
if err != nil {
t.Fatalf("execute: %v", err)
}
if resp.Summary != test.summary {
t.Errorf("summary = %q, want %q", resp.Summary, test.summary)
}
})
}
}

func TestTopicControlHelpWarnsAboutMailboxChanges(t *testing.T) {
root := newRootCmd()
for _, name := range []string{"restore", "mark-spam"} {
command, _, err := root.Find([]string{name})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(command.Long, "changes mailbox state") || !strings.Contains(command.Long, "confirm the exact") {
t.Errorf("%s help does not explain the state change: %q", name, command.Long)
}
}
}

func TestTopicControlsRejectInvalidInputWithoutRequest(t *testing.T) {
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

tests := []struct {
args []string
message string
}{
{args: []string{"restore", "0"}, message: "invalid topic ID"},
{args: []string{"mark-spam", "nope"}, message: "invalid entry ID"},
}

for _, test := range tests {
_, err := runTopicControl(t, server, test.args...)
if err == nil || !strings.Contains(err.Error(), test.message) {
t.Fatalf("%v error = %v, want %q", test.args, err, test.message)
}
}
if requests.Load() != 0 {
t.Fatalf("requests = %d, want 0", requests.Load())
}
}

func TestTopicControlsRejectListOnlyFormatsBeforeRequest(t *testing.T) {
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

for _, args := range [][]string{
{"restore", "123", "--ids-only"},
{"mark-spam", "456", "--count"},
} {
_, err := runTopicControl(t, server, args...)
if err == nil || !strings.Contains(err.Error(), "requires list data") {
t.Fatalf("%v error = %v, want list format rejection", args, err)
}
}
if requests.Load() != 0 {
t.Fatalf("requests = %d, want 0", requests.Load())
}
}
7 changes: 7 additions & 0 deletions skills/hey/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ triggers:
- hey forward
- hey compose
- hey drafts
- hey restore
- hey mark-spam
# Calendar actions
- hey calendars
- hey recordings
Expand Down Expand Up @@ -98,6 +100,7 @@ CLI for HEY: mailboxes, email threads, contacts, replies, compose, calendars, to
1. **Always use `--json`** for structured, predictable output
2. **Authentication required** for all data commands — run `hey auth login` first
3. **HTML output** is available via `--html` for commands that return HTML content
4. **Confirm the exact ID before topic controls** because restore and mark-spam change mailbox state

## Quick Reference

Expand All @@ -122,6 +125,8 @@ CLI for HEY: mailboxes, email threads, contacts, replies, compose, calendars, to
| Compose email | `hey compose --to user@example.com --subject "Hello"` |
| Compose with CC/BCC | `hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello"` |
| List drafts | `hey drafts --json` |
| Restore from Trash | Confirm first, then `hey restore <topic_id> --json` |
| Mark one entry as spam | Confirm first, then `hey mark-spam <entry_id> --json` |
| List calendars | `hey calendars --json` |
| List calendar events | `hey recordings 123 --json` |
| List todos | `hey todo list --json` |
Expand Down Expand Up @@ -161,6 +166,8 @@ Want to read email?
├── Need available refinements? → hey search filters --json
├── List or view contacts? → hey contacts list --json / hey contacts show <id> --json
├── Read full thread? → hey threads <topic_id> --json
├── Restore a trashed topic? → confirm first, then hey restore <topic_id> --json
├── Mark one entry as spam? → confirm first, then hey mark-spam <entry_id> --json
├── Mark as seen? → hey seen <id>
├── Mark as unseen? → hey unseen <id>
├── Move to another box? → hey move <id> --to <box>
Expand Down
Loading