From cde18e6e389e999522f84864d0d61af8b8176ba1 Mon Sep 17 00:00:00 2001 From: jeissonneira Date: Tue, 1 Sep 2026 14:28:02 -0500 Subject: [PATCH 1/4] Add hey thread comment for private thread notes Posts an internal note via PostForm to /topics/{id}/comments, not a mailed reply. --- .surface | 2 + API-COVERAGE.md | 3 +- internal/cmd/thread_comment.go | 73 +++++++++++++++++ internal/cmd/thread_comment_test.go | 118 ++++++++++++++++++++++++++++ internal/cmd/topic.go | 3 +- skills/hey/SKILL.md | 12 ++- 6 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 internal/cmd/thread_comment.go create mode 100644 internal/cmd/thread_comment_test.go diff --git a/.surface b/.surface index 4ebe280ab..dfc4f3894 100644 --- a/.surface +++ b/.surface @@ -359,6 +359,8 @@ hey snippet update --name hey spam hey stop-ignoring hey thread +hey thread comment +hey thread comment --message hey thread read hey thread read --allow-partial hey timetrack diff --git a/API-COVERAGE.md b/API-COVERAGE.md index ee2e73773..84e5cd342 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -70,7 +70,8 @@ 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}.json` | GET | SDK `Topics().Get` | `hey forward `, `hey reply `, TUI `r` | covered | +| `/topics/{id}.json` | GET | SDK `Topics().Get` | `hey forward `, `hey reply `, `hey thread comment `, TUI `r` | covered | +| `/topics/{id}/comments` | POST | `Client.PostForm` (not generated — no OpenAPI operation) | `hey thread comment ` | 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 | | `/bulk_replies.json` | POST | SDK `BulkReplies().Send` | `hey bulk-reply send`, TUI bulk reply | covered | diff --git a/internal/cmd/thread_comment.go b/internal/cmd/thread_comment.go new file mode 100644 index 000000000..24009334c --- /dev/null +++ b/internal/cmd/thread_comment.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/output" +) + +// threadCommentCommand posts a private internal note on a thread. This is not in the +// SDK's OpenAPI surface, so it goes straight through Client.PostForm rather than a typed +// service — the same way Collections and Publications reach the form endpoints HEY has +// no JSON for. +type threadCommentCommand struct { + cmd *cobra.Command + message string +} + +func newThreadCommentCommand() *threadCommentCommand { + commentCommand := &threadCommentCommand{} + commentCommand.cmd = &cobra.Command{ + Use: "comment ", + Short: "Add a private internal note to a thread", + Long: "Add a private internal note to a thread. This is visible only to you and anyone else on the account — it is not mailed to anyone. Use hey reply to send a mailed reply instead.", + Example: ` hey thread comment 12345 -m "Following up with accounting on this."`, + Annotations: map[string]string{ + "agent_notes": "Posts a private internal note on the thread, not a mailed reply — use hey reply for that. Accepts the topic_id from hey box view, hey label view, or hey search output. The note is plain text; Markdown is not converted.", + }, + RunE: commentCommand.run, + Args: usageExactOneArg(), + } + commentCommand.cmd.Flags().StringVarP(&commentCommand.message, "message", "m", "", "Note text (required)") + + return commentCommand +} + +func (c *threadCommentCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + threadID, err := parsePositiveID(args[0], "thread") + if err != nil { + return err + } + message := strings.TrimSpace(c.message) + if message == "" { + return apierr.ErrUsage("--message is required") + } + + topic, err := rootSDK.Topics().Get(cmd.Context(), threadID) + if err != nil { + return apierr.FromSDK(err) + } + if topic == nil || topic.AccountId == 0 { + return apierr.ErrAPI(0, fmt.Sprintf("thread %d did not identify its mail account", threadID)) + } + + values := url.Values{} + values.Set("comment[content]", message) + path := fmt.Sprintf("/topics/%d/comments?account_id=%d", threadID, topic.AccountId) + if _, err := rootSDK.PostForm(cmd.Context(), path, values); err != nil { + return apierr.FromSDK(err) + } + + return writeMutation(cmd, fmt.Sprintf("Comment added to thread %d", threadID), map[string]any{"thread_id": threadID}, + output.WithBreadcrumbs(output.Breadcrumb{Action: "read", Command: fmt.Sprintf("hey thread read %d", threadID), Description: "Read this thread"}), + ) +} diff --git a/internal/cmd/thread_comment_test.go b/internal/cmd/thread_comment_test.go new file mode 100644 index 000000000..65cdc7d4f --- /dev/null +++ b/internal/cmd/thread_comment_test.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "net/http" + "strings" + "testing" +) + +// threadCommentServer answers the fixture topic 42 (account 9) and records what a +// comment post carried. GET /imbox fails the test outright: a comment redirects to +// /imbox and the CLI must not follow it. +func threadCommentServer(t *testing.T) (http.Handler, *sentComment) { + t.Helper() + sent := &sentComment{} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/topics/42.json": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":42,"account_id":9}`)) + case r.Method == http.MethodPost && r.URL.Path == "/topics/42/comments": + if got := r.URL.Query().Get("account_id"); got != "9" { + t.Errorf("account_id = %q, want 9", got) + } + if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" { + t.Errorf("content-type = %q", got) + } + if got := r.Header.Get("Accept"); got != "*/*" { + t.Errorf("accept = %q", got) + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + sent.Content = r.PostForm.Get("comment[content]") + sent.Called = true + w.Header().Set("Location", "/imbox") + w.WriteHeader(http.StatusFound) + case r.URL.Path == "/imbox" || r.URL.Path == "/imbox.json": + t.Fatal("comment redirect was followed") + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }) + return handler, sent +} + +type sentComment struct { + Called bool + Content string +} + +func TestThreadCommentPostsPlainTextNote(t *testing.T) { + handler, sent := threadCommentServer(t) + response, err := runJSONCommand(t, handler, "thread", "comment", "42", "-m", "Following up with accounting on this.") + if err != nil { + t.Fatal(err) + } + if !sent.Called { + t.Fatal("expected a comment to be posted") + } + if sent.Content != "Following up with accounting on this." { + t.Errorf("content = %q", sent.Content) + } + if response.Summary != "Comment added to thread 42" { + t.Errorf("summary = %q", response.Summary) + } + if response.Data.(map[string]any)["thread_id"] != float64(42) { + t.Errorf("data = %#v", response.Data) + } +} + +func TestThreadCommentSendsMarkdownLookingTextVerbatim(t *testing.T) { + handler, sent := threadCommentServer(t) + _, err := runJSONCommand(t, handler, "thread", "comment", "42", "-m", "**Not** converted, _as-is_.") + if err != nil { + t.Fatal(err) + } + if sent.Content != "**Not** converted, _as-is_." { + t.Errorf("content = %q, want the Markdown-looking text sent verbatim", sent.Content) + } +} + +func TestThreadCommentStyledOutput(t *testing.T) { + handler, sent := threadCommentServer(t) + styled, err := runStyledCommand(t, handler, "thread", "comment", "42", "-m", "Noted.") + if err != nil { + t.Fatal(err) + } + if !sent.Called { + t.Fatal("expected a comment to be posted") + } + if !strings.Contains(styled, "Comment added to thread 42") { + t.Errorf("styled = %q", styled) + } +} + +func TestThreadCommentValidatesInput(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + tests := []struct { + name string + args []string + want string + }{ + {name: "missing message", args: []string{"thread", "comment", "42"}, want: "--message is required"}, + {name: "empty message", args: []string{"thread", "comment", "42", "-m", " "}, want: "--message is required"}, + {name: "invalid thread id", args: []string{"thread", "comment", "zero", "-m", "hi"}, want: "invalid thread ID: zero"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := runJSONCommand(t, handler, tt.args...) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want %q", err, tt.want) + } + }) + } +} diff --git a/internal/cmd/topic.go b/internal/cmd/topic.go index b315f1285..969c87784 100644 --- a/internal/cmd/topic.go +++ b/internal/cmd/topic.go @@ -54,10 +54,11 @@ func newThreadCommand() *cobra.Command { Use: "thread", Short: "Read email threads", Annotations: map[string]string{ - "agent_notes": "Subcommands: read. Thread IDs come from hey box view. Use the same ID with hey reply or hey forward.", + "agent_notes": "Subcommands: read, comment. Thread IDs come from hey box view. Use the same ID with hey reply or hey forward. hey thread comment posts a private internal note, not a mailed reply.", }, } thread.AddCommand(newThreadsCommand().cmd) + thread.AddCommand(newThreadCommentCommand().cmd) return thread } diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 2ba84aab2..1e25eb13d 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -20,6 +20,7 @@ triggers: - hey search - hey contact - hey thread read + - hey thread comment - hey share - hey unshare - hey reply @@ -193,6 +194,7 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box | Set private contact note | `hey contact note set "Prefers email"` | | Delete private contact note | `hey contact note delete ` | | Read email thread | `hey thread read --json` | +| Add a private note to a thread (not mailed) | `hey thread comment -m "Following up with accounting on this."` | | Get a sharing link | `hey share ` | | Turn off a sharing link | `hey unshare ` | | Reply to email | `hey reply -m "Friday works for me."` | @@ -277,6 +279,7 @@ Want to read email? ├── Need available refinements? → hey search filters --json ├── List or view contacts? → hey contact list --json / hey contact show --json ├── Read full thread? → hey thread read --json +├── Add a private note to a thread (not mailed)? → hey thread comment -m "note" ├── Get a sharing link? → hey share ├── Turn off the sharing link? → hey unshare ├── Mark as seen? → hey seen @@ -342,7 +345,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`. The reverse is not caught yet: `hey seen`, `hey unseen` and `hey move` silently ignore any id that is not one of your box items — a `topic_id`, a typo — and still answer success counting every id given, because HEY's endpoints do not report a non-match. Every id in the same call that *is* one of your box items is changed, so a mixed batch is a partial success reported as a whole one, and a `topic_id` that happens to equal one of your other box item ids marks or moves that unrelated thread. Confirm rather than trust the envelope: `hey box view --json --all --jq '{notice, next_page: .data.next_page, match: [.data.postings[] | select(.id == ) | {id, topic_id, seen}]}'` for a mark, or the destination box for a move. `--all` reads every page up to the command's cap of 101; an empty `match` with `next_page` still set means the box is larger than that, so continue with `--page ` rather than calling it a non-match, and `topic_id` in the match says which thread an id actually named. `hey trash`, `hey spam` and the label commands do answer `not_found`. +**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 thread comment`, `hey reply`, `hey forward`, `hey share` and `hey attachment list`. A box item `id` passed to `hey thread read` answers `not_found`. The reverse is not caught yet: `hey seen`, `hey unseen` and `hey move` silently ignore any id that is not one of your box items — a `topic_id`, a typo — and still answer success counting every id given, because HEY's endpoints do not report a non-match. Every id in the same call that *is* one of your box items is changed, so a mixed batch is a partial success reported as a whole one, and a `topic_id` that happens to equal one of your other box item ids marks or moves that unrelated thread. Confirm rather than trust the envelope: `hey box view --json --all --jq '{notice, next_page: .data.next_page, match: [.data.postings[] | select(.id == ) | {id, topic_id, seen}]}'` for a mark, or the destination box for a move. `--all` reads every page up to the command's cap of 101; an empty `match` with `next_page` still set means the box is larger than that, so continue with `--page ` rather than calling it a non-match, and `topic_id` in the match says which thread an id actually named. `hey trash`, `hey spam` and the label commands do answer `not_found`. 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 +406,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 thread comment`, `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. ### Contacts @@ -434,6 +437,7 @@ HEY hides contacts instead of permanently deleting them. A hidden contact leaves ```bash hey thread read --json # Read full email thread hey thread read --html # Read with raw HTML content +hey thread comment -m "Following up with accounting on this." # Private note, not mailed hey share # Get a sharing link hey unshare # Turn off the sharing link ``` @@ -444,9 +448,11 @@ tables and code survive and links keep their URLs — read it as structure rathe flattened text. `--html` returns the original HTML instead. There is no `recipients` field on an entry; use `hey reply`, which works the addressing out itself. +`hey thread comment` adds a **private internal note** to the thread — visible only to you and anyone else on the account, never mailed to anyone. It is not `hey reply`: nothing is sent, and there is no recipient. Unlike other `-m` flags in this CLI, the text is posted exactly as typed rather than converted from Markdown. + `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 — except `hey seen`, `hey unseen` and `hey move`, which ignore an unmatched id, act on every id that does match, and answer success either way (confirm with `hey box view --json --all`, as in the note above). +**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 thread comment`, `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 — except `hey seen`, `hey unseen` and `hey move`, which ignore an unmatched id, act on every id that does match, and answer success either way (confirm with `hey box view --json --all`, as in the note above). `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). From 755ad8a1889a85b557b3a1108c497d0e178fc3c6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 10 Sep 2026 14:32:43 -0700 Subject: [PATCH 2/4] Move the thread comment notes to docs/cli.md, where the manual now lives --- docs/cli.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index 3e66e59a3..583fd97e7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -217,6 +217,7 @@ hey screener clear # empty the queue without deciding hey thread read 123 # read a full email thread hey thread read 123 --markdown # the thread as one Markdown document hey thread read 123 --html > 123.html # HEY's original HTML, to a file +hey thread comment 123 -m "Following up with accounting on this." # a private note, not a mailed reply hey share 123 # get a sharing link for a thread hey unshare 123 # turn off the sharing link hey attachment list 123 # list files attached to the thread @@ -256,7 +257,7 @@ hey ignore 12345 # ignore future activity on a thread hey stop-ignoring 12345 # resume attention for a thread ``` -`hey thread read` reads a whole thread, oldest entry first, however many pages HEY serves it in — within limits it states: a hundred pages past the first, two thousand entries, as many bodies, 64 MiB of content and two minutes in all. A thread that could only be read in part — a body HEY would not serve, a limit reached — is refused rather than passed off as whole; `--allow-partial` takes what was read, with a `notice` saying what is missing and each entry's `body_state` saying whether its body was `hydrated`, `bodyless` (HEY served none), `over_limit` or `failed`. `--count` and `--ids-only` read the entry index and no bodies, so only a truncated index can make them partial. `--markdown` writes the thread as one Markdown document — a heading per entry naming the sender, date and ID, then the body — which is the shape to hand an agent or a notes app. `hey attachment list` reads the bodies in every format, since that is where attachment metadata lives, and answers a partial thread the same way. `hey reply` answers the thread's latest entry and addresses the reply the way HEY does: it asks HEY for the reply's recipients — everyone that entry was addressed to, its sender moved onto the To line, and your own addresses, aliases and catch-alls excluded — falling back to computing them from the entry when that read is unavailable. +`hey thread read` reads a whole thread, oldest entry first, however many pages HEY serves it in — within limits it states: a hundred pages past the first, two thousand entries, as many bodies, 64 MiB of content and two minutes in all. A thread that could only be read in part — a body HEY would not serve, a limit reached — is refused rather than passed off as whole; `--allow-partial` takes what was read, with a `notice` saying what is missing and each entry's `body_state` saying whether its body was `hydrated`, `bodyless` (HEY served none), `over_limit` or `failed`. `--count` and `--ids-only` read the entry index and no bodies, so only a truncated index can make them partial. `--markdown` writes the thread as one Markdown document — a heading per entry naming the sender, date and ID, then the body — which is the shape to hand an agent or a notes app. `hey attachment list` reads the bodies in every format, since that is where attachment metadata lives, and answers a partial thread the same way. `hey reply` answers the thread's latest entry and addresses the reply the way HEY does: it asks HEY for the reply's recipients — everyone that entry was addressed to, its sender moved onto the To line, and your own addresses, aliases and catch-alls excluded — falling back to computing them from the entry when that read is unavailable. `hey thread comment -m ` is different from `hey reply`: it adds a private internal note to the thread rather than mailing anyone, and — unlike every other `-m` in this CLI — the text is sent exactly as typed rather than converted from Markdown. Email bodies come back as Markdown. `hey thread read` and the TUI render that Markdown for the terminal — headings, emphasis, lists, quotes, tables and code survive, and links keep their URLs and stay clickable where the terminal supports it. `--json` carries the same Markdown in `body`, so an agent reading a thread sees the structure a human sees rather than a flattened wall of text. `--html` still returns HEY's original HTML. From d88a059029ac82635681b76f2aa1848e49ad8c6b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 10 Sep 2026 14:35:23 -0700 Subject: [PATCH 3/4] Send the note as typed, name the write in thread's Short, and note the Markdown exception Trimming only decides whether -m is blank; the text itself goes to HEY untouched, as the command documents. The parent command's listing now says it writes as well as reads, and the skill's Markdown-by-default sentence names thread comment as the one exception. --- internal/cmd/help_test.go | 2 +- internal/cmd/thread_comment.go | 5 ++--- internal/cmd/thread_comment_test.go | 16 +++++++++------- internal/cmd/topic.go | 2 +- skills/hey/SKILL.md | 3 ++- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index a09d6a831..0ca8f5220 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -107,7 +107,7 @@ USAGE CORE COMMANDS tui Launch the interactive terminal UI box List HEY boxes and their email threads - thread Read email threads + thread Read and annotate email threads reply Reply to a thread compose Write and send a new email search Search email threads and messages diff --git a/internal/cmd/thread_comment.go b/internal/cmd/thread_comment.go index 24009334c..8bcdf986d 100644 --- a/internal/cmd/thread_comment.go +++ b/internal/cmd/thread_comment.go @@ -47,8 +47,7 @@ func (c *threadCommentCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return err } - message := strings.TrimSpace(c.message) - if message == "" { + if strings.TrimSpace(c.message) == "" { return apierr.ErrUsage("--message is required") } @@ -61,7 +60,7 @@ func (c *threadCommentCommand) run(cmd *cobra.Command, args []string) error { } values := url.Values{} - values.Set("comment[content]", message) + values.Set("comment[content]", c.message) path := fmt.Sprintf("/topics/%d/comments?account_id=%d", threadID, topic.AccountId) if _, err := rootSDK.PostForm(cmd.Context(), path, values); err != nil { return apierr.FromSDK(err) diff --git a/internal/cmd/thread_comment_test.go b/internal/cmd/thread_comment_test.go index 65cdc7d4f..1f43041ca 100644 --- a/internal/cmd/thread_comment_test.go +++ b/internal/cmd/thread_comment_test.go @@ -70,13 +70,15 @@ func TestThreadCommentPostsPlainTextNote(t *testing.T) { } func TestThreadCommentSendsMarkdownLookingTextVerbatim(t *testing.T) { - handler, sent := threadCommentServer(t) - _, err := runJSONCommand(t, handler, "thread", "comment", "42", "-m", "**Not** converted, _as-is_.") - if err != nil { - t.Fatal(err) - } - if sent.Content != "**Not** converted, _as-is_." { - t.Errorf("content = %q, want the Markdown-looking text sent verbatim", sent.Content) + for _, message := range []string{"**Not** converted, _as-is_.", " indented, with a trailing newline\n"} { + handler, sent := threadCommentServer(t) + _, err := runJSONCommand(t, handler, "thread", "comment", "42", "-m", message) + if err != nil { + t.Fatal(err) + } + if sent.Content != message { + t.Errorf("content = %q, want %q sent verbatim", sent.Content, message) + } } } diff --git a/internal/cmd/topic.go b/internal/cmd/topic.go index 969c87784..8e22e753e 100644 --- a/internal/cmd/topic.go +++ b/internal/cmd/topic.go @@ -52,7 +52,7 @@ type topicCommand struct { func newThreadCommand() *cobra.Command { thread := &cobra.Command{ Use: "thread", - Short: "Read email threads", + Short: "Read and annotate email threads", Annotations: map[string]string{ "agent_notes": "Subcommands: read, comment. Thread IDs come from hey box view. Use the same ID with hey reply or hey forward. hey thread comment posts a private internal note, not a mailed reply.", }, diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 1e25eb13d..05255cab7 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -491,7 +491,8 @@ A reply HEY cannot address is saved as a draft rather than sent, so the command rather than guessing when it cannot work out the recipients. Everything you send is Markdown by default — `-m`, `--content`, `--note`, positional -content, stdin, and `$EDITOR` alike — and is converted to rich text on the way out. To +content, stdin, and `$EDITOR` alike — and is converted to rich text on the way out. The one +exception is `hey thread comment`, whose `-m` is posted exactly as typed. To send raw HTML instead, use the flag's HTML twin: `--message-html` on `compose`, `reply`, `forward`, and `bulk-reply send`; `--content-html` on `journal write` and `snippet create`/`update`; `--note-html` on `contacts note set`. Each pair is mutually From bab600a8e19cc767bf66b14b80fa2cb67d735a35 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 24 Oct 2026 16:45:26 -0700 Subject: [PATCH 4/4] Post thread notes through HEY's JSON endpoint HEY now serves notes as JSON (POST /topics/:id/comments.json, and GET /topics/:id/comments/new.json for who a note would reach), and the SDK wraps both as Topics().CreateComment and GetCommentAudience. Use them in place of the web form: the answer carries the note's id and who sees it, a blank note is a 422 rather than a redirect read as success, and the extra topic read for its account id goes away. - Name the note's id and visible_to in the answer; --dry-run asks who a note would reach without adding one. - Convert -m from Markdown like every other -m, so text such as "<" and "&" is escaped into the HTML HEY stores; --message-html sends HTML. - Correct the privacy wording: a note is never emailed, but everyone with access to the thread sees it, HEY for Domains teammates included. - Replace the hand-written API-COVERAGE row with the two typed operations. --- .surface | 2 + API-COVERAGE.md | 5 +- docs/cli.md | 6 +- internal/cmd/thread_comment.go | 155 ++++++++++++++++---- internal/cmd/thread_comment_test.go | 220 +++++++++++++++++++++------- internal/cmd/topic.go | 2 +- skills/hey/SKILL.md | 5 +- 7 files changed, 305 insertions(+), 90 deletions(-) diff --git a/.surface b/.surface index 96e1a8bcb..2a1da24b5 100644 --- a/.surface +++ b/.surface @@ -378,7 +378,9 @@ hey spam hey stop-ignoring hey thread hey thread comment +hey thread comment --dry-run hey thread comment --message +hey thread comment --message-html hey thread read hey thread read --allow-partial hey thread update diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 2f091ebca..7306f665c 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -76,8 +76,9 @@ forward answers. The drafts index (`/entries/drafts.json`) pages the same way, w | 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}.json` | GET | SDK `Topics().Get` | `hey forward `, `hey reply `, `hey thread comment `, TUI `r` | covered | -| `/topics/{id}/comments` | POST | `Client.PostForm` (not generated — no OpenAPI operation) | `hey thread comment ` | covered | +| `/topics/{id}.json` | GET | SDK `Topics().Get` | `hey forward `, `hey reply `, TUI `r` | covered | +| `/topics/{id}/comments.json` | POST | SDK `Topics().CreateComment` | `hey thread comment ` | covered | +| `/topics/{id}/comments/new.json` | GET | SDK `Topics().GetCommentAudience` | `hey thread comment --dry-run` | 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 | | `/bulk_replies.json` | POST | SDK `BulkReplies().Send` | `hey bulk-reply send`, TUI bulk reply | covered | diff --git a/docs/cli.md b/docs/cli.md index 5b8d26fb5..845c102e4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -226,7 +226,7 @@ hey screener clear # trash everything waiting, deciding no one hey thread read 123 # read a full email thread hey thread read 123 --markdown # the thread as one Markdown document hey thread read 123 --html > 123.html # HEY's original HTML, to a file -hey thread comment 123 -m "Following up with accounting on this." # a private note, not a mailed reply +hey thread comment 123 -m "Following up with accounting on this." # a note on the thread, not mailed hey thread update 123 --name "Kitchen renovation quotes" # rename a thread (or: hey thread rename) hey share 123 # get a sharing link for a thread hey unshare 123 # turn off the sharing link @@ -273,7 +273,7 @@ hey stop-ignoring 12345 # resume attention for a thread `hey thread update --name ` renames a thread, as HEY's web app does: the new subject is what your boxes and the thread show, for everyone in your account with access to the thread; nothing is emailed, so the other people on it keep the subject they received, and messages already sent keep the subject they went out with. `rename` and `edit` are aliases. The name is trimmed and required — HEY would store a blank as "No subject" — and at most 1024 bytes, where HEY would otherwise cut it without saying so. A thread HEY merged into another is not renamed: the command fails rather than confirming a rename that did not happen, so rename the thread it was merged into instead. A collection's discussion thread is named after its collection — HEY accepts the rename but keeps showing the collection's name — so rename it with `hey collection update`. -`hey thread comment -m ` is different from `hey reply`: it adds a private internal note to the thread rather than mailing anyone, and — unlike every other `-m` in this CLI — the text is sent exactly as typed rather than converted from Markdown. +`hey thread comment -m ` adds a note to the thread. It is not `hey reply`: HEY never emails a note to anyone. It is not private either: everyone with access to the thread sees it, and on a HEY for Domains account that includes your teammates on it; on a thread no one else can reach it is a note to self. The answer names the note's `id` and `visible_to`, the other people who see it (`[]` for a note to self), and `collection_only`, true on a collection's own thread. `--dry-run` asks HEY who a note would reach and adds nothing. The note is Markdown like every other `-m`, so plain text such as `1 < 2 & 3` arrives as typed; `--message-html` sends raw HTML instead. A blank note is refused. `hey thread read` reads a whole thread, oldest entry first, however many pages HEY serves it in — within limits it states: a hundred pages past the first, two thousand entries, as many bodies, 64 MiB of retained thread data and two minutes in all. The byte budget covers entry-index metadata, message bodies and metadata, the recipient identities retained for thread output, and inbound delivery addresses and resolved contact identities retained for JSON. A thread that could only be read in part — a body HEY would not serve, a limit reached — is refused rather than passed off as whole; `--allow-partial` takes what was read, with a `notice` saying what is missing and each entry's `body_state` saying whether its body was `hydrated`, `bodyless` (HEY served none), `over_limit` or `failed`. Each entry whose message was read carries `recipients`, with `to`, `cc` and `bcc` contact lists; the object is absent when the message was not read, while a known-empty line is `[]`. `creator` is the entry's author — the external sender for inbound mail, your own contact for mail you sent; if you sent from a different address (an alias, custom domain or external account), the hydrated entry also has `sender` with that address, and the displayed From uses it. A missing `sender` does not mean the same sender was verified: the index-only formats and entries whose message was not read have no sender data. In JSON, an inbound entry also carries `received_via`: every exact account address HEY recorded the message arriving through, including plus tags and catch-all aliases. These are delivery records, not the visible To/CC/BCC recipients. A record's `contact` is optional and is omitted when HEY did not resolve one; the whole field is omitted for sent or generated messages and whenever the message was not read. `--count` and `--ids-only` read the entry index and no messages, so only a truncated index can make them partial. `--markdown` writes the thread as one Markdown document — a heading per entry naming the sender, date and ID, then the body — which is the shape to hand an agent or a notes app. An entry's `kind` is `message` for mail; a note (`comment`) or share notice (`access_notice`) is internal — visible to everyone with access to the thread, never emailed — and `--markdown`, styled and `--html` output head it "Note by …" or "… shared this thread" instead of a From line. `hey attachment list` reads the bodies in every format, since that is where attachment metadata lives, and answers a partial thread the same way. `hey reply` answers the thread's latest emailed message — never a note or share notice posted after it, as in HEY's web app; a thread with nothing else is refused, and `hey forward` and `hey compose --thread-id` pick the same message — and addresses the reply the way HEY does: it asks HEY for the reply's recipients — everyone that entry was addressed to, its sender moved onto the To line, and your own addresses, aliases and catch-alls excluded — falling back to the latest message's metadata for a send when that read is unavailable. @@ -283,7 +283,7 @@ Repeatable `hey reply --to`, `--cc` and `--bcc` flags add recipients to that env Email bodies come back as Markdown. `hey thread read` and the TUI render that Markdown for the terminal — headings, emphasis, lists, quotes, tables and code survive, and links keep their URLs and stay clickable where the terminal supports it. `--json` carries the same Markdown in `body`, so an agent reading a thread sees the structure a human sees rather than a flattened wall of text. `--html` keeps HEY's original body HTML and frames each entry with its From, To, CC and BCC headers. -Writing is Markdown too, for message bodies, drafts, journal entries, snippets and contact notes: `-m`, `--content`, `--note`, positional content, stdin, and `$EDITOR` (which opens prefilled with the existing entry or note as Markdown; for a journal entry or contact note whose Markdown would drop part of it, the editor is refused and `--content-html` or `--note-html` is the way to change it). Every such flag has a raw-HTML twin — `--message-html`, `--content-html`, `--note-html` — for sending markup verbatim; each pair is mutually exclusive. HEY serves a journal entry, a snippet's `content_html` and a contact's `note_html` inside its editor's `
` wrapper, so `--content-html` and `--note-html` take that wrapper off, and HTML read back and written again does not sink one level deeper each time. The TUI's compose and bulk-reply forms convert Markdown the same way, and the compose editor renders it live as you type — `**bold**` turns bold, markers and all. A fenced code block's language (` ```ruby `) is carried the way HEY's own editor stores it, so the web app syntax-highlights it — for the languages HEY highlights (Ruby, Python, JavaScript, TypeScript, Go, Rust, Java, C#, C++, PHP, Swift, HTML, CSS); any other is dropped. Clip passages, event notes and time track notes are plain text. +Writing is Markdown too, for message bodies, drafts, thread notes, journal entries, snippets and contact notes: `-m`, `--content`, `--note`, positional content, stdin, and `$EDITOR` (which opens prefilled with the existing entry or note as Markdown; for a journal entry or contact note whose Markdown would drop part of it, the editor is refused and `--content-html` or `--note-html` is the way to change it). Every such flag has a raw-HTML twin — `--message-html`, `--content-html`, `--note-html` — for sending markup verbatim; each pair is mutually exclusive. HEY serves a journal entry, a snippet's `content_html` and a contact's `note_html` inside its editor's `
` wrapper, so `--content-html` and `--note-html` take that wrapper off, and HTML read back and written again does not sink one level deeper each time. The TUI's compose and bulk-reply forms convert Markdown the same way, and the compose editor renders it live as you type — `**bold**` turns bold, markers and all. A fenced code block's language (` ```ruby `) is carried the way HEY's own editor stores it, so the web app syntax-highlights it — for the languages HEY highlights (Ruby, Python, JavaScript, TypeScript, Go, Rust, Java, C#, C++, PHP, Swift, HTML, CSS); any other is dropped. Clip passages, event notes and time track notes are plain text. Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown, `hey draft edit` revises it (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the CLI has no flag for it, and HEY's API schedules only to a whole hour — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides. diff --git a/internal/cmd/thread_comment.go b/internal/cmd/thread_comment.go index 8bcdf986d..7e289a8e4 100644 --- a/internal/cmd/thread_comment.go +++ b/internal/cmd/thread_comment.go @@ -2,38 +2,66 @@ package cmd import ( "fmt" - "net/url" "strings" "github.com/spf13/cobra" + "github.com/basecamp/hey-sdk/go/pkg/generated" + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/htmlutil" "github.com/basecamp/hey-cli/internal/output" + "github.com/basecamp/hey-cli/internal/terminal" ) -// threadCommentCommand posts a private internal note on a thread. This is not in the -// SDK's OpenAPI surface, so it goes straight through Client.PostForm rather than a typed -// service — the same way Collections and Publications reach the form endpoints HEY has -// no JSON for. +// threadCommentCommand adds a note to a thread through the SDK's Topics().CreateComment +// (POST /topics/{id}/comments.json), and with --dry-run asks Topics().GetCommentAudience +// (GET /topics/{id}/comments/new.json) who the note would reach without adding it. type threadCommentCommand struct { - cmd *cobra.Command - message string + cmd *cobra.Command + message string + messageHTML string + dryRun bool +} + +// threadCommentResult is what the command answers: the note HEY created (absent on a dry +// run) and who sees it besides its author. VisibleTo is empty for a note to self, and +// absent only when HEY did not say. +type threadCommentResult struct { + ID int64 `json:"id,omitempty"` + ThreadID int64 `json:"thread_id"` + VisibleTo []threadContact `json:"visible_to"` + CollectionOnly bool `json:"collection_only"` + DryRun bool `json:"dry_run,omitempty"` } func newThreadCommentCommand() *threadCommentCommand { commentCommand := &threadCommentCommand{} commentCommand.cmd = &cobra.Command{ - Use: "comment ", - Short: "Add a private internal note to a thread", - Long: "Add a private internal note to a thread. This is visible only to you and anyone else on the account — it is not mailed to anyone. Use hey reply to send a mailed reply instead.", - Example: ` hey thread comment 12345 -m "Following up with accounting on this."`, + Use: "comment ", + Short: "Add a note to a thread", + Long: "Add a note to a thread. A note is internal: HEY never emails it to anyone, but " + + "everyone with access to the thread sees it — on a HEY for Domains account that " + + "includes your teammates on it. On a thread no one else can reach, it is a note to " + + "self. The note is Markdown; use --message-html to send raw HTML instead. " + + "--dry-run shows who would see the note without adding it. " + + "Use hey reply to send a mailed reply instead.", + Example: ` hey thread comment 12345 -m "Following up with accounting on this." + hey thread comment 12345 --dry-run`, Annotations: map[string]string{ - "agent_notes": "Posts a private internal note on the thread, not a mailed reply — use hey reply for that. Accepts the topic_id from hey box view, hey label view, or hey search output. The note is plain text; Markdown is not converted.", + "agent_notes": "Adds a note (an entry of kind \"comment\") to the thread by its topic_id, from hey box view, hey label view or hey search; it is not a mailed reply — use hey reply for that. " + + "The note is never emailed, but it is not private: everyone with access to the thread sees it, including HEY for Domains teammates. " + + "The answer names the note's id and visible_to, the other users who see it (empty for a note to self); collection_only is true on a collection's own thread. " + + "--dry-run needs no message (one given is not sent), adds nothing, and answers visible_to and collection_only for the thread — run it first when the audience matters. " + + "The message is Markdown via -m, or raw HTML via --message-html; a blank message is refused.", }, RunE: commentCommand.run, Args: usageExactOneArg(), } - commentCommand.cmd.Flags().StringVarP(&commentCommand.message, "message", "m", "", "Note text (required)") + commentCommand.cmd.Flags().StringVarP(&commentCommand.message, "message", "m", "", "Note as Markdown (required unless --dry-run)") + commentCommand.cmd.Flags().StringVar(&commentCommand.messageHTML, "message-html", "", "Note as raw HTML instead of Markdown") + commentCommand.cmd.Flags().BoolVar(&commentCommand.dryRun, "dry-run", false, "Show who would see the note without adding it") + commentCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html") return commentCommand } @@ -47,26 +75,101 @@ func (c *threadCommentCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return err } - if strings.TrimSpace(c.message) == "" { - return apierr.ErrUsage("--message is required") - } - topic, err := rootSDK.Topics().Get(cmd.Context(), threadID) - if err != nil { - return apierr.FromSDK(err) + if c.dryRun { + audience, audienceErr := sdk.Topics().GetCommentAudience(cmd.Context(), threadID) + if audienceErr != nil { + return apierr.FromSDK(audienceErr) + } + result := threadCommentResult{ + ThreadID: threadID, + VisibleTo: threadContacts(audience.VisibleTo), + CollectionOnly: audience.CollectionOnly, + DryRun: true, + } + return writeMutationLine(cmd, + fmt.Sprintf("A note on thread %d would be %s.", threadID, describeNoteAudience(audience.VisibleTo, audience.CollectionOnly)), + fmt.Sprintf("Dry run: a note on thread %d would reach %s", threadID, countNoteAudience(audience.VisibleTo)), + result, + ) } - if topic == nil || topic.AccountId == 0 { - return apierr.ErrAPI(0, fmt.Sprintf("thread %d did not identify its mail account", threadID)) + + content := threadCommentContent(c.message, c.messageHTML) + if content == "" { + return apierr.ErrUsage("a note is required (use -m or --message-html )") } - values := url.Values{} - values.Set("comment[content]", c.message) - path := fmt.Sprintf("/topics/%d/comments?account_id=%d", threadID, topic.AccountId) - if _, err := rootSDK.PostForm(cmd.Context(), path, values); err != nil { + note, err := sdk.Topics().CreateComment(cmd.Context(), threadID, content) + if err != nil { return apierr.FromSDK(err) } - return writeMutation(cmd, fmt.Sprintf("Comment added to thread %d", threadID), map[string]any{"thread_id": threadID}, + result := threadCommentResult{ + ID: note.Id, + ThreadID: threadID, + CollectionOnly: note.CollectionOnly, + } + line := fmt.Sprintf("Note %d added to thread %d.", note.Id, threadID) + if note.VisibleTo != nil { + result.VisibleTo = threadContacts(note.VisibleTo) + line = fmt.Sprintf("Note %d added to thread %d, %s.", note.Id, threadID, describeNoteAudience(note.VisibleTo, note.CollectionOnly)) + } + return writeMutationLine(cmd, line, + fmt.Sprintf("Note %d added to thread %d", note.Id, threadID), + result, output.WithBreadcrumbs(output.Breadcrumb{Action: "read", Command: fmt.Sprintf("hey thread read %d", threadID), Description: "Read this thread"}), ) } + +// threadCommentContent is the note's HTML, which is what HEY stores: Markdown converted +// the way every other -m in this CLI is, so text such as "a < b & c" arrives escaped, or +// raw HTML as given. Blank either way is "". +func threadCommentContent(markdown, rawHTML string) string { + if strings.TrimSpace(rawHTML) != "" { + return strings.TrimSpace(rawHTML) + } + if strings.TrimSpace(markdown) == "" { + return "" + } + return htmlutil.FromMarkdown(strings.TrimSpace(markdown)) +} + +func describeNoteAudience(visibleTo []generated.Contact, collectionOnly bool) string { + switch { + case collectionOnly && len(visibleTo) == 0: + return "seen only on its collection" + case collectionOnly: + return "seen only on its collection, by " + joinNoteAudience(visibleTo) + case len(visibleTo) == 0: + return "visible only to you" + default: + return "visible to you and " + joinNoteAudience(visibleTo) + } +} + +func joinNoteAudience(visibleTo []generated.Contact) string { + names := make([]string, len(visibleTo)) + for i, contact := range visibleTo { + name, address := terminal.SanitizeLine(contact.Name), terminal.SanitizeLine(contact.EmailAddress) + switch { + case name != "" && address != "": + names[i] = fmt.Sprintf("%s <%s>", name, address) + case name != "": + names[i] = name + default: + names[i] = address + } + } + return strings.Join(names, ", ") +} + +func countNoteAudience(visibleTo []generated.Contact) string { + switch len(visibleTo) { + case 0: + return "only you" + case 1: + return "you and 1 other person" + default: + return fmt.Sprintf("you and %d other people", len(visibleTo)) + } +} diff --git a/internal/cmd/thread_comment_test.go b/internal/cmd/thread_comment_test.go index 1f43041ca..e784abd10 100644 --- a/internal/cmd/thread_comment_test.go +++ b/internal/cmd/thread_comment_test.go @@ -1,41 +1,68 @@ package cmd import ( + "encoding/json" "net/http" "strings" + "sync" "testing" ) -// threadCommentServer answers the fixture topic 42 (account 9) and records what a -// comment post carried. GET /imbox fails the test outright: a comment redirects to -// /imbox and the CLI must not follow it. -func threadCommentServer(t *testing.T) (http.Handler, *sentComment) { +// sentComment is what the fixture server saw. The server goroutine writes it and the test +// reads it after the round trip; the race detector does not count the socket as +// synchronization, so it is guarded. +type sentComment struct { + mu sync.Mutex + calls int + contentType string + content string + hasContent bool + audience int +} + +func (s *sentComment) snapshot() (calls int, contentType, content string, hasContent bool, audience int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls, s.contentType, s.content, s.hasContent, s.audience +} + +const threadCommentNoteJSON = `{"id":901,"kind":"comment","topic_id":42,"content":"

ok

",` + + `"visible_to":[{"id":7,"name":"Ana Pereira","email_address":"ana@example.com"},{"id":8,"name":"Bo","email_address":"bo@example.com"}],` + + `"collection_only":false}` + +// threadCommentServer answers the JSON endpoints HEY serves notes on, and fails anything +// else — the form endpoint (/topics/42/comments without .json) included. +func threadCommentServer(t *testing.T, createStatus int, createBody string) (http.Handler, *sentComment) { t.Helper() sent := &sentComment{} handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && r.URL.Path == "/topics/42.json": - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"id":42,"account_id":9}`)) - case r.Method == http.MethodPost && r.URL.Path == "/topics/42/comments": - if got := r.URL.Query().Get("account_id"); got != "9" { - t.Errorf("account_id = %q, want 9", got) - } - if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" { - t.Errorf("content-type = %q", got) + case r.Method == http.MethodPost && r.URL.Path == "/topics/42/comments.json": + var body struct { + Comment struct { + Content *string `json:"content"` + } `json:"comment"` } - if got := r.Header.Get("Accept"); got != "*/*" { - t.Errorf("accept = %q", got) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) } - if err := r.ParseForm(); err != nil { - t.Fatal(err) + sent.mu.Lock() + sent.calls++ + sent.contentType = r.Header.Get("Content-Type") + if body.Comment.Content != nil { + sent.hasContent = true + sent.content = *body.Comment.Content } - sent.Content = r.PostForm.Get("comment[content]") - sent.Called = true - w.Header().Set("Location", "/imbox") - w.WriteHeader(http.StatusFound) - case r.URL.Path == "/imbox" || r.URL.Path == "/imbox.json": - t.Fatal("comment redirect was followed") + sent.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(createStatus) + _, _ = w.Write([]byte(createBody)) + case r.Method == http.MethodGet && r.URL.Path == "/topics/42/comments/new.json": + sent.mu.Lock() + sent.audience++ + sent.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"visible_to":[{"id":7,"name":"Ana Pereira","email_address":"ana@example.com"}],"collection_only":false}`)) default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) http.NotFound(w, r) @@ -44,69 +71,142 @@ func threadCommentServer(t *testing.T) (http.Handler, *sentComment) { return handler, sent } -type sentComment struct { - Called bool - Content string -} - -func TestThreadCommentPostsPlainTextNote(t *testing.T) { - handler, sent := threadCommentServer(t) +func TestThreadCommentPostsToTheJSONEndpoint(t *testing.T) { + handler, sent := threadCommentServer(t, http.StatusCreated, threadCommentNoteJSON) response, err := runJSONCommand(t, handler, "thread", "comment", "42", "-m", "Following up with accounting on this.") if err != nil { t.Fatal(err) } - if !sent.Called { - t.Fatal("expected a comment to be posted") + calls, contentType, content, hasContent, _ := sent.snapshot() + if calls != 1 { + t.Fatalf("POST /topics/42/comments.json calls = %d, want 1", calls) + } + if !strings.HasPrefix(contentType, "application/json") { + t.Errorf("content-type = %q, want application/json", contentType) } - if sent.Content != "Following up with accounting on this." { - t.Errorf("content = %q", sent.Content) + if !hasContent || content != "

Following up with accounting on this.

" { + t.Errorf("comment.content = %q (present %v)", content, hasContent) } - if response.Summary != "Comment added to thread 42" { + if response.Summary != "Note 901 added to thread 42" { t.Errorf("summary = %q", response.Summary) } - if response.Data.(map[string]any)["thread_id"] != float64(42) { - t.Errorf("data = %#v", response.Data) + data := response.Data.(map[string]any) + if data["id"] != float64(901) || data["thread_id"] != float64(42) || data["collection_only"] != false { + t.Errorf("data = %#v", data) + } + visibleTo, ok := data["visible_to"].([]any) + if !ok || len(visibleTo) != 2 || visibleTo[0].(map[string]any)["email_address"] != "ana@example.com" { + t.Errorf("visible_to = %#v", data["visible_to"]) } } -func TestThreadCommentSendsMarkdownLookingTextVerbatim(t *testing.T) { - for _, message := range []string{"**Not** converted, _as-is_.", " indented, with a trailing newline\n"} { - handler, sent := threadCommentServer(t) - _, err := runJSONCommand(t, handler, "thread", "comment", "42", "-m", message) - if err != nil { - t.Fatal(err) - } - if sent.Content != message { - t.Errorf("content = %q, want %q sent verbatim", sent.Content, message) - } +func TestThreadCommentEscapesPlainText(t *testing.T) { + handler, sent := threadCommentServer(t, http.StatusCreated, threadCommentNoteJSON) + if _, err := runJSONCommand(t, handler, "thread", "comment", "42", "-m", "Tom & Jerry say 1 < 2"); err != nil { + t.Fatal(err) + } + _, _, content, _, _ := sent.snapshot() + if content != "

Tom & Jerry say 1 < 2

" { + t.Errorf("content = %q, want the & and < escaped", content) + } +} + +func TestThreadCommentConvertsMarkdownAndPassesHTML(t *testing.T) { + handler, sent := threadCommentServer(t, http.StatusCreated, threadCommentNoteJSON) + if _, err := runJSONCommand(t, handler, "thread", "comment", "42", "-m", "**Paid** in full"); err != nil { + t.Fatal(err) + } + if _, _, content, _, _ := sent.snapshot(); content != "

Paid in full

" { + t.Errorf("markdown content = %q", content) + } + + handler, sent = threadCommentServer(t, http.StatusCreated, threadCommentNoteJSON) + if _, err := runJSONCommand(t, handler, "thread", "comment", "42", "--message-html", "
As typed
"); err != nil { + t.Fatal(err) + } + if _, _, content, _, _ := sent.snapshot(); content != "
As typed
" { + t.Errorf("html content = %q", content) } } -func TestThreadCommentStyledOutput(t *testing.T) { - handler, sent := threadCommentServer(t) +func TestThreadCommentStyledOutputNamesTheAudience(t *testing.T) { + handler, _ := threadCommentServer(t, http.StatusCreated, threadCommentNoteJSON) styled, err := runStyledCommand(t, handler, "thread", "comment", "42", "-m", "Noted.") if err != nil { t.Fatal(err) } - if !sent.Called { - t.Fatal("expected a comment to be posted") + want := "Note 901 added to thread 42, visible to you and Ana Pereira , Bo ." + if !strings.Contains(styled, want) { + t.Errorf("styled = %q, want %q", styled, want) } - if !strings.Contains(styled, "Comment added to thread 42") { +} + +func TestThreadCommentNoteToSelf(t *testing.T) { + handler, _ := threadCommentServer(t, http.StatusCreated, `{"id":902,"kind":"comment","topic_id":42,"visible_to":[]}`) + styled, err := runStyledCommand(t, handler, "thread", "comment", "42", "-m", "Noted.") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(styled, "Note 902 added to thread 42, visible only to you.") { + t.Errorf("styled = %q", styled) + } + + handler, _ = threadCommentServer(t, http.StatusCreated, `{"id":902,"kind":"comment","topic_id":42,"visible_to":[]}`) + response, err := runJSONCommand(t, handler, "thread", "comment", "42", "-m", "Noted.") + if err != nil { + t.Fatal(err) + } + if visibleTo, ok := response.Data.(map[string]any)["visible_to"].([]any); !ok || len(visibleTo) != 0 { + t.Errorf("visible_to = %#v, want []", response.Data.(map[string]any)["visible_to"]) + } +} + +func TestThreadCommentDryRunShowsTheAudienceWithoutPosting(t *testing.T) { + handler, sent := threadCommentServer(t, http.StatusCreated, threadCommentNoteJSON) + response, err := runJSONCommand(t, handler, "thread", "comment", "42", "--dry-run") + if err != nil { + t.Fatal(err) + } + calls, _, _, _, audience := sent.snapshot() + if calls != 0 || audience != 1 { + t.Fatalf("create calls = %d, audience calls = %d; want 0 and 1", calls, audience) + } + data := response.Data.(map[string]any) + if data["dry_run"] != true || data["id"] != nil { + t.Errorf("data = %#v", data) + } + visibleTo, ok := data["visible_to"].([]any) + if !ok || len(visibleTo) != 1 || visibleTo[0].(map[string]any)["name"] != "Ana Pereira" { + t.Errorf("visible_to = %#v", data["visible_to"]) + } + + handler, sent = threadCommentServer(t, http.StatusCreated, threadCommentNoteJSON) + styled, err := runStyledCommand(t, handler, "thread", "comment", "42", "--dry-run", "-m", "not sent") + if err != nil { + t.Fatal(err) + } + if calls, _, _, _, _ := sent.snapshot(); calls != 0 { + t.Errorf("a dry run with -m posted %d notes", calls) + } + if !strings.Contains(styled, "A note on thread 42 would be visible to you and Ana Pereira .") { t.Errorf("styled = %q", styled) } } -func TestThreadCommentValidatesInput(t *testing.T) { +func TestThreadCommentRefusesBlankBeforeSending(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) }) tests := []struct { name string args []string want string }{ - {name: "missing message", args: []string{"thread", "comment", "42"}, want: "--message is required"}, - {name: "empty message", args: []string{"thread", "comment", "42", "-m", " "}, want: "--message is required"}, + {name: "missing message", args: []string{"thread", "comment", "42"}, want: "a note is required"}, + {name: "blank message", args: []string{"thread", "comment", "42", "-m", " \n "}, want: "a note is required"}, + {name: "blank html", args: []string{"thread", "comment", "42", "--message-html", " "}, want: "a note is required"}, + {name: "both flags", args: []string{"thread", "comment", "42", "-m", "a", "--message-html", "

b

"}, want: "none of the others can be"}, {name: "invalid thread id", args: []string{"thread", "comment", "zero", "-m", "hi"}, want: "invalid thread ID: zero"}, } for _, tt := range tests { @@ -118,3 +218,11 @@ func TestThreadCommentValidatesInput(t *testing.T) { }) } } + +func TestThreadCommentSurfacesHEYsValidationError(t *testing.T) { + handler, _ := threadCommentServer(t, http.StatusUnprocessableEntity, `{"error":"Content can't be blank"}`) + _, err := runJSONCommand(t, handler, "thread", "comment", "42", "--message-html", "
") + if err == nil { + t.Fatal("a 422 from HEY was reported as success") + } +} diff --git a/internal/cmd/topic.go b/internal/cmd/topic.go index 33a2e7d0e..03a457e6a 100644 --- a/internal/cmd/topic.go +++ b/internal/cmd/topic.go @@ -79,7 +79,7 @@ func newThreadCommand() *cobra.Command { Use: "thread", Short: "Read, rename and annotate email threads", Annotations: map[string]string{ - "agent_notes": "Subcommands: read, update, comment. Thread IDs come from hey box view. Use the same ID with hey reply or hey forward. hey thread comment posts a private internal note, not a mailed reply.", + "agent_notes": "Subcommands: read, update, comment. Thread IDs come from hey box view. Use the same ID with hey reply or hey forward. hey thread comment adds a note everyone with access to the thread sees; it is never emailed.", }, } thread.AddCommand(newThreadsCommand().cmd) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 055c4cc1d..80ebb9cf5 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -178,7 +178,7 @@ hey box list --quiet --jq '.[].name' Every email thread has two IDs: `id`, its **box item ID** (one posting in one box), and `topic_id`, its **thread ID**. Each posting in a thread listing has `id`, `topic_id`, `kind` (`bundle` for a bundle row), `name` (subject), `created_at` and `app_url`; a thread row also has `contacts`, `summary` and `visible_entry_count`. - **Box item ID** (`id`): `hey seen`, `unseen`, `move`, `label add|remove|create`, `trash`, `spam`, `ignore`, `stop-ignoring`, `bubble up|pop`, `set-aside group`, `bulk-reply` and `bundle view`. -- **Thread ID** (`topic_id`): `hey thread read|update`, `share`, `unshare`, `attachment list`, `reply`, `forward`, `compose --thread-id`, `collection add|remove` and `workflow add|move|remove`. +- **Thread ID** (`topic_id`): `hey thread read|update|comment`, `share`, `unshare`, `attachment list`, `reply`, `forward`, `compose --thread-id`, `collection add|remove` and `workflow add|move|remove`. - **Entry ID**: an entry's `id` from `hey thread read`; `hey clip create` takes it, and the passage must appear in that message's text. - **Contact ID** from `hey contact list` or contact data, **clearance ID** from `hey screener list` (which also gives the `topic_id` of the thread to read before deciding), **box ID** from `hey box list`, **label ID** from `hey label list`. Never substitute one kind for another, or an email address for a contact ID. @@ -200,11 +200,12 @@ for a mark, or read the destination box for a move. An empty `match` with `next_ - `delayed: true` means Undo Send is still holding it, and HEY leaves it out of its thread until it goes out: `hey thread read` shows the thread without it, or answers `not_found` for a thread it started. Wait and read again rather than treating the send as lost. - A send HEY refused (usually the account's sending limit) fails with `not_delivered` (exit 7). HEY kept it as a draft, named by `draft_id` in the error; `hey draft send ` sends it later. Do not resend the message; that makes a second draft. - To let a person review before anything goes out, add `--draft` to `compose` or `reply`; the answer carries the draft ID for `hey draft show|edit|send|delete`. The person reviews and sends it from any HEY app, or you send it later with `hey draft send`. +- `hey thread comment -m "..."` adds a note to a thread, which is never emailed but is not private: everyone with access to the thread sees it, HEY for Domains teammates included. The answer lists them in `visible_to`; `--dry-run` answers the same without adding anything. To mail the people on a thread, use `hey reply`. - `hey bulk-reply`: run `preview` before `send`. A delivery sent by mistake can be recalled with `hey bulk-reply undo ` while the window is open. ## Writing content -Message bodies, drafts, journal entries, snippets and contact notes are Markdown by default (`-m`, `--content`, `--note`, positional content, stdin and `$EDITOR` alike) and are converted to rich text on the way out; clip passages, event notes and time track notes are plain text. Each Markdown flag has an HTML twin (such as `--message-html`, `--note-html`, `--content-html`) for raw HTML, and each pair is mutually exclusive. A fenced code block's language survives for the languages HEY highlights (Ruby, Python, JavaScript, TypeScript, Go, Rust, Java, C#, C++, PHP, Swift, HTML and CSS); any other is dropped. +Message bodies, drafts, thread notes, journal entries, snippets and contact notes are Markdown by default (`-m`, `--content`, `--note`, positional content, stdin and `$EDITOR` alike) and are converted to rich text on the way out; clip passages, event notes and time track notes are plain text. Each Markdown flag has an HTML twin (such as `--message-html`, `--note-html`, `--content-html`) for raw HTML, and each pair is mutually exclusive. A fenced code block's language survives for the languages HEY highlights (Ruby, Python, JavaScript, TypeScript, Go, Rust, Java, C#, C++, PHP, Swift, HTML and CSS); any other is dropped. Everything a calendar holds is a recording, and each kind has its own command: `hey event`, `hey todo`, `hey journal`, `hey habit`, `hey timetrack`. Without a date, those commands read the account's time zone to find today; name the date to skip the account read.