diff --git a/docs/cli.md b/docs/cli.md index f5ee2732..e0f79296 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 `--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. 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..8df12635 100644 --- a/internal/cmd/compose.go +++ b/internal/cmd/compose.go @@ -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" ) @@ -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.", }, @@ -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. +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 addrs +} + +func parseAddresses(s string) []string { + return mail.SplitAddresses(s) } diff --git a/internal/cmd/compose_test.go b/internal/cmd/compose_test.go index bb2d9359..3e7a979d 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" @@ -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" , bob@example.com`, + want: []string{`"Bryan, Annie" `, "bob@example.com"}, + }, } for _, tt := range tests { @@ -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) + } + }) + } +} diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index d5742eae..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.", }, @@ -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") diff --git a/internal/cmd/forward.go b/internal/cmd/forward.go index 51f96a12..9563ed9e 100644 --- a/internal/cmd/forward.go +++ b/internal/cmd/forward.go @@ -24,13 +24,17 @@ 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.", }, 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)") diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index 75edd76b..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.", }, @@ -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)") diff --git a/internal/mail/address.go b/internal/mail/address.go new file mode 100644 index 00000000..27226a5e --- /dev/null +++ b/internal/mail/address.go @@ -0,0 +1,198 @@ +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, in characters of the address +// as it writes it out, name included (Contact::CertifiedMailAddress). +const maxAddressSize = 500 + +// 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]\?[^?]*\?=`) + +// 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. +// +// 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 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 { + if !deliverable(address) { + return address + } + } + } + return "" +} + +func deliverable(address string) bool { + spec, size, ok := addrSpec(address) + if !ok || size > maxAddressSize { + return false + } + at := strings.LastIndexByte(spec, '@') + if at <= 0 || at == len(spec)-1 || encodedWord.MatchString(spec[:at]) { + return false + } + 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. 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(" <>") + // 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 + } + 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 + } + 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. +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. +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 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 + } + tld := domain[strings.LastIndexByte(domain, '.')+1:] + if tld == "" || strings.HasPrefix(tld, "xn--") { + return false + } + ascii, err := idna.Punycode.ToASCII(tld) + if err != nil { + return false + } + // 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 new file mode 100644 index 00000000..d9705882 --- /dev/null +++ b/internal/mail/address_test.go @@ -0,0 +1,100 @@ +package mail + +import ( + "strings" + "testing" +) + +func TestInvalidAddressFollowsHEYsRule(t *testing.T) { + for address, deliverable := range map[string]bool{ + "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, + // 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, + "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, + "(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@example.notatld": false, + "annie@example.com.": false, + "annie@@example.com": false, + "Annie ": false, + // 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 { + 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) + } +} + +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 31e87cad..28cd60fd 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" } @@ -384,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 ee5054d0..21383bcd 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,59 @@ 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 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) @@ -217,6 +270,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) 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" ];