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
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.2-0.20260831223614-ed5cf6fadf55
github.com/basecamp/hey-sdk/go v0.28.2-0.20260901001535-51b9228e1844
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.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/hey-sdk/go v0.28.2-0.20260901001535-51b9228e1844 h1:EU5eR2Awg3HNFlwfIczwF2ZqwpxqNeXDg7/xbtJLAiY=
github.com/basecamp/hey-sdk/go v0.28.2-0.20260901001535-51b9228e1844/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, target.Subject, messageWithAttachments,
draftID, draftErr := replySDK.Entries().CreateReplyDraft(ctx, target.EntryID, target.ActingSenderID, target.Subject, messageWithAttachments,
Comment thread
jeremy marked this conversation as resolved.
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, target.Subject, messageWithAttachments,
if err := replySDK.Entries().CreateReply(ctx, target.EntryID, target.ActingSenderID, target.Subject, messageWithAttachments,
target.Addressed.To, target.Addressed.CC, target.Addressed.BCC); err != nil {
return apierr.FromSDK(err)
}
Expand Down
5 changes: 5 additions & 0 deletions internal/cmd/draft.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ func draftContentFrom(edit *generated.MessageEditState) hey.DraftContent {
To: addressEmails(edit.Addressed.Directly),
CC: addressEmails(edit.Addressed.Copied),
BCC: addressEmails(edit.Addressed.Blindcopied),

// The identity the draft was saved as rides every rewrite, or a draft
// composed from a shared or alternate address would be handed back to
// the account default by its first edit or the final send.
ActingSenderID: edit.Sender.Id,
}
if !edit.ScheduledDeliveryAt.IsZero() {
// HEY's API reads a schedule's date and hour in UTC (ApiRequest sets
Expand Down
32 changes: 32 additions & 0 deletions internal/cmd/draft_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,38 @@ func TestDraftSendDeliversWithTheDraftsOwnState(t *testing.T) {
}
}

func TestDraftLifecyclePreservesTheDraftsOwnSender(t *testing.T) {
// A draft composed from a shared or alternate address carries that identity in
// its edit state; every rewrite must say it back, or the first `draft update`
// or the final `draft send` would hand the draft to the account default.
altSenderEditJSON := `{"id":12345,"subject":"Quarterly planning","content":"<div>Agenda to follow.</div>",
"updated_at":"2026-08-24T10:00:00Z",
"sender":{"id":4242,"name":"Support","email_address":"support@example.com"},
"addressed":{"directly":[{"id":7,"name":"Maria Delgado","email_address":"maria@example.com"}]}}`

var writes []draftWrite
if _, err := runJSONCommand(t, draftLifecycleServer(t, altSenderEditJSON, &writes),
"draft", "send", "12345"); err != nil {
t.Fatalf("draft send: %v", err)
}
if len(writes) != 1 {
t.Fatalf("writes = %+v", writes)
}
if got, _ := writes[0].Body["acting_sender_id"].(float64); got != 4242 {
t.Errorf("acting_sender_id = %v, want the draft's own sender 4242", writes[0].Body["acting_sender_id"])
}

// Without a sender in the edit state, the account default still applies.
writes = nil
if _, err := runJSONCommand(t, draftLifecycleServer(t, draftEditJSON, &writes),
"draft", "send", "12345"); err != nil {
t.Fatalf("draft send: %v", err)
}
if got, _ := writes[0].Body["acting_sender_id"].(float64); got != 42 {
t.Errorf("acting_sender_id = %v, want the account default 42", writes[0].Body["acting_sender_id"])
}
}

func TestDraftSendRefusesADraftWithNoRecipients(t *testing.T) {
var writes []draftWrite
bare := `{"id":12345,"subject":"Quarterly planning","content":"<div>Agenda.</div>","addressed":{}}`
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, target.Subject, message,
draftID, draftErr := replySDK.Entries().CreateReplyDraft(ctx, target.EntryID, target.ActingSenderID, target.Subject, message,
Comment thread
jeremy marked this conversation as resolved.
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, target.Subject, message, target.Addressed.To, target.Addressed.CC, target.Addressed.BCC); err != nil {
if err = replySDK.Entries().CreateReply(ctx, target.EntryID, target.ActingSenderID, target.Subject, message, target.Addressed.To, target.Addressed.CC, target.Addressed.BCC); err != nil {
return apierr.FromSDK(err)
}

Expand Down
94 changes: 57 additions & 37 deletions internal/cmd/thread_reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,21 @@ type replyRecipients struct {
BCC []string
}

// 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.
// 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
// 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. ActingSenderID is the identity the reply goes out as —
// the sender HEY resolved for the thread, which on a shared or alternate address is
// not the account default; zero (the prefill named none, or was unreachable) leaves
// the SDK on the account default.
type threadReplyTarget struct {
EntryID int64
AccountID int64
Subject string
Addressed replyRecipients
client *hey.Client
EntryID int64
AccountID int64
ActingSenderID int64
Subject string
Addressed replyRecipients
client *hey.Client
}

// resolveThreadReply returns the thread's latest entry, linked account, and the
Expand All @@ -54,10 +58,11 @@ func resolveThreadReply(ctx context.Context, threadID int64) (*threadReplyTarget
AccountID: topic.AccountId,
client: threadSDK,
}
subject, addressed, ok := replyPrefillFromServer(ctx, threadSDK, entryID)
prefill, ok := replyPrefillFromServer(ctx, threadSDK, entryID)
target.ActingSenderID = prefill.ActingSenderID
target.Subject = prefill.Subject
if ok {
target.Subject = subject
target.Addressed = addressed
target.Addressed = prefill.Addressed
return target, nil
}

Expand All @@ -69,44 +74,59 @@ 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)
if target.Subject == "" {
target.Subject = replySubject(message.Subject)
}
target.Subject = subject
target.Addressed = addressed
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, 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) {
// (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 "", 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 prefilled.Subject, replyRecipients{}, false
}
return prefilled.Subject, addressed, true
return replyPrefill{}, false
}
prefill := replyPrefill{
Subject: prefilled.Subject,
ActingSenderID: prefilled.Sender.Id,
Comment thread
jeremy marked this conversation as resolved.
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
Expand Down
33 changes: 33 additions & 0 deletions internal/cmd/thread_reply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,39 @@ func TestReplyPrefersTheServersComputedRecipients(t *testing.T) {
}
}

// The prefill also names the sender the reply goes out as — the identity HEY resolved
// from the entry's own to and from addresses. On a shared or alternate address that is
// not the account default, and sending as the default delivers the reply from the
// wrong identity. The prefill's sender wins.
func TestReplySendsAsThePrefillsSender(t *testing.T) {
server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12)
sent.ReplyNewJSON = `{"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 Ramirez","email_address":"rick@example.com"}]}}`

if err := runCLI(t, server, "--account", "8", "reply", "7", "-m", "sounds good"); err != nil {
t.Fatalf("reply: %v", err)
}
if sent.ActingSenderID != 215 {
t.Errorf("acting_sender_id = %d, want the prefill's sender 215, not the account default", sent.ActingSenderID)
}
}

// A prefill that names no sender means the acting user replies as themselves, and the
// account's default sender is the right identity.
func TestReplyWithoutAPrefillSenderActsAsTheAccountDefault(t *testing.T) {
server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12)
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 {
t.Fatalf("reply: %v", err)
}
if sent.ActingSenderID != 42 {
t.Errorf("acting_sender_id = %d, want the account default 42", sent.ActingSenderID)
}
}

// An empty answer from replies/new means everyone HEY excludes is everyone there is —
// a thread with yourself — and the local computation is what keeps that reply
// addressable. The 404 case rides through every other test in this file, which runs
Expand Down
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": "ed5cf6fadf55becfd142ba4138c28b5c240c12cf",
"ref": "go/v0.28.2-0.20260831223614-ed5cf6fadf55",
"commit": "51b9228e184444d714d3a140d3d1c661217e227c",
"ref": "go/v0.28.2-0.20260901001535-51b9228e1844",
"files": ["behavior-model.json", "openapi.json"],
"synced_by": "scripts/sync-mcp-model.sh"
}
Loading
Loading