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
22 changes: 12 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
62 changes: 46 additions & 16 deletions internal/cmd/thread_reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Comment thread
jeremy marked this conversation as resolved.
}
Expand All @@ -64,36 +69,61 @@ 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),
CC: addressEmails(prefilled.Addressed.Copied),
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
Expand Down
42 changes: 40 additions & 2 deletions internal/cmd/thread_reply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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":"<div>quoted</div>","is_reply":true,
sent.ReplyNewJSON = `{"subject":"Re: Weekly sync","content":"<div>quoted</div>","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 {
Expand All @@ -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 —
Expand All @@ -382,14 +394,18 @@ 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":"<div>quoted</div>","is_reply":true,"addressed":{}}`
sent.ReplyNewJSON = `{"subject":"Re: Weekly sync per HEY","content":"<div>quoted</div>","is_reply":true,"addressed":{}}`

if err := runCLI(t, server, "--account", "8", "reply", "7", "-m", "note to self"); err != nil {
t.Fatalf("reply: %v", err)
}
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) {
Expand All @@ -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)
}
}
}
4 changes: 2 additions & 2 deletions internal/mcpserver/model/PROVENANCE.json
Original file line number Diff line number Diff line change
@@ -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"
}
6 changes: 5 additions & 1 deletion internal/mcpserver/model/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
},
Comment thread
jeremy marked this conversation as resolved.
"content": {
"type": "string"
}
Expand Down
Loading
Loading