Skip to content
Open
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
5 changes: 5 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,14 @@ hey move
hey move --to
hey reply
hey reply --attach
hey reply --bcc
hey reply --cc
hey reply --draft
hey reply --dry-run
hey reply --message
hey reply --message-html
hey reply --replace-recipients
hey reply --to
hey screener
hey screener approve
hey screener approve --box
Expand Down
6 changes: 5 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ hey unshare 123 # turn off the sharing link
hey attachment list 123 # list files attached to the thread
hey attachment save 456:1 # save a file using its attachment ID
hey reply 123 -m "Friday works for me — I'll send an agenda." # or omit -m for $EDITOR
hey reply 123 --to support@example.com -m "The replacement is on the way."
hey reply 123 --to support@example.com --replace-recipients --dry-run --json
hey reply 123 -m "Here is the wiring diagram." --attach ./diagram.png
hey bulk-reply preview 12345 67890 # inspect threads and exact To/CC/BCC recipients
hey bulk-reply send 12345 67890 -m "Thanks for the update."
Expand Down Expand Up @@ -260,7 +262,9 @@ 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 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 `[]`. 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. `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 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 `[]`. 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. `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 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.

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.

Expand Down
3 changes: 3 additions & 0 deletions internal/cmd/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error {
if resolveErr != nil {
return resolveErr
}
if !replyHasRecipients(target.Addressed) {
return apierr.ErrUsage("could not determine thread recipients; use hey reply with --to, --cc or --bcc")
}
replySDK := target.client
messageWithAttachments, attachErr := attachFilesWithClient(ctx, replySDK, message, c.attachments)
if attachErr != nil {
Expand Down
13 changes: 13 additions & 0 deletions internal/cmd/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,19 @@ func TestComposeSubjectRequiredOnlyForANewMessage(t *testing.T) {
}
}

func TestComposeThreadReplyWithoutRecipientsIsRefusedBeforeWriting(t *testing.T) {
server, sent := threadReplyServer(t, messageWithoutRecipients, 11, 12)

err := runCLI(t, server, "--account", "8", "compose", "--thread-id", "7", "-m", "must not send")
var cliErr *apierr.Error
if !errors.As(err, &cliErr) || cliErr.Code != "usage" || !strings.Contains(err.Error(), "use hey reply with --to") {
t.Fatalf("expected an actionable usage error, got %v", err)
}
if sent.Path != "" {
t.Errorf("unaddressed compose reply wrote to %q", sent.Path)
}
}

func TestComposeSendsTheMessageAsMarkdown(t *testing.T) {
server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12)

Expand Down
134 changes: 125 additions & 9 deletions internal/cmd/reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,40 @@ package cmd
import (
"fmt"
"strconv"
"strings"

"github.com/spf13/cobra"

"github.com/basecamp/hey-cli/internal/apierr"
"github.com/basecamp/hey-cli/internal/editor"
"github.com/basecamp/hey-cli/internal/htmlutil"
"github.com/basecamp/hey-cli/internal/mail"
"github.com/basecamp/hey-cli/internal/output"
"github.com/basecamp/hey-cli/internal/terminal"
)

type replyCommand struct {
cmd *cobra.Command
message string
messageHTML string
attachments []string
draft bool
cmd *cobra.Command
message string
messageHTML string
attachments []string
to []string
cc []string
bcc []string
draft bool
replaceRecipients bool
dryRun bool
}

type replyPreview struct {
ThreadID int64 `json:"thread_id"`
EntryID int64 `json:"entry_id"`
AccountID int64 `json:"account_id"`
Subject string `json:"subject"`
From mail.ReplySender `json:"from"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
}

func newReplyCommand() *replyCommand {
Expand All @@ -28,12 +47,16 @@ func newReplyCommand() *replyCommand {
Long: `Reply to a thread's latest entry.

The reply is addressed the way HEY's own web app addresses one: everyone that entry was
addressed to, with whoever wrote it on the To line. HEY saves an unaddressed reply as a
draft rather than sending it, so the command fails when it cannot work the recipients out.`,
addressed to, with whoever wrote it on the To line. Repeatable --to, --cc and --bcc
values add or move recipients on those lines; each value can also be comma-separated.
--replace-recipients uses only the explicitly supplied recipients instead. A dry run
resolves and prints the complete envelope without requiring a message or sending one.`,
Annotations: map[string]string{
"agent_notes": "Replies to the latest entry in a thread, addressed the way HEY addresses a reply: everyone that entry was addressed to, plus its sender on the To line, minus the acting user's own addresses. Accepts message via -m, stdin, or $EDITOR, plus repeatable --attach files; an attachment can be sent without body text. The message is Markdown; use --message-html to send raw HTML instead. --draft saves the reply as a draft — carrying those recipients and answers the draft ID for hey draft show/edit/send/delete.",
"agent_notes": "Replies to the latest entry in a thread, addressed the way HEY addresses a reply: everyone that entry was addressed to, plus its sender on the To line, minus the acting user's own addresses. Repeatable --to/--cc/--bcc flags merge explicit recipients into that prefill; --replace-recipients uses only the explicit lists. --dry-run is read-only, needs no message, and returns the resolved sender, recipients, subject, account, thread and entry. Accepts message via -m, stdin, or $EDITOR, plus repeatable --attach files; an attachment can be sent without body text. The message is Markdown; use --message-html to send raw HTML instead. --draft saves the reply as a draft, carries the resolved recipients, and answers the draft ID for hey draft show/edit/send/delete.",
},
Example: ` hey reply 12345 -m "Friday works for me — I'll send an agenda."
hey reply 12345 --to support@example.com -m "The replacement is on the way."
hey reply 12345 --to support@example.com --replace-recipients --dry-run --json
hey reply 12345 -m "Attached is the report." --attach ./report.pdf
hey reply 12345 -m "Drafting a longer answer — sending tomorrow." --draft
echo "Longer reply from a file or a heredoc" | hey reply 12345`,
Expand All @@ -44,8 +67,14 @@ draft rather than sending it, so the command fails when it cannot work the recip
replyCommand.cmd.Flags().StringVarP(&replyCommand.message, "message", "m", "", "Reply message as Markdown (or opens $EDITOR)")
replyCommand.cmd.Flags().StringVar(&replyCommand.messageHTML, "message-html", "", "Reply message as raw HTML instead of Markdown")
replyCommand.cmd.Flags().StringArrayVar(&replyCommand.attachments, "attach", nil, "File to attach (repeatable)")
replyCommand.cmd.Flags().StringArrayVar(&replyCommand.to, "to", nil, "To recipient email address (repeatable or comma-separated)")
replyCommand.cmd.Flags().StringArrayVar(&replyCommand.cc, "cc", nil, "CC recipient email address (repeatable or comma-separated)")
replyCommand.cmd.Flags().StringArrayVar(&replyCommand.bcc, "bcc", nil, "BCC recipient email address (repeatable or comma-separated)")
replyCommand.cmd.Flags().BoolVar(&replyCommand.draft, "draft", false, "Save as a draft instead of sending")
replyCommand.cmd.Flags().BoolVar(&replyCommand.replaceRecipients, "replace-recipients", false, "Replace HEY's reply recipients with the explicit To, CC and BCC lists")
replyCommand.cmd.Flags().BoolVar(&replyCommand.dryRun, "dry-run", false, "Print the resolved reply envelope without sending")
replyCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html")
replyCommand.cmd.MarkFlagsMutuallyExclusive("draft", "dry-run")

return replyCommand
}
Expand All @@ -55,17 +84,46 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error {
return err
}

overrides := replyRecipients{
To: parseReplyAddresses(c.to),
CC: parseReplyAddresses(c.cc),
BCC: parseReplyAddresses(c.bcc),
}
if _, err := applyReplyRecipientOverrides(replyRecipients{}, overrides, c.replaceRecipients); err != nil {
return err
}

threadID, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return apierr.ErrUsage(fmt.Sprintf("invalid thread ID: %s", args[0]))
}

ctx := cmd.Context()

target, err := resolveThreadReply(ctx, threadID)
resolve := resolveThreadReply
if c.dryRun || c.replaceRecipients {
resolve = resolveThreadReplyWithoutMessage
}
target, err := resolve(ctx, threadID)
if err != nil {
return err
}
if target.Subject == "" {
return apierr.ErrUsage("could not determine the reply subject")
}
if c.dryRun && !c.replaceRecipients && !target.RecipientsResolved {
return apierr.ErrUsageHint("could not resolve HEY's reply recipients without reading the message", "use --replace-recipients with explicit --to, --cc or --bcc addresses")
}
target.Addressed, err = applyReplyRecipientOverrides(target.Addressed, overrides, c.replaceRecipients)
if err != nil {
return err
}
if !replyHasRecipients(target.Addressed) {
return apierr.ErrUsage("could not determine thread recipients; supply --to, --cc or --bcc")
}
if c.dryRun {
return writeReplyPreview(cmd, threadID, target)
}
replySDK := target.client

message := c.messageHTML
Expand Down Expand Up @@ -115,3 +173,61 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error {
}),
)
}

func parseReplyAddresses(values []string) []string {
addresses := make([]string, 0, len(values))
for _, value := range values {
addresses = append(addresses, parseAddresses(value)...)
}
return addresses
}

func writeReplyPreview(cmd *cobra.Command, threadID int64, target *threadReplyTarget) error {
from, err := replySenderForPreview(cmd.Context(), target)
if err != nil {
return err
}
preview := replyPreview{
ThreadID: threadID,
EntryID: target.EntryID,
AccountID: target.AccountID,
Subject: target.Subject,
From: from,
To: nonNilAddresses(target.Addressed.To),
CC: nonNilAddresses(target.Addressed.CC),
BCC: nonNilAddresses(target.Addressed.BCC),
}
if writer.IsStyled() {
fmt.Fprintf(cmd.OutOrStdout(), "Thread: %d\n", preview.ThreadID)
fmt.Fprintf(cmd.OutOrStdout(), "Entry: %d\n", preview.EntryID)
fmt.Fprintf(cmd.OutOrStdout(), "Account: %d\n", preview.AccountID)
fmt.Fprintf(cmd.OutOrStdout(), "Subject: %s\n", terminal.SanitizeLine(preview.Subject))
fmt.Fprintf(cmd.OutOrStdout(), "From: %s\n", terminal.SanitizeLine(formatReplySender(preview.From)))
fmt.Fprintf(cmd.OutOrStdout(), "To: %s\n", terminal.SanitizeLine(strings.Join(preview.To, ", ")))
fmt.Fprintf(cmd.OutOrStdout(), "CC: %s\n", terminal.SanitizeLine(strings.Join(preview.CC, ", ")))
fmt.Fprintf(cmd.OutOrStdout(), "BCC: %s\n", terminal.SanitizeLine(strings.Join(preview.BCC, ", ")))
fmt.Fprintln(cmd.OutOrStdout(), "Nothing sent.")
return nil
}
return writeOK(preview, output.WithSummary("Reply preview; nothing sent"))
}

func nonNilAddresses(addresses []string) []string {
if addresses == nil {
return []string{}
}
return addresses
}

func formatReplySender(sender mail.ReplySender) string {
name := terminal.SanitizeLine(sender.Name)
email := terminal.SanitizeLine(sender.EmailAddress)
switch {
case name != "" && email != "":
return fmt.Sprintf("%s <%s>", name, email)
case email != "":
return email
default:
return strconv.FormatInt(sender.ID, 10)
}
}
Loading
Loading