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