From c790ad73215fa4a3d2149ef2ecc0f6be5aaf435a Mon Sep 17 00:00:00 2001 From: Ellis Givens Date: Wed, 19 Aug 2026 13:28:20 -0500 Subject: [PATCH] Add restore command --- .surface | 1 + API-COVERAGE.md | 1 + README.md | 1 + docs/cli.md | 3 +- internal/cmd/help.go | 2 +- internal/cmd/help_test.go | 3 +- internal/cmd/restore.go | 50 ++++++++++++++ internal/cmd/restore_test.go | 123 +++++++++++++++++++++++++++++++++++ internal/cmd/root.go | 1 + skills/hey/SKILL.md | 18 +++-- tests/smoke/restore_test.go | 112 +++++++++++++++++++++++++++++++ 11 files changed, 307 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/restore.go create mode 100644 internal/cmd/restore_test.go create mode 100644 tests/smoke/restore_test.go diff --git a/.surface b/.surface index 7d35c367..1ae20247 100644 --- a/.surface +++ b/.surface @@ -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 diff --git a/API-COVERAGE.md b/API-COVERAGE.md index ee2e7377..24a28784 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -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 ` | covered | | `/messages.json` | POST | SDK `Messages().Create`, `Messages().CreateDraft` | `hey compose`, `hey compose --draft`, `hey forward ` | covered | | `/entries/{id}/replies` | POST | SDK `Entries().CreateReply`, `Entries().CreateReplyDraft` | `hey reply `, `hey reply --draft`, `hey compose --thread-id [--draft]` | covered | +| `/topics/{id}/status/active.json` | PUT | SDK `Topics().Restore` | `hey restore ...` | covered | | `/topics/{id}.json` | GET | SDK `Topics().Get` | `hey forward `, `hey reply `, TUI `r` | covered | | `/entries/{id}/forwards/new.json` | GET | SDK `Entries().NewForward` | `hey forward ` | covered | | `/bulk_replies/new.json` | GET | SDK `BulkReplies().Draft` | `hey bulk-reply preview`, `hey bulk-reply send`, TUI `ctrl+b` | covered | diff --git a/README.md b/README.md index 0c27d406..92135a4d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/cli.md b/docs/cli.md index 830e78bf..a9ae680d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 @@ -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 ` 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 ` 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 ` 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 ...` 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 ` 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 ` 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. diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 8c044833..bccd3248 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -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", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index a09d6a83..413e3806 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -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 { @@ -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 diff --git a/internal/cmd/restore.go b/internal/cmd/restore.go new file mode 100644 index 00000000..a4595af8 --- /dev/null +++ b/internal/cmd/restore.go @@ -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 ...", + 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) +} diff --git a/internal/cmd/restore_test.go b/internal/cmd/restore_test.go new file mode 100644 index 00000000..e4de6ff6 --- /dev/null +++ b/internal/cmd/restore_test.go @@ -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 ..." { + 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) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 3c1206cf..7b005280 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 747ffe67..50ea2aad 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -46,12 +46,14 @@ triggers: - hey move - hey bubble - hey trash + - hey restore - hey spam - hey ignore - hey stop-ignoring - move email - bubble a thread up - trash email + - restore email from trash - mark as spam - ignore email thread - stop ignoring email thread @@ -235,6 +237,7 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box | List bubbled-up and scheduled threads | `hey bubble list --json` | | Cancel a bubble-up | `hey bubble pop 12345` | | Move email threads to Trash | `hey trash 12345` | +| Restore email threads from Trash | `hey restore ...` | | Mark email threads as spam | `hey spam 12345` | | Ignore email threads | `hey ignore 12345` | | Stop ignoring email threads | `hey stop-ignoring 12345` | @@ -284,6 +287,7 @@ Want to read email? ├── Move to another box? → hey move --to ├── Remove or unmark Reply Later? → hey move --to imbox ├── Move to Trash? → hey trash +├── Restore from Trash? → hey restore ... ├── Mark as spam? → hey spam ├── Ignore future activity? → hey ignore ├── Stop ignoring? → hey stop-ignoring @@ -342,7 +346,7 @@ hey box view imbox --page next-cursor --json # Continue from an earlier listing Box names: `imbox`, `feedbox`, `trailbox`, `asidebox`, `laterbox`, `bubblebox` -**Response format:** `hey box view --json` returns the box itself — `id`, `kind`, `name`, `app_url`, `next_history_url`, `next_page` — with a `postings` array of the email threads in it. Each posting has: `id` (box item ID), `topic_id` (thread ID), `name` (subject), `seen` (read status), `created_at`, `contacts`, `summary`, `app_url`, `visible_entry_count`. Use `id` for `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring`, and `topic_id` for `hey thread read`, `hey reply`, `hey forward`, `hey share` and `hey attachment list`. A box item `id` passed to `hey thread read` answers `not_found`, and so does a `topic_id` passed to `hey move`. +**Response format:** `hey box view --json` returns the box itself — `id`, `kind`, `name`, `app_url`, `next_history_url`, `next_page` — with a `postings` array of the email threads in it. Each posting has: `id` (box item ID), `topic_id` (thread ID), `name` (subject), `seen` (read status), `created_at`, `contacts`, `summary`, `app_url`, `visible_entry_count`. Use `id` for `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring`, and `topic_id` for `hey thread read`, `hey reply`, `hey forward`, `hey share` and `hey attachment list`. A box item `id` passed to `hey thread read` answers `not_found`, and so does a `topic_id` passed to `hey move`. Restore is different because a trashed thread has no box item ID; use its `topic_id` from `hey search --in trash` today, or from `hey thread list --in trash` once that listing is available. A posting that bundles a contact's mail into one row can **omit `topic_id`**: a bundle names its sender rather than a thread, and its `name` joins the bundled subjects with `•`. A bundle that does carry a `topic_id` opens as that thread — its one unseen thread — and `hey threads` reads it as usual. For a bundle without one, never substitute the box item `id` (`hey threads ` answers `not_found`); there is no command that lists the threads inside a bundle, so run `hey contacts unbundle ` — the contact is in the posting's `contacts` — to list that sender's mail as separate rows, or direct the user to open the bundle in HEY. @@ -403,7 +407,7 @@ Search refinements are `--required`, `--any`, `--none`, `--exact`, `--from`, `-- `--in`, `--date`, `--label` and `--attachment` accept only the values `hey search filters` lists: boxes are `imbox`, `feed`, `papertrail`, `trash`; dates are `last_7_days`, `last_30_days`, `last_90_days` or a four-digit year; attachment kinds are `any`, `images`, `pdfs`, `calendar_invites`, `documents`, `spreadsheets`, `presentations`, `media`, `zip_files`. The kinds are plural — `--attachment pdfs`, not `pdf`. An unrecognized `--in`, `--date` or `--attachment` is refused as a usage error naming the values it accepts, before anything is sent; `--label` is not checked, so read `hey search filters` when unsure of a label. -**Response format:** `data` contains one item per matching thread. Each result has `id` (box item ID for organization actions), `topic_id` (thread ID for `hey thread read`, `hey reply`, and `hey forward`), `subject`, `updated_at`, and `messages` containing the matching message IDs, senders, dates, and summaries. A result can omit `id` when the thread has no active box item. +**Response format:** `data` contains one item per matching thread. Each result has `id` (box item ID for organization actions), `topic_id` (thread ID for `hey thread read`, `hey reply`, `hey forward`, and `hey restore`), `subject`, `updated_at`, and `messages` containing the matching message IDs, senders, dates, and summaries. A result can omit `id` when the thread has no active box item; that is why `hey restore` takes `topic_id` from `hey search --in trash`. ### Contacts @@ -446,7 +450,7 @@ on an entry; use `hey reply`, which works the addressing out itself. `hey share` returns a URL that shows the entire thread and future emails or replies sent to it. Anyone with the link can open it. `hey unshare` turns off the sharing link. -**ID note:** Every email thread has two IDs: an `id` (its box item ID) and a `topic_id` (its thread ID). `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring` expect `id`. `hey thread read`, `hey share`, `hey unshare`, `hey attachment list`, `hey reply`, `hey forward`, `hey collection add`, and `hey collection remove` expect `topic_id`. Passing the wrong one answers `not_found`, not a redirect. +**ID note:** Every email thread has two IDs: an `id` (its box item ID) and a `topic_id` (its thread ID). `hey seen`, `hey unseen`, `hey move`, `hey label add`, `hey label remove`, `hey trash`, `hey spam`, `hey ignore`, and `hey stop-ignoring` expect `id`. `hey thread read`, `hey share`, `hey unshare`, `hey attachment list`, `hey reply`, `hey forward`, `hey collection add`, `hey collection remove`, and `hey restore` expect `topic_id`. Passing the wrong one answers `not_found`, not a redirect. `hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry both — except a bundle posting, which can omit `topic_id` (see the Boxes section). @@ -569,16 +573,20 @@ Takes box item IDs (the `id` field from `hey box view --json`). `hey bubble up` `hey bubble list --json` answers two buckets: `bubbled_up`, the threads back in the Imbox after bubbling up, and `scheduled`, the threads waiting in Bubble Up — each scheduled row carries `bubble_up_schedule.bubble_up_at`, and `surprise_me` when HEY picked the time. Use `id` with `hey bubble pop`, `topic_id` with `hey thread read`. -### Email - Trash and Spam +### Email - Trash, Restore, and Spam ```bash hey trash 12345 # Move one thread to Trash hey trash 12345 67890 # Move multiple threads to Trash +hey restore 98765 # Restore one thread from Trash +hey restore 98765 43210 # Restore multiple threads from Trash hey spam 12345 # Mark one thread as spam hey spam 12345 67890 # Mark multiple threads as spam ``` -Takes box item IDs (the `id` field from `hey box view --json`). Trashing a shared thread removes your access instead of deleting it for everyone. Marking a thread as spam moves it to Spam and trains HEY's filters. +`hey trash` and `hey spam` take box item IDs (the `id` field from `hey box view --json`). Trashing a shared thread removes your access instead of deleting it for everyone. Marking a thread as spam moves it to Spam and trains HEY's filters. + +`hey restore` is Trash-only and takes thread IDs, not box item IDs, because a trashed thread no longer has a box item. Use the `topic_id` column from `hey search --in trash --json` today; once `hey thread list --in trash` is available, its Trash listing will provide the same IDs. Pass one or more values in the same invocation. ### Email - Ignoring Threads diff --git a/tests/smoke/restore_test.go b/tests/smoke/restore_test.go new file mode 100644 index 00000000..0b60cbfd --- /dev/null +++ b/tests/smoke/restore_test.go @@ -0,0 +1,112 @@ +package smoke_test + +import ( + "encoding/json" + "fmt" + "strconv" + "testing" + "time" +) + +type disposableRestorePosting struct { + ID int64 `json:"id"` + TopicID int64 `json:"topic_id"` + Name string `json:"name"` +} + +func createDisposableRestoreTopic(t *testing.T, purpose string) disposableRestorePosting { + t.Helper() + subject := fmt.Sprintf("Disposable %s %s", purpose, uniqueID()) + _, stderr, code := hey(t, "compose", + "--to", smokeEmail, + "--subject", subject, + "-m", "This disposable thread verifies restoring from Trash.", + "--json", + ) + if code != 0 { + skipf(t, "could not create a disposable thread (exit %d): %s", code, stderr) + } + if _, err := waitForPostingIDBySubject(t, subject); err != nil { + skipf(t, "disposable thread %q did not appear in the Imbox: %v", subject, err) + } + + type boxResponse struct { + Postings []disposableRestorePosting `json:"postings"` + } + box := dataAs[boxResponse](t, heyJSON(t, "box", "view", "imbox", "--all")) + for _, posting := range box.Postings { + if posting.Name == subject { + if posting.TopicID <= 0 { + t.Fatalf("disposable thread has no topic_id: %+v", posting) + } + return posting + } + } + skipf(t, "disposable thread %q disappeared from the Imbox", subject) + return disposableRestorePosting{} +} + +func waitForTrashedTopicID(t *testing.T, subject string) int64 { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + type searchResult struct { + TopicID int64 `json:"topic_id"` + Subject string `json:"subject"` + } + results := dataAs[[]searchResult](t, heyJSON(t, "search", "--subject", subject, "--in", "trash", "--all")) + for _, result := range results { + if result.Subject == subject && result.TopicID > 0 { + return result.TopicID + } + } + time.Sleep(250 * time.Millisecond) + } + skipf(t, "trashed thread %q did not appear in search", subject) + return 0 +} + +func cleanupDisposableRestoreTopic(t *testing.T, posting disposableRestorePosting) { + t.Helper() + _, _, _ = hey(t, "restore", strconv.FormatInt(posting.TopicID, 10), "--json") + + postingID, err := waitForPostingIDBySubject(t, posting.Name) + if err != nil || postingID == 0 { + t.Logf("could not locate disposable thread %q while cleaning up: %v", posting.Name, err) + return + } + if _, stderr, code := hey(t, "trash", strconv.FormatInt(postingID, 10), "--json"); code != 0 { + t.Logf("could not move disposable thread %q to Trash: %s", posting.Name, stderr) + } +} + +func TestRestore(t *testing.T) { + first := createDisposableRestoreTopic(t, "restore test") + second := createDisposableRestoreTopic(t, "bulk restore test") + t.Cleanup(func() { cleanupDisposableRestoreTopic(t, first) }) + t.Cleanup(func() { cleanupDisposableRestoreTopic(t, second) }) + + heyOK(t, "trash", strconv.FormatInt(first.ID, 10), strconv.FormatInt(second.ID, 10), "--json") + firstTopicID := waitForTrashedTopicID(t, first.Name) + secondTopicID := waitForTrashedTopicID(t, second.Name) + + stdout := heyOK(t, "restore", strconv.FormatInt(firstTopicID, 10), strconv.FormatInt(secondTopicID, 10), "--json") + var response Response + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("failed to parse restore response: %v", err) + } + if response.Summary != "2 threads restored from Trash" { + t.Errorf("summary = %q, want two restored threads", response.Summary) + } + + for _, posting := range []disposableRestorePosting{first, second} { + if _, err := waitForPostingIDBySubject(t, posting.Name); err != nil { + t.Errorf("restored thread %q did not return to the Imbox: %v", posting.Name, err) + } + } +} + +func TestRestoreValidation(t *testing.T) { + heyFail(t, "restore", "--json") + heyFail(t, "restore", "not-a-thread", "--json") +}