diff --git a/AGENTS.md b/AGENTS.md index ec0e243c..d56a30d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,13 +153,14 @@ because both were mis-stated here before: 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. + compute locally. Both reply paths — `hey reply` in `internal/cmd/thread_reply.go`, + and the TUI's reply form via `loadReplyContext` in `internal/tui/compose.go` — ask + the shared `mail.ReplyPrefillFromServer` (`internal/mail/reply_prefill.go`) first and + fall back to their 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 + `mail.ReplyPrefillFromServer` rather than reimplementing HEY's exclusion rules in + each caller. `internal/htmlutil` provides `ToMarkdown` (HTML→Markdown), `ToText` (HTML→plain text), `ExtractImageURLs` and `ExtractAttachments`, which are presentation helpers rather than diff --git a/internal/cmd/thread_reply.go b/internal/cmd/thread_reply.go index ae85d507..54d79bfd 100644 --- a/internal/cmd/thread_reply.go +++ b/internal/cmd/thread_reply.go @@ -10,14 +10,11 @@ import ( "github.com/basecamp/hey-sdk/go/pkg/generated" "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/mail" ) // replyRecipients is who a reply goes out to, in HEY's three kinds of addressing. -type replyRecipients struct { - To []string - CC []string - BCC []string -} +type replyRecipients = mail.ReplyRecipients // 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 @@ -58,7 +55,7 @@ func resolveThreadReply(ctx context.Context, threadID int64) (*threadReplyTarget AccountID: topic.AccountId, client: threadSDK, } - prefill, ok := replyPrefillFromServer(ctx, threadSDK, entryID) + prefill, ok := mail.ReplyPrefillFromServer(ctx, threadSDK, entryID) target.ActingSenderID = prefill.ActingSenderID target.Subject = prefill.Subject if ok { @@ -88,47 +85,6 @@ func resolveThreadReply(ctx context.Context, threadID int64) (*threadReplyTarget 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; 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 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 // 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 diff --git a/internal/mail/reply_prefill.go b/internal/mail/reply_prefill.go new file mode 100644 index 00000000..31e0000f --- /dev/null +++ b/internal/mail/reply_prefill.go @@ -0,0 +1,73 @@ +package mail + +import ( + "context" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + hey "github.com/basecamp/hey-sdk/go/pkg/hey" +) + +// ReplyRecipients is who a reply goes out to, in HEY's three kinds of addressing. +type ReplyRecipients struct { + To []string + CC []string + BCC []string +} + +// 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. The prefill's +// quoted content is deliberately not carried: a reply's content is the writer's body +// alone — the server appends the quoted original at delivery (auto_quoting defaults +// on), so echoing the prefill's quote back would double it. +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; 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 no client can compute locally, and the reason +// a reply used to be able to CC its writer back to themselves. A false answer sends +// the caller to its local fallback: a failed read needs one, and so does an empty +// recipient list — 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 ReplyPrefill{}, false + } + prefill := ReplyPrefill{ + Subject: prefilled.Subject, + ActingSenderID: prefilled.Sender.Id, + Addressed: ReplyRecipients{ + To: contactEmails(prefilled.Addressed.Directly), + CC: contactEmails(prefilled.Addressed.Copied), + BCC: contactEmails(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 +} + +// contactEmails answers the contacts' email addresses verbatim, dropping blanks: the +// prefill's lists are HEY's own computation, not input to clean up. +func contactEmails(contacts []generated.Contact) []string { + var emails []string + for _, contact := range contacts { + if contact.EmailAddress != "" { + emails = append(emails, contact.EmailAddress) + } + } + return emails +} diff --git a/internal/mail/reply_prefill_test.go b/internal/mail/reply_prefill_test.go new file mode 100644 index 00000000..c3b49e76 --- /dev/null +++ b/internal/mail/reply_prefill_test.go @@ -0,0 +1,93 @@ +package mail + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + + hey "github.com/basecamp/hey-sdk/go/pkg/hey" +) + +// replyPrefillClient answers GET /entries/12/replies/new.json with the given body, the +// way HEY serves a reply prefill. +func replyPrefillClient(t *testing.T, prefillJSON string) *hey.Client { + t.Helper() + return testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/entries/12/replies/new.json" { + t.Errorf("read %s %s, want the entry's reply prefill", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, prefillJSON) + }) +} + +func TestReplyPrefillFromServer(t *testing.T) { + client := replyPrefillClient(t, `{ + "subject": "Re: Weekly sync", "content": "