From 1228a2a9aa26e5065ecd79572cdb6a0c64a95745 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 13:42:23 -0400 Subject: [PATCH 1/6] Refuse a recipient HEY would drop instead of losing the message HEY does not refuse an address it cannot deliver to: it drops it, sends to whoever is left, and when nobody is left saves the message as a draft and redirects to it, which the SDK follows and reports as sent. So "a" in the To field closed the composer as if the message had gone. Every recipient is now checked first, by HEY's own rule: it parses as an address, bare or with a name, and its domain ends in a top-level domain on the public suffix list. The TUI composer stays open and names the address; hey compose, reply, forward and draft edit refuse it as a usage error before any request. --- docs/cli.md | 2 ++ docs/tui.md | 2 +- internal/cmd/compose.go | 15 ++++++++++ internal/cmd/compose_test.go | 29 +++++++++++++++++++ internal/cmd/draft.go | 3 ++ internal/cmd/forward.go | 3 ++ internal/cmd/reply.go | 3 ++ internal/mail/address.go | 54 +++++++++++++++++++++++++++++++++++ internal/mail/address_test.go | 44 ++++++++++++++++++++++++++++ internal/tui/compose.go | 4 +++ internal/tui/compose_test.go | 32 ++++++++++++++++++++- 11 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 internal/mail/address.go create mode 100644 internal/mail/address_test.go diff --git a/docs/cli.md b/docs/cli.md index f5ee2732..f42974d3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 address you give them before anything is sent, by HEY's own rule: it parses as an email address, bare or as `Name
`, and its domain ends in a real top-level domain. 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 a bad one is refused here as a usage error naming it: `not a valid email address: a`. + 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. diff --git a/docs/tui.md b/docs/tui.md index 711d57ec..312d99bf 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -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. diff --git a/internal/cmd/compose.go b/internal/cmd/compose.go index 55c58933..c6d0da0c 100644 --- a/internal/cmd/compose.go +++ b/internal/cmd/compose.go @@ -14,6 +14,7 @@ import ( "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" ) @@ -79,6 +80,11 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error { if c.subject == "" && c.threadID == "" { return apierr.ErrUsageHint("--subject is required", "hey compose --to --subject -m ") } + // Checked before the editor opens, so nobody writes a message to an address HEY + // would drop. + if err := checkRecipients(parseAddresses(c.to), parseAddresses(c.cc), parseAddresses(c.bcc)); err != nil { + return err + } ctx := cmd.Context() var senderClient *hey.Client @@ -223,6 +229,15 @@ func writeDraftSaved(cmd *cobra.Command, draftID int64, attachments int) error { ) } +// checkRecipients refuses an address HEY would drop without saying so; see +// mail.InvalidAddress. +func checkRecipients(lists ...[]string) error { + if address := mail.InvalidAddress(lists...); address != "" { + return apierr.ErrUsage("not a valid email address: " + address) + } + return nil +} + func parseAddresses(s string) []string { if s == "" { return nil diff --git a/internal/cmd/compose_test.go b/internal/cmd/compose_test.go index bb2d9359..eb9a8b78 100644 --- a/internal/cmd/compose_test.go +++ b/internal/cmd/compose_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/http/httptest" "strings" "testing" @@ -272,3 +273,31 @@ 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"}, + } { + 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) + } + }) + } +} diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index d5742eae..f8e04012 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -240,6 +240,9 @@ func (c *draftEditCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return err } + if err = checkRecipients(parseAddresses(c.to), parseAddresses(c.cc), parseAddresses(c.bcc)); err != nil { + return err + } ctx := cmd.Context() edit, err := sdk.Messages().GetEdit(ctx, draftID) diff --git a/internal/cmd/forward.go b/internal/cmd/forward.go index 51f96a12..ff99c8ca 100644 --- a/internal/cmd/forward.go +++ b/internal/cmd/forward.go @@ -59,6 +59,9 @@ func (c *forwardCommand) run(cmd *cobra.Command, args []string) error { if len(to)+len(cc)+len(bcc) == 0 { return apierr.ErrUsageHint("at least one recipient is required", "hey forward --to ") } + if err = checkRecipients(to, cc, bcc); err != nil { + return err + } ctx := cmd.Context() topic, err := rootSDK.Topics().Get(ctx, threadID) diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index 75edd76b..d9f97d27 100644 --- a/internal/cmd/reply.go +++ b/internal/cmd/reply.go @@ -94,6 +94,9 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { if _, err := applyReplyRecipientOverrides(replyRecipients{}, overrides, c.replaceRecipients); err != nil { return err } + if err := checkRecipients(overrides.To, overrides.CC, overrides.BCC); err != nil { + return err + } threadID, err := strconv.ParseInt(args[0], 10, 64) if err != nil { diff --git a/internal/mail/address.go b/internal/mail/address.go new file mode 100644 index 00000000..3df972a5 --- /dev/null +++ b/internal/mail/address.go @@ -0,0 +1,54 @@ +package mail + +import ( + netmail "net/mail" + "strings" + + "golang.org/x/net/idna" + "golang.org/x/net/publicsuffix" +) + +// maxAddressSize is the longest address HEY delivers to (Contact::CertifiedMailAddress). +const maxAddressSize = 500 + +// InvalidAddress returns the first recipient HEY would not deliver to, or "" when +// every one is deliverable. +// +// HEY does not refuse a bad recipient: it drops it. A message left with nobody is +// saved as a draft and answered with a redirect to it, and a message with somebody +// left goes to them alone. Either way the sender is not told, so an address is +// checked here, by HEY's own rule (LenientMailFieldsParser), before anything is sent: +// it parses as an address, bare or with a name; it has a local part and a domain; the +// domain ends in a top-level domain on the public suffix list, or in localdomain; and +// it is no longer than HEY keeps. +func InvalidAddress(lists ...[]string) string { + for _, list := range lists { + for _, address := range list { + if !deliverable(address) { + return address + } + } + } + return "" +} + +func deliverable(address string) bool { + parsed, err := netmail.ParseAddress(address) + if err != nil || len(parsed.Address) > maxAddressSize { + return false + } + at := strings.LastIndexByte(parsed.Address, '@') + if at <= 0 || at == len(parsed.Address)-1 { + return false + } + domain := strings.ToLower(parsed.Address[at+1:]) + if strings.HasSuffix(domain, "localdomain") { + return true + } + tld, err := idna.Lookup.ToASCII(domain[strings.LastIndexByte(domain, '.')+1:]) + if err != nil || tld == "" { + return false + } + suffix, icann := publicsuffix.PublicSuffix(tld) + return icann && suffix == tld +} diff --git a/internal/mail/address_test.go b/internal/mail/address_test.go new file mode 100644 index 00000000..8adfbbca --- /dev/null +++ b/internal/mail/address_test.go @@ -0,0 +1,44 @@ +package mail + +import ( + "strings" + "testing" +) + +func TestInvalidAddressFollowsHEYsRule(t *testing.T) { + for address, deliverable := range map[string]bool{ + "annie@example.com": true, + "Annie Bryan ": true, + `"Bryan, Annie" `: true, + "J. Smith ": true, + "annie+newsletters@example.co.uk": true, + "annie@пример.рф": true, + "annie@build.localdomain": true, + "a": false, + "annie": false, + "annie@": false, + "@example.com": false, + "annie@example": false, + "annie@example.notatld": false, + "annie@@example.com": false, + "annie@example.com>": false, + strings.Repeat("a", 490) + "@example.com": false, + } { + got := InvalidAddress([]string{address}) == "" + if got != deliverable { + t.Errorf("deliverable(%q) = %v, want %v", address, got, deliverable) + } + } +} + +func TestInvalidAddressNamesTheFirstBadRecipientInAnyList(t *testing.T) { + to := []string{"annie@example.com"} + cc := []string{"frank.castillo@example.org", "frank"} + bcc := []string{"a"} + if got := InvalidAddress(to, cc, bcc); got != "frank" { + t.Errorf("InvalidAddress = %q, want the first bad recipient, frank", got) + } + if got := InvalidAddress(to, nil, nil); got != "" { + t.Errorf("InvalidAddress of good recipients = %q, want none", got) + } +} diff --git a/internal/tui/compose.go b/internal/tui/compose.go index 31e87cad..2ae9e86f 100644 --- a/internal/tui/compose.go +++ b/internal/tui/compose.go @@ -15,6 +15,7 @@ import ( "github.com/basecamp/hey-cli/internal/htmlutil" "github.com/basecamp/hey-cli/internal/mail" + "github.com/basecamp/hey-cli/internal/terminal" ) // --- Messages --- @@ -221,6 +222,9 @@ func (f *composeForm) validate() string { if f.mode != composeReply && len(to)+len(cc)+len(bcc) == 0 { return "Add at least one recipient" } + if address := mail.InvalidAddress(to, cc, bcc); address != "" { + return "Not a valid email address: " + terminal.SanitizeLine(address) + } if f.mode != composeReply && subject == "" { return "Subject is required" } diff --git a/internal/tui/compose_test.go b/internal/tui/compose_test.go index ee5054d0..c3cd356e 100644 --- a/internal/tui/compose_test.go +++ b/internal/tui/compose_test.go @@ -143,7 +143,7 @@ func TestComposeValidatesBeforeSending(t *testing.T) { if !composeModal(v).isError || !strings.Contains(composeModal(v).status, "recipient") { t.Errorf("expected a recipient error, got %q", composeModal(v).status) } - typeText(v, "a@b.com") + typeText(v, "annie@example.com") v.HandleContentKey(keyPress("tab")) // cc v.HandleContentKey(keyPress("tab")) // bcc v.HandleContentKey(keyPress("tab")) // subject @@ -155,6 +155,36 @@ func TestComposeValidatesBeforeSending(t *testing.T) { } } +func TestComposeRefusesAnAddressHEYWouldDrop(t *testing.T) { + for _, recipients := range []string{"a", "a, annie@example.com"} { + v, rec := composeTestServer(t) + v.Resize(80, 30) + v.HandleContentKey(keyPress("c")) + typeText(v, recipients) + v.HandleContentKey(keyPress("tab")) // cc + v.HandleContentKey(keyPress("tab")) // bcc + v.HandleContentKey(keyPress("tab")) // subject + typeText(v, "Quarterly planning notes") + v.HandleContentKey(keyPress("tab")) // body + typeText(v, "Here are the notes from Tuesday.") + + if cmd := v.HandleContentKey(ctrlS()); cmd != nil { + runCmd(cmd) + t.Fatalf("To %q: ctrl+s sent a message HEY would not deliver to everyone on it", recipients) + } + form := composeModal(v) + if form == nil || form.sending { + t.Fatalf("To %q: the form closed or is sending", recipients) + } + if !form.isError || form.status != "Not a valid email address: a" { + t.Errorf("To %q: status = %q, want the bad address named", recipients, form.status) + } + if rec.method != "" { + t.Errorf("To %q: sent %s %s", recipients, rec.method, rec.path) + } + } +} + func TestComposeSendsMessage(t *testing.T) { v, rec := composeTestServer(t) v.Resize(80, 30) From 5e6a5a83da2ba81b72fc25ac411c8297698d63b0 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 14:05:12 -0400 Subject: [PATCH 2/6] Refuse only the recipients HEY certainly drops Codex compared the check with HEY's pinned mail and public_suffix gems and found it refused addresses HEY delivers to, which is worse than the silence it was meant to end: - whole country domains under a wildcard rule (.np, .ck, .jm) failed a lookup of the top-level domain alone; the whole domain is looked up now, with the top-level domain asked on its own only to see past a private rule. - net/mail rejects quoted local parts, comments and stray spaces that Ruby's parser accepts and HEY prefills in replies; an address net/mail cannot parse is judged on its bare address instead of refused outright. - the length limit counted bytes of the bare address; HEY counts characters of the whole address, name included. It also let through addresses HEY drops: a punycode or fullwidth top-level domain, which HEY's Unicode suffix list never matches, and an encoded word before the @. And with --account the account was resolved over the network before the check ran; the recipient flags are checked with the arguments now, which cobra does before the root's PersistentPreRunE. --- docs/cli.md | 2 +- internal/cmd/compose.go | 42 +++++++++---- internal/cmd/compose_test.go | 4 ++ internal/cmd/draft.go | 5 +- internal/cmd/forward.go | 5 +- internal/cmd/reply.go | 5 +- internal/mail/address.go | 109 +++++++++++++++++++++++++++++----- internal/mail/address_test.go | 53 ++++++++++++----- internal/tui/compose_test.go | 19 ++++++ 9 files changed, 189 insertions(+), 55 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index f42974d3..3b905b0f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -269,7 +269,7 @@ 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 address you give them before anything is sent, by HEY's own rule: it parses as an email address, bare or as `Name
`, and its domain ends in a real top-level domain. 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 a bad one is refused here as a usage error naming it: `not a valid email address: a`. +`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
`, quoted and commented forms HEY accepts are accepted here too. 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. diff --git a/internal/cmd/compose.go b/internal/cmd/compose.go index c6d0da0c..6436cc34 100644 --- a/internal/cmd/compose.go +++ b/internal/cmd/compose.go @@ -8,6 +8,7 @@ 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" @@ -38,6 +39,7 @@ func newComposeCommand() *composeCommand { composeCommand.cmd = &cobra.Command{ Use: "compose", Short: "Write and send a new email", + 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.", }, @@ -80,11 +82,6 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error { if c.subject == "" && c.threadID == "" { return apierr.ErrUsageHint("--subject is required", "hey compose --to --subject -m ") } - // Checked before the editor opens, so nobody writes a message to an address HEY - // would drop. - if err := checkRecipients(parseAddresses(c.to), parseAddresses(c.cc), parseAddresses(c.bcc)); err != nil { - return err - } ctx := cmd.Context() var senderClient *hey.Client @@ -229,13 +226,36 @@ func writeDraftSaved(cmd *cobra.Command, draftID int64, attachments int) error { ) } -// checkRecipients refuses an address HEY would drop without saying so; see -// mail.InvalidAddress. -func checkRecipients(lists ...[]string) error { - if address := mail.InvalidAddress(lists...); address != "" { - return apierr.ErrUsage("not a valid email address: " + address) +// 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. +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)) + } + } + if address := mail.InvalidAddress(lists...); address != "" { + return apierr.ErrUsage("not a valid email address: " + address) + } + return nil } - return nil } func parseAddresses(s string) []string { diff --git a/internal/cmd/compose_test.go b/internal/cmd/compose_test.go index eb9a8b78..8cfdf1ca 100644 --- a/internal/cmd/compose_test.go +++ b/internal/cmd/compose_test.go @@ -281,6 +281,10 @@ func TestCommandsRefuseAnAddressHEYWouldDropBeforeAnyRequest(t *testing.T) { "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 diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index f8e04012..f1b2fe5b 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -219,7 +219,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") @@ -240,9 +240,6 @@ func (c *draftEditCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return err } - if err = checkRecipients(parseAddresses(c.to), parseAddresses(c.cc), parseAddresses(c.bcc)); err != nil { - return err - } ctx := cmd.Context() edit, err := sdk.Messages().GetEdit(ctx, draftID) diff --git a/internal/cmd/forward.go b/internal/cmd/forward.go index ff99c8ca..c13482ab 100644 --- a/internal/cmd/forward.go +++ b/internal/cmd/forward.go @@ -30,7 +30,7 @@ func newForwardCommand() *forwardCommand { 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)") @@ -59,9 +59,6 @@ func (c *forwardCommand) run(cmd *cobra.Command, args []string) error { if len(to)+len(cc)+len(bcc) == 0 { return apierr.ErrUsageHint("at least one recipient is required", "hey forward --to ") } - if err = checkRecipients(to, cc, bcc); err != nil { - return err - } ctx := cmd.Context() topic, err := rootSDK.Topics().Get(ctx, threadID) diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index d9f97d27..2445d827 100644 --- a/internal/cmd/reply.go +++ b/internal/cmd/reply.go @@ -63,7 +63,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)") @@ -94,9 +94,6 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { if _, err := applyReplyRecipientOverrides(replyRecipients{}, overrides, c.replaceRecipients); err != nil { return err } - if err := checkRecipients(overrides.To, overrides.CC, overrides.BCC); err != nil { - return err - } threadID, err := strconv.ParseInt(args[0], 10, 64) if err != nil { diff --git a/internal/mail/address.go b/internal/mail/address.go index 3df972a5..64f08f07 100644 --- a/internal/mail/address.go +++ b/internal/mail/address.go @@ -2,25 +2,37 @@ package mail import ( netmail "net/mail" + "regexp" "strings" + "unicode/utf8" "golang.org/x/net/idna" "golang.org/x/net/publicsuffix" ) -// maxAddressSize is the longest address HEY delivers to (Contact::CertifiedMailAddress). +// maxAddressSize is the longest address HEY delivers to, in characters of the address +// as it writes it out, name included (Contact::CertifiedMailAddress). const maxAddressSize = 500 -// InvalidAddress returns the first recipient HEY would not deliver to, or "" when -// every one is deliverable. +// encodedWord is an RFC 2047 encoded word. HEY decodes one in an address and drops the +// address when the decoded form parses differently, which it always does in the part +// before the @. +var encodedWord = regexp.MustCompile(`=\?[^?]*\?[bBqQ]\?[^?]*\?=`) + +// InvalidAddress returns the first recipient HEY would certainly not deliver to, or "" +// when there is none. // // HEY does not refuse a bad recipient: it drops it. A message left with nobody is // saved as a draft and answered with a redirect to it, and a message with somebody // left goes to them alone. Either way the sender is not told, so an address is -// checked here, by HEY's own rule (LenientMailFieldsParser), before anything is sent: -// it parses as an address, bare or with a name; it has a local part and a domain; the -// domain ends in a top-level domain on the public suffix list, or in localdomain; and -// it is no longer than HEY keeps. +// checked here against HEY's own rule (LenientMailFieldsParser): it has a part before +// the @ and a domain after it; the domain ends in a public suffix, or in localdomain; +// and it is no longer than HEY keeps. +// +// Refusing an address HEY would deliver to is worse than the silence this prevents, +// so the check leans towards letting an address through. HEY parses with Ruby's mail +// gem, which accepts more than net/mail does — quoted parts, comments, stray spaces +// — and an address net/mail cannot parse is judged on what it can still see. func InvalidAddress(lists ...[]string) string { for _, list := range lists { for _, address := range list { @@ -33,22 +45,89 @@ func InvalidAddress(lists ...[]string) string { } func deliverable(address string) bool { - parsed, err := netmail.ParseAddress(address) - if err != nil || len(parsed.Address) > maxAddressSize { + spec, size := addrSpec(address) + if size > maxAddressSize { return false } - at := strings.LastIndexByte(parsed.Address, '@') - if at <= 0 || at == len(parsed.Address)-1 { + at := strings.LastIndexByte(spec, '@') + if at <= 0 || at == len(spec)-1 || encodedWord.MatchString(spec[:at]) { return false } - domain := strings.ToLower(parsed.Address[at+1:]) + return deliverableDomain(spec[at+1:]) +} + +// addrSpec returns the bare address and the fewest characters HEY could write the +// whole address out in, so that a size over the limit is over it for HEY too. +func addrSpec(address string) (spec string, size int) { + if parsed, err := netmail.ParseAddress(address); err == nil { + size = utf8.RuneCountInString(parsed.Address) + if parsed.Name != "" { + size += utf8.RuneCountInString(parsed.Name) + len(" <>") + } + return parsed.Address, size + } + spec = withoutComments(address) + if open := strings.LastIndexByte(spec, '<'); open >= 0 { + if end := strings.IndexByte(spec[open:], '>'); end > 0 { + spec = spec[open+1 : open+end] + } + } + spec = strings.Join(strings.Fields(spec), "") + return spec, utf8.RuneCountInString(spec) +} + +// withoutComments drops parenthesized comments outside quoted strings. +func withoutComments(s string) string { + var b strings.Builder + depth, quoted := 0, false + for _, r := range s { + switch { + case r == '"' && depth == 0: + quoted = !quoted + case r == '(' && !quoted: + depth++ + continue + case r == ')' && !quoted && depth > 0: + depth-- + continue + } + if depth == 0 { + b.WriteRune(r) + } + } + return b.String() +} + +// deliverableDomain asks whether a domain ends in a public suffix, the way HEY's +// PublicSuffix lookup does: ICANN rules only, wildcards included, private rules +// ignored. HEY's list spells an internationalized suffix in Unicode, so a label typed +// in punycode matches no rule there and is kept from matching one here. +func deliverableDomain(domain string) bool { + domain = strings.ToLower(domain) if strings.HasSuffix(domain, "localdomain") { return true } - tld, err := idna.Lookup.ToASCII(domain[strings.LastIndexByte(domain, '.')+1:]) - if err != nil || tld == "" { - return false + labels := strings.Split(domain, ".") + for i, label := range labels { + switch { + case label == "": + return false + case strings.HasPrefix(label, "xn--"): + labels[i] = "punycode-label" + default: + ascii, err := idna.Punycode.ToASCII(label) + if err != nil { + return false + } + labels[i] = ascii + } + } + if _, icann := publicsuffix.PublicSuffix(strings.Join(labels, ".")); icann { + return true } + // A private rule — blogspot.com — outranks the ICANN rule under it in the lookup, + // and HEY ignores private rules, so the top-level domain is asked on its own. + tld := labels[len(labels)-1] suffix, icann := publicsuffix.PublicSuffix(tld) return icann && suffix == tld } diff --git a/internal/mail/address_test.go b/internal/mail/address_test.go index 8adfbbca..4d83d1e2 100644 --- a/internal/mail/address_test.go +++ b/internal/mail/address_test.go @@ -7,22 +7,43 @@ import ( func TestInvalidAddressFollowsHEYsRule(t *testing.T) { for address, deliverable := range map[string]bool{ - "annie@example.com": true, - "Annie Bryan ": true, - `"Bryan, Annie" `: true, - "J. Smith ": true, - "annie+newsletters@example.co.uk": true, - "annie@пример.рф": true, - "annie@build.localdomain": true, - "a": false, - "annie": false, - "annie@": false, - "@example.com": false, - "annie@example": false, - "annie@example.notatld": false, - "annie@@example.com": false, - "annie@example.com>": false, - strings.Repeat("a", 490) + "@example.com": false, + "annie@example.com": true, + "ANNIE@EXAMPLE.COM": true, + "Annie Bryan ": true, + `"Bryan, Annie" `: true, + "J. Smith ": true, + "annie+newsletters@example.co.uk": true, + "annie@пример.рф": true, + "annie@build.localdomain": true, + "annie@photos.blogspot.com": true, + // Whole country domains are wildcard rules, which only a full lookup matches. + "annie@example.np": true, + "annie@www.ck": true, + "annie@example.jm": true, + // Ruby's parser takes these, and HEY keeps and prefills them; net/mail does not. + `a."b"@example.com`: true, + `"a"."b"@example.com`: true, + "(comment)annie@example.com": true, + "annie @example.com": true, + // Length is HEY's: characters, not bytes. + strings.Repeat("é", 245) + "@example.com": true, + + "a": false, + "annie": false, + "annie@": false, + "@example.com": false, + "annie@example": false, + "annie@np": false, + "annie@example.notatld": false, + "annie@example.com.": false, + // HEY's list spells suffixes in Unicode, so punycode and fullwidth forms match nothing. + "annie@xn--e1afmkfd.xn--p1ai": false, + "annie@example.com": false, + // An encoded word decodes to a different address, which HEY drops. + "=?utf-8?q?annie=40example.org?=@example.com": false, + // Length counts the name HEY writes out with the address. + strings.Repeat("a", 490) + "@example.com": false, + strings.Repeat("A", 490) + " ": false, } { got := InvalidAddress([]string{address}) == "" if got != deliverable { diff --git a/internal/tui/compose_test.go b/internal/tui/compose_test.go index c3cd356e..0aaa1583 100644 --- a/internal/tui/compose_test.go +++ b/internal/tui/compose_test.go @@ -247,6 +247,25 @@ func TestComposeSendFailureKeepsForm(t *testing.T) { } } +func TestReplyPrefilledWithAnAddressHEYAcceptsStillSends(t *testing.T) { + v, rec := composeTestServer(t) + v.Resize(80, 30) + // HEY keeps quoted local parts net/mail cannot parse, and prefills them in a reply. + v.Update(replyContextLoadedMsg{ + boxID: 1, topicID: 7, topicName: "Kitchen", entryID: 99, subject: "Re: Kitchen", + actingSenderID: 7, + to: []string{`annie."bryan"@example.com`}, + }) + typeText(v, "Cabinets land the week of the 14th.") + cmd := v.HandleContentKey(ctrlS()) + if cmd == nil { + t.Fatalf("a reply HEY prefilled was refused: %q", composeModal(v).status) + } + if sent, ok := runCmd(cmd).(composeSentMsg); !ok || sent.err != nil || rec.method != "POST" { + t.Fatalf("expected the reply to be sent, got %#v via %s %s", sent, rec.method, rec.path) + } +} + func TestReplyFormPrefillsAndSends(t *testing.T) { v, rec := composeTestServer(t) v.Resize(80, 30) From 8374fc57d5054d3b377eee35fcd26397db57c32d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 14:08:20 -0400 Subject: [PATCH 3/6] Match HEY on every address the dev server was asked about Sending each disputed address to the dev server showed HEY's lookup comes down to whether the top-level domain is known: annie@np and annie@example..com are delivered, so only the last label is asked about, under a label so that a top-level domain with only a wildcard rule matches. An unclosed angle bracket and a second @ outside quotes are dropped, so the fallback parser refuses those. 34 addresses now agree with HEY's own parser. --- internal/mail/address.go | 78 ++++++++++++++++++++--------------- internal/mail/address_test.go | 20 +++++---- 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/internal/mail/address.go b/internal/mail/address.go index 64f08f07..8b9ab9dc 100644 --- a/internal/mail/address.go +++ b/internal/mail/address.go @@ -45,8 +45,8 @@ func InvalidAddress(lists ...[]string) string { } func deliverable(address string) bool { - spec, size := addrSpec(address) - if size > maxAddressSize { + spec, size, ok := addrSpec(address) + if !ok || size > maxAddressSize { return false } at := strings.LastIndexByte(spec, '@') @@ -57,23 +57,44 @@ func deliverable(address string) bool { } // addrSpec returns the bare address and the fewest characters HEY could write the -// whole address out in, so that a size over the limit is over it for HEY too. -func addrSpec(address string) (spec string, size int) { +// whole address out in, so that a size over the limit is over it for HEY too. ok is +// false for what HEY's parser refuses outright: an unclosed angle bracket, or a second +// @ outside quotes. +func addrSpec(address string) (spec string, size int, ok bool) { if parsed, err := netmail.ParseAddress(address); err == nil { size = utf8.RuneCountInString(parsed.Address) if parsed.Name != "" { size += utf8.RuneCountInString(parsed.Name) + len(" <>") } - return parsed.Address, size + return parsed.Address, size, true } spec = withoutComments(address) if open := strings.LastIndexByte(spec, '<'); open >= 0 { - if end := strings.IndexByte(spec[open:], '>'); end > 0 { - spec = spec[open+1 : open+end] + end := strings.IndexByte(spec[open:], '>') + if end < 0 { + return "", 0, false } + spec = spec[open+1 : open+end] } spec = strings.Join(strings.Fields(spec), "") - return spec, utf8.RuneCountInString(spec) + if at := strings.LastIndexByte(spec, '@'); at >= 0 && strings.Contains(withoutQuoted(spec[:at]), "@") { + return "", 0, false + } + return spec, utf8.RuneCountInString(spec), true +} + +// withoutQuoted drops quoted strings, where an @ belongs to the name it is in. +func withoutQuoted(s string) string { + var b strings.Builder + quoted := false + for _, r := range s { + if r == '"' { + quoted = !quoted + } else if !quoted { + b.WriteRune(r) + } + } + return b.String() } // withoutComments drops parenthesized comments outside quoted strings. @@ -98,36 +119,27 @@ func withoutComments(s string) string { return b.String() } -// deliverableDomain asks whether a domain ends in a public suffix, the way HEY's -// PublicSuffix lookup does: ICANN rules only, wildcards included, private rules -// ignored. HEY's list spells an internationalized suffix in Unicode, so a label typed -// in punycode matches no rule there and is kept from matching one here. +// deliverableDomain asks whether a domain ends in a top-level domain HEY's public +// suffix list knows, which is all HEY's lookup comes down to: some rule matches any +// domain under a known top-level domain, and none matches one under an unknown one. +// HEY's list spells an internationalized top-level domain in Unicode, so one typed in +// punycode matches nothing there, and x/net's list, spelled in punycode, is asked in +// punycode without the lookup's mapping, which would turn a fullwidth .com into .com. func deliverableDomain(domain string) bool { domain = strings.ToLower(domain) if strings.HasSuffix(domain, "localdomain") { return true } - labels := strings.Split(domain, ".") - for i, label := range labels { - switch { - case label == "": - return false - case strings.HasPrefix(label, "xn--"): - labels[i] = "punycode-label" - default: - ascii, err := idna.Punycode.ToASCII(label) - if err != nil { - return false - } - labels[i] = ascii - } + tld := domain[strings.LastIndexByte(domain, '.')+1:] + if tld == "" || strings.HasPrefix(tld, "xn--") { + return false } - if _, icann := publicsuffix.PublicSuffix(strings.Join(labels, ".")); icann { - return true + ascii, err := idna.Punycode.ToASCII(tld) + if err != nil { + return false } - // A private rule — blogspot.com — outranks the ICANN rule under it in the lookup, - // and HEY ignores private rules, so the top-level domain is asked on its own. - tld := labels[len(labels)-1] - suffix, icann := publicsuffix.PublicSuffix(tld) - return icann && suffix == tld + // Asked under a label, so a top-level domain with only a wildcard rule (*.np) + // matches as well. + _, icann := publicsuffix.PublicSuffix("x." + ascii) + return icann } diff --git a/internal/mail/address_test.go b/internal/mail/address_test.go index 4d83d1e2..a45cf537 100644 --- a/internal/mail/address_test.go +++ b/internal/mail/address_test.go @@ -20,6 +20,9 @@ func TestInvalidAddressFollowsHEYsRule(t *testing.T) { "annie@example.np": true, "annie@www.ck": true, "annie@example.jm": true, + // Checked against the dev server: HEY asks only whether the top-level domain is known. + "annie@np": true, + "annie@example..com": true, // Ruby's parser takes these, and HEY keeps and prefills them; net/mail does not. `a."b"@example.com`: true, `"a"."b"@example.com`: true, @@ -28,14 +31,15 @@ func TestInvalidAddressFollowsHEYsRule(t *testing.T) { // Length is HEY's: characters, not bytes. strings.Repeat("é", 245) + "@example.com": true, - "a": false, - "annie": false, - "annie@": false, - "@example.com": false, - "annie@example": false, - "annie@np": false, - "annie@example.notatld": false, - "annie@example.com.": false, + "a": false, + "annie": false, + "annie@": false, + "@example.com": false, + "annie@example": false, + "annie@example.notatld": false, + "annie@example.com.": false, + "annie@@example.com": false, + "Annie Date: Wed, 30 Sep 2026 14:13:15 -0400 Subject: [PATCH 4/6] Update the Nix vendor hash for the public suffix and IDNA packages The recipient check imports golang.org/x/net/publicsuffix and idna, which go mod vendor now includes. --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index 1b6e09f3..7111de62 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-Akrxc7s8WwKr8qXhwikiDIChRbOhXHkj5rIdf4mNFYM="; + vendorHash = "sha256-yELmXG1Tr8oCkVPFTbL0+x3KKrEW0URusQGDIdhXx7o="; subPackages = [ "cmd/hey" ]; From 8140179eb7b9311d4569e5ec0cc58cd10c22a084 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 14:19:15 -0400 Subject: [PATCH 5/6] Keep a quoted name with a comma in it as one recipient Every recipient list was split on each comma, so "Bryan, Annie" went out as two broken fragments, and with the check in place was refused as "Bryan. The CLI and the TUI now split with mail.SplitAddresses, which leaves a comma inside quotes, a comment or angle brackets alone, and the same split is used to check and to send. The fallback parser also counts a display name towards HEY's 500 characters, and compose, reply, forward and draft edit say in --help what they refuse. --- docs/cli.md | 2 +- internal/cmd/compose.go | 18 +++++-------- internal/cmd/compose_test.go | 5 ++++ internal/cmd/draft.go | 4 +++ internal/cmd/forward.go | 4 +++ internal/cmd/reply.go | 5 +++- internal/mail/address.go | 50 ++++++++++++++++++++++++++++++++++- internal/mail/address_test.go | 30 ++++++++++++++++++++- internal/tui/compose.go | 8 +----- internal/tui/compose_test.go | 23 ++++++++++++++++ 10 files changed, 126 insertions(+), 23 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 3b905b0f..e0f79296 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -269,7 +269,7 @@ 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
`, quoted and commented forms HEY accepts are accepted here too. +`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
`, 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" , 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. diff --git a/internal/cmd/compose.go b/internal/cmd/compose.go index 6436cc34..8df12635 100644 --- a/internal/cmd/compose.go +++ b/internal/cmd/compose.go @@ -39,7 +39,11 @@ func newComposeCommand() *composeCommand { composeCommand.cmd = &cobra.Command{ Use: "compose", Short: "Write and send a new email", - Args: recipientsChecked(nil), + 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.", }, @@ -259,15 +263,5 @@ func recipientsChecked(args cobra.PositionalArgs) cobra.PositionalArgs { } 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) - } - } - return addrs + return mail.SplitAddresses(s) } diff --git a/internal/cmd/compose_test.go b/internal/cmd/compose_test.go index 8cfdf1ca..3e7a979d 100644 --- a/internal/cmd/compose_test.go +++ b/internal/cmd/compose_test.go @@ -48,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" , bob@example.com`, + want: []string{`"Bryan, Annie" `, "bob@example.com"}, + }, } for _, tt := range tests { diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index f1b2fe5b..2009523e 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -211,6 +211,10 @@ func newDraftEditCommand() *draftEditCommand { editCommand.cmd = &cobra.Command{ Use: "edit ", 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.", }, diff --git a/internal/cmd/forward.go b/internal/cmd/forward.go index c13482ab..9563ed9e 100644 --- a/internal/cmd/forward.go +++ b/internal/cmd/forward.go @@ -24,6 +24,10 @@ func newForwardCommand() *forwardCommand { forwardCommand.cmd = &cobra.Command{ Use: "forward ", 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.", }, diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index 2445d827..78649223 100644 --- a/internal/cmd/reply.go +++ b/internal/cmd/reply.go @@ -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.", }, diff --git a/internal/mail/address.go b/internal/mail/address.go index 8b9ab9dc..a5beb3b0 100644 --- a/internal/mail/address.go +++ b/internal/mail/address.go @@ -19,6 +19,48 @@ const maxAddressSize = 500 // before the @. var encodedWord = regexp.MustCompile(`=\?[^?]*\?[bBqQ]\?[^?]*\?=`) +// SplitAddresses splits a comma-separated recipient list, leaving a comma inside a +// quoted name, a comment or angle brackets where it is: "Bryan, Annie" +// is one recipient. +func SplitAddresses(s string) []string { + var addresses []string + var b strings.Builder + quoted, depth, angled := false, 0, false + flush := func() { + if address := strings.TrimSpace(b.String()); address != "" { + addresses = append(addresses, address) + } + b.Reset() + } + escaped := false + for _, r := range s { + switch { + case escaped: + escaped = false + case r == '\\' && quoted: + escaped = true + case r == '"' && depth == 0: + quoted = !quoted + case quoted: + case r == '(': + depth++ + case r == ')' && depth > 0: + depth-- + case depth > 0: + case r == '<': + angled = true + case r == '>': + angled = false + case r == ',' && !angled: + flush() + continue + } + b.WriteRune(r) + } + flush() + return addresses +} + // InvalidAddress returns the first recipient HEY would certainly not deliver to, or "" // when there is none. // @@ -69,18 +111,24 @@ func addrSpec(address string) (spec string, size int, ok bool) { return parsed.Address, size, true } spec = withoutComments(address) + name := "" if open := strings.LastIndexByte(spec, '<'); open >= 0 { end := strings.IndexByte(spec[open:], '>') if end < 0 { return "", 0, false } + name = strings.Join(strings.Fields(spec[:open]), " ") spec = spec[open+1 : open+end] } spec = strings.Join(strings.Fields(spec), "") if at := strings.LastIndexByte(spec, '@'); at >= 0 && strings.Contains(withoutQuoted(spec[:at]), "@") { return "", 0, false } - return spec, utf8.RuneCountInString(spec), true + size = utf8.RuneCountInString(spec) + if name != "" { + size += utf8.RuneCountInString(name) + len(" <>") + } + return spec, size, true } // withoutQuoted drops quoted strings, where an @ belongs to the name it is in. diff --git a/internal/mail/address_test.go b/internal/mail/address_test.go index a45cf537..db4d3039 100644 --- a/internal/mail/address_test.go +++ b/internal/mail/address_test.go @@ -15,7 +15,9 @@ func TestInvalidAddressFollowsHEYsRule(t *testing.T) { "annie+newsletters@example.co.uk": true, "annie@пример.рф": true, "annie@build.localdomain": true, - "annie@photos.blogspot.com": true, + // HEY's rule is /localdomain$/i, and the dev server delivers to this. + "annie@notlocaldomain": true, + "annie@photos.blogspot.com": true, // Whole country domains are wildcard rules, which only a full lookup matches. "annie@example.np": true, "annie@www.ck": true, @@ -48,6 +50,9 @@ func TestInvalidAddressFollowsHEYsRule(t *testing.T) { // Length counts the name HEY writes out with the address. strings.Repeat("a", 490) + "@example.com": false, strings.Repeat("A", 490) + " ": false, + // Even where net/mail cannot parse the address, the name counts. + strings.Repeat("A", 490) + ` `: false, + strings.Repeat("A", 470) + ` `: true, } { got := InvalidAddress([]string{address}) == "" if got != deliverable { @@ -67,3 +72,26 @@ func TestInvalidAddressNamesTheFirstBadRecipientInAnyList(t *testing.T) { t.Errorf("InvalidAddress of good recipients = %q, want none", got) } } + +func TestSplitAddressesKeepsCommasInsideAnAddress(t *testing.T) { + for input, want := range map[string][]string{ + "annie@example.com, frank@example.org": {"annie@example.com", "frank@example.org"}, + ` "Bryan, Annie" ,frank@example.org,`: {`"Bryan, Annie" `, "frank@example.org"}, + `"Castillo, \"Frank\"" , annie@example.com`: {`"Castillo, \"Frank\"" `, "annie@example.com"}, + "annie@example.com (Bryan, Annie), frank@example.org": {"annie@example.com (Bryan, Annie)", "frank@example.org"}, + "": nil, + " , ": nil, + } { + got := SplitAddresses(input) + if len(got) != len(want) { + t.Errorf("SplitAddresses(%q) = %q, want %q", input, got, want) + continue + } + for i := range got { + if got[i] != want[i] { + t.Errorf("SplitAddresses(%q) = %q, want %q", input, got, want) + break + } + } + } +} diff --git a/internal/tui/compose.go b/internal/tui/compose.go index 2ae9e86f..28cd60fd 100644 --- a/internal/tui/compose.go +++ b/internal/tui/compose.go @@ -388,13 +388,7 @@ func (f *composeForm) view() string { // parseAddressList splits a comma-separated list, trimming blanks. func parseAddressList(s string) []string { - var out []string - for _, a := range strings.Split(s, ",") { - if a = strings.TrimSpace(a); a != "" { - out = append(out, a) - } - } - return out + return mail.SplitAddresses(s) } // --- mailView glue --- diff --git a/internal/tui/compose_test.go b/internal/tui/compose_test.go index 0aaa1583..21383bcd 100644 --- a/internal/tui/compose_test.go +++ b/internal/tui/compose_test.go @@ -185,6 +185,29 @@ func TestComposeRefusesAnAddressHEYWouldDrop(t *testing.T) { } } +func TestComposeSendsAQuotedNameWithACommaAsOneRecipient(t *testing.T) { + v, rec := composeTestServer(t) + v.Resize(80, 30) + v.HandleContentKey(keyPress("c")) + typeText(v, `"Bryan, Annie" `) + v.HandleContentKey(keyPress("tab")) // cc + v.HandleContentKey(keyPress("tab")) // bcc + v.HandleContentKey(keyPress("tab")) // subject + typeText(v, "Kitchen remodel timeline") + v.HandleContentKey(keyPress("tab")) // body + typeText(v, "Cabinets land the week of the 14th.") + + cmd := v.HandleContentKey(ctrlS()) + if cmd == nil { + t.Fatalf("a quoted name with a comma was refused: %q", composeModal(v).status) + } + runCmd(cmd) + directly := rec.body["entry"].(map[string]any)["addressed"].(map[string]any)["directly"].([]any) + if len(directly) != 1 || directly[0] != `"Bryan, Annie" ` { + t.Errorf("directly = %v, want the one recipient as typed", directly) + } +} + func TestComposeSendsMessage(t *testing.T) { v, rec := composeTestServer(t) v.Resize(80, 30) From 1db1400dd416002f0038f49f7a38e073c5b9971a Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 14:26:08 -0400 Subject: [PATCH 6/6] Count the quotes HEY writes around a name that needs them The mail gem quotes a display name holding a comma or another special and escapes any quote or backslash inside, so a 479-character "Bryan, Annie"-style name with annie@example.com is 501 characters as HEY writes it. The size now includes those, keeping it a lower bound on HEY's. --- internal/mail/address.go | 5 +++++ internal/mail/address_test.go | 3 +++ 2 files changed, 8 insertions(+) diff --git a/internal/mail/address.go b/internal/mail/address.go index a5beb3b0..27226a5e 100644 --- a/internal/mail/address.go +++ b/internal/mail/address.go @@ -107,6 +107,11 @@ func addrSpec(address string) (spec string, size int, ok bool) { size = utf8.RuneCountInString(parsed.Address) if parsed.Name != "" { size += utf8.RuneCountInString(parsed.Name) + len(" <>") + // The mail gem writes a name holding one of these in quotes, escaping any + // quote or backslash inside. + if strings.ContainsAny(parsed.Name, `()<>[]:;@\,."`) { + size += len(`""`) + strings.Count(parsed.Name, `"`) + strings.Count(parsed.Name, `\`) + } } return parsed.Address, size, true } diff --git a/internal/mail/address_test.go b/internal/mail/address_test.go index db4d3039..d9705882 100644 --- a/internal/mail/address_test.go +++ b/internal/mail/address_test.go @@ -53,6 +53,9 @@ func TestInvalidAddressFollowsHEYsRule(t *testing.T) { // Even where net/mail cannot parse the address, the name counts. strings.Repeat("A", 490) + ` `: false, strings.Repeat("A", 470) + ` `: true, + // A name the mail gem has to quote is two characters longer written out. + `"` + strings.Repeat("A", 472) + `, Annie" `: false, + strings.Repeat("A", 474) + ` Annie `: true, } { got := InvalidAddress([]string{address}) == "" if got != deliverable {