diff --git a/go.mod b/go.mod
index cafaf14c..238a39aa 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
charm.land/glamour/v2 v2.0.1
charm.land/lipgloss/v2 v2.0.6
github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655
- github.com/basecamp/hey-sdk/go v0.28.2-0.20260831223614-ed5cf6fadf55
+ github.com/basecamp/hey-sdk/go v0.28.2-0.20260901001535-51b9228e1844
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d
github.com/charmbracelet/x/ansi v0.11.8
github.com/fsnotify/fsnotify v1.10.1
diff --git a/go.sum b/go.sum
index d0451ebe..04fa528d 100644
--- a/go.sum
+++ b/go.sum
@@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655 h1:zz0WUSEmjURj0T+soXuTtgX291nYouqa+UoyYY3Xxk8=
github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs=
-github.com/basecamp/hey-sdk/go v0.28.2-0.20260831223614-ed5cf6fadf55 h1:aBprx230epAZVi6by7FkrTTn0kN/etiVXTRz9YR7+6k=
-github.com/basecamp/hey-sdk/go v0.28.2-0.20260831223614-ed5cf6fadf55/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk=
+github.com/basecamp/hey-sdk/go v0.28.2-0.20260901001535-51b9228e1844 h1:EU5eR2Awg3HNFlwfIczwF2ZqwpxqNeXDg7/xbtJLAiY=
+github.com/basecamp/hey-sdk/go v0.28.2-0.20260901001535-51b9228e1844/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk=
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4=
github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d/go.mod h1:Ee2c/q1/pg+5T5741PIuA3s6VJMQC7I0XBNXIHIujzA=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
diff --git a/internal/cmd/compose.go b/internal/cmd/compose.go
index d43436f0..3b1f75d6 100644
--- a/internal/cmd/compose.go
+++ b/internal/cmd/compose.go
@@ -113,14 +113,14 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error {
return attachErr
}
if c.draft {
- draftID, draftErr := replySDK.Entries().CreateReplyDraft(ctx, target.EntryID, target.Subject, messageWithAttachments,
+ draftID, draftErr := replySDK.Entries().CreateReplyDraft(ctx, target.EntryID, target.ActingSenderID, target.Subject, messageWithAttachments,
target.Addressed.To, target.Addressed.CC, target.Addressed.BCC)
if draftErr != nil {
return apierr.FromSDK(draftErr)
}
return writeDraftSaved(cmd, draftID, len(c.attachments))
}
- if err := replySDK.Entries().CreateReply(ctx, target.EntryID, target.Subject, messageWithAttachments,
+ if err := replySDK.Entries().CreateReply(ctx, target.EntryID, target.ActingSenderID, target.Subject, messageWithAttachments,
target.Addressed.To, target.Addressed.CC, target.Addressed.BCC); err != nil {
return apierr.FromSDK(err)
}
diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go
index 49813271..1879ea95 100644
--- a/internal/cmd/draft.go
+++ b/internal/cmd/draft.go
@@ -81,6 +81,11 @@ func draftContentFrom(edit *generated.MessageEditState) hey.DraftContent {
To: addressEmails(edit.Addressed.Directly),
CC: addressEmails(edit.Addressed.Copied),
BCC: addressEmails(edit.Addressed.Blindcopied),
+
+ // The identity the draft was saved as rides every rewrite, or a draft
+ // composed from a shared or alternate address would be handed back to
+ // the account default by its first edit or the final send.
+ ActingSenderID: edit.Sender.Id,
}
if !edit.ScheduledDeliveryAt.IsZero() {
// HEY's API reads a schedule's date and hour in UTC (ApiRequest sets
diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go
index 3c8ff5a8..a6dd06b4 100644
--- a/internal/cmd/draft_test.go
+++ b/internal/cmd/draft_test.go
@@ -230,6 +230,38 @@ func TestDraftSendDeliversWithTheDraftsOwnState(t *testing.T) {
}
}
+func TestDraftLifecyclePreservesTheDraftsOwnSender(t *testing.T) {
+ // A draft composed from a shared or alternate address carries that identity in
+ // its edit state; every rewrite must say it back, or the first `draft update`
+ // or the final `draft send` would hand the draft to the account default.
+ altSenderEditJSON := `{"id":12345,"subject":"Quarterly planning","content":"
Agenda to follow.
",
+ "updated_at":"2026-08-24T10:00:00Z",
+ "sender":{"id":4242,"name":"Support","email_address":"support@example.com"},
+ "addressed":{"directly":[{"id":7,"name":"Maria Delgado","email_address":"maria@example.com"}]}}`
+
+ var writes []draftWrite
+ if _, err := runJSONCommand(t, draftLifecycleServer(t, altSenderEditJSON, &writes),
+ "draft", "send", "12345"); err != nil {
+ t.Fatalf("draft send: %v", err)
+ }
+ if len(writes) != 1 {
+ t.Fatalf("writes = %+v", writes)
+ }
+ if got, _ := writes[0].Body["acting_sender_id"].(float64); got != 4242 {
+ t.Errorf("acting_sender_id = %v, want the draft's own sender 4242", writes[0].Body["acting_sender_id"])
+ }
+
+ // Without a sender in the edit state, the account default still applies.
+ writes = nil
+ if _, err := runJSONCommand(t, draftLifecycleServer(t, draftEditJSON, &writes),
+ "draft", "send", "12345"); err != nil {
+ t.Fatalf("draft send: %v", err)
+ }
+ if got, _ := writes[0].Body["acting_sender_id"].(float64); got != 42 {
+ t.Errorf("acting_sender_id = %v, want the account default 42", writes[0].Body["acting_sender_id"])
+ }
+}
+
func TestDraftSendRefusesADraftWithNoRecipients(t *testing.T) {
var writes []draftWrite
bare := `{"id":12345,"subject":"Quarterly planning","content":"Agenda.
","addressed":{}}`
diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go
index 78b8c1a2..a6b3ef84 100644
--- a/internal/cmd/reply.go
+++ b/internal/cmd/reply.go
@@ -96,14 +96,14 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error {
return err
}
if c.draft {
- draftID, draftErr := replySDK.Entries().CreateReplyDraft(ctx, target.EntryID, target.Subject, message,
+ draftID, draftErr := replySDK.Entries().CreateReplyDraft(ctx, target.EntryID, target.ActingSenderID, target.Subject, message,
target.Addressed.To, target.Addressed.CC, target.Addressed.BCC)
if draftErr != nil {
return apierr.FromSDK(draftErr)
}
return writeDraftSaved(cmd, draftID, len(c.attachments))
}
- if err = replySDK.Entries().CreateReply(ctx, target.EntryID, target.Subject, message, target.Addressed.To, target.Addressed.CC, target.Addressed.BCC); err != nil {
+ if err = replySDK.Entries().CreateReply(ctx, target.EntryID, target.ActingSenderID, target.Subject, message, target.Addressed.To, target.Addressed.CC, target.Addressed.BCC); err != nil {
return apierr.FromSDK(err)
}
diff --git a/internal/cmd/thread_reply.go b/internal/cmd/thread_reply.go
index 1e1f0c3e..ae85d507 100644
--- a/internal/cmd/thread_reply.go
+++ b/internal/cmd/thread_reply.go
@@ -19,17 +19,21 @@ type replyRecipients struct {
BCC []string
}
-// threadReplyTarget carries the entry a reply answers, its subject and recipients, and
-// an immutable client bound to the thread's mail account. HEY saves an unaddressed
-// reply as a draft, so the recipients are not optional. The subject is not optional
-// either: HEY never derives one, so a reply sent without it saves drafts that read
-// "No subject" in Drafts.
+// threadReplyTarget carries the entry a reply answers, its subject, sender and
+// recipients, and an immutable client bound to the thread's mail account. HEY saves an
+// unaddressed reply as a draft, so the recipients are not optional. The subject is not
+// optional either: HEY never derives one, so a reply sent without it saves drafts that
+// read "No subject" in Drafts. ActingSenderID is the identity the reply goes out as —
+// the sender HEY resolved for the thread, which on a shared or alternate address is
+// not the account default; zero (the prefill named none, or was unreachable) leaves
+// the SDK on the account default.
type threadReplyTarget struct {
- EntryID int64
- AccountID int64
- Subject string
- Addressed replyRecipients
- client *hey.Client
+ EntryID int64
+ AccountID int64
+ ActingSenderID int64
+ Subject string
+ Addressed replyRecipients
+ client *hey.Client
}
// resolveThreadReply returns the thread's latest entry, linked account, and the
@@ -54,10 +58,11 @@ func resolveThreadReply(ctx context.Context, threadID int64) (*threadReplyTarget
AccountID: topic.AccountId,
client: threadSDK,
}
- subject, addressed, ok := replyPrefillFromServer(ctx, threadSDK, entryID)
+ prefill, ok := replyPrefillFromServer(ctx, threadSDK, entryID)
+ target.ActingSenderID = prefill.ActingSenderID
+ target.Subject = prefill.Subject
if ok {
- target.Subject = subject
- target.Addressed = addressed
+ target.Addressed = prefill.Addressed
return target, nil
}
@@ -69,44 +74,59 @@ func resolveThreadReply(ctx context.Context, threadID int64) (*threadReplyTarget
return nil, apierr.ErrNotFound("message", fmt.Sprintf("%d", entryID))
}
- addressed = recipientsForReplyTo(*message)
+ addressed := recipientsForReplyTo(*message)
if len(addressed.To) == 0 && len(addressed.CC) == 0 && len(addressed.BCC) == 0 {
return nil, apierr.ErrUsage("could not determine thread recipients")
}
// The prefill's subject survives an empty recipient list: only the recipients
// needed the local computation.
- if subject == "" {
- subject = replySubject(message.Subject)
+ if target.Subject == "" {
+ target.Subject = replySubject(message.Subject)
}
- target.Subject = subject
target.Addressed = addressed
return target, nil
}
+// replyPrefill is how a reply starts out, as HEY prefills it: the "Re: …" subject it
+// goes out under, the sender it goes out as, and who it goes out to.
+type replyPrefill struct {
+ Subject string
+ ActingSenderID int64
+ Addressed replyRecipients
+}
+
// replyPrefillFromServer asks HEY how a reply to the entry starts out
-// (GET /entries/{id}/replies/new): the "Re: …" subject the reply carries, and its
-// recipients — the entry's sender moved onto the To line and the acting user's own
-// addresses, aliases and catch-alls excluded — the exclusion this CLI cannot compute
-// locally, and the reason a reply used to be able to CC its writer back to themselves.
-// A failed read falls back to the local computation, and so does an empty answer: on a
-// thread with yourself, everyone HEY excludes is everyone there is, and the local list
-// is what keeps that reply addressable. The subject is answered even when the
-// recipients are not — only they need the fallback, not the subject HEY supplied.
-func replyPrefillFromServer(ctx context.Context, client *hey.Client, entryID int64) (string, replyRecipients, bool) {
+// (GET /entries/{id}/replies/new): the "Re: …" subject the reply carries; the sender
+// it goes out as — resolved from the entry's own to and from addresses, so a thread on
+// a shared or alternate address answers as that address, not the account default, and
+// named only when it differs from the acting user; and its recipients — the entry's
+// sender moved onto the To line and the acting user's own addresses, aliases and
+// catch-alls excluded — the exclusion this CLI cannot compute locally, and the reason
+// a reply used to be able to CC its writer back to themselves. A failed read falls
+// back to the local computation, and so does an empty answer: on a thread with
+// yourself, everyone HEY excludes is everyone there is, and the local list is what
+// keeps that reply addressable. The subject and sender are answered even when the
+// recipients are not — only they need the fallback, not what HEY already supplied.
+func replyPrefillFromServer(ctx context.Context, client *hey.Client, entryID int64) (replyPrefill, bool) {
prefilled, err := client.Entries().NewReply(ctx, entryID)
if err != nil || prefilled == nil {
- return "", replyRecipients{}, false
- }
- addressed := replyRecipients{
- To: addressEmails(prefilled.Addressed.Directly),
- CC: addressEmails(prefilled.Addressed.Copied),
- BCC: addressEmails(prefilled.Addressed.Blindcopied),
- }
- if len(addressed.To)+len(addressed.CC)+len(addressed.BCC) == 0 {
- return prefilled.Subject, replyRecipients{}, false
- }
- return prefilled.Subject, addressed, true
+ return replyPrefill{}, false
+ }
+ prefill := replyPrefill{
+ Subject: prefilled.Subject,
+ ActingSenderID: prefilled.Sender.Id,
+ Addressed: replyRecipients{
+ To: addressEmails(prefilled.Addressed.Directly),
+ CC: addressEmails(prefilled.Addressed.Copied),
+ BCC: addressEmails(prefilled.Addressed.Blindcopied),
+ },
+ }
+ if len(prefill.Addressed.To)+len(prefill.Addressed.CC)+len(prefill.Addressed.BCC) == 0 {
+ prefill.Addressed = replyRecipients{}
+ return prefill, false
+ }
+ return prefill, true
}
// replySubject answers the subject a reply to the given subject carries, the way HEY
diff --git a/internal/cmd/thread_reply_test.go b/internal/cmd/thread_reply_test.go
index 262e5910..0deccfe4 100644
--- a/internal/cmd/thread_reply_test.go
+++ b/internal/cmd/thread_reply_test.go
@@ -388,6 +388,39 @@ func TestReplyPrefersTheServersComputedRecipients(t *testing.T) {
}
}
+// The prefill also names the sender the reply goes out as — the identity HEY resolved
+// from the entry's own to and from addresses. On a shared or alternate address that is
+// not the account default, and sending as the default delivers the reply from the
+// wrong identity. The prefill's sender wins.
+func TestReplySendsAsThePrefillsSender(t *testing.T) {
+ server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12)
+ sent.ReplyNewJSON = `{"subject":"Re: Weekly sync","content":"quoted
","is_reply":true,
+ "sender":{"id":215,"name":"Support","email_address":"support@example.com"},
+ "addressed":{"directly":[{"id":31,"name":"Rick Ramirez","email_address":"rick@example.com"}]}}`
+
+ if err := runCLI(t, server, "--account", "8", "reply", "7", "-m", "sounds good"); err != nil {
+ t.Fatalf("reply: %v", err)
+ }
+ if sent.ActingSenderID != 215 {
+ t.Errorf("acting_sender_id = %d, want the prefill's sender 215, not the account default", sent.ActingSenderID)
+ }
+}
+
+// A prefill that names no sender means the acting user replies as themselves, and the
+// account's default sender is the right identity.
+func TestReplyWithoutAPrefillSenderActsAsTheAccountDefault(t *testing.T) {
+ server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12)
+ sent.ReplyNewJSON = `{"subject":"Re: Weekly sync","content":"quoted
","is_reply":true,
+ "addressed":{"directly":[{"id":31,"name":"Rick Ramirez","email_address":"rick@example.com"}]}}`
+
+ if err := runCLI(t, server, "--account", "8", "reply", "7", "-m", "sounds good"); err != nil {
+ t.Fatalf("reply: %v", err)
+ }
+ if sent.ActingSenderID != 42 {
+ t.Errorf("acting_sender_id = %d, want the account default 42", sent.ActingSenderID)
+ }
+}
+
// An empty answer from replies/new means everyone HEY excludes is everyone there is —
// a thread with yourself — and the local computation is what keeps that reply
// addressable. The 404 case rides through every other test in this file, which runs
diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json
index d62bd2d8..20b8140b 100644
--- a/internal/mcpserver/model/PROVENANCE.json
+++ b/internal/mcpserver/model/PROVENANCE.json
@@ -1,7 +1,7 @@
{
"source": "github.com/basecamp/hey-sdk",
- "commit": "ed5cf6fadf55becfd142ba4138c28b5c240c12cf",
- "ref": "go/v0.28.2-0.20260831223614-ed5cf6fadf55",
+ "commit": "51b9228e184444d714d3a140d3d1c661217e227c",
+ "ref": "go/v0.28.2-0.20260901001535-51b9228e1844",
"files": ["behavior-model.json", "openapi.json"],
"synced_by": "scripts/sync-mcp-model.sh"
}
diff --git a/internal/tui/compose.go b/internal/tui/compose.go
index 4951e124..1f975cc6 100644
--- a/internal/tui/compose.go
+++ b/internal/tui/compose.go
@@ -19,18 +19,20 @@ import (
// --- Messages ---
// replyContextLoadedMsg carries what a reply needs from the thread: the entry to
-// reply to, the "Re: …" subject it goes out under, and who the thread is addressed to.
+// reply to, the "Re: …" subject it goes out under, the sender it goes out as, and who
+// the thread is addressed to.
type replyContextLoadedMsg struct {
- requestID uint64
- boxID int64
- topicID int64
- topicName string
- entryID int64
- sdk *hey.Client
- subject string
- to, cc []string
- bcc []string
- err error
+ requestID uint64
+ boxID int64
+ topicID int64
+ topicName string
+ entryID int64
+ sdk *hey.Client
+ actingSenderID int64
+ subject string
+ to, cc []string
+ bcc []string
+ err error
}
// forwardContextLoadedMsg carries HEY's prefilled subject and quoted message
@@ -77,12 +79,13 @@ const (
// its inputs, validation and status; sending is done by mailView so the form
// stays free of SDK calls.
type composeForm struct {
- mode composeMode
- topicName string
- entryID int64 // reply target (composeReply only)
- replySubject string // the "Re: …" subject a reply goes out under (composeReply only)
- sendSDK *hey.Client
- forwardedContent string
+ mode composeMode
+ topicName string
+ entryID int64 // reply target (composeReply only)
+ replySubject string // the "Re: …" subject a reply goes out under (composeReply only)
+ replyActingSenderID int64 // the sender a reply goes out as; 0 = account default (composeReply only)
+ sendSDK *hey.Client
+ forwardedContent string
inputs []textinput.Model // to, cc, bcc, subject (subject omitted for replies)
body textarea.Model
@@ -138,6 +141,7 @@ func newReplyForm(ctxMsg replyContextLoadedMsg, s styles) *composeForm {
f.topicName = ctxMsg.topicName
f.entryID = ctxMsg.entryID
f.replySubject = ctxMsg.subject
+ f.replyActingSenderID = ctxMsg.actingSenderID
f.sendSDK = ctxMsg.sdk
f.inputs[fieldTo].SetValue(strings.Join(ctxMsg.to, ", "))
f.inputs[fieldCc].SetValue(strings.Join(ctxMsg.cc, ", "))
@@ -419,30 +423,35 @@ func (v *mailView) loadReplyContext(topicID int64, topicName string) tea.Cmd {
entryID := topic.Entries[len(topic.Entries)-1].Id
// HEY's reply prefill (GET /entries/{id}/replies/new) is the authority on how
- // a reply starts out: the "Re: …" subject it goes out under, and recipients
- // with the acting user's own addresses, aliases and catch-alls excluded — an
- // exclusion this client cannot compute locally. A failed read falls back to
- // the local computation, and so does an empty answer: on a thread with
- // yourself, everyone HEY excludes is everyone there is. The prefill's subject
- // survives that recipient fallback — only the recipients needed it.
+ // a reply starts out: the "Re: …" subject it goes out under, the sender it
+ // goes out as — on a shared or alternate address, not the account default —
+ // and recipients with the acting user's own addresses, aliases and catch-alls
+ // excluded — an exclusion this client cannot compute locally. A failed read
+ // falls back to the local computation, and so does an empty answer: on a
+ // thread with yourself, everyone HEY excludes is everyone there is. The
+ // prefill's subject and sender survive that recipient fallback — only the
+ // recipients needed it.
var prefillSubject string
+ var prefillSenderID int64
if prefilled, prefillErr := accountSDK.Entries().NewReply(ctx, entryID); prefillErr == nil && prefilled != nil {
prefillSubject = prefilled.Subject
+ prefillSenderID = prefilled.Sender.Id
to := addressesOf(prefilled.Addressed.Directly, "")
cc := addressesOf(prefilled.Addressed.Copied, "")
bcc := addressesOf(prefilled.Addressed.Blindcopied, "")
if len(to)+len(cc)+len(bcc) > 0 {
return replyContextLoadedMsg{
- requestID: requestID,
- boxID: boxID,
- topicID: topicID,
- topicName: topicName,
- entryID: entryID,
- sdk: accountSDK,
- subject: prefillSubject,
- to: to,
- cc: cc,
- bcc: bcc,
+ requestID: requestID,
+ boxID: boxID,
+ topicID: topicID,
+ topicName: topicName,
+ entryID: entryID,
+ sdk: accountSDK,
+ actingSenderID: prefillSenderID,
+ subject: prefillSubject,
+ to: to,
+ cc: cc,
+ bcc: bcc,
}
}
}
@@ -464,16 +473,17 @@ func (v *mailView) loadReplyContext(topicID int64, topicName string) tea.Cmd {
subject = replySubjectFor(*message)
}
return replyContextLoadedMsg{
- requestID: requestID,
- boxID: boxID,
- topicID: topicID,
- topicName: topicName,
- entryID: entryID,
- sdk: accountSDK,
- subject: subject,
- to: to,
- cc: cc,
- bcc: bcc,
+ requestID: requestID,
+ boxID: boxID,
+ topicID: topicID,
+ topicName: topicName,
+ entryID: entryID,
+ sdk: accountSDK,
+ actingSenderID: prefillSenderID,
+ subject: subject,
+ to: to,
+ cc: cc,
+ bcc: bcc,
}
}
}
@@ -608,8 +618,9 @@ func (v *mailView) send(f *composeForm) tea.Cmd {
switch f.mode {
case composeReply:
entryID := f.entryID
+ actingSenderID := f.replyActingSenderID
return func() tea.Msg {
- err := sdk.Entries().CreateReply(ctx, entryID, subject, body, to, cc, bcc)
+ err := sdk.Entries().CreateReply(ctx, entryID, actingSenderID, subject, body, to, cc, bcc)
return composeSentMsg{label: "Reply sent", err: err}
}
case composeForward:
diff --git a/internal/tui/compose_test.go b/internal/tui/compose_test.go
index eac8328e..4866e802 100644
--- a/internal/tui/compose_test.go
+++ b/internal/tui/compose_test.go
@@ -59,6 +59,7 @@ func composeTestServer(t *testing.T) (*mailView, *struct {
"addressed":{"directly":[{"id":1,"name":"Jane Doe","email_address":"jane@example.com"}]}}`))
case "/entries/501/replies/new.json":
_, _ = w.Write([]byte(`{"subject":"Re: Quarterly planning",
+ "sender":{"id":215,"name":"Support","email_address":"support@example.com"},
"addressed":{"directly":[{"id":3,"name":"Rick Sanchez","email_address":"rick@example.com"}]}}`))
case "/entries/501/forwards/new.json":
_, _ = w.Write([]byte(`{"subject":"Fwd: Quarterly planning","content":"Quoted message
"}`))
@@ -221,7 +222,8 @@ func TestReplyFormPrefillsAndSends(t *testing.T) {
v.Resize(80, 30)
v.Update(replyContextLoadedMsg{
boxID: 1, topicID: 7, topicName: "Kitchen", entryID: 99, subject: "Re: Kitchen",
- to: []string{"jane@x.com"}, cc: []string{"bob@x.com"},
+ actingSenderID: 7,
+ to: []string{"jane@x.com"}, cc: []string{"bob@x.com"},
})
f := composeModal(v)
if f == nil || f.mode != composeReply {
@@ -252,6 +254,10 @@ func TestReplyFormPrefillsAndSends(t *testing.T) {
if rec.body["message"].(map[string]any)["subject"] != "Re: Kitchen" {
t.Errorf("subject = %v, want Re: Kitchen", rec.body["message"].(map[string]any)["subject"])
}
+ // The reply goes out as the sender the context resolved, not the account default.
+ if got, _ := rec.body["acting_sender_id"].(float64); got != 7 {
+ t.Errorf("acting_sender_id = %v, want the reply context's sender 7", rec.body["acting_sender_id"])
+ }
addressed := rec.body["entry"].(map[string]any)["addressed"].(map[string]any)
if got := addressed["directly"].([]any); len(got) != 1 || got[0] != "jane@x.com" {
t.Errorf("directly = %v", got)
@@ -280,6 +286,11 @@ func TestReplyLoadsAndSendsThroughThreadAccount(t *testing.T) {
if ctxMsg.subject != "Re: Quarterly planning" {
t.Errorf("subject = %q, want %q", ctxMsg.subject, "Re: Quarterly planning")
}
+ // And the sender: on a thread sent to a shared or alternate address, the
+ // prefill names the identity the reply goes out as.
+ if ctxMsg.actingSenderID != 215 {
+ t.Errorf("actingSenderID = %d, want the prefill's sender 215", ctxMsg.actingSenderID)
+ }
v.Update(ctxMsg)
typeText(v, "Thanks!")
msg := runCmd(v.HandleContentKey(ctrlS()))
@@ -289,6 +300,9 @@ func TestReplyLoadsAndSendsThroughThreadAccount(t *testing.T) {
if rec.path != "/entries/501/replies.json" || rec.account != "9" {
t.Fatalf("reply path/account = %s/%q, want /entries/501/replies.json/9", rec.path, rec.account)
}
+ if got, _ := rec.body["acting_sender_id"].(float64); got != 215 {
+ t.Errorf("acting_sender_id = %v, want the prefill's sender 215, not the account default", rec.body["acting_sender_id"])
+ }
}
func TestReplyContextFallsBackWhenPrefillIsEmpty(t *testing.T) {
@@ -296,16 +310,18 @@ func TestReplyContextFallsBackWhenPrefillIsEmpty(t *testing.T) {
// everyone there is; the local computation keeps that reply addressable. The
// subject only falls back with it when the prefill answered none.
for name, testCase := range map[string]struct {
- prefillJSON string
- wantSubject string
+ prefillJSON string
+ wantSubject string
+ wantSenderID int64
}{
"an empty prefill derives the subject locally": {
prefillJSON: `{}`,
wantSubject: "Re: Quarterly planning",
},
- "a recipientless prefill keeps its subject": {
- prefillJSON: `{"subject":"Re: Quarterly planning per HEY"}`,
- wantSubject: "Re: Quarterly planning per HEY",
+ "a recipientless prefill keeps its subject and sender": {
+ prefillJSON: `{"subject":"Re: Quarterly planning per HEY","sender":{"id":215,"email_address":"support@example.com"}}`,
+ wantSubject: "Re: Quarterly planning per HEY",
+ wantSenderID: 215,
},
} {
t.Run(name, func(t *testing.T) {
@@ -346,6 +362,9 @@ func TestReplyContextFallsBackWhenPrefillIsEmpty(t *testing.T) {
if ctxMsg.subject != testCase.wantSubject {
t.Errorf("subject = %q, want %q", ctxMsg.subject, testCase.wantSubject)
}
+ if ctxMsg.actingSenderID != testCase.wantSenderID {
+ t.Errorf("actingSenderID = %d, want %d", ctxMsg.actingSenderID, testCase.wantSenderID)
+ }
})
}
}
diff --git a/nix/package.nix b/nix/package.nix
index d2e18104..c0aa1e02 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: {
# To update: run `make update-nix-hash` (Docker). It rewrites this quoted
# value in place, so keep it a string literal rather than lib.fakeHash.
- vendorHash = "sha256-i6v/DtZ7QDwnSObld0I/P7QO/Y+OkO3bVXczxrc65So=";
+ vendorHash = "sha256-hs2gFLO2xk8x1iFVZhBhkAkVQ/AreTvGdhf7nj5N/MU=";
subPackages = [ "cmd/hey" ];