From 697ce225de1d377ab14433b2c6fe6ed1f5a8280a Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 24 Sep 2026 19:00:31 -0500 Subject: [PATCH] Add whole-box support to hey seen --- .surface | 1 + API-COVERAGE.md | 1 + docs/cli.md | 3 + internal/cmd/seen.go | 44 +++++- internal/cmd/seen_test.go | 306 ++++++++++++++++++++++++++++++++++++++ skills/hey/SKILL.md | 3 +- 6 files changed, 353 insertions(+), 5 deletions(-) diff --git a/.surface b/.surface index 38644877c..7d463eac0 100644 --- a/.surface +++ b/.surface @@ -321,6 +321,7 @@ hey search --subject hey search --to hey search filters hey seen +hey seen --box hey set-aside hey set-aside group hey set-aside group add diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 1480e1db5..e12cee010 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -15,6 +15,7 @@ which is what `Entries().ListDraftsPage` and `hey draft list --page` exist for. |----------|--------|--------|-------------|--------| | `/boxes.json` | GET | SDK `Boxes().List` | `hey box list` | covered | | `/boxes/{id}.json` | GET | SDK `Boxes().Get`, `Boxes().GetPage` | `hey box view `, TUI mail list growth | covered | +| `/boxes/{id}/observation.json` | POST | SDK `Boxes().MarkSeen` | `hey seen --box ` | covered (queued) | | `/imbox.json` | GET | SDK `Boxes().GetImbox` | `hey box view imbox` | covered | | `/imbox/seen.json` | GET | SDK `Boxes().GetImboxSeen` | TUI Previously Seen screen | covered | | `/feedbox.json` | GET | SDK `Boxes().GetFeedbox` | `hey box view feedbox` | covered | diff --git a/docs/cli.md b/docs/cli.md index 7bb4bdce7..c83019565 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -247,6 +247,7 @@ hey compose --from billing@example.org --to alice@example.com --subject "Board u hey draft send 12345 # deliver it hey draft delete 12345 # trash it hey seen 12345 # mark a thread as seen +hey seen --box "paper trail" # queue marking a whole box as seen hey unseen 12345 67890 # mark threads as unseen hey move 12345 --to feed # move a thread to another box hey move 12345 67890 --to imbox # remove Reply Later from seen threads @@ -320,6 +321,8 @@ Snippets are named reusable email content, separate from clips saved out of rece Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Reply Later is a box rather than a separate flag: moving a Reply Later thread to Imbox removes Reply Later, preserves its seen state, and leaves a seen thread in Previously Seen. It does not return the thread to the box it occupied before Reply Later. A bundle row is refused rather than moved: it stands in for one sender's whole stream in the box they are delivered to, so moving it leaves nothing there for their next email to join and it arrives unbundled instead. Group or ungroup a sender with `hey contact bundle` and `hey contact unbundle`, and read a bundle with `hey bundle view`. Bubble Up has its own commands: `hey bubble up` raises a thread right away with `--now`, on a date with `--on` (HEY resurfaces it at its morning hour of that day, or its evening hour when the date is today), or at the morning hour of tomorrow, Saturday, or next Monday with `--tomorrow`, `--weekend`, and `--next-week`; `hey bubble pop` cancels one. `hey bubble list` shows both buckets — the threads back in the Imbox after bubbling up and the ones still scheduled, each with when it resurfaces. 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 seen` accepts either box item IDs or `--box `, never both. The box resolves the same way as `hey box view`, using the current account selection; for The Feed, use `feedbox` or `"the feed"`. Whole-box work is queued by HEY, so the acknowledgment confirms the request was accepted and threads may still appear unseen immediately afterward. + ## Watching for changes ```bash diff --git a/internal/cmd/seen.go b/internal/cmd/seen.go index e886ac3d0..b3e38b151 100644 --- a/internal/cmd/seen.go +++ b/internal/cmd/seen.go @@ -3,38 +3,74 @@ package cmd import ( "fmt" "strconv" + "strings" "github.com/spf13/cobra" "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/terminal" ) type seenCommand struct { cmd *cobra.Command + box string } func newSeenCommand() *seenCommand { seenCommand := &seenCommand{} seenCommand.cmd = &cobra.Command{ - Use: "seen ...", + Use: "seen (... | --box )", Short: "Mark email threads as seen", + Long: "Mark email threads as seen by box item ID, or queue marking a whole box as seen. " + + "--box resolves a name or ID the same way as hey box view and cannot be combined with box item IDs. " + + "Whole-box work is queued; threads may still appear unseen immediately afterward.", Example: ` hey seen 12345 - hey seen 12345 67890`, + hey seen 12345 67890 + hey seen --box "paper trail" + hey seen --box 987`, Annotations: map[string]string{ - "agent_notes": "Accepts one or more box item IDs from hey box view output. Marks each email thread as seen/read.", + "agent_notes": "Accepts one or more box item IDs from hey box view output, or --box to mark a whole box as seen. The two target modes are mutually exclusive. --box resolves like hey box view and uses the current account selection. Whole-box work is queued, so success acknowledges the request rather than completed changes.", }, RunE: seenCommand.run, - Args: usageMinOneArg(), + Args: seenCommand.validateArgs, } + seenCommand.cmd.Flags().StringVar(&seenCommand.box, "box", "", "Queue marking a whole box as seen (name or ID, as in hey box view)") return seenCommand } +func (c *seenCommand) validateArgs(cmd *cobra.Command, args []string) error { + if !cmd.Flags().Changed("box") { + return usageMinOneArg()(cmd, args) + } + if len(args) > 0 { + return apierr.ErrUsage("--box cannot be combined with box item IDs") + } + if strings.TrimSpace(c.box) == "" { + return apierr.ErrUsage("--box requires a box name or ID") + } + return nil +} + func (c *seenCommand) run(cmd *cobra.Command, args []string) error { if err := requireAuth(); err != nil { return err } + if c.box != "" { + box, err := resolveBox(cmd.Context(), c.box, "") + if err != nil { + return err + } + if box == nil || box.Id <= 0 { + return apierr.ErrAPI(0, "HEY returned no valid box ID") + } + if err := sdk.Boxes().MarkSeen(cmd.Context(), box.Id); err != nil { + return apierr.FromSDK(err) + } + return writeMutation(cmd, fmt.Sprintf("Queued marking %s as seen", terminal.SanitizeLine(box.Name)), nil) + } + ids, err := parseIntArgs(args) if err != nil { return err diff --git a/internal/cmd/seen_test.go b/internal/cmd/seen_test.go index e12d28cfc..5a0a8a4ec 100644 --- a/internal/cmd/seen_test.go +++ b/internal/cmd/seen_test.go @@ -2,16 +2,322 @@ package cmd import ( "bytes" + "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" + "reflect" "strings" + "sync" "testing" + "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/output" ) +type seenRequest struct { + Method string + Path string + Query string + Body string +} + +type recordedSeen struct { + mu sync.Mutex + requests []seenRequest + handler http.HandlerFunc +} + +func (s *recordedSeen) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + s.mu.Lock() + s.requests = append(s.requests, seenRequest{r.Method, r.URL.Path, r.URL.RawQuery, string(body)}) + s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if s.handler == nil { + http.NotFound(w, r) + return + } + s.handler(w, r) +} + +func (s *recordedSeen) snapshot() []seenRequest { + s.mu.Lock() + defer s.mu.Unlock() + return append([]seenRequest(nil), s.requests...) +} + +func TestSeenBoxQueuesTheResolvedBox(t *testing.T) { + recorded := &recordedSeen{handler: func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /paper_trail.json": + _, _ = io.WriteString(w, `{"id":7,"name":"Paper Trail","postings":[{"id":101},{"id":102}],"next_history_url":"/paper_trail.json?page=next-cursor"}`) + case "POST /boxes/7/observation.json": + w.WriteHeader(http.StatusCreated) + default: + http.NotFound(w, r) + } + }} + resp, err := runJSONCommand(t, recorded, "seen", "--box", "paper trail") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !resp.OK || resp.Summary != "Queued marking Paper Trail as seen" || resp.Data != nil { + t.Fatalf("response = %#v, want queued acknowledgment without posting data", resp) + } + want := []seenRequest{ + {Method: "GET", Path: "/paper_trail.json"}, + {Method: "POST", Path: "/boxes/7/observation.json"}, + } + if got := recorded.snapshot(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSeenIDsSendOnlyOneBulkRequest(t *testing.T) { + recorded := &recordedSeen{handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" && r.URL.Path == "/postings/seen.json" { + w.WriteHeader(http.StatusCreated) + return + } + http.NotFound(w, r) + }} + resp, err := runJSONCommand(t, recorded, "seen", "12345", "67890", "12345") + if err != nil { + t.Fatalf("execute: %v", err) + } + if !resp.OK || resp.Summary != "2 threads marked as seen" || resp.Data != nil { + t.Fatalf("response = %#v", resp) + } + want := []seenRequest{{Method: "POST", Path: "/postings/seen.json", Body: `{"posting_ids":[12345,67890]}`}} + if got := recorded.snapshot(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSeenBoxResolution(t *testing.T) { + for _, tc := range []struct { + name string + target string + reads []seenRequest + }{ + {"named", "PAPER TRAIL", []seenRequest{{Method: "GET", Path: "/paper_trail.json"}}}, + {"numeric", "7", []seenRequest{{Method: "GET", Path: "/boxes/7.json"}}}, + {"fallback", "Receipts", []seenRequest{{Method: "GET", Path: "/boxes.json"}, {Method: "GET", Path: "/boxes/7.json"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + recorded := &recordedSeen{handler: func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /boxes.json": + _, _ = io.WriteString(w, `[{"id":7,"kind":"trailbox","name":"Receipts"}]`) + case "GET /paper_trail.json", "GET /boxes/7.json": + _, _ = io.WriteString(w, `{"id":7,"name":"Paper Trail","postings":[]}`) + case "POST /boxes/7/observation.json": + w.WriteHeader(http.StatusCreated) + default: + http.NotFound(w, r) + } + }} + resp, err := runJSONCommand(t, recorded, "seen", "--box", tc.target) + if err != nil || !resp.OK || resp.Summary != "Queued marking Paper Trail as seen" { + t.Fatalf("response = %#v, error = %v", resp, err) + } + want := append(tc.reads, seenRequest{Method: "POST", Path: "/boxes/7/observation.json"}) + if got := recorded.snapshot(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } + }) + } +} + +func TestSeenTargetValidationMakesNoRequests(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want string + }{ + {"missing", nil, "Usage: hey seen (... | --box )"}, + {"empty box", []string{"--box", ""}, "--box requires a box name or ID"}, + {"blank box", []string{"--box", " \t"}, "--box requires a box name or ID"}, + {"conflicting", []string{"12345", "--box", "paper trail"}, "--box cannot be combined with box item IDs"}, + {"empty box with IDs", []string{"12345", "--box", ""}, "--box cannot be combined with box item IDs"}, + } { + t.Run(tc.name, func(t *testing.T) { + recorded := &recordedSeen{} + // Account selection would read /identity.json if validation ran too late. + args := append([]string{"seen", "--account", "2"}, tc.args...) + resp, err := runJSONCommand(t, recorded, args...) + if err == nil || err.Error() != tc.want { + t.Fatalf("error = %v, want %q", err, tc.want) + } + if resp.OK || resp.Summary != "" || len(recorded.snapshot()) != 0 { + t.Fatalf("response = %#v, requests = %#v; want no success or requests", resp, recorded.snapshot()) + } + }) + } +} + +func TestSeenBoxLookupFailureDoesNotMutate(t *testing.T) { + for _, tc := range []struct { + name string + target string + path string + status int + body string + code string + }{ + {"unknown", "missing", "/boxes.json", 200, `[]`, apierr.CodeNotFound}, + {"refused", "paper trail", "/paper_trail.json", 404, `{}`, apierr.CodeNotFound}, + {"nil box", "paper trail", "/paper_trail.json", 204, "", apierr.CodeAPI}, + {"zero ID", "paper trail", "/paper_trail.json", 200, `{"id":0,"name":"Paper Trail"}`, apierr.CodeAPI}, + {"negative ID", "paper trail", "/paper_trail.json", 200, `{"id":-1,"name":"Paper Trail"}`, apierr.CodeAPI}, + } { + t.Run(tc.name, func(t *testing.T) { + recorded := &recordedSeen{handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != tc.path { + http.NotFound(w, r) + return + } + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + }} + resp, err := runJSONCommand(t, recorded, "seen", "--box", tc.target) + var apiErr *apierr.Error + if !errors.As(err, &apiErr) || apiErr.Code != tc.code { + t.Fatalf("error = %v, want %s", err, tc.code) + } + if tc.code == apierr.CodeAPI && err.Error() != "HEY returned no valid box ID" { + t.Fatalf("error = %v, want invalid box ID error", err) + } + if resp.OK || resp.Summary != "" { + t.Fatalf("unexpected success: %#v", resp) + } + want := []seenRequest{{Method: "GET", Path: tc.path}} + if got := recorded.snapshot(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } + }) + } +} + +func TestSeenBoxMutationFailureIsNotRetriedOrReportedAsSuccess(t *testing.T) { + recorded := &recordedSeen{handler: func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /paper_trail.json": + _, _ = io.WriteString(w, `{"id":7,"name":"Paper Trail"}`) + case "POST /boxes/7/observation.json": + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, `{"error":"Service unavailable"}`) + default: + http.NotFound(w, r) + } + }} + stdout, err := runFormattedCommand(t, recorded, []string{"--json"}, "seen", "--box", "paper trail") + var apiErr *apierr.Error + if !errors.As(err, &apiErr) || apiErr.HTTPStatus != http.StatusServiceUnavailable { + t.Fatalf("error = %v, want API error with status 503", err) + } + if stdout != "" { + t.Fatalf("unexpected success output: %q", stdout) + } + want := []seenRequest{ + {Method: "GET", Path: "/paper_trail.json"}, + {Method: "POST", Path: "/boxes/7/observation.json"}, + } + if got := recorded.snapshot(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +// This checks the account filter on the wire, not which postings HEY changes. +func TestSeenBoxUsesTheSelectedAccountForLookupAndMutation(t *testing.T) { + recorded := &recordedSeen{handler: func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /paper_trail.json": + _, _ = io.WriteString(w, `{"id":7,"name":"Paper Trail"}`) + case "POST /boxes/7/observation.json": + w.WriteHeader(http.StatusCreated) + default: + http.NotFound(w, r) + } + }} + server := linkedAccountServer(t, recorded.ServeHTTP) + stdout, err := runAccountsCLI(t, server, "seen", "--box", "paper trail", "--account", "2") + if err != nil { + t.Fatalf("execute: %v", err) + } + var resp output.Response + if err := json.Unmarshal([]byte(stdout), &resp); err != nil || !resp.OK { + t.Fatalf("response = %q, decode error = %v", stdout, err) + } + want := []seenRequest{ + {Method: "GET", Path: "/paper_trail.json", Query: "filtered_account_id=2"}, + {Method: "POST", Path: "/boxes/7/observation.json", Query: "filtered_account_id=2"}, + } + if got := recorded.snapshot(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSeenBoxCancellationDuringLookupDoesNotMutate(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + recorded := &recordedSeen{handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && r.URL.Path == "/paper_trail.json" { + cancel() + _, _ = io.WriteString(w, `{"id":7,"name":"Paper Trail"}`) + return + } + http.NotFound(w, r) + }} + server := httptest.NewServer(recorded) + t.Cleanup(server.Close) + t.Setenv("HEY_TOKEN", "test-token") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + t.Setenv("HEY_ACCOUNT_ID", "") + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetArgs([]string{"seen", "--box", "paper trail", "--json", "--base-url", server.URL}) + if err := root.ExecuteContext(ctx); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { + t.Fatalf("error = %v, want cancellation", err) + } + if stdout.Len() != 0 { + t.Fatalf("unexpected success output: %q", stdout.String()) + } + want := []seenRequest{{Method: "GET", Path: "/paper_trail.json"}} + if got := recorded.snapshot(); !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestSeenBoxStyledAcknowledgmentSanitizesTheName(t *testing.T) { + recorded := &recordedSeen{handler: func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /paper_trail.json": + _, _ = io.WriteString(w, `{"id":7,"name":"\u001b[31mPaper\u001b[0m\nTrail\u0007"}`) + case "POST /boxes/7/observation.json": + w.WriteHeader(http.StatusCreated) + default: + http.NotFound(w, r) + } + }} + stdout, err := runStyledCommand(t, recorded, "seen", "--box", "paper trail") + if err != nil { + t.Fatalf("execute: %v", err) + } + if want := "Queued marking Paper Trail as seen.\n"; stdout != want { + t.Fatalf("output = %q, want %q", stdout, want) + } +} + func seenServer(t *testing.T) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 639ed2193..6d6f658c1 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -554,11 +554,12 @@ state, undo URL and undo command. `undo` works only while HEY's undo window is o ```bash hey seen 12345 # Mark a thread as seen hey seen 12345 67890 # Mark multiple threads as seen +hey seen --box "paper trail" # Queue marking a whole box as seen hey unseen 12345 # Mark a thread as unseen hey unseen 12345 67890 # Mark multiple threads as unseen ``` -Takes box item IDs (the `id` field from `hey box view` output). +Takes box item IDs (the `id` field from `hey box view` output). Alternatively, `hey seen --box ` resolves a box like `hey box view`, using the current account selection; it cannot be combined with item IDs. Whole-box work is queued, so success acknowledges the request and threads may still appear unseen immediately afterward. ### Email - Moving Threads