Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ hey stop-ignoring 12345 # resume attention for a thread

`hey thread comment <topic_id> -m <text>` 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.

Expand Down
4 changes: 3 additions & 1 deletion internal/cmd/reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 3 additions & 37 deletions internal/cmd/thread_reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
41 changes: 39 additions & 2 deletions internal/cmd/thread_reply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
},
{
Expand Down
62 changes: 62 additions & 0 deletions internal/mail/reply_recipients.go
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
jeremy marked this conversation as resolved.
Resolved
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
}
44 changes: 44 additions & 0 deletions internal/mail/reply_recipients_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
34 changes: 4 additions & 30 deletions internal/tui/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions internal/tui/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading