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
1 change: 1 addition & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ hey reply --attach
hey reply --draft
hey reply --message
hey reply --message-html
hey restore
hey screener
hey screener approve
hey screener approve --box
Expand Down
1 change: 1 addition & 0 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ which is what `Entries().ListDraftsPage` and `hey draft list --page` exist for.
| signed Active Storage blob URL | GET | SDK `DownloadBlob` | `hey attachment save <id>` | covered |
| `/messages.json` | POST | SDK `Messages().Create`, `Messages().CreateDraft` | `hey compose`, `hey compose --draft`, `hey forward <topic-id>` | covered |
| `/entries/{id}/replies` | POST | SDK `Entries().CreateReply`, `Entries().CreateReplyDraft` | `hey reply <topic-id>`, `hey reply --draft`, `hey compose --thread-id [--draft]` | covered |
| `/topics/{id}/status/active.json` | PUT | SDK `Topics().Restore` | `hey restore <thread-id>...` | covered |
| `/topics/{id}.json` | GET | SDK `Topics().Get` | `hey forward <topic-id>`, `hey reply <topic-id>`, TUI `r` | covered |
| `/entries/{id}/forwards/new.json` | GET | SDK `Entries().NewForward` | `hey forward <topic-id>` | covered |
| `/bulk_replies/new.json` | GET | SDK `BulkReplies().Draft` | `hey bulk-reply preview`, `hey bulk-reply send`, TUI `ctrl+b` | covered |
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ hey thread read 12345 # a whole thread, as Markdown
hey reply 12345 -m "Friday works for me."
hey compose --to alice@example.com --subject "Lunch?" -m "Thursday at noon?"
hey search --from jane@example.com --date last_30_days
hey restore 12345 67890 # restore topic_id values found with hey search --in trash
hey screener list # first-time senders waiting on you
hey event add "Design review" --starts-on 2026-09-02 --start-time 14:00
hey watch --box imbox --events new # a line of JSON for every new email, as it lands
Expand Down
3 changes: 2 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ hey bubble up 12345 --weekend # bubble a thread up Saturday morning
hey bubble list # list bubbled-up and scheduled threads
hey bubble pop 12345 # cancel a thread's bubble-up
hey trash 12345 # move a thread to Trash
hey restore 98765 43210 # restore Trash threads by thread ID
hey spam 12345 # mark a thread as spam
hey ignore 12345 # ignore future activity on a thread
hey stop-ignoring 12345 # resume attention for a thread
Expand All @@ -275,7 +276,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 attachment list <thread-id>` returns every named downloadable file, including named inline images. Direct files keep stable message-and-position IDs such as `456:1`; files inside embedded HTML receive opaque IDs scoped to their message. Pass either returned ID to `hey attachment 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`. 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`. Restoring is the exception to the organization-ID rule: a trashed thread has no box item ID, so `hey restore <thread-id>...` takes one or more `topic_id` values from `hey search --in trash --json` today. Once `hey thread list --in trash` is available, that Trash listing will provide the same thread IDs too. Restore only works for threads currently in Trash. `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/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ var curatedCategories = []struct {
},
{
heading: "ORGANIZE",
names: []string{"label", "collection", "workflow", "seen", "unseen", "move", "set-aside", "bubble", "trash", "spam", "ignore", "stop-ignoring"},
names: []string{"label", "collection", "workflow", "seen", "unseen", "move", "set-aside", "bubble", "trash", "restore", "spam", "ignore", "stop-ignoring"},
},
{
heading: "CALENDAR & TASKS",
Expand Down
3 changes: 2 additions & 1 deletion internal/cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func TestCuratedCommandHelpUsesUserFacingLanguage(t *testing.T) {

func TestEmailCommandHelpKeepsPostingAsAnInternalTerm(t *testing.T) {
root := newRootCmd()
for _, name := range []string{"box", "label", "workflow", "clip", "snippet", "thread", "attachment", "bulk-reply", "search", "seen", "unseen", "move", "bubble", "trash", "spam", "ignore", "stop-ignoring", "watch"} {
for _, name := range []string{"box", "label", "workflow", "clip", "snippet", "thread", "attachment", "bulk-reply", "search", "seen", "unseen", "move", "bubble", "trash", "restore", "spam", "ignore", "stop-ignoring", "watch"} {
t.Run(name, func(t *testing.T) {
command, _, err := root.Find([]string{name})
if err != nil {
Expand Down Expand Up @@ -142,6 +142,7 @@ ORGANIZE
set-aside List and group email threads in Set Aside
bubble Bubble email threads up in the Imbox
trash Move email threads to Trash
restore Restore email threads from Trash
spam Mark email threads as spam
ignore Ignore email threads
stop-ignoring Stop ignoring email threads
Expand Down
50 changes: 50 additions & 0 deletions internal/cmd/restore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

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

type restoreCommand struct {
cmd *cobra.Command
}

func newRestoreCommand() *restoreCommand {
restoreCommand := &restoreCommand{}
restoreCommand.cmd = &cobra.Command{
Use: "restore <thread-id>...",
Short: "Restore email threads from Trash",
Long: "Restore one or more email threads from Trash to active mail. This only works for threads currently in Trash. Use the topic_id column from hey search --in trash today; once hey thread list --in trash is available, that Trash listing will provide thread IDs too. Do not use a box item ID.",
Example: ` hey restore 12345
hey restore 12345 67890`,
Annotations: map[string]string{
"agent_notes": "Accepts one or more thread IDs, not box item IDs. Use topic_id from hey search --in trash today; once hey thread list --in trash is available, that Trash listing is another source. A trashed thread has no box item ID. Only restores threads currently in Trash.",
},
Args: usageMinOneArg(),
RunE: restoreCommand.run,
}

return restoreCommand
}

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

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

for _, id := range ids {
if err := sdk.Topics().Restore(cmd.Context(), id); err != nil {
return apierr.FromSDK(err)
}
}

return writeMutation(cmd, fmt.Sprintf("%d %s restored from Trash", len(ids), threadNoun(len(ids))), nil)
}
123 changes: 123 additions & 0 deletions internal/cmd/restore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package cmd

import (
"errors"
"net/http"
"reflect"
"strings"
"sync/atomic"
"testing"

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

func TestRestoreOneOrMoreThreads(t *testing.T) {
var paths []string
response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("method = %s, want PUT", r.Method)
}
paths = append(paths, r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}), "restore", "12345", "67890", "12345")
if err != nil {
t.Fatalf("restore failed: %v", err)
}

wantPaths := []string{"/topics/12345/status/active.json", "/topics/67890/status/active.json"}
if !reflect.DeepEqual(paths, wantPaths) {
t.Errorf("paths = %v, want %v", paths, wantPaths)
}
if response.Summary != "2 threads restored from Trash" {
t.Errorf("summary = %q, want %q", response.Summary, "2 threads restored from Trash")
}
if response.Data != nil {
t.Errorf("data = %#v, want it omitted", response.Data)
}
}

func TestRestoreHelpNamesThreadIDsAndTrash(t *testing.T) {
command, _, err := newRootCmd().Find([]string{"restore"})
if err != nil {
t.Fatal(err)
}

if command.Use != "restore <thread-id>..." {
t.Errorf("use = %q, want thread IDs", command.Use)
}
for field, text := range map[string]string{
"long help": command.Long,
"agent notes": command.Annotations["agent_notes"],
} {
for _, want := range []string{"topic_id", "hey search --in trash", "once hey thread list --in trash is available", "currently in Trash"} {
if !strings.Contains(text, want) {
t.Errorf("%s = %q, want %q", field, text, want)
}
}
}
if !strings.Contains(command.Annotations["agent_notes"], "thread IDs") || !strings.Contains(command.Annotations["agent_notes"], "not box item IDs") {
t.Errorf("agent notes do not distinguish thread IDs from box item IDs: %q", command.Annotations["agent_notes"])
}
}

func TestRestoreRejectsMissingOrInvalidIDsBeforeRequest(t *testing.T) {
var requests atomic.Int64
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusNoContent)
})

tests := [][]string{
{"restore"},
{"restore", "not-an-id"},
{"restore", "0"},
{"restore", "--", "-12345"},
}
for _, args := range tests {
t.Run(strings.Join(args, " "), func(t *testing.T) {
_, err := runJSONCommand(t, handler, args...)
if err == nil {
t.Fatalf("%v was accepted", args)
}
var cliErr *apierr.Error
if len(args) > 1 && !errors.As(err, &cliErr) {
t.Errorf("error = %v, want a structured usage error", err)
}
})
}
if requests.Load() != 0 {
t.Fatalf("invalid input made %d requests", requests.Load())
}
}

func TestRestoreStopsAndReportsAThreadFailure(t *testing.T) {
var paths []string
_, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
if r.URL.Path == "/topics/67890/status/active.json" {
http.Error(w, "cannot restore", http.StatusUnprocessableEntity)
return
}
w.WriteHeader(http.StatusNoContent)
}), "restore", "12345", "67890", "24680")
if err == nil {
t.Fatal("server failure was not reported")
}

wantPaths := []string{"/topics/12345/status/active.json", "/topics/67890/status/active.json"}
if !reflect.DeepEqual(paths, wantPaths) {
t.Errorf("paths = %v, want the command to stop after the failure at %v", paths, wantPaths)
}
}

func TestRestoreStyledOutputUsesMutationFormat(t *testing.T) {
stdout, err := runStyledCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}), "restore", "12345", "67890")
if err != nil {
t.Fatalf("restore failed: %v", err)
}
if stdout != "2 threads restored from Trash.\n" {
t.Errorf("styled output = %q", stdout)
}
}
1 change: 1 addition & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newSetAsideCommand().cmd)
root.AddCommand(newBubbleCommand().cmd)
root.AddCommand(newTrashCommand().cmd)
root.AddCommand(newRestoreCommand().cmd)
root.AddCommand(newSpamCommand().cmd)
root.AddCommand(newIgnoreCommand().cmd)
root.AddCommand(newStopIgnoringCommand().cmd)
Expand Down
Loading
Loading