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..e8d56c49 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.EmailAddress != "" {
+ 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..5b6d7dd6 100644
--- a/internal/cmd/topic_test.go
+++ b/internal/cmd/topic_test.go
@@ -439,12 +439,33 @@ 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
entry threadEntry
want string
}{
+ {
+ 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 ",
+ },
+ {
+ 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
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