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
2 changes: 2 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ hey stop-ignoring 12345 # resume attention for a thread

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.

`hey compose`, `hey reply`, `hey forward` and `hey draft edit` check every `--to`, `--cc` and `--bcc` address before anything is sent. HEY does not refuse an address it cannot deliver to — it drops it, sending to whoever is left, or saving the message as a draft when nobody is — so an address HEY would certainly drop is refused here as a usage error naming it: `not a valid email address: a`. That is one with no part before the `@` or no domain after it, a domain that does not end in a public suffix such as `.com` or `.co.uk` (or in `localdomain`), or one longer than the 500 characters HEY keeps. The check follows HEY's rule and leans towards letting an address through: bare or as `Name <address>`, quoted and commented forms HEY accepts are accepted here too. A comma inside a quoted name or a comment does not split an address: `--to '"Bryan, Annie" <annie@example.com>, frank@example.org'` is two recipients.

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.
Expand Down
2 changes: 1 addition & 1 deletion docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ uppercase belongs to Labels:

A thread opens on its latest message; `k` steps back through the ones before it and `j` forward again.

Sending a reply or a forward from a thread closes the thread and returns you to the list you opened it from — the Imbox, a search, a bundle. A send that fails keeps the form open.
Sending a reply or a forward from a thread closes the thread and returns you to the list you opened it from — the Imbox, a search, a bundle. A send that fails keeps the form open, and so does an address HEY would not deliver to: the form names it (`Not a valid email address: a`) rather than sending a message HEY would quietly keep as a draft, or send without that recipient.

While reading a thread, the From header shows the actual send-as address when HEY records one separately from the account user.

Expand Down
49 changes: 39 additions & 10 deletions internal/cmd/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ import (

"github.com/basecamp/hey-sdk/go/pkg/generated"
"github.com/spf13/cobra"
"github.com/spf13/pflag"

hey "github.com/basecamp/hey-sdk/go/pkg/hey"

"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"
)

Expand All @@ -37,6 +39,11 @@ func newComposeCommand() *composeCommand {
composeCommand.cmd = &cobra.Command{
Use: "compose",
Short: "Write and send a new email",
Long: `Write and send a new email, or reply to a thread with --thread-id.

A --to, --cc or --bcc address HEY would drop without saying so — one with no domain,
or a top-level domain HEY does not know — is refused before anything is sent.`,
Args: recipientsChecked(nil),
Annotations: map[string]string{
"agent_notes": "--from selects a configured sender email or ID from account senders; --account must agree. --from is only for new messages. Starts a new thread with --to (optionally --cc/--bcc), which requires --subject, or replies to an existing one with --thread-id, which does not. Repeatable --attach files are uploaded before sending and can be sent without body text. The body is Markdown; use --message-html to send raw HTML instead. --draft saves instead of sending — recipients become optional — and answers the draft ID for hey draft show/edit/send/delete. A new message ends with the sender's HEY name tag, as one composed in HEY does; --no-name-tag leaves it out.",
},
Expand Down Expand Up @@ -223,16 +230,38 @@ func writeDraftSaved(cmd *cobra.Command, draftID int64, attachments int) error {
)
}

func parseAddresses(s string) []string {
if s == "" {
return nil
}
var addrs []string
for _, addr := range strings.Split(s, ",") {
addr = strings.TrimSpace(addr)
if addr != "" {
addrs = append(addrs, addr)
// recipientsChecked refuses a --to, --cc or --bcc address HEY would drop without
// saying so (see mail.InvalidAddress). It runs as the command's arguments are checked,
// which cobra does before the root's PersistentPreRunE resolves an account over the
// network, so a bad address is refused before any request, upload or editor.
Comment thread
robzolkos marked this conversation as resolved.
func recipientsChecked(args cobra.PositionalArgs) cobra.PositionalArgs {
return func(cmd *cobra.Command, positional []string) error {
if args != nil {
if err := args(cmd, positional); err != nil {
return err
}
}
var lists [][]string
for _, name := range []string{"to", "cc", "bcc"} {
flag := cmd.Flags().Lookup(name)
if flag == nil || !flag.Changed {
continue
}
values := []string{flag.Value.String()}
if slice, ok := flag.Value.(pflag.SliceValue); ok {
values = slice.GetSlice()
}
for _, value := range values {
lists = append(lists, parseAddresses(value))
}
Comment thread
robzolkos marked this conversation as resolved.
}
if address := mail.InvalidAddress(lists...); address != "" {
return apierr.ErrUsage("not a valid email address: " + address)
}
return nil
}
return addrs
}

func parseAddresses(s string) []string {
return mail.SplitAddresses(s)
}
38 changes: 38 additions & 0 deletions internal/cmd/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

Expand Down Expand Up @@ -47,6 +48,11 @@ func TestParseAddresses(t *testing.T) {
input: "alice@example.com,,bob@example.com",
want: []string{"alice@example.com", "bob@example.com"},
},
{
name: "a comma in a quoted name",
input: `"Bryan, Annie" <annie@example.com>, bob@example.com`,
want: []string{`"Bryan, Annie" <annie@example.com>`, "bob@example.com"},
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -272,3 +278,35 @@ func TestComposeUsesTheSelectedAccountsSendersNameTag(t *testing.T) {
t.Errorf("acting_sender_id = %v, want the work account's sender 43", got)
}
}

func TestCommandsRefuseAnAddressHEYWouldDropBeforeAnyRequest(t *testing.T) {
for name, args := range map[string][]string{
"compose": {"compose", "--to", "a", "--subject", "Quarterly planning notes", "-m", "Here are the notes."},
"compose cc": {"compose", "--to", "annie@example.com", "--cc", "frank", "--subject", "Quarterly planning notes", "-m", "Here are the notes."},
"reply": {"reply", "7", "--to", "a", "-m", "Thanks, Annie."},
"forward": {"forward", "7", "--to", "a"},
"draft edit": {"draft", "edit", "12", "--to", "a"},
// An account is resolved over the network before a command runs, so the
// address has to be refused before that.
"compose with account": {"--account", "8", "compose", "--to", "a", "--subject", "Quarterly planning notes", "-m", "Here are the notes."},
"reply with account": {"--account", "8", "reply", "7", "--cc", "frank", "-m", "Thanks, Annie."},
} {
t.Run(name, func(t *testing.T) {
var requests []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
http.NotFound(w, r)
}))
t.Cleanup(server.Close)

err := runCLI(t, server, args...)
var cliErr *apierr.Error
if !errors.As(err, &cliErr) || cliErr.Code != "usage" || !strings.Contains(err.Error(), "not a valid email address: ") {
t.Fatalf("expected a usage error naming the address, got %v", err)
}
if len(requests) != 0 {
t.Errorf("made requests before refusing the address: %v", requests)
}
})
}
}
6 changes: 5 additions & 1 deletion internal/cmd/draft.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,10 @@ func newDraftEditCommand() *draftEditCommand {
editCommand.cmd = &cobra.Command{
Use: "edit <draft-id>",
Short: "Change a draft",
Long: `Change a draft.

A --to, --cc or --bcc address HEY would drop without saying so — one with no domain,
or a top-level domain HEY does not know — is refused before anything is sent.`,
Annotations: map[string]string{
"agent_notes": "--from chooses a configured sender email or ID within this draft account. It preserves the body verbatim, including existing signatures; use a body flag to replace those. Each flag replaces its field and an omitted flag keeps what the draft has — --to/--cc/--bcc replace that whole recipient kind (an explicit empty value clears it). With no field flags the body opens in $EDITOR as Markdown. A scheduled delivery is preserved.",
},
Expand All @@ -219,7 +223,7 @@ func newDraftEditCommand() *draftEditCommand {
hey draft edit 12345 -m "Rewritten agenda: budget first, hiring second."
hey draft edit 12345 # open the body in $EDITOR`,
RunE: editCommand.run,
Args: usageExactOneArg(),
Args: recipientsChecked(usageExactOneArg()),
}
editCommand.cmd.Flags().StringVar(&editCommand.from, "from", "", "Replace the sender with a configured email or ID in this draft account")
editCommand.cmd.Flags().StringVar(&editCommand.subject, "subject", "", "Replace the subject")
Expand Down
6 changes: 5 additions & 1 deletion internal/cmd/forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ func newForwardCommand() *forwardCommand {
forwardCommand.cmd = &cobra.Command{
Use: "forward <thread-id>",
Short: "Forward the latest message in a thread",
Long: `Forward the latest message in a thread.

A --to, --cc or --bcc address HEY would drop without saying so — one with no domain,
or a top-level domain HEY does not know — is refused before anything is sent.`,
Annotations: map[string]string{
"agent_notes": "Forwards the latest entry in a thread with HEY's quoted content. Accepts comma-separated recipients and an optional note via -m.",
},
Example: ` hey forward 12345 --to alice@example.com
hey forward 12345 --to alice@example.com --cc bob@example.org -m "For your review"`,
RunE: forwardCommand.run,
Args: usageExactOneArg(),
Args: recipientsChecked(usageExactOneArg()),
}

forwardCommand.cmd.Flags().StringVar(&forwardCommand.to, "to", "", "Recipient email address(es)")
Expand Down
7 changes: 5 additions & 2 deletions internal/cmd/reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ prefill is unavailable or names no one, a send falls back to the message's own r
which can include you. 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.`,
resolves and prints the complete envelope without requiring a message or sending one.

A --to, --cc or --bcc address HEY would drop without saying so — one with no domain,
or a top-level domain HEY does not know — is refused before anything is sent.`,
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. 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.",
},
Expand All @@ -63,7 +66,7 @@ resolves and prints the complete envelope without requiring a message or sending
hey reply 12345 -m "Drafting a longer answer — sending tomorrow." --draft
echo "Longer reply from a file or a heredoc" | hey reply 12345`,
RunE: replyCommand.run,
Args: usageExactOneArg(),
Args: recipientsChecked(usageExactOneArg()),
}

replyCommand.cmd.Flags().StringVarP(&replyCommand.message, "message", "m", "", "Reply message as Markdown (or opens $EDITOR)")
Expand Down
Loading
Loading