Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 3 additions & 47 deletions internal/cmd/thread_reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions internal/mail/reply_prefill.go
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
jeremy marked this conversation as resolved.
Comment thread
jeremy marked this conversation as resolved.
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 {
Comment thread
jeremy marked this conversation as resolved.
var emails []string
Comment thread
jeremy marked this conversation as resolved.
for _, contact := range contacts {
if contact.EmailAddress != "" {
emails = append(emails, contact.EmailAddress)
}
}
return emails
}
93 changes: 93 additions & 0 deletions internal/mail/reply_prefill_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Comment thread
jeremy marked this conversation as resolved.
client := replyPrefillClient(t, `{
"subject": "Re: Weekly sync", "content": "<div>quoted</div>", "is_reply": true,
"sender": {"id": 215, "name": "Support", "email_address": "support@example.com"},
"addressed": {
"directly": [{"id": 31, "name": "Rick", "email_address": "rick@example.com"}, {"id": 32}],
"copied": [{"id": 33, "email_address": "cc@example.com"}],
"blindcopied": [{"id": 34, "email_address": "bcc@example.com"}]
}
}`)

prefill, ok := ReplyPrefillFromServer(context.Background(), client, 12)
if !ok {
t.Fatal("an addressed prefill answers; no fallback is needed")
}
if prefill.Subject != "Re: Weekly sync" {
t.Errorf("subject = %q, want the prefilled one", prefill.Subject)
}
if prefill.ActingSenderID != 215 {
t.Errorf("acting sender = %d, want the prefill's 215", prefill.ActingSenderID)
}
// The addressless contact is dropped; the rest ride verbatim. The quoted content
// is not carried at all: HEY appends it at delivery, and echoing it back would
// double the quote.
want := ReplyRecipients{
To: []string{"rick@example.com"},
CC: []string{"cc@example.com"},
BCC: []string{"bcc@example.com"},
}
if !reflect.DeepEqual(prefill.Addressed, want) {
t.Errorf("addressed = %+v, want %+v", prefill.Addressed, want)
}
}

// The subject and sender are answered even when the recipients are not: on a thread
// with yourself, everyone HEY excludes is everyone there is, and only the recipients
// need the caller's local fallback.
func TestReplyPrefillFromServerWithoutRecipients(t *testing.T) {
client := replyPrefillClient(t, `{"subject": "Re: Weekly sync",
"sender": {"id": 215, "email_address": "support@example.com"}, "addressed": {}}`)

prefill, ok := ReplyPrefillFromServer(context.Background(), client, 12)
if ok {
t.Fatal("a recipientless prefill sends the caller to its local fallback")
}
if prefill.Subject != "Re: Weekly sync" || prefill.ActingSenderID != 215 {
t.Errorf("subject = %q, sender = %d — both survive an empty recipient list",
prefill.Subject, prefill.ActingSenderID)
}
if !reflect.DeepEqual(prefill.Addressed, ReplyRecipients{}) {
t.Errorf("addressed = %+v, want none", prefill.Addressed)
}
}

// A read that fails answers nothing: subject, sender and recipients all fall back.
func TestReplyPrefillFromServerUnreachable(t *testing.T) {
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
http.Error(w, `{"message":"not found"}`, http.StatusNotFound)
})

prefill, ok := ReplyPrefillFromServer(context.Background(), client, 12)
if ok || !reflect.DeepEqual(prefill, ReplyPrefill{}) {
t.Errorf("prefill = %+v, ok = %v — an unreachable prefill answers nothing", prefill, ok)
}
}
55 changes: 22 additions & 33 deletions internal/tui/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
hey "github.com/basecamp/hey-sdk/go/pkg/hey"

"github.com/basecamp/hey-cli/internal/htmlutil"
"github.com/basecamp/hey-cli/internal/mail"
)

// --- Messages ---
Expand Down Expand Up @@ -422,37 +423,25 @@ 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, 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,
actingSenderID: prefillSenderID,
subject: prefillSubject,
to: to,
cc: cc,
bcc: bcc,
}
// HEY's reply prefill is the authority on how a reply starts out — see
// mail.ReplyPrefillFromServer. 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.
prefill, ok := mail.ReplyPrefillFromServer(ctx, accountSDK, entryID)
if ok {
return replyContextLoadedMsg{
requestID: requestID,
boxID: boxID,
topicID: topicID,
topicName: topicName,
entryID: entryID,
sdk: accountSDK,
actingSenderID: prefill.ActingSenderID,
subject: prefill.Subject,
to: prefill.Addressed.To,
cc: prefill.Addressed.CC,
bcc: prefill.Addressed.BCC,
}
}

Expand All @@ -468,7 +457,7 @@ func (v *mailView) loadReplyContext(topicID int64, topicName string) tea.Cmd {
}
}
to, cc, bcc := recipientsForReplyTo(*message)
subject := prefillSubject
subject := prefill.Subject
if subject == "" {
subject = replySubjectFor(*message)
}
Expand All @@ -479,7 +468,7 @@ func (v *mailView) loadReplyContext(topicID int64, topicName string) tea.Cmd {
topicName: topicName,
entryID: entryID,
sdk: accountSDK,
actingSenderID: prefillSenderID,
actingSenderID: prefill.ActingSenderID,
subject: subject,
to: to,
cc: cc,
Expand Down
Loading