From 61659df87d52d1810962db45d6459978423397a8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 31 Oct 2026 14:14:52 -0700 Subject: [PATCH 1/3] A local reply leaves out the people the answered message blind copied When HEY's prefill is unavailable or empty, hey reply and the TUI composer fall back to working out recipients locally, and they carried the answered message's Bcc over to the reply. HEY stopped doing that in basecamp/haystack#9169, so a follow-up could reach someone who was blind copied "this once" without anyone seeing it. The fallbacks now leave the Bcc'd people off. They keep them, in Bcc, only when nobody else is left to address: the message went to the sender alone ("To: me, Bcc: the list") or only to blind copied people. The fallback cannot know who is replying, so it treats the sender as the replier. A Bcc-only message written by someone else therefore keeps its Bcc here, where HEY would drop it; the server prefill, the primary path, has the exact answer. Verified: new cases for the plain drop and both whole-audience keeps fail on the old code (the keeps pin existing behaviour); make test and make lint pass. --- internal/cmd/thread_reply.go | 23 ++++++++++++++--- internal/cmd/thread_reply_test.go | 41 +++++++++++++++++++++++++++++-- internal/tui/compose.go | 21 ++++++++++++++-- internal/tui/compose_test.go | 26 +++++++++++++++++--- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/internal/cmd/thread_reply.go b/internal/cmd/thread_reply.go index 2b8ef693c..74168e13b 100644 --- a/internal/cmd/thread_reply.go +++ b/internal/cmd/thread_reply.go @@ -282,7 +282,9 @@ func replySubject(subject string) string { // 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. +// 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 recipientsForReplyTo(message generated.Message) replyRecipients { sender := message.Sender.EmailAddress if sender == "" { @@ -293,9 +295,11 @@ func recipientsForReplyTo(message generated.Message) replyRecipients { 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), + 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) @@ -303,6 +307,17 @@ func recipientsForReplyAddressed(addressed generated.Addressed, sender string) r 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 { 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/tui/compose.go b/internal/tui/compose.go index 71b5fe6e9..8ee7929f3 100644 --- a/internal/tui/compose.go +++ b/internal/tui/compose.go @@ -691,7 +691,9 @@ func replySubjectFor(message generated.Message) string { // 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. +// 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 recipientsForReplyTo(message generated.Message) (to, cc, bcc []string) { sender := message.Sender.EmailAddress if sender == "" { @@ -702,7 +704,22 @@ func recipientsForReplyTo(message generated.Message) (to, cc, bcc []string) { if sender != "" { to = append(to, sender) } - return to, addressesOf(message.Addressed.Copied, sender), addressesOf(message.Addressed.Blindcopied, sender) + cc = addressesOf(message.Addressed.Copied, sender) + if keepsBlindCopied(message.Addressed, sender) { + bcc = addressesOf(message.Addressed.Blindcopied, sender) + } + return to, cc, bcc +} + +// 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 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", From fc6d623a0c058dc9f8dd67af59f4f2433d238da9 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 31 Oct 2026 21:38:22 -0700 Subject: [PATCH 2/3] The reply fallback's recipient rule lives in internal/mail, one copy for the CLI and the TUI Review finding (Codex, P1): the Bcc exclusion rule and its helpers were written twice, in internal/cmd and internal/tui, and AGENTS.md says to extend internal/mail rather than reimplement HEY's rules in a caller. mail.ReplyRecipientsFor now holds recipientsForReplyTo, keepsBlindCopied and addressesOf; both callers delegate to it. Behaviour is unchanged. Verified with make test and make lint, the existing CLI and TUI cases unchanged, plus a mail-level test of both Bcc outcomes. Rejected: leaving two copies in step by a comment, which is the drift the finding names. --- internal/cmd/thread_reply.go | 55 ++--------------------- internal/mail/reply_recipients.go | 62 ++++++++++++++++++++++++++ internal/mail/reply_recipients_test.go | 44 ++++++++++++++++++ internal/tui/compose.go | 51 ++------------------- 4 files changed, 113 insertions(+), 99 deletions(-) create mode 100644 internal/mail/reply_recipients.go create mode 100644 internal/mail/reply_recipients_test.go diff --git a/internal/cmd/thread_reply.go b/internal/cmd/thread_reply.go index 74168e13b..eb463c287 100644 --- a/internal/cmd/thread_reply.go +++ b/internal/cmd/thread_reply.go @@ -279,57 +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. The people it -// blind copied stay off the reply, as HEY leaves them off, unless they are all the -// audience there is: see keepsBlindCopied. +// 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), - } - 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 + return mail.ReplyRecipientsFor(message) } 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 8ee7929f3..6db97689d 100644 --- a/internal/tui/compose.go +++ b/internal/tui/compose.go @@ -688,54 +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. The people it -// blind copied stay off the reply, as HEY leaves them off, unless they are all the -// audience there is: see keepsBlindCopied. +// 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) - } - cc = addressesOf(message.Addressed.Copied, sender) - if keepsBlindCopied(message.Addressed, sender) { - bcc = addressesOf(message.Addressed.Blindcopied, sender) - } - return to, cc, bcc -} - -// 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 + recipients := mail.ReplyRecipientsFor(message) + return recipients.To, recipients.CC, recipients.BCC } // loadForwardContext fetches HEY's prefilled forward for the latest emailed message in From 6c8f3bd3b68de740b0ec6112dc929432e6f2a46c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 31 Oct 2026 21:39:39 -0700 Subject: [PATCH 3/3] The reply help and manual say who the offline fallback leaves out Review finding (Copilot): the help, agent notes and CLI manual promised a reply to everyone the entry addressed, but the fallback now leaves out the people the message blind copied. The help and docs/cli.md now say the fallback drops blind copied people unless they are everyone left to address, and that it takes the sender as the replier. The agent notes describe HEY's prefill, which is unchanged. Verified with make test and make lint. Rejected: editing the agent notes, whose sentence is about HEY's prefill. --- docs/cli.md | 2 +- internal/cmd/reply.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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.