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 @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions docs/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions internal/cmd/thread_partial_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {
Expand Down
101 changes: 101 additions & 0 deletions internal/cmd/thread_sender_test.go
Original file line number Diff line number Diff line change
@@ -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":"<p>Numbers.</p>","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 <billing@example.org>") {
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 &lt;billing@example.org&gt;") {
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)
}
}
22 changes: 18 additions & 4 deletions internal/cmd/topic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -89,11 +90,12 @@ func newThreadsCommand() *topicCommand {
threadsCommand.cmd = &cobra.Command{
Use: "read <thread-id>",
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 <article> 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 <article> 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -450,6 +463,7 @@ func newThreadEntry(loaded *threadload.Entry, html bool) threadEntry {
BodyState: string(loaded.State),
BodyHTML: bodyHTML,
Creator: newThreadContact(creator),
Sender: sender,
}
}

Expand Down
21 changes: 21 additions & 0 deletions internal/cmd/topic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <billing@example.org>",
},
{
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"}},
Expand Down
2 changes: 2 additions & 0 deletions internal/mail/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Entry struct {
ID int64
CreatedAt time.Time
Creator Contact
Sender Contact
AlternativeSenderName string
Summary string
Body htmlutil.Markdown
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 11 additions & 0 deletions internal/mail/entry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 3 additions & 1 deletion internal/threadload/threadload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand All @@ -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)
}
Expand Down
Loading
Loading