From 51be320b021b4d1db41752dd3c436caa05d6a89a Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 22 Sep 2026 17:49:00 -0400 Subject: [PATCH 1/3] Show HEY send-as address separately from the account creator HEY stores the configured From in message.sender while creator remains the account user; threadload dropped sender and thread read therefore made successful sends appear to use the wrong address. Retain and display the sender without changing creator. --- docs/cli.md | 2 +- docs/tui.md | 2 + internal/cmd/thread_partial_test.go | 8 +- internal/cmd/thread_sender_test.go | 101 +++++++++++++++++++++++++ internal/cmd/topic.go | 22 +++++- internal/cmd/topic_test.go | 10 +++ internal/mail/entry.go | 2 + internal/mail/entry_test.go | 11 +++ internal/threadload/threadload.go | 4 +- internal/threadload/threadload_test.go | 15 ++++ internal/tui/mail.go | 6 ++ internal/tui/mail_test.go | 15 ++++ 12 files changed, 190 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/thread_sender_test.go diff --git a/docs/cli.md b/docs/cli.md index dabbd90d..7bb4bdce 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -262,7 +262,7 @@ hey ignore 12345 # ignore future activity on a thread hey stop-ignoring 12345 # resume attention for a thread ``` -`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 `[]`. 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. `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 entry 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` remains the HEY account user; if HEY sent as a different address, 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. `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 entry 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. 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/docs/tui.md b/docs/tui.md index 045c2621..0d6d9f87 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -61,6 +61,8 @@ uppercase belongs to Labels: | Ctrl+A | switch linked account | | Ctrl+V | choose an Imbox cover | +While reading a thread, the From header shows the actual send-as address when HEY records one separately from the account user. + While reading a thread, links can be selected without a mouse. Tab selects the next link in document order and Shift+Tab selects the previous one; both wrap at the ends. A fixed row above the shortcut bar shows the complete selected destination without moving the thread. Press Enter to open it; opening stays unavailable until the terminal is wide enough to show the complete destination. Press Escape once to clear the selection, and Escape again to leave the thread. A thread with no selectable links keeps the normal global Tab focus behavior. Existing OSC 8 mouse links remain available. Most of those keep working while you are reading a thread, the way the web app's topic diff --git a/internal/cmd/thread_partial_test.go b/internal/cmd/thread_partial_test.go index fc459ab1..e24ba74a 100644 --- a/internal/cmd/thread_partial_test.go +++ b/internal/cmd/thread_partial_test.go @@ -115,6 +115,9 @@ func assertThreadEntryOmitsMessageMetadata(t *testing.T, stdout string, id int64 if receivedVia, present := entry["received_via"]; present { t.Errorf("entry %d carries received_via %#v, want the key omitted", id, receivedVia) } + if sender, present := entry["sender"]; present { + t.Errorf("entry %d carries sender %#v without a message", id, sender) + } return } } @@ -145,10 +148,11 @@ func TestThreadsJSONOmitsMessageDataItDidNotRead(t *testing.T) { body, hasBody := entry["body"] _, hasRecipients := entry["recipients"] _, hasReceivedVia := entry["received_via"] + _, hasSender := entry["sender"] switch entry["id"] { case float64(12): - if hasBody || hasRecipients || hasReceivedVia { - t.Errorf("entry 12 carries body %#v, recipients %v or received_via %v; want message data omitted", body, hasRecipients, hasReceivedVia) + if hasBody || hasRecipients || hasReceivedVia || hasSender { + t.Errorf("entry 12 carries body %#v, recipients %v, received_via %v or sender %v; want message data omitted", body, hasRecipients, hasReceivedVia, hasSender) } default: if text, ok := body.(string); !ok || text == "" || !hasRecipients || !hasReceivedVia { diff --git a/internal/cmd/thread_sender_test.go b/internal/cmd/thread_sender_test.go new file mode 100644 index 00000000..2cc62341 --- /dev/null +++ b/internal/cmd/thread_sender_test.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// HEY keeps the account user as creator and the selected send-as contact as sender. +// Sending from an alias must not make a later thread read look like it came from +// the account user's primary address. +func TestComposeFromIsVisibleOnThreadRead(t *testing.T) { + var actingSenderID atomic.Int64 + var messageReads atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(r.URL.Path, "identity"): + fmt.Fprint(w, senderIdentity) + case r.Method == http.MethodPost && r.URL.Path == "/messages.json": + var request struct { + ActingSenderID int64 `json:"acting_sender_id"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + } + actingSenderID.Store(request.ActingSenderID) + w.WriteHeader(http.StatusNoContent) + case r.URL.Path == "/topics/7/entries.json": + fmt.Fprint(w, `[{"id":11,"kind":"message","creator":{"id":77,"name":"Personal","email_address":"personal@example.org"}}]`) + case r.URL.Path == "/messages/11.json": + messageReads.Add(1) + fmt.Fprint(w, `{"id":11,"content":"

Numbers.

","creator":{"id":77,"name":"Personal","email_address":"personal@example.org"},"sender":{"id":88,"name":"Billing","email_address":"billing@example.org"}}`) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.RequestURI()) + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + stdoutTerminal(t, false) + + _, _, err := runCLIRaw(t, server, "--json", "compose", "--from", "billing@example.org", "--to", "maria@example.com", "--subject", "Board update", "-m", "Numbers.") + if err != nil { + t.Fatal(err) + } + if actingSenderID.Load() != 88 { + t.Fatalf("sent with sender id %d, want 88", actingSenderID.Load()) + } + + stdout, _, err := runCLIRaw(t, server, "--json", "thread", "read", "7") + if err != nil { + t.Fatal(err) + } + var response struct { + Data []struct { + Creator struct { + EmailAddress string `json:"email_address"` + } `json:"creator"` + Sender struct { + EmailAddress string `json:"email_address"` + } `json:"sender"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &response); err != nil || len(response.Data) != 1 { + t.Fatalf("invalid thread response: %v %s", err, stdout) + } + entry := response.Data[0] + if entry.Creator.EmailAddress != "personal@example.org" || entry.Sender.EmailAddress != "billing@example.org" { + t.Fatalf("read back creator %q and sender %q, want the account user and selected From address", entry.Creator.EmailAddress, entry.Sender.EmailAddress) + } + + styled, _, err := runCLIRaw(t, server, "--styled", "thread", "read", "7") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(styled, "From: Billing ") { + t.Fatalf("thread displays the wrong From: %s", styled) + } + markdown, _, err := runCLIRaw(t, server, "--markdown", "thread", "read", "7") + if err != nil || !strings.Contains(markdown, "Billing") || !strings.Contains(markdown, `billing\@example\.org`) { + t.Fatalf("Markdown thread hides From: %v %s", err, markdown) + } + html, _, err := runCLIRaw(t, server, "--html", "thread", "read", "7") + if err != nil || !strings.Contains(html, "Billing <billing@example.org>") { + t.Fatalf("HTML thread hides From: %v %s", err, html) + } + + before := messageReads.Load() + for _, format := range []string{"--count", "--ids-only"} { + if _, _, err := runCLIRaw(t, server, format, "thread", "read", "7"); err != nil { + t.Fatal(err) + } + } + if messageReads.Load() != before { + t.Fatalf("index-only formats read %d message bodies, want none", messageReads.Load()-before) + } +} diff --git a/internal/cmd/topic.go b/internal/cmd/topic.go index 1d107895..9b27e8c6 100644 --- a/internal/cmd/topic.go +++ b/internal/cmd/topic.go @@ -56,6 +56,7 @@ type threadEntry struct { CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Creator threadContact `json:"creator"` + Sender *threadContact `json:"sender,omitempty"` AlternativeSenderName string `json:"alternative_sender_name"` Summary string `json:"summary"` Kind string `json:"kind"` @@ -89,11 +90,12 @@ func newThreadsCommand() *topicCommand { threadsCommand.cmd = &cobra.Command{ Use: "read ", Short: "Read a thread", - Long: "Read every entry in a thread, oldest first. JSON entries include ordinary " + - "To/CC/BCC recipients and, for inbound mail, received_via with the exact " + - "account delivery addresses HEY recorded.", + Long: "Read every entry in a thread, oldest first. JSON entries keep the " + + "creator as the account user and, when HEY used another From address, " + + "include that address as sender. Entries also include To/CC/BCC recipients " + + "and, for inbound mail, received_via with the exact account delivery addresses HEY recorded.", Annotations: map[string]string{ - "agent_notes": "Returns a thread with all entries, oldest first. Entry bodies are Markdown; each entry whose message was read carries recipients as to, cc and bcc contact lists. In JSON, inbound entries also carry received_via: the exact account delivery addresses HEY recorded, distinct from the visible recipients, with an optional resolved contact. --html writes an HTML document instead, one
per entry with a From/To/CC/BCC header and HEY's original body HTML. A thread that could only be read in part is refused unless --allow-partial is passed, in which case each entry's body_state says what was read. Use the topic ID with hey reply or hey forward.", + "agent_notes": "Returns a thread with all entries, oldest first. Entry bodies are Markdown; the creator is the account user, while a non-default send-as address is the optional sender on a hydrated entry. Each entry whose message was read carries recipients as to, cc and bcc contact lists. In JSON, inbound entries also carry received_via: the exact account delivery addresses HEY recorded, distinct from the visible recipients, with an optional resolved contact. --html writes an HTML document instead, one
per entry with a From/To/CC/BCC header and HEY's original body HTML. A thread that could only be read in part is refused unless --allow-partial is passed, in which case each entry's body_state says what was read. Use the topic ID with hey reply or hey forward.", }, Example: ` hey thread read 12345 hey thread read 12345 --json @@ -375,6 +377,12 @@ func htmlCommentSafe(value string) string { } func threadEntrySender(entry threadEntry) string { + if entry.Sender != nil && entry.Sender.EmailAddress != "" { + if entry.Sender.Name != "" { + return fmt.Sprintf("%s <%s>", terminal.SanitizeLine(entry.Sender.Name), terminal.SanitizeLine(entry.Sender.EmailAddress)) + } + return entry.Sender.EmailAddress + } switch { case entry.AlternativeSenderName != "": return entry.AlternativeSenderName @@ -408,6 +416,7 @@ func newThreadEntry(loaded *threadload.Entry, html bool) threadEntry { bodyHTML := "" var recipients *threadRecipients var receivedVia []threadReceivedVia + var sender *threadContact if message := loaded.Message; message != nil { if creator.Id == 0 { @@ -432,6 +441,10 @@ func newThreadEntry(loaded *threadload.Entry, html bool) threadEntry { } recipients = newThreadRecipients(message.Addressed) receivedVia = newThreadReceivedVia(message.ReceivedVia) + if message.Sender.Id != 0 { + contact := newThreadContact(message.Sender) + sender = &contact + } // The loaded thread's copy is released as it is converted. loaded.Message = nil } @@ -450,6 +463,7 @@ func newThreadEntry(loaded *threadload.Entry, html bool) threadEntry { BodyState: string(loaded.State), BodyHTML: bodyHTML, Creator: newThreadContact(creator), + Sender: sender, } } diff --git a/internal/cmd/topic_test.go b/internal/cmd/topic_test.go index f1cccd7f..0303a1ad 100644 --- a/internal/cmd/topic_test.go +++ b/internal/cmd/topic_test.go @@ -445,6 +445,16 @@ func TestThreadEntrySender(t *testing.T) { entry threadEntry want string }{ + { + name: "selected send-as address with name", + entry: threadEntry{Creator: threadContact{Name: "Personal"}, Sender: &threadContact{Name: "Billing", EmailAddress: "billing@example.org"}}, + want: "Billing ", + }, + { + name: "selected send-as address without name", + entry: threadEntry{Creator: threadContact{Name: "Personal"}, Sender: &threadContact{EmailAddress: "billing@example.org"}}, + want: "billing@example.org", + }, { name: "an alternative sender name wins", entry: threadEntry{AlternativeSenderName: "Support", Creator: threadContact{Name: "Rick Sanchez"}}, diff --git a/internal/mail/entry.go b/internal/mail/entry.go index 76534caf..29448a02 100644 --- a/internal/mail/entry.go +++ b/internal/mail/entry.go @@ -20,6 +20,7 @@ type Entry struct { ID int64 CreatedAt time.Time Creator Contact + Sender Contact AlternativeSenderName string Summary string Body htmlutil.Markdown @@ -64,6 +65,7 @@ func NewEntry(entry generated.Entry, message generated.Message) Entry { ID: entry.Id, CreatedAt: createdAt, Creator: contactOf(creator), + Sender: contactOf(message.Sender), AlternativeSenderName: terminal.SanitizeLine(entry.AlternativeSenderName), Summary: terminal.SanitizeLine(summary), Body: htmlutil.ToMarkdown(message.Content), diff --git a/internal/mail/entry_test.go b/internal/mail/entry_test.go index 1c85b6ef..aec458bc 100644 --- a/internal/mail/entry_test.go +++ b/internal/mail/entry_test.go @@ -7,6 +7,17 @@ import ( "github.com/basecamp/hey-sdk/go/pkg/generated" ) +func TestNewEntryKeepsTheSelectedSender(t *testing.T) { + entry := NewEntry(generated.Entry{ + Id: 501, Creator: generated.Contact{Id: 77, Name: "Personal", EmailAddress: "personal@example.org"}, + }, generated.Message{ + Sender: generated.Contact{Id: 88, Name: "Billing", EmailAddress: "billing@example.org"}, + }) + if entry.Creator.EmailAddress != "personal@example.org" || entry.Sender.EmailAddress != "billing@example.org" { + t.Fatalf("creator = %+v, sender = %+v", entry.Creator, entry.Sender) + } +} + func TestNewEntry(t *testing.T) { created := time.Date(2026, 8, 18, 9, 30, 0, 0, time.UTC) diff --git a/internal/threadload/threadload.go b/internal/threadload/threadload.go index 1a77f9e9..9850ac58 100644 --- a/internal/threadload/threadload.go +++ b/internal/threadload/threadload.go @@ -427,6 +427,7 @@ func retained(message *generated.Message, retainRecipients, retainReceivedVia bo Subject: message.Subject, Url: message.Url, Creator: retainedContact(message.Creator), + Sender: retainedContact(message.Sender), CreatedAt: message.CreatedAt, UpdatedAt: message.UpdatedAt, } @@ -441,7 +442,8 @@ func retained(message *generated.Message, retainRecipients, retainReceivedVia bo kept.ReceivedVia = retainedReceivedVia(message.ReceivedVia) } size := int64(len(kept.Content)+len(kept.Subject)+len(kept.Url)+ - len(kept.Creator.Name)+len(kept.Creator.EmailAddress)) + retainedOverhead + len(kept.Creator.Name)+len(kept.Creator.EmailAddress)+ + len(kept.Sender.Name)+len(kept.Sender.EmailAddress)) + retainedOverhead if retainRecipients { size += contactsSize(kept.Addressed.Directly) + contactsSize(kept.Addressed.Copied) + contactsSize(kept.Addressed.Blindcopied) } diff --git a/internal/threadload/threadload_test.go b/internal/threadload/threadload_test.go index 8f4221da..5dc2d43e 100644 --- a/internal/threadload/threadload_test.go +++ b/internal/threadload/threadload_test.go @@ -415,6 +415,21 @@ func TestLoadDropsMessageMetadataUnlessRequested(t *testing.T) { } } +func TestRetainedKeepsAndChargesTheSendAsIdentity(t *testing.T) { + message := &generated.Message{Sender: generated.Contact{ + Id: 88, Name: "Billing", EmailAddress: "billing@example.org", + AvatarUrl: strings.Repeat("a", 1024), NameTag: strings.Repeat("n", 1024), + }} + kept, size := retained(message, false, false) + want := generated.Contact{Id: 88, Name: "Billing", EmailAddress: "billing@example.org"} + if kept.Sender != want { + t.Errorf("sender = %+v, want only identity fields %+v", kept.Sender, want) + } + if size != retainedOverhead+int64(len(want.Name)+len(want.EmailAddress)) { + t.Errorf("retained size = %d, want the selected sender charged", size) + } +} + func TestRetainedKeepsEveryRecipientKindAndOnlyIdentityFields(t *testing.T) { message := &generated.Message{Addressed: generated.Addressed{ Directly: []generated.Contact{fakeRecipient}, diff --git a/internal/tui/mail.go b/internal/tui/mail.go index d0526068..e98a88db 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -3313,6 +3313,12 @@ func (v *mailView) renderEntriesWithLinks(entries []mail.Entry) (string, []int, if e.AlternativeSenderName != "" { from = e.AlternativeSenderName } + if e.Sender.EmailAddress != "" { + from = e.Sender.EmailAddress + if e.Sender.Name != "" { + from = fmt.Sprintf("%s <%s>", terminal.SanitizeLine(e.Sender.Name), terminal.SanitizeLine(e.Sender.EmailAddress)) + } + } // Each arm below opens with a blank line, which is what separates the header // from whatever follows it. The summary is HEY's ~105-character preview of the diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index fe5cd9bc..6e8cdcd1 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -3837,6 +3837,21 @@ func TestRenderEntriesShowsTheBodyRatherThanHEYsPreviewOfIt(t *testing.T) { } } +func TestRenderEntriesShowsTheSelectedSendAsAddress(t *testing.T) { + v := newMailView(testVC()) + v.vc.width = 80 + entry := mail.NewEntry(generated.Entry{ + Id: 11, Creator: generated.Contact{Id: 77, Name: "Personal", EmailAddress: "personal@example.org"}, + }, generated.Message{ + Id: 11, Sender: generated.Contact{Id: 88, Name: "Billing", EmailAddress: "billing@example.org"}, + Content: "

Numbers.

", + }) + rendered, _ := v.renderEntries([]mail.Entry{entry}) + if !strings.Contains(rendered, "Billing ") || strings.Contains(rendered, "Personal ") { + t.Fatalf("thread shows the wrong From: %q", rendered) + } +} + func TestRenderEntriesReportsEveryMessageHeaderOffset(t *testing.T) { v := newMailView(testVC()) v.vc.width = 60 From ce57bdd027bd1684c65d9e8863a08309b1a1e878 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 22 Sep 2026 17:55:27 -0400 Subject: [PATCH 2/3] Retain address-only sender metadata on thread reads --- internal/cmd/topic.go | 2 +- internal/cmd/topic_test.go | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/internal/cmd/topic.go b/internal/cmd/topic.go index 9b27e8c6..e8d56c49 100644 --- a/internal/cmd/topic.go +++ b/internal/cmd/topic.go @@ -441,7 +441,7 @@ func newThreadEntry(loaded *threadload.Entry, html bool) threadEntry { } recipients = newThreadRecipients(message.Addressed) receivedVia = newThreadReceivedVia(message.ReceivedVia) - if message.Sender.Id != 0 { + if message.Sender.EmailAddress != "" { contact := newThreadContact(message.Sender) sender = &contact } diff --git a/internal/cmd/topic_test.go b/internal/cmd/topic_test.go index 0303a1ad..5b6d7dd6 100644 --- a/internal/cmd/topic_test.go +++ b/internal/cmd/topic_test.go @@ -439,6 +439,17 @@ func TestThreadReadHelpDistinguishesDeliveryAddressesFromRecipients(t *testing.T } } +func TestThreadEntryKeepsASenderAddressWithoutAContactID(t *testing.T) { + loaded := threadload.Entry{ + Entry: generated.Entry{Id: 11, Creator: generated.Contact{Id: 77, EmailAddress: "personal@example.org"}}, + Message: &generated.Message{Sender: generated.Contact{EmailAddress: "billing@example.org"}}, + } + entry := newThreadEntry(&loaded, false) + if entry.Sender == nil || entry.Sender.EmailAddress != "billing@example.org" { + t.Fatalf("sender = %+v, want the address HEY served", entry.Sender) + } +} + func TestThreadEntrySender(t *testing.T) { for _, testCase := range []struct { name string @@ -446,8 +457,8 @@ func TestThreadEntrySender(t *testing.T) { want string }{ { - name: "selected send-as address with name", - entry: threadEntry{Creator: threadContact{Name: "Personal"}, Sender: &threadContact{Name: "Billing", EmailAddress: "billing@example.org"}}, + name: "selected send-as address with name even when the index repeats the creator name", + entry: threadEntry{Creator: threadContact{Name: "Personal"}, AlternativeSenderName: "Personal", Sender: &threadContact{Name: "Billing", EmailAddress: "billing@example.org"}}, want: "Billing ", }, { From b40768b56abbc582d5ccf4d90d8dc60268f4e260 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 22 Sep 2026 18:02:08 -0400 Subject: [PATCH 3/3] Document thread send-as identity for HEY agents --- skills/hey/SKILL.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 97b1ad23..639ed219 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -444,9 +444,11 @@ hey unshare # Turn off the sharing link `hey thread read` returns every entry in the thread, oldest first. Each entry's `body` is **Markdown**, converted from HEY's Trix HTML at the edge, so headings, lists, quotes, tables and code survive and links keep their URLs — read it as structure rather than as -flattened text. An entry whose message was read also carries `recipients`, with `to`, -`cc` and `bcc` contact lists; a known-empty line is `[]`, while an entry whose message -was not hydrated omits the object. In JSON, an inbound entry also carries `received_via`: +flattened text. `creator` is the account user; when HEY records a separate From address, +a hydrated entry also has `sender`. Use `sender.email_address` for the actual From; +fall back to `creator.email_address` only when the message was read and `sender` is absent. +An entry whose message was read also carries `recipients`, with `to`, `cc` and `bcc` contact lists; a known-empty +line is `[]`, while an entry whose message was not hydrated omits the object. In JSON, an inbound entry also carries `received_via`: every exact account address HEY recorded it arriving through, including aliases and plus tags. These delivery records are distinct from visible To/CC/BCC recipients. Each one's resolved `contact` is optional; `received_via` is omitted for sent/generated messages and