diff --git a/.surface b/.surface index d0359079..2a1da24b 100644 --- a/.surface +++ b/.surface @@ -377,6 +377,10 @@ hey snippet update --name 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 166f3d4a..7306f665 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -77,6 +77,8 @@ forward answers. The drafts index (`/entries/drafts.json`) pages the same way, w | `/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}/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 2b99fbb0..845c102e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -226,6 +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 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 @@ -272,6 +273,8 @@ 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 ` 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. Repeatable `hey reply --to`, `--cc` and `--bcc` flags add recipients to that envelope; comma-separated addresses also work. An explicitly named address moves to that line instead of appearing twice. `--replace-recipients` discards HEY's prefill and requires at least one explicit address. `--dry-run` needs no body, does not read the original message body, uploads nothing and sends nothing; its JSON data reports the account, thread, entry, subject, resolved sender, and final To, CC and BCC lists. If HEY's envelope prefill is unavailable, a dry run refuses to guess the original recipient lists; use `--replace-recipients` with explicit addresses to preview a complete replacement instead. @@ -280,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/help_test.go b/internal/cmd/help_test.go index 6c058cbd..f5f2a342 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -110,7 +110,7 @@ USAGE CORE COMMANDS tui Launch the interactive terminal UI box List HEY boxes and their email threads - thread Read and rename email threads + thread Read, rename 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 new file mode 100644 index 00000000..7e289a8e --- /dev/null +++ b/internal/cmd/thread_comment.go @@ -0,0 +1,175 @@ +package cmd + +import ( + "fmt" + "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 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 + 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 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": "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 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 +} + +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 + } + + 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, + ) + } + + content := threadCommentContent(c.message, c.messageHTML) + if content == "" { + return apierr.ErrUsage("a note is required (use -m or --message-html )") + } + + note, err := sdk.Topics().CreateComment(cmd.Context(), threadID, content) + if err != nil { + return apierr.FromSDK(err) + } + + 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 new file mode 100644 index 00000000..e784abd1 --- /dev/null +++ b/internal/cmd/thread_comment_test.go @@ -0,0 +1,228 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "sync" + "testing" +) + +// 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.MethodPost && r.URL.Path == "/topics/42/comments.json": + var body struct { + Comment struct { + Content *string `json:"content"` + } `json:"comment"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", 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.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) + } + }) + return handler, sent +} + +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) + } + 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 !hasContent || content != "

Following up with accounting on this.

" { + t.Errorf("comment.content = %q (present %v)", content, hasContent) + } + if response.Summary != "Note 901 added to thread 42" { + t.Errorf("summary = %q", response.Summary) + } + 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 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 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) + } + 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) + } +} + +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 TestThreadCommentRefusesBlankBeforeSending(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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: "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 { + 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) + } + }) + } +} + +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 23a94eea..03a457e6 100644 --- a/internal/cmd/topic.go +++ b/internal/cmd/topic.go @@ -77,13 +77,14 @@ type topicCommand struct { func newThreadCommand() *cobra.Command { thread := &cobra.Command{ Use: "thread", - Short: "Read and rename email threads", + Short: "Read, rename and annotate email threads", Annotations: map[string]string{ - "agent_notes": "Subcommands: read, update. Thread IDs come from hey box view. Use the same ID with hey reply or hey forward.", + "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) thread.AddCommand(newThreadUpdateCommand().cmd) + thread.AddCommand(newThreadCommentCommand().cmd) return thread } diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 55a59b25..80ebb9cf 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -21,6 +21,7 @@ triggers: - hey contact - hey bundle - hey thread read + - hey thread comment - hey thread update - rename a thread - hey attachment @@ -177,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. @@ -199,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.