` 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" ];