diff --git a/.surface b/.surface index 984150af..4b647c05 100644 --- a/.surface +++ b/.surface @@ -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 @@ -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 diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 8ebc25dc..02aab2f2 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -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 ` | covered | | `/messages.json` | POST | SDK `Messages().Create` | `hey compose`, `hey forward ` | covered | | `/entries/{id}/replies` | POST | SDK `Entries().CreateReply` | `hey reply ` | covered | +| `/topics/{id}/status/active` | PUT | SDK `Topics().Restore` | `hey restore ` | covered | +| `/entries/{id}/status/spam` | PUT | SDK `Entries().MarkSpam` | `hey mark-spam ` | covered | | `/topics/{id}.json` | GET | SDK `Topics().Get` | `hey forward ` | covered | | `/entries/{id}/forwards/new.json` | GET | SDK `Entries().NewForward` | `hey forward ` | covered | | `/postings/moves.json` | POST | SDK `Postings().Move` | `hey move --to `, TUI `m` | covered | diff --git a/README.md b/README.md index 4567d0db..75e3fe1e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/internal/cmd/help.go b/internal/cmd/help.go index a23a74f4..bbc4e3cc 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -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", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 06af45e9..aedfccdd 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -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 diff --git a/internal/cmd/mark_spam.go b/internal/cmd/mark_spam.go new file mode 100644 index 00000000..79d5b1a8 --- /dev/null +++ b/internal/cmd/mark_spam.go @@ -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 ", + 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)) +} diff --git a/internal/cmd/restore.go b/internal/cmd/restore.go new file mode 100644 index 00000000..dfe3132b --- /dev/null +++ b/internal/cmd/restore.go @@ -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 ", + 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)) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index e5ee58e5..c66d4a5f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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) root.AddCommand(newCalendarsCommand().cmd) root.AddCommand(newRecordingsCommand().cmd) root.AddCommand(newTodoCommand().cmd) diff --git a/internal/cmd/topic_controls.go b/internal/cmd/topic_controls.go new file mode 100644 index 00000000..6aa39f28 --- /dev/null +++ b/internal/cmd/topic_controls.go @@ -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)) +} diff --git a/internal/cmd/topic_controls_test.go b/internal/cmd/topic_controls_test.go new file mode 100644 index 00000000..a0a8031d --- /dev/null +++ b/internal/cmd/topic_controls_test.go @@ -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()) + } +} diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 61c3e4e6..b9011031 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -18,6 +18,8 @@ triggers: - hey forward - hey compose - hey drafts + - hey restore + - hey mark-spam # Calendar actions - hey calendars - hey recordings @@ -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 @@ -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 --json` | +| Mark one entry as spam | Confirm first, then `hey mark-spam --json` | | List calendars | `hey calendars --json` | | List calendar events | `hey recordings 123 --json` | | List todos | `hey todo list --json` | @@ -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 --json ├── Read full thread? → hey threads --json +├── Restore a trashed topic? → confirm first, then hey restore --json +├── Mark one entry as spam? → confirm first, then hey mark-spam --json ├── Mark as seen? → hey seen ├── Mark as unseen? → hey unseen ├── Move to another box? → hey move --to diff --git a/tests/smoke/mark_spam_test.go b/tests/smoke/mark_spam_test.go new file mode 100644 index 00000000..7c5fb22f --- /dev/null +++ b/tests/smoke/mark_spam_test.go @@ -0,0 +1,32 @@ +package smoke_test + +import ( + "encoding/json" + "testing" +) + +func TestMarkSpam(t *testing.T) { + posting, subject := createDisposableTopic(t, "mark spam test") + topicID := extractTopicID(posting.AppURL) + t.Cleanup(func() { cleanupDisposableTopic(t, topicID, subject) }) + + type entry struct { + ID int `json:"id"` + } + entries := dataAs[[]entry](t, heyJSON(t, "threads", topicID)) + if len(entries) == 0 { + t.Skip("disposable topic has no email entry to mark as spam") + } + + stdout := heyOK(t, "mark-spam", intStr(entries[0].ID), "--json") + var resp Response + if err := json.Unmarshal([]byte(stdout), &resp); err != nil { + t.Fatalf("failed to parse mark-spam response: %v", err) + } + assertContains(t, resp.Summary, "marked as spam") +} + +func TestMarkSpamValidation(t *testing.T) { + heyFail(t, "mark-spam", "--json") + heyFail(t, "mark-spam", "not-an-entry", "--json") +} diff --git a/tests/smoke/restore_test.go b/tests/smoke/restore_test.go new file mode 100644 index 00000000..ed53dd46 --- /dev/null +++ b/tests/smoke/restore_test.go @@ -0,0 +1,101 @@ +package smoke_test + +import ( + "encoding/json" + "fmt" + "testing" +) + +type disposableTopicPosting struct { + ID int `json:"id"` + Name string `json:"name"` + AppURL string `json:"app_url"` +} + +func createDisposableTopic(t *testing.T, purpose string) (disposableTopicPosting, string) { + t.Helper() + subject := fmt.Sprintf("Disposable %s %s", purpose, uniqueID()) + _, stderr, code := hey(t, "compose", + "--to", "david@basecamp.com", + "--subject", subject, + "-m", "This disposable thread verifies a mailbox control.", + "--json", + ) + if code != 0 { + t.Skipf("could not create a disposable thread (exit %d): %s", code, stderr) + } + + type boxResponse struct { + Postings []disposableTopicPosting `json:"postings"` + } + box := dataAs[boxResponse](t, heyJSON(t, "box", "imbox", "--all")) + for _, posting := range box.Postings { + if posting.Name == subject { + if extractTopicID(posting.AppURL) == "" { + t.Fatalf("disposable thread has no topic ID: %s", posting.AppURL) + } + return posting, subject + } + } + t.Skip("disposable thread did not appear in Imbox") + return disposableTopicPosting{}, "" +} + +func cleanupDisposableTopic(t *testing.T, topicID, subject string) { + t.Helper() + _, _, _ = hey(t, "restore", topicID, "--json") + + type boxResponse struct { + Postings []disposableTopicPosting `json:"postings"` + } + resp, _, code := hey(t, "box", "imbox", "--all", "--json") + if code != 0 { + t.Logf("could not inspect Imbox while cleaning up %q", subject) + return + } + var envelope Response + if err := json.Unmarshal([]byte(resp), &envelope); err != nil { + t.Logf("could not decode Imbox while cleaning up %q: %v", subject, err) + return + } + box := dataAs[boxResponse](t, envelope) + for _, posting := range box.Postings { + if posting.Name == subject { + _, stderr, trashCode := hey(t, "trash", intStr(posting.ID), "--json") + if trashCode != 0 { + t.Logf("could not move disposable thread %q to Trash: %s", subject, stderr) + } + return + } + } +} + +func TestRestore(t *testing.T) { + posting, subject := createDisposableTopic(t, "restore test") + topicID := extractTopicID(posting.AppURL) + t.Cleanup(func() { cleanupDisposableTopic(t, topicID, subject) }) + + heyOK(t, "trash", intStr(posting.ID), "--json") + stdout := heyOK(t, "restore", topicID, "--json") + var resp Response + if err := json.Unmarshal([]byte(stdout), &resp); err != nil { + t.Fatalf("failed to parse restore response: %v", err) + } + assertContains(t, resp.Summary, "restored to active mail") + + type boxResponse struct { + Postings []disposableTopicPosting `json:"postings"` + } + box := dataAs[boxResponse](t, heyJSON(t, "box", "imbox", "--all")) + for _, candidate := range box.Postings { + if candidate.Name == subject { + return + } + } + t.Errorf("restored topic %s did not return to Imbox", topicID) +} + +func TestRestoreValidation(t *testing.T) { + heyFail(t, "restore", "--json") + heyFail(t, "restore", "not-a-topic", "--json") +}