diff --git a/AGENTS.md b/AGENTS.md index d59aa87b..d9575e64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,16 +148,18 @@ because both were mis-stated here before: `internal/threadload/sdk.go` classifies a failed message read by status before size — an oversized 500 is still systemic and an oversized 404 is still just a missing message; only an oversized success is `over_limit`. -- **A reply's recipients come from the entry it answers.** `Messages().Get` carries that - entry's `Addressed` (`directly`/`copied`/`blindcopied`), and `recipientsForReplyTo` — - in `internal/cmd/thread_reply.go` for `hey reply`, and in `internal/tui/compose.go` for - the TUI's reply form — turns it into To/CC/BCC with the entry's sender moved onto the To - line. That last part is haystack's `directly_address_sender`, and without it a reply to - an inbound email reaches everyone except the person who wrote it. What HEY also does and - this cannot is *remove* the acting user and their aliases, catch-alls and redelivery - contacts: that needs `GET /entries/{id}/replies/new.json`, which the SDK does not expose - yet. Until it does, a reply may CC the sender back to themselves. Add that operation to - the SDK rather than reimplementing the exclusion rules here. +- **A reply starts from HEY's prefill, with a local fallback.** The SDK's + `Entries().NewReply` (`GET /entries/{id}/replies/new.json`) answers how a reply starts + out: its "Re: …" subject and its recipients, with the entry's sender moved onto the To + line (haystack's `directly_address_sender`) *and* the acting user's own addresses, + aliases, catch-alls and redelivery contacts removed — the exclusion this CLI cannot + compute locally. Both reply paths — `replyPrefillFromServer` in + `internal/cmd/thread_reply.go` for `hey reply`, and `loadReplyContext` in + `internal/tui/compose.go` for the TUI's reply form — ask the prefill first and fall + back to the local computation (`recipientsForReplyTo` plus the derived subject) on a + failed read or an empty recipient answer, which a thread with yourself produces; the + prefill's subject survives that recipient fallback. Extend the prefill flow rather + than reimplementing HEY's exclusion rules here. `internal/htmlutil` provides `ToMarkdown` (HTML→Markdown), `ToText` (HTML→plain text), `ExtractImageURLs` and `ExtractAttachments`, which are presentation helpers rather than diff --git a/go.mod b/go.mod index 5dbb825f..cafaf14c 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.1 + github.com/basecamp/hey-sdk/go v0.28.2-0.20260831223614-ed5cf6fadf55 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 a91ed135..d0451ebe 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.1 h1:qv7fpN2gEJa9TO0VBXic/QEfmSJZSF3ui0VuRHlRQUw= -github.com/basecamp/hey-sdk/go v0.28.1/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= +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/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 5967f94e..d43436f0 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, messageWithAttachments, + draftID, draftErr := replySDK.Entries().CreateReplyDraft(ctx, target.EntryID, 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, messageWithAttachments, + if err := replySDK.Entries().CreateReply(ctx, target.EntryID, target.Subject, messageWithAttachments, target.Addressed.To, target.Addressed.CC, target.Addressed.BCC); err != nil { return apierr.FromSDK(err) } diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index 67f6a4ac..78b8c1a2 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, message, + draftID, draftErr := replySDK.Entries().CreateReplyDraft(ctx, target.EntryID, 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, message, target.Addressed.To, target.Addressed.CC, target.Addressed.BCC); err != nil { + if err = replySDK.Entries().CreateReply(ctx, target.EntryID, 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 1b77b2c9..1e1f0c3e 100644 --- a/internal/cmd/thread_reply.go +++ b/internal/cmd/thread_reply.go @@ -19,12 +19,15 @@ type replyRecipients struct { BCC []string } -// threadReplyTarget carries the entry a reply answers, its 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. +// 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. type threadReplyTarget struct { EntryID int64 AccountID int64 + Subject string Addressed replyRecipients client *hey.Client } @@ -51,7 +54,9 @@ func resolveThreadReply(ctx context.Context, threadID int64) (*threadReplyTarget AccountID: topic.AccountId, client: threadSDK, } - if addressed, ok := replyRecipientsFromServer(ctx, threadSDK, entryID); ok { + subject, addressed, ok := replyPrefillFromServer(ctx, threadSDK, entryID) + if ok { + target.Subject = subject target.Addressed = addressed return target, nil } @@ -64,26 +69,34 @@ 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) + } + target.Subject = subject target.Addressed = addressed return target, nil } -// replyRecipientsFromServer asks HEY who a reply to the entry goes to -// (GET /entries/{id}/replies/new): 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. -func replyRecipientsFromServer(ctx context.Context, client *hey.Client, entryID int64) (replyRecipients, bool) { +// 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) { prefilled, err := client.Entries().NewReply(ctx, entryID) if err != nil || prefilled == nil { - return replyRecipients{}, false + return "", replyRecipients{}, false } addressed := replyRecipients{ To: addressEmails(prefilled.Addressed.Directly), @@ -91,9 +104,26 @@ func replyRecipientsFromServer(ctx context.Context, client *hey.Client, entryID BCC: addressEmails(prefilled.Addressed.Blindcopied), } if len(addressed.To)+len(addressed.CC)+len(addressed.BCC) == 0 { - return replyRecipients{}, false + return prefilled.Subject, replyRecipients{}, false + } + return prefilled.Subject, addressed, true +} + +// replySubject answers the subject a reply to the given subject carries, the way HEY +// derives it in Entry::Replyable#reply_subject: a "Re: " prefix, without doubling one +// already there in any casing. An empty subject stays empty rather than becoming a +// bare "Re:". +func replySubject(subject string) string { + subject = strings.TrimSpace(subject) + if subject == "" { + return "" + } + + rest := subject + if len(rest) >= 3 && strings.EqualFold(rest[:3], "Re:") { + rest = strings.TrimPrefix(rest[3:], " ") } - return addressed, true + return strings.TrimRight("Re: "+rest, " ") } // recipientsForReplyTo answers who a reply to this message goes to: the message's own diff --git a/internal/cmd/thread_reply_test.go b/internal/cmd/thread_reply_test.go index 6447d9e3..262e5910 100644 --- a/internal/cmd/thread_reply_test.go +++ b/internal/cmd/thread_reply_test.go @@ -21,6 +21,7 @@ import ( // To line and Cee on the CC line. const messageAddressedToJane = `{ "id": 12, + "subject": "Weekly sync", "creator": {"id": 3, "name": "Rick Sanchez", "email_address": "rick@example.com"}, "sender": {"id": 3, "name": "Rick Sanchez", "email_address": "rick@example.com"}, "addressed": { @@ -35,6 +36,7 @@ const messageWithoutRecipients = `{"id": 12}` // sentReply is what the server saw a reply arrive as. type sentReply struct { Path string + Subject string Content string TopicAccountFilter string MessageAccountFilter string @@ -73,6 +75,7 @@ func threadReplyServer(t *testing.T, messageJSON string, entryIDs ...int64) (*ht var body struct { ActingSenderID int64 `json:"acting_sender_id"` Message struct { + Subject string `json:"subject"` Content string `json:"content"` } `json:"message"` Entry struct { @@ -86,6 +89,7 @@ func threadReplyServer(t *testing.T, messageJSON string, entryIDs ...int64) (*ht } _ = json.NewDecoder(r.Body).Decode(&body) sent.Path = r.URL.Path + sent.Subject = body.Message.Subject sent.Content = body.Message.Content sent.ActingSenderID = body.ActingSenderID sent.Status = body.Entry.Status @@ -158,6 +162,10 @@ func TestResolveThreadReply(t *testing.T) { if target.EntryID != 12 { t.Errorf("entry = %d, want the last one (12)", target.EntryID) } + // HEY never derives a reply's subject, so the target carries the "Re: …" one. + if target.Subject != "Re: Weekly sync" { + t.Errorf("subject = %q, want %q", target.Subject, "Re: Weekly sync") + } if target.AccountID != 9 { t.Errorf("account = %d, want 9", target.AccountID) } @@ -362,7 +370,7 @@ func runCLI(t *testing.T, server *httptest.Server, args ...string) error { // its list wins. func TestReplyPrefersTheServersComputedRecipients(t *testing.T) { server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12) - sent.ReplyNewJSON = `{"content":"
quoted
","is_reply":true, + 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 { @@ -374,6 +382,10 @@ func TestReplyPrefersTheServersComputedRecipients(t *testing.T) { if len(sent.CC) != 0 { t.Errorf("cc = %v, want none", sent.CC) } + // The prefill's subject rides along verbatim — the server computed it already. + if sent.Subject != "Re: Weekly sync" { + t.Errorf("subject = %q, want the prefilled one", sent.Subject) + } } // An empty answer from replies/new means everyone HEY excludes is everyone there is — @@ -382,7 +394,7 @@ func TestReplyPrefersTheServersComputedRecipients(t *testing.T) { // against a fake that does not serve the endpoint at all. func TestReplyFallsBackWhenTheServerAnswersNobody(t *testing.T) { server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12) - sent.ReplyNewJSON = `{"content":"
quoted
","is_reply":true,"addressed":{}}` + sent.ReplyNewJSON = `{"subject":"Re: Weekly sync per HEY","content":"
quoted
","is_reply":true,"addressed":{}}` if err := runCLI(t, server, "--account", "8", "reply", "7", "-m", "note to self"); err != nil { t.Fatalf("reply: %v", err) @@ -390,6 +402,10 @@ func TestReplyFallsBackWhenTheServerAnswersNobody(t *testing.T) { if len(sent.To) == 0 { t.Errorf("to = %v, want the locally computed recipients", sent.To) } + // Only the recipients needed the fallback: the subject HEY supplied survives it. + if sent.Subject != "Re: Weekly sync per HEY" { + t.Errorf("subject = %q, want the prefilled one", sent.Subject) + } } func TestReplyDraftSavesInsteadOfSending(t *testing.T) { @@ -410,4 +426,26 @@ func TestReplyDraftSavesInsteadOfSending(t *testing.T) { if !strings.Contains(sent.Content, "drafting this") { t.Errorf("content = %q", sent.Content) } + // The whole point of carrying the subject: without it this draft reads + // "No subject" in HEY's Drafts. Here the fake serves no replies/new endpoint, + // so the subject is the locally derived one. + if sent.Subject != "Re: Weekly sync" { + t.Errorf("subject = %q, want %q", sent.Subject, "Re: Weekly sync") + } +} + +func TestReplySubject(t *testing.T) { + for subject, want := range map[string]string{ + "Weekly sync": "Re: Weekly sync", + "Re: Weekly sync": "Re: Weekly sync", + "RE: SHOUTING": "Re: SHOUTING", + "re:no space": "Re: no space", + "Reply guide": "Re: Reply guide", + "": "", + " ": "", + } { + if got := replySubject(subject); got != want { + t.Errorf("replySubject(%q) = %q, want %q", subject, got, want) + } + } } diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index 02876d96..d62bd2d8 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,7 @@ { "source": "github.com/basecamp/hey-sdk", - "commit": "bf5835025544322da130864735352d801059a516", - "ref": "go/v0.28.1", + "commit": "ed5cf6fadf55becfd142ba4138c28b5c240c12cf", + "ref": "go/v0.28.2-0.20260831223614-ed5cf6fadf55", "files": ["behavior-model.json", "openapi.json"], "synced_by": "scripts/sync-mcp-model.sh" } diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index 6cc8b534..674be0a1 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -11485,7 +11485,7 @@ }, "CreateReplyRequestContent": { "type": "object", - "description": "Wire format: {acting_sender_id, message: {content}, entry: {addressed: {directly: [...]}}}\nentry.addressed is optional on the wire but a reply posted without it is saved as a\ndraft rather than delivered — HEY does not reply-all for the caller. Resolve the\nthread's recipients first and always send them.", + "description": "Wire format: {acting_sender_id, message: {subject, content}, entry: {addressed: {directly: [...]}}}\nentry.addressed is optional on the wire but a reply posted without it is saved as a\ndraft rather than delivered — HEY does not reply-all for the caller. Resolve the\nthread's recipients first and always send them.", "properties": { "acting_sender_id": { "type": "integer", @@ -13218,7 +13218,11 @@ }, "ReplyMessagePayload": { "type": "object", + "description": "HEY does not derive a subject for a reply: a reply draft saved without message.subject\nreads \"No subject\" in Drafts. NewEntryReply hands back the prefilled subject (\"Re: …\") —\nsend it here. Content is the caller's reply body alone: the server appends the quoted\noriginal at delivery (auto_quoting defaults on), so the prefill's quoted content must\nnot be echoed back.", "properties": { + "subject": { + "type": "string" + }, "content": { "type": "string" } diff --git a/internal/tui/compose.go b/internal/tui/compose.go index 357abdac..4951e124 100644 --- a/internal/tui/compose.go +++ b/internal/tui/compose.go @@ -19,7 +19,7 @@ import ( // --- Messages --- // replyContextLoadedMsg carries what a reply needs from the thread: the entry to -// reply to and who the thread is addressed to. +// reply to, the "Re: …" subject it goes out under, and who the thread is addressed to. type replyContextLoadedMsg struct { requestID uint64 boxID int64 @@ -27,6 +27,7 @@ type replyContextLoadedMsg struct { topicName string entryID int64 sdk *hey.Client + subject string to, cc []string bcc []string err error @@ -78,7 +79,8 @@ const ( type composeForm struct { mode composeMode topicName string - entryID int64 // reply target (composeReply only) + entryID int64 // reply target (composeReply only) + replySubject string // the "Re: …" subject a reply goes out under (composeReply only) sendSDK *hey.Client forwardedContent string @@ -135,6 +137,7 @@ func newReplyForm(ctxMsg replyContextLoadedMsg, s styles) *composeForm { f := newComposeForm(composeReply, s) f.topicName = ctxMsg.topicName f.entryID = ctxMsg.entryID + f.replySubject = ctxMsg.subject f.sendSDK = ctxMsg.sdk f.inputs[fieldTo].SetValue(strings.Join(ctxMsg.to, ", ")) f.inputs[fieldCc].SetValue(strings.Join(ctxMsg.cc, ", ")) @@ -190,7 +193,9 @@ func (f *composeForm) values() (to, cc, bcc []string, subject, body string) { to = parseAddressList(f.inputs[fieldTo].Value()) cc = parseAddressList(f.inputs[fieldCc].Value()) bcc = parseAddressList(f.inputs[fieldBcc].Value()) - if f.mode != composeReply { + if f.mode == composeReply { + subject = f.replySubject + } else { subject = strings.TrimSpace(f.inputs[fieldSubject].Value()) } body = strings.TrimSpace(f.body.Value()) @@ -412,6 +417,36 @@ func (v *mailView) loadReplyContext(topicID int64, topicName string) tea.Cmd { return replyContextLoadedMsg{requestID: requestID, boxID: boxID, err: err} } 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. + var prefillSubject string + if prefilled, prefillErr := accountSDK.Entries().NewReply(ctx, entryID); prefillErr == nil && prefilled != nil { + prefillSubject = prefilled.Subject + 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, + } + } + } + message, err := accountSDK.Messages().Get(ctx, entryID) if err != nil { return replyContextLoadedMsg{requestID: requestID, boxID: boxID, err: err} @@ -424,6 +459,10 @@ func (v *mailView) loadReplyContext(topicID int64, topicName string) tea.Cmd { } } to, cc, bcc := recipientsForReplyTo(*message) + subject := prefillSubject + if subject == "" { + subject = replySubjectFor(*message) + } return replyContextLoadedMsg{ requestID: requestID, boxID: boxID, @@ -431,6 +470,7 @@ func (v *mailView) loadReplyContext(topicID int64, topicName string) tea.Cmd { topicName: topicName, entryID: entryID, sdk: accountSDK, + subject: subject, to: to, cc: cc, bcc: bcc, @@ -438,6 +478,24 @@ func (v *mailView) loadReplyContext(topicID int64, topicName string) tea.Cmd { } } +// replySubjectFor answers the subject a reply to this message goes out under, the way +// HEY derives it in Entry::Replyable#reply_subject: a "Re: " prefix, without doubling +// one already there in any casing. HEY never derives a reply's subject server-side, so +// the reply must carry it. An empty subject stays empty rather than becoming a bare +// "Re:". +func replySubjectFor(message generated.Message) string { + subject := strings.TrimSpace(message.Subject) + if subject == "" { + return "" + } + + rest := subject + if len(rest) >= 3 && strings.EqualFold(rest[:3], "Re:") { + rest = strings.TrimPrefix(rest[3:], " ") + } + return strings.TrimRight("Re: "+rest, " ") +} + // recipientsForReplyTo answers who a reply to this message goes to: the message's own // recipients, with whoever sent it moved onto the To line. That is what HEY does in // Entry::Addressed#participating_contacts_in_reply_by_kind, so a reply reaches the @@ -551,7 +609,7 @@ func (v *mailView) send(f *composeForm) tea.Cmd { case composeReply: entryID := f.entryID return func() tea.Msg { - err := sdk.Entries().CreateReply(ctx, entryID, body, to, cc, bcc) + err := sdk.Entries().CreateReply(ctx, entryID, 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 7b07bf09..eac8328e 100644 --- a/internal/tui/compose_test.go +++ b/internal/tui/compose_test.go @@ -55,8 +55,11 @@ func composeTestServer(t *testing.T) (*mailView, *struct { case "/topics/100.json": _, _ = w.Write([]byte(`{"id":100,"account_id":9,"name":"Quarterly planning","entries":[{"id":500},{"id":501}]}`)) case "/messages/501.json": - _, _ = w.Write([]byte(`{"id":501,"sender":{"id":3,"name":"Rick Sanchez","email_address":"rick@example.com"}, + _, _ = w.Write([]byte(`{"id":501,"subject":"Quarterly planning","sender":{"id":3,"name":"Rick Sanchez","email_address":"rick@example.com"}, "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", + "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
"}`)) case "/snippets.json": @@ -217,7 +220,7 @@ func TestReplyFormPrefillsAndSends(t *testing.T) { v, rec := composeTestServer(t) v.Resize(80, 30) v.Update(replyContextLoadedMsg{ - boxID: 1, topicID: 7, topicName: "Kitchen", entryID: 99, + boxID: 1, topicID: 7, topicName: "Kitchen", entryID: 99, subject: "Re: Kitchen", to: []string{"jane@x.com"}, cc: []string{"bob@x.com"}, }) f := composeModal(v) @@ -245,6 +248,10 @@ func TestReplyFormPrefillsAndSends(t *testing.T) { if rec.body["message"].(map[string]any)["content"] != "

Thanks!

" { t.Errorf("body = %v", rec.body) } + // HEY never derives a reply's subject, so the reply carries the prefilled one. + if rec.body["message"].(map[string]any)["subject"] != "Re: Kitchen" { + t.Errorf("subject = %v, want Re: Kitchen", rec.body["message"].(map[string]any)["subject"]) + } 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) @@ -263,10 +270,16 @@ func TestReplyLoadsAndSendsThroughThreadAccount(t *testing.T) { if accountID, ok := ctxMsg.sdk.AccountID(); !ok || accountID != 9 { t.Fatalf("reply SDK account = %d, %v", accountID, ok) } - // The recipients come from the entry the reply answers, and reach whoever wrote it. - if want := []string{"jane@example.com", "rick@example.com"}; !slices.Equal(ctxMsg.to, want) { + // The recipients come from HEY's reply prefill, which excludes the acting + // user's own addresses — jane is absent even though the entry addresses her. + if want := []string{"rick@example.com"}; !slices.Equal(ctxMsg.to, want) { t.Errorf("to = %v, want %v", ctxMsg.to, want) } + // So does the subject: HEY never derives a reply's server-side, so the + // prefill's "Re: …" is what the reply must carry. + if ctxMsg.subject != "Re: Quarterly planning" { + t.Errorf("subject = %q, want %q", ctxMsg.subject, "Re: Quarterly planning") + } v.Update(ctxMsg) typeText(v, "Thanks!") msg := runCmd(v.HandleContentKey(ctrlS())) @@ -278,6 +291,65 @@ func TestReplyLoadsAndSendsThroughThreadAccount(t *testing.T) { } } +func TestReplyContextFallsBackWhenPrefillIsEmpty(t *testing.T) { + // On a thread with yourself, everyone HEY excludes from the prefill is + // 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 + }{ + "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", + }, + } { + t.Run(name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/identity.json": + _, _ = w.Write([]byte(`{"id":1,"accounts":[{"id":9,"status":"active"}],"senders":[{"id":42,"account_id":9,"default":true}]}`)) + case "/topics/100.json": + _, _ = w.Write([]byte(`{"id":100,"account_id":9,"name":"Quarterly planning","entries":[{"id":501}]}`)) + case "/messages/501.json": + _, _ = w.Write([]byte(`{"id":501,"subject":"Quarterly planning","sender":{"id":3,"name":"Rick Sanchez","email_address":"rick@example.com"}, + "addressed":{"directly":[{"id":1,"name":"Jane Doe","email_address":"jane@example.com"}]}}`)) + case "/entries/501/replies/new.json": + _, _ = w.Write([]byte(testCase.prefillJSON)) + default: + _, _ = w.Write([]byte(`{}`)) + } + })) + t.Cleanup(srv.Close) + sdk := hey.NewClient(&hey.Config{BaseURL: srv.URL}, &hey.StaticTokenProvider{Token: "t"}, hey.WithMaxRetries(0)) + vc := testVC() + vc.rootSDK = sdk + vc.sdk = sdk + vc.ctx = context.Background() + v := newMailView(vc) + v.boxes = orderBoxes(testBoxes()) + v.Update(currentPostingsLoaded(v, testPostings())) + + loaded := runCmd(v.loadReplyContext(100, "Quarterly planning")) + ctxMsg, ok := loaded.(replyContextLoadedMsg) + if !ok || ctxMsg.err != nil { + t.Fatalf("reply command returned %#v", loaded) + } + if want := []string{"jane@example.com", "rick@example.com"}; !slices.Equal(ctxMsg.to, want) { + t.Errorf("to = %v, want %v", ctxMsg.to, want) + } + if ctxMsg.subject != testCase.wantSubject { + t.Errorf("subject = %q, want %q", ctxMsg.subject, testCase.wantSubject) + } + }) + } +} + func TestRecipientsForReplyTo(t *testing.T) { contact := func(address string) generated.Contact { return generated.Contact{EmailAddress: address} diff --git a/nix/package.nix b/nix/package.nix index 32de2df0..d2e18104 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-xUVghXU7kEc4Rdz6JvR5ULRvjgtuh2Zrf2miXTNhVEg="; + vendorHash = "sha256-i6v/DtZ7QDwnSObld0I/P7QO/Y+OkO3bVXczxrc65So="; subPackages = [ "cmd/hey" ];