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" ];