Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <topic-id>` | covered |
| `/entries/{id}/replies` | POST | SDK `Entries().CreateReply`, `Entries().CreateReplyDraft` | `hey reply <topic-id>`, `hey reply --draft`, `hey compose --thread-id [--draft]` | covered |
| `/topics/{id}.json` | GET | SDK `Topics().Get` | `hey forward <topic-id>`, `hey reply <topic-id>`, TUI `r` | covered |
| `/topics/{id}/comments.json` | POST | SDK `Topics().CreateComment` | `hey thread comment <topic-id>` | covered |
| `/topics/{id}/comments/new.json` | GET | SDK `Topics().GetCommentAudience` | `hey thread comment <topic-id> --dry-run` | covered |
| `/entries/{id}/forwards/new.json` | GET | SDK `Entries().NewForward` | `hey forward <topic-id>` | covered |
| `/bulk_replies/new.json` | GET | SDK `BulkReplies().Draft` | `hey bulk-reply preview`, `hey bulk-reply send`, TUI `ctrl+b` | covered |
| `/bulk_replies.json` | POST | SDK `BulkReplies().Send` | `hey bulk-reply send`, TUI bulk reply | covered |
Expand Down
5 changes: 4 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -272,6 +273,8 @@ hey stop-ignoring 12345 # resume attention for a thread

`hey thread update <topic_id> --name <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 <topic_id> -m <text>` 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.
Expand All @@ -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 `<div class="trix-content">` 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 `<div class="trix-content">` 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.

Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
175 changes: 175 additions & 0 deletions internal/cmd/thread_comment.go
Original file line number Diff line number Diff line change
@@ -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 <thread-id>",
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 <text> or --message-html <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))
}
}
Loading
Loading