diff --git a/docs/cli.md b/docs/cli.md index 845c102e4..a5efa26a8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -275,7 +275,7 @@ hey stop-ignoring 12345 # resume attention for a thread `hey thread comment -m ` adds a note to the thread. It is not `hey reply`: HEY never emails a note to anyone. It is not private either: everyone with access to the thread sees it, and on a HEY for Domains account that includes your teammates on it; on a thread no one else can reach it is a note to self. The answer names the note's `id` and `visible_to`, the other people who see it (`[]` for a note to self), and `collection_only`, true on a collection's own thread. `--dry-run` asks HEY who a note would reach and adds nothing. The note is Markdown like every other `-m`, so plain text such as `1 < 2 & 3` arrives as typed; `--message-html` sends raw HTML instead. A blank note is refused. -`hey thread read` reads a whole thread, oldest entry first, however many pages HEY serves it in — within limits it states: a hundred pages past the first, two thousand entries, as many bodies, 64 MiB of retained thread data and two minutes in all. The byte budget covers entry-index metadata, message bodies and metadata, the recipient identities retained for thread output, and inbound delivery addresses and resolved contact identities retained for JSON. A thread that could only be read in part — a body HEY would not serve, a limit reached — is refused rather than passed off as whole; `--allow-partial` takes what was read, with a `notice` saying what is missing and each entry's `body_state` saying whether its body was `hydrated`, `bodyless` (HEY served none), `over_limit` or `failed`. Each entry whose message was read carries `recipients`, with `to`, `cc` and `bcc` contact lists; the object is absent when the message was not read, while a known-empty line is `[]`. `creator` is the entry's author — the external sender for inbound mail, your own contact for mail you sent; if you sent from a different address (an alias, custom domain or external account), the hydrated entry also has `sender` with that address, and the displayed From uses it. A missing `sender` does not mean the same sender was verified: the index-only formats and entries whose message was not read have no sender data. In JSON, an inbound entry also carries `received_via`: every exact account address HEY recorded the message arriving through, including plus tags and catch-all aliases. These are delivery records, not the visible To/CC/BCC recipients. A record's `contact` is optional and is omitted when HEY did not resolve one; the whole field is omitted for sent or generated messages and whenever the message was not read. `--count` and `--ids-only` read the entry index and no messages, so only a truncated index can make them partial. `--markdown` writes the thread as one Markdown document — a heading per entry naming the sender, date and ID, then the body — which is the shape to hand an agent or a notes app. An entry's `kind` is `message` for mail; a note (`comment`) or share notice (`access_notice`) is internal — visible to everyone with access to the thread, never emailed — and `--markdown`, styled and `--html` output head it "Note by …" or "… shared this thread" instead of a From line. `hey attachment list` reads the bodies in every format, since that is where attachment metadata lives, and answers a partial thread the same way. `hey reply` answers the thread's latest emailed message — never a note or share notice posted after it, as in HEY's web app; a thread with nothing else is refused, and `hey forward` and `hey compose --thread-id` pick the same message — and addresses the reply the way HEY does: it asks HEY for the reply's recipients — everyone that entry was addressed to, its sender moved onto the To line, and your own addresses, aliases and catch-alls excluded — falling back to the latest message's metadata for a send when that read is unavailable. +`hey thread read` reads a whole thread, oldest entry first, however many pages HEY serves it in — within limits it states: a hundred pages past the first, two thousand entries, as many bodies, 64 MiB of retained thread data and two minutes in all. The byte budget covers entry-index metadata, message bodies and metadata, the recipient identities retained for thread output, and inbound delivery addresses and resolved contact identities retained for JSON. A thread that could only be read in part — a body HEY would not serve, a limit reached — is refused rather than passed off as whole; `--allow-partial` takes what was read, with a `notice` saying what is missing and each entry's `body_state` saying whether its body was `hydrated`, `bodyless` (HEY served none), `over_limit` or `failed`. Each entry whose message was read carries `recipients`, with `to`, `cc` and `bcc` contact lists; the object is absent when the message was not read, while a known-empty line is `[]`. `creator` is the entry's author — the external sender for inbound mail, your own contact for mail you sent; if you sent from a different address (an alias, custom domain or external account), the hydrated entry also has `sender` with that address, and the displayed From uses it. A missing `sender` does not mean the same sender was verified: the index-only formats and entries whose message was not read have no sender data. In JSON, an inbound entry also carries `received_via`: every exact account address HEY recorded the message arriving through, including plus tags and catch-all aliases. These are delivery records, not the visible To/CC/BCC recipients. A record's `contact` is optional and is omitted when HEY did not resolve one; the whole field is omitted for sent or generated messages and whenever the message was not read. `--count` and `--ids-only` read the entry index and no messages, so only a truncated index can make them partial. `--markdown` writes the thread as one Markdown document — a heading per entry naming the sender, date and ID, then the body — which is the shape to hand an agent or a notes app. An entry's `kind` is `message` for mail; a note (`comment`) or share notice (`access_notice`) is internal — visible to everyone with access to the thread, never emailed — and `--markdown`, styled and `--html` output head it "Note by …" or "… shared this thread" instead of a From line. `hey attachment list` reads the bodies in every format, since that is where attachment metadata lives, and answers a partial thread the same way. `hey reply` answers the thread's latest emailed message — never a note or share notice posted after it, as in HEY's web app; a thread with nothing else is refused, and `hey forward` and `hey compose --thread-id` pick the same message — and addresses the reply the way HEY does: it asks HEY for the reply's recipients — everyone that entry was addressed to, its sender moved onto the To line, and your own addresses, aliases and catch-alls excluded — falling back to the latest message's metadata for a send when that read is unavailable. The fallback leaves out the people that message blind copied, as HEY does, unless they are everyone left to address; it cannot tell who you are, so it takes the sender as the replier, and answering your own message can keep its blind copied people. 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. diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index 4401ba24f..71b6ab87b 100644 --- a/internal/cmd/reply.go +++ b/internal/cmd/reply.go @@ -53,7 +53,9 @@ nothing else is refused. The reply is addressed the way HEY's own web app addresses one: everyone that entry was addressed to, with whoever wrote it on the To line, minus your own addresses. If HEY's prefill is unavailable or names no one, a send falls back to the message's own recipients, -which can include you. Repeatable --to, --cc and --bcc +which can include you. The fallback leaves out the people that message blind copied, as HEY +does, unless they are everyone left to address, and it takes the sender as the replier, so +answering your own message can keep its blind copied people. 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. diff --git a/internal/cmd/thread_reply.go b/internal/cmd/thread_reply.go index 2b8ef693c..eb463c287 100644 --- a/internal/cmd/thread_reply.go +++ b/internal/cmd/thread_reply.go @@ -279,42 +279,8 @@ func replySubject(subject string) string { return strings.TrimRight("Re: "+rest, " ") } -// recipientsForReplyTo answers who a reply to this message goes to: the message's own -// recipients, with whoever sent it moved onto the To line. That is what HEY does in -// Entry::Addressed#participating_contacts_in_reply_by_kind, so a reply reaches the -// person who wrote the message as well as everyone they wrote to. +// recipientsForReplyTo answers who a reply to this message goes to when HEY's prefill is +// out of reach; the rule lives in mail.ReplyRecipientsFor, shared with the TUI. func recipientsForReplyTo(message generated.Message) replyRecipients { - sender := message.Sender.EmailAddress - if sender == "" { - sender = message.Creator.EmailAddress - } - return recipientsForReplyAddressed(message.Addressed, sender) -} - -func recipientsForReplyAddressed(addressed generated.Addressed, sender string) replyRecipients { - recipients := replyRecipients{ - To: addressesOf(addressed.Directly, sender), - CC: addressesOf(addressed.Copied, sender), - BCC: addressesOf(addressed.Blindcopied, sender), - } - if sender != "" { - recipients.To = append(recipients.To, sender) - } - return recipients -} - -// addressesOf answers the contacts' email addresses, dropping blanks, repeats, and the -// one address HEY addresses directly instead. -func addressesOf(contacts []generated.Contact, excluding string) []string { - seen := map[string]bool{strings.ToLower(excluding): true} - var addresses []string - for _, contact := range contacts { - address := strings.TrimSpace(contact.EmailAddress) - key := strings.ToLower(address) - if address != "" && !seen[key] { - seen[key] = true - addresses = append(addresses, address) - } - } - return addresses + return mail.ReplyRecipientsFor(message) } diff --git a/internal/cmd/thread_reply_test.go b/internal/cmd/thread_reply_test.go index 24dcb4aa2..2d3ee7bea 100644 --- a/internal/cmd/thread_reply_test.go +++ b/internal/cmd/thread_reply_test.go @@ -329,9 +329,46 @@ func TestRecipientsForReplyTo(t *testing.T) { Blindcopied: []generated.Contact{contact("bcc@example.com")}, }, }, + want: replyRecipients{To: []string{"jane@example.com", "Rick@example.com"}}, + }, + { + name: "a reply leaves out the people the message blind copied", + message: generated.Message{ + Sender: contact("rick@example.com"), + Addressed: generated.Addressed{ + Directly: []generated.Contact{contact("jane@example.com")}, + Copied: []generated.Contact{contact("cc@example.com")}, + Blindcopied: []generated.Contact{contact("bcc@example.com")}, + }, + }, + want: replyRecipients{ + To: []string{"jane@example.com", "rick@example.com"}, + CC: []string{"cc@example.com"}, + }, + }, + { + name: "a reply to our own message to ourselves keeps the list we blind copied", + message: generated.Message{ + Sender: contact("me@example.com"), + Addressed: generated.Addressed{ + Directly: []generated.Contact{contact("me@example.com")}, + Blindcopied: []generated.Contact{contact("a@example.com"), contact("b@example.com")}, + }, + }, + want: replyRecipients{ + To: []string{"me@example.com"}, + BCC: []string{"a@example.com", "b@example.com"}, + }, + }, + { + name: "a message only blind copied people received keeps them blind copied", + message: generated.Message{ + Sender: contact("me@example.com"), + Addressed: generated.Addressed{Blindcopied: []generated.Contact{contact("a@example.com"), contact("b@example.com")}}, + }, want: replyRecipients{ - To: []string{"jane@example.com", "Rick@example.com"}, - BCC: []string{"bcc@example.com"}, + To: []string{"me@example.com"}, + BCC: []string{"a@example.com", "b@example.com"}, }, }, { diff --git a/internal/mail/reply_recipients.go b/internal/mail/reply_recipients.go new file mode 100644 index 000000000..685652179 --- /dev/null +++ b/internal/mail/reply_recipients.go @@ -0,0 +1,62 @@ +package mail + +import ( + "strings" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +// ReplyRecipientsFor answers who a reply to this message goes to when HEY's own prefill +// is out of reach: the message's own recipients, with whoever sent it moved onto the To +// line. That is what HEY does in Entry::Addressed#participating_contacts_in_reply_by_kind, +// so a reply reaches the person who wrote the message as well as everyone they wrote to. +// The people it blind copied stay off the reply, as HEY leaves them off, unless they are +// all the audience there is: see keepsBlindCopied. +func ReplyRecipientsFor(message generated.Message) ReplyRecipients { + sender := message.Sender.EmailAddress + if sender == "" { + sender = message.Creator.EmailAddress + } + return replyRecipientsAddressed(message.Addressed, sender) +} + +func replyRecipientsAddressed(addressed generated.Addressed, sender string) ReplyRecipients { + recipients := ReplyRecipients{ + To: addressesOf(addressed.Directly, sender), + CC: addressesOf(addressed.Copied, sender), + } + if keepsBlindCopied(addressed, sender) { + recipients.BCC = addressesOf(addressed.Blindcopied, sender) + } + if sender != "" { + recipients.To = append(recipients.To, sender) + } + return recipients +} + +// keepsBlindCopied is HEY's rule (haystack#9169) for whether a reply keeps the people the +// answered message blind copied: only when they are the whole audience, because blind +// copying is often "this once" and a follow-up shouldn't reach them unseen. Nobody is left +// to address once the sender, who is the replier when they answer their own message +// (the fallback cannot tell otherwise), is set aside, as with "To: me, Bcc: the list" +// or a thread of only blind copied people. +func keepsBlindCopied(addressed generated.Addressed, sender string) bool { + visible := append(append([]generated.Contact{}, addressed.Directly...), addressed.Copied...) + return len(addressesOf(visible, sender)) == 0 +} + +// addressesOf answers the contacts' email addresses, dropping blanks, repeats, and the +// one address HEY addresses directly instead. +func addressesOf(contacts []generated.Contact, excluding string) []string { + seen := map[string]bool{strings.ToLower(excluding): true} + var addresses []string + for _, contact := range contacts { + address := strings.TrimSpace(contact.EmailAddress) + key := strings.ToLower(address) + if address != "" && !seen[key] { + seen[key] = true + addresses = append(addresses, address) + } + } + return addresses +} diff --git a/internal/mail/reply_recipients_test.go b/internal/mail/reply_recipients_test.go new file mode 100644 index 000000000..860454f82 --- /dev/null +++ b/internal/mail/reply_recipients_test.go @@ -0,0 +1,44 @@ +package mail + +import ( + "reflect" + "testing" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +func contacts(addresses ...string) []generated.Contact { + list := make([]generated.Contact, 0, len(addresses)) + for _, address := range addresses { + list = append(list, generated.Contact{EmailAddress: address}) + } + return list +} + +func TestReplyRecipientsForBlindCopies(t *testing.T) { + sender := generated.Contact{EmailAddress: "jane@example.com"} + tests := []struct { + name string + addressed generated.Addressed + want ReplyRecipients + }{ + { + name: "blind copies stay off a reply with a visible audience", + addressed: generated.Addressed{Directly: contacts("rick@example.com"), Blindcopied: contacts("secret@example.com")}, + want: ReplyRecipients{To: []string{"rick@example.com", "jane@example.com"}}, + }, + { + name: "blind copies are kept when they are the whole audience", + addressed: generated.Addressed{Directly: contacts("jane@example.com"), Blindcopied: contacts("list@example.com")}, + want: ReplyRecipients{To: []string{"jane@example.com"}, BCC: []string{"list@example.com"}}, + }, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + got := ReplyRecipientsFor(generated.Message{Sender: sender, Addressed: testCase.addressed}) + if !reflect.DeepEqual(got, testCase.want) { + t.Fatalf("got %+v, want %+v", got, testCase.want) + } + }) + } +} diff --git a/internal/tui/compose.go b/internal/tui/compose.go index 71b5fe6e9..6db97689d 100644 --- a/internal/tui/compose.go +++ b/internal/tui/compose.go @@ -688,37 +688,11 @@ func replySubjectFor(message generated.Message) string { return strings.TrimRight("Re: "+rest, " ") } -// recipientsForReplyTo answers who a reply to this message goes to: the message's own -// recipients, with whoever sent it moved onto the To line. That is what HEY does in -// Entry::Addressed#participating_contacts_in_reply_by_kind, so a reply reaches the -// person who wrote the message as well as everyone they wrote to. +// recipientsForReplyTo answers who a reply to this message goes to when HEY's prefill is +// out of reach; the rule lives in mail.ReplyRecipientsFor, shared with the CLI. func recipientsForReplyTo(message generated.Message) (to, cc, bcc []string) { - sender := message.Sender.EmailAddress - if sender == "" { - sender = message.Creator.EmailAddress - } - - to = addressesOf(message.Addressed.Directly, sender) - if sender != "" { - to = append(to, sender) - } - return to, addressesOf(message.Addressed.Copied, sender), addressesOf(message.Addressed.Blindcopied, sender) -} - -// addressesOf answers the contacts' email addresses, dropping blanks, repeats, and the -// one address HEY addresses directly instead. -func addressesOf(contacts []generated.Contact, excluding string) []string { - seen := map[string]bool{strings.ToLower(excluding): true} - var addresses []string - for _, contact := range contacts { - address := strings.TrimSpace(contact.EmailAddress) - key := strings.ToLower(address) - if address != "" && !seen[key] { - seen[key] = true - addresses = append(addresses, address) - } - } - return addresses + recipients := mail.ReplyRecipientsFor(message) + return recipients.To, recipients.CC, recipients.BCC } // loadForwardContext fetches HEY's prefilled forward for the latest emailed message in diff --git a/internal/tui/compose_test.go b/internal/tui/compose_test.go index eadad109a..81c062f51 100644 --- a/internal/tui/compose_test.go +++ b/internal/tui/compose_test.go @@ -509,9 +509,29 @@ func TestRecipientsForReplyTo(t *testing.T) { Blindcopied: []generated.Contact{contact("bcc@example.com")}, }, }, - wantTo: []string{"jane@example.com", "rick@example.com"}, - wantCC: []string{"cc@example.com"}, - wantBCC: []string{"bcc@example.com"}, + wantTo: []string{"jane@example.com", "rick@example.com"}, + wantCC: []string{"cc@example.com"}, + }, + { + name: "a reply to our own message to ourselves keeps the list we blind copied", + message: generated.Message{ + Sender: contact("me@example.com"), + Addressed: generated.Addressed{ + Directly: []generated.Contact{contact("me@example.com")}, + Blindcopied: []generated.Contact{contact("a@example.com"), contact("b@example.com")}, + }, + }, + wantTo: []string{"me@example.com"}, + wantBCC: []string{"a@example.com", "b@example.com"}, + }, + { + name: "a message only blind copied people received keeps them blind copied", + message: generated.Message{ + Sender: contact("me@example.com"), + Addressed: generated.Addressed{Blindcopied: []generated.Contact{contact("a@example.com"), contact("b@example.com")}}, + }, + wantTo: []string{"me@example.com"}, + wantBCC: []string{"a@example.com", "b@example.com"}, }, { name: "the creator stands in for a missing sender",