From b8524cf1c76544efa03b7174996a3b5c0776d095 Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 11:25:09 +0200 Subject: [PATCH 01/13] feat: add HEY draft commands --- .surface | 15 ++ README.md | 4 + internal/cmd/draft.go | 425 +++++++++++++++++++++++++++++++++++++ internal/cmd/draft_test.go | 50 +++++ internal/cmd/help.go | 2 +- internal/cmd/root.go | 1 + skills/hey/SKILL.md | 14 ++ 7 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/draft.go create mode 100644 internal/cmd/draft_test.go diff --git a/.surface b/.surface index c688616..3a73bb5 100644 --- a/.surface +++ b/.surface @@ -40,6 +40,21 @@ hey config hey config set hey config show hey doctor +hey draft +hey draft create +hey draft create --bcc +hey draft create --cc +hey draft create --message +hey draft create --subject +hey draft create --thread-id +hey draft create --to +hey draft delete +hey draft update +hey draft update --bcc +hey draft update --cc +hey draft update --message +hey draft update --subject +hey draft update --to hey drafts hey drafts --all hey drafts --limit diff --git a/README.md b/README.md index 8525306..a21c19f 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ hey threads 123 # read a full email thread hey reply 123 -m "Thanks!" # reply to a thread (or omit -m to open $EDITOR) hey compose --to user@example.com --subject "Hello" # compose a new message hey compose --to user@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello" # with CC/BCC +hey draft create --to user@example.com --subject "Hello" -m "Draft body" # save without sending +hey draft create --thread-id 123 -m "Thanks!" # save a reply draft without sending +hey draft update 456 --to user@example.com --subject "Hello" -m "Updated body" +hey draft delete 456 hey drafts # list drafts ``` diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go new file mode 100644 index 0000000..f3270f6 --- /dev/null +++ b/internal/cmd/draft.go @@ -0,0 +1,425 @@ +package cmd + +import ( + "context" + "fmt" + "html" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/editor" + "github.com/basecamp/hey-cli/internal/htmlutil" + "github.com/basecamp/hey-cli/internal/output" +) + +type draftCommand struct { + cmd *cobra.Command +} + +var ( + messageSubjectInputRe = regexp.MustCompile(`(?s)]+name="message\[subject\]"[^>]*>`) + valueAttrRe = regexp.MustCompile(`\svalue="([^"]*)"`) +) + +func newDraftCommand() *draftCommand { + draftCommand := &draftCommand{} + draftCommand.cmd = &cobra.Command{ + Use: "draft", + Short: "Create, update, or delete drafts", + Annotations: map[string]string{ + "agent_notes": "Use draft create/update/delete for draft-safe email work. These commands save drafts and do not send.", + }, + } + + draftCommand.cmd.AddCommand(newDraftCreateCommand()) + draftCommand.cmd.AddCommand(newDraftUpdateCommand()) + draftCommand.cmd.AddCommand(newDraftDeleteCommand()) + + return draftCommand +} + +type draftCreateCommand struct { + to string + cc string + bcc string + subject string + message string + threadID string +} + +func newDraftCreateCommand() *cobra.Command { + c := &draftCreateCommand{} + cmd := &cobra.Command{ + Use: "create", + Short: "Create a saved draft without sending", + Example: ` hey draft create --to alice@example.com --subject "Hello" -m "Hi there" + hey draft create --thread-id 12345 -m "Thanks, I'll take a look."`, + RunE: c.run, + } + + cmd.Flags().StringVar(&c.to, "to", "", "Recipient email address(es)") + cmd.Flags().StringVar(&c.cc, "cc", "", "CC recipient email address(es)") + cmd.Flags().StringVar(&c.bcc, "bcc", "", "BCC recipient email address(es)") + cmd.Flags().StringVar(&c.subject, "subject", "", "Message subject") + cmd.Flags().StringVarP(&c.message, "message", "m", "", "Draft body (or opens $EDITOR)") + cmd.Flags().StringVar(&c.threadID, "thread-id", "", "Thread ID to draft a reply in") + + return cmd +} + +func (c *draftCreateCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + message, err := draftMessage(c.message) + if err != nil { + return err + } + + req := draftFormRequest{ + Subject: c.subject, + Content: message, + To: parseAddresses(c.to), + CC: parseAddresses(c.cc), + BCC: parseAddresses(c.bcc), + } + + if c.threadID != "" { + threadID, err := strconv.ParseInt(c.threadID, 10, 64) + if err != nil { + return output.ErrUsage(fmt.Sprintf("invalid thread ID: %s", c.threadID)) + } + return createReplyDraft(cmd.Context(), cmd.OutOrStdout(), threadID, req) + } + + if req.Subject == "" { + return output.ErrUsageHint("--subject is required for new drafts", "hey draft create --to --subject -m ") + } + + return createMessageDraft(cmd.Context(), cmd.OutOrStdout(), req) +} + +type draftUpdateCommand struct { + to string + cc string + bcc string + subject string + message string +} + +func newDraftUpdateCommand() *cobra.Command { + c := &draftUpdateCommand{} + cmd := &cobra.Command{ + Use: "update ", + Short: "Replace a saved draft without sending", + Example: ` hey draft update 12345 --to alice@example.com --subject "Hello" -m "Updated body"`, + Args: usageExactOneArg(), + RunE: c.run, + } + + cmd.Flags().StringVar(&c.to, "to", "", "Recipient email address(es)") + cmd.Flags().StringVar(&c.cc, "cc", "", "CC recipient email address(es)") + cmd.Flags().StringVar(&c.bcc, "bcc", "", "BCC recipient email address(es)") + cmd.Flags().StringVar(&c.subject, "subject", "", "Message subject (required)") + cmd.Flags().StringVarP(&c.message, "message", "m", "", "Draft body (or opens $EDITOR)") + + return cmd +} + +func (c *draftUpdateCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + draftID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage(fmt.Sprintf("invalid draft ID: %s", args[0])) + } + if c.subject == "" { + return output.ErrUsageHint("--subject is required", "hey draft update --to --subject -m ") + } + + message, err := draftMessage(c.message) + if err != nil { + return err + } + + return updateDraft(cmd.Context(), cmd.OutOrStdout(), draftID, draftFormRequest{ + Subject: c.subject, + Content: message, + To: parseAddresses(c.to), + CC: parseAddresses(c.cc), + BCC: parseAddresses(c.bcc), + }) +} + +type draftDeleteCommand struct{} + +func newDraftDeleteCommand() *cobra.Command { + c := &draftDeleteCommand{} + return &cobra.Command{ + Use: "delete ", + Short: "Delete a saved draft", + Example: ` hey draft delete 12345`, + Args: usageExactOneArg(), + RunE: c.run, + } +} + +func (c *draftDeleteCommand) run(cmd *cobra.Command, args []string) error { + if err := requireAuth(); err != nil { + return err + } + + draftID, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + return output.ErrUsage(fmt.Sprintf("invalid draft ID: %s", args[0])) + } + + return deleteDraft(cmd.Context(), cmd.OutOrStdout(), draftID) +} + +type draftFormRequest struct { + Subject string + Content string + To []string + CC []string + BCC []string +} + +type draftResponse struct { + ID int64 `json:"id"` + URL string `json:"url"` + EditURL string `json:"edit_url"` + Subject string `json:"subject,omitempty"` +} + +func createMessageDraft(ctx context.Context, w io.Writer, draft draftFormRequest) error { + senderID, err := sdk.DefaultSenderID(ctx) + if err != nil { + return convertSDKError(err) + } + + values := draftValues(senderID, draft) + resp, err := submitDraftForm(ctx, "POST", "/messages", values) + if err != nil { + return err + } + + return writeDraftSaved(w, resp, "Draft created") +} + +func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft draftFormRequest) error { + senderID, err := sdk.DefaultSenderID(ctx) + if err != nil { + return convertSDKError(err) + } + + topicResp, err := sdk.GetHTML(ctx, fmt.Sprintf("/topics/%d", threadID)) + if err != nil { + return convertSDKError(err) + } + addressed := htmlutil.ParseTopicAddressed(string(topicResp.Data)) + + entriesResp, err := sdk.GetHTML(ctx, fmt.Sprintf("/topics/%d/entries", threadID)) + if err != nil { + return convertSDKError(err) + } + entries := htmlutil.ParseTopicEntriesHTML(string(entriesResp.Data)) + if len(entries) == 0 { + return output.ErrNotFound("entries for thread", fmt.Sprintf("%d", threadID)) + } + + latestEntryID := entries[len(entries)-1].ID + if draft.Subject == "" { + subject, err := defaultReplySubject(ctx, latestEntryID) + if err != nil { + return err + } + draft.Subject = subject + } + if len(draft.To) == 0 && len(draft.CC) == 0 && len(draft.BCC) == 0 { + draft.To = addressed.To + draft.CC = addressed.CC + draft.BCC = addressed.BCC + } + + values := draftValues(senderID, draft) + resp, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/entries/%d/replies", latestEntryID), values) + if err != nil { + return err + } + + return writeDraftSaved(w, resp, "Reply draft created") +} + +func defaultReplySubject(ctx context.Context, entryID int64) (string, error) { + resp, err := sdk.GetHTML(ctx, fmt.Sprintf("/entries/%d/replies/new", entryID)) + if err != nil { + return "", convertSDKError(err) + } + subject := parseMessageSubject(string(resp.Data)) + if subject == "" { + return "", output.ErrAPI(0, "could not determine reply draft subject") + } + return subject, nil +} + +func updateDraft(ctx context.Context, w io.Writer, draftID int64, draft draftFormRequest) error { + senderID, err := sdk.DefaultSenderID(ctx) + if err != nil { + return convertSDKError(err) + } + + values := draftValues(senderID, draft) + values.Set("_method", "patch") + + resp, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/messages/%d", draftID), values) + if err != nil { + return err + } + if resp.ID == 0 { + resp.ID = draftID + resp.URL = fmt.Sprintf("%s/messages/%d", strings.TrimRight(cfg.BaseURL, "/"), draftID) + resp.EditURL = resp.URL + "/edit" + } + + return writeDraftSaved(w, resp, "Draft updated") +} + +func deleteDraft(ctx context.Context, w io.Writer, draftID int64) error { + values := url.Values{} + values.Set("_method", "delete") + values.Set("status", "drafted") + + if _, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/messages/%d", draftID), values); err != nil { + return err + } + + if writer.IsStyled() { + fmt.Fprintf(w, "Draft %d deleted.\n", draftID) + return nil + } + + return writeOK(map[string]int64{"id": draftID}, output.WithSummary("Draft deleted")) +} + +func draftValues(senderID int64, draft draftFormRequest) url.Values { + values := url.Values{} + values.Set("acting_sender_id", fmt.Sprintf("%d", senderID)) + values.Set("entry[status]", "drafted") + values.Set("message[subject]", draft.Subject) + values.Set("message[content]", draft.Content) + for _, to := range draft.To { + values.Add("entry[addressed][directly][]", to) + } + for _, cc := range draft.CC { + values.Add("entry[addressed][copied][]", cc) + } + for _, bcc := range draft.BCC { + values.Add("entry[addressed][blindcopied][]", bcc) + } + return values +} + +func submitDraftForm(ctx context.Context, method, path string, values url.Values) (draftResponse, error) { + reqURL := strings.TrimRight(cfg.BaseURL, "/") + path + req, err := http.NewRequestWithContext(ctx, method, reqURL, strings.NewReader(values.Encode())) + if err != nil { + return draftResponse{}, err + } + if err := authMgr.AuthenticateRequest(ctx, req); err != nil { + return draftResponse{}, output.ErrAuth(err.Error()) + } + req.Header.Set("User-Agent", "hey-cli") + req.Header.Set("Accept", "*/*") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return draftResponse{}, output.ErrNetwork(err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + msg := strings.TrimSpace(string(body)) + if msg == "" { + msg = resp.Status + } + return draftResponse{}, output.ErrAPI(resp.StatusCode, msg) + } + + location := resp.Header.Get("Location") + return draftResponseFromLocation(location), nil +} + +func draftResponseFromLocation(location string) draftResponse { + if location == "" { + return draftResponse{} + } + location = strings.TrimRight(location, "/") + id, _ := strconv.ParseInt(location[strings.LastIndex(location, "/")+1:], 10, 64) + return draftResponse{ + ID: id, + URL: location, + EditURL: location + "/edit", + } +} + +func parseMessageSubject(pageHTML string) string { + input := messageSubjectInputRe.FindString(pageHTML) + if input == "" { + return "" + } + match := valueAttrRe.FindStringSubmatch(input) + if match == nil { + return "" + } + return html.UnescapeString(match[1]) +} + +func writeDraftSaved(w io.Writer, resp draftResponse, summary string) error { + if writer.IsStyled() { + if resp.ID > 0 { + fmt.Fprintf(w, "%s: %d\n", summary, resp.ID) + } else { + fmt.Fprintln(w, summary+".") + } + return nil + } + return writeOK(resp, output.WithSummary(summary)) +} + +func draftMessage(inline string) (string, error) { + if inline != "" { + return inline, nil + } + if !stdinIsTerminal() { + message, err := readStdin() + if err != nil { + return "", err + } + if message == "" { + return "", output.ErrUsage("no message provided (use -m or --message to provide inline, or pipe to stdin)") + } + return message, nil + } + + message, err := editor.Open("") + if err != nil { + return "", output.ErrAPI(0, fmt.Sprintf("could not open editor: %v", err)) + } + if message == "" { + return "", output.ErrUsage("empty message, aborting") + } + return message, nil +} diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go new file mode 100644 index 0000000..c201db0 --- /dev/null +++ b/internal/cmd/draft_test.go @@ -0,0 +1,50 @@ +package cmd + +import "testing" + +func TestDraftValues(t *testing.T) { + values := draftValues(123, draftFormRequest{ + Subject: "Hello", + Content: "
Hi there
", + To: []string{"alice@example.com", "bob@example.org"}, + CC: []string{"carol@example.com"}, + BCC: []string{"dave@example.org"}, + }) + + if got := values.Get("acting_sender_id"); got != "123" { + t.Fatalf("acting_sender_id = %q, want 123", got) + } + if got := values.Get("entry[status]"); got != "drafted" { + t.Fatalf("entry status = %q, want drafted", got) + } + if got := values.Get("message[subject]"); got != "Hello" { + t.Fatalf("subject = %q, want Hello", got) + } + if got := values.Get("message[content]"); got != "
Hi there
" { + t.Fatalf("content = %q", got) + } + + to := values["entry[addressed][directly][]"] + if len(to) != 2 || to[0] != "alice@example.com" || to[1] != "bob@example.org" { + t.Fatalf("to = %#v", to) + } +} + +func TestDraftResponseFromLocation(t *testing.T) { + resp := draftResponseFromLocation("https://app.hey.com/messages/2159062391") + + if resp.ID != 2159062391 { + t.Fatalf("ID = %d, want 2159062391", resp.ID) + } + if resp.EditURL != "https://app.hey.com/messages/2159062391/edit" { + t.Fatalf("EditURL = %q", resp.EditURL) + } +} + +func TestParseMessageSubject(t *testing.T) { + html := `` + + if got := parseMessageSubject(html); got != "Re: Research & Planning" { + t.Fatalf("subject = %q", got) + } +} diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 85d4796..9c2d413 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -17,7 +17,7 @@ var curatedCategories = []struct { }{ { heading: "EMAIL", - names: []string{"boxes", "box", "threads", "compose", "reply", "drafts", "seen", "unseen"}, + names: []string{"boxes", "box", "threads", "compose", "reply", "draft", "drafts", "seen", "unseen"}, }, { heading: "CALENDAR & TASKS", diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 568b3a2..d1e3e45 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -122,6 +122,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newThreadsCommand().cmd) root.AddCommand(newReplyCommand().cmd) root.AddCommand(newComposeCommand().cmd) + root.AddCommand(newDraftCommand().cmd) root.AddCommand(newDraftsCommand().cmd) root.AddCommand(newCalendarsCommand().cmd) root.AddCommand(newRecordingsCommand().cmd) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 3159689..b1508be 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -14,6 +14,7 @@ triggers: - hey threads - hey reply - hey compose + - hey draft - hey drafts # Calendar actions - hey calendars @@ -88,6 +89,10 @@ CLI for HEY email: mailboxes, email threads, replies, compose, calendars, todos, | Reply to email | `hey reply -m "Thanks!"` | | Compose email | `hey compose --to user@example.com --subject "Hello"` | | Compose with CC/BCC | `hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello"` | +| Create draft | `hey draft create --to user@example.com --subject "Hello" -m "Draft body"` | +| Create reply draft | `hey draft create --thread-id 12345 -m "Thanks!"` | +| Update draft | `hey draft update 12345 --to user@example.com --subject "Hello" -m "Updated body"` | +| Delete draft | `hey draft delete 12345` | | List drafts | `hey drafts --json` | | List calendars | `hey calendars --json` | | List calendar events | `hey recordings 123 --json` | @@ -135,6 +140,8 @@ Want to send email? │ ├── With body? → hey compose --to --subject "Subject" -m "Body" │ ├── With CC? → add --cc │ └── With BCC? → add --bcc +├── Save a draft instead? → hey draft create --to --subject "Subject" -m "Body" +├── Save a reply draft? → hey draft create --thread-id -m "Body" └── Check drafts? → hey drafts --json ``` @@ -198,8 +205,15 @@ Takes posting IDs (the `id` field from `hey box` output). ```bash hey drafts --json # List drafts +hey draft create --to user@example.com --subject "Hello" -m "Body" # Save new draft +hey draft create --thread-id 12345 -m "Thanks!" # Save reply draft +hey draft update 67890 --to user@example.com --subject "Hello" -m "Updated body" +hey draft delete 67890 # Delete draft ``` +Use `hey draft ...` when an agent must prepare mail without sending. `hey compose` +and `hey reply` send immediately. + ### Calendars ```bash From a5135b5dc39d3e123bb6377dbb071f46a3d42be0 Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 11:34:32 +0200 Subject: [PATCH 02/13] fix: preserve HEY draft fields on update --- internal/cmd/draft.go | 191 +++++++++++++++++++++++++++++++------ internal/cmd/draft_test.go | 43 ++++++++- internal/cmd/root.go | 3 +- 3 files changed, 204 insertions(+), 33 deletions(-) diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index f3270f6..38726e0 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -10,6 +10,7 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/spf13/cobra" @@ -24,7 +25,11 @@ type draftCommand struct { var ( messageSubjectInputRe = regexp.MustCompile(`(?s)]+name="message\[subject\]"[^>]*>`) + messageContentInputRe = regexp.MustCompile(`(?s)]+name="message\[content\]"[^>]*>`) + csrfMetaRe = regexp.MustCompile(`(?s)]+name="csrf-token"[^>]*>`) + optionRe = regexp.MustCompile(`(?s)]*\bselected\b[^>]*>`) valueAttrRe = regexp.MustCompile(`\svalue="([^"]*)"`) + contentAttrRe = regexp.MustCompile(`\scontent="([^"]*)"`) ) func newDraftCommand() *draftCommand { @@ -118,7 +123,7 @@ func newDraftUpdateCommand() *cobra.Command { c := &draftUpdateCommand{} cmd := &cobra.Command{ Use: "update ", - Short: "Replace a saved draft without sending", + Short: "Update a saved draft without sending", Example: ` hey draft update 12345 --to alice@example.com --subject "Hello" -m "Updated body"`, Args: usageExactOneArg(), RunE: c.run, @@ -127,7 +132,7 @@ func newDraftUpdateCommand() *cobra.Command { cmd.Flags().StringVar(&c.to, "to", "", "Recipient email address(es)") cmd.Flags().StringVar(&c.cc, "cc", "", "CC recipient email address(es)") cmd.Flags().StringVar(&c.bcc, "bcc", "", "BCC recipient email address(es)") - cmd.Flags().StringVar(&c.subject, "subject", "", "Message subject (required)") + cmd.Flags().StringVar(&c.subject, "subject", "", "Message subject") cmd.Flags().StringVarP(&c.message, "message", "m", "", "Draft body (or opens $EDITOR)") return cmd @@ -142,22 +147,33 @@ func (c *draftUpdateCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return output.ErrUsage(fmt.Sprintf("invalid draft ID: %s", args[0])) } - if c.subject == "" { - return output.ErrUsageHint("--subject is required", "hey draft update --to --subject -m ") + existing, err := loadMessageDraft(cmd.Context(), draftID) + if err != nil { + return err } + draft := existing.Request - message, err := draftMessage(c.message) + flags := cmd.Flags() + if flags.Changed("subject") { + draft.Subject = c.subject + } + if flags.Changed("to") { + draft.To = parseAddresses(c.to) + } + if flags.Changed("cc") { + draft.CC = parseAddresses(c.cc) + } + if flags.Changed("bcc") { + draft.BCC = parseAddresses(c.bcc) + } + + message, err := draftMessageWithInitial(c.message, draft.Content, flags.Changed("message")) if err != nil { return err } + draft.Content = message - return updateDraft(cmd.Context(), cmd.OutOrStdout(), draftID, draftFormRequest{ - Subject: c.subject, - Content: message, - To: parseAddresses(c.to), - CC: parseAddresses(c.cc), - BCC: parseAddresses(c.bcc), - }) + return updateDraft(cmd.Context(), cmd.OutOrStdout(), draftID, draft, existing.CSRFToken) } type draftDeleteCommand struct{} @@ -201,14 +217,23 @@ type draftResponse struct { Subject string `json:"subject,omitempty"` } +type draftFormState struct { + Request draftFormRequest + CSRFToken string +} + func createMessageDraft(ctx context.Context, w io.Writer, draft draftFormRequest) error { senderID, err := sdk.DefaultSenderID(ctx) if err != nil { return convertSDKError(err) } + csrfToken, err := loadCSRFToken(ctx, "/messages/new") + if err != nil { + return err + } values := draftValues(senderID, draft) - resp, err := submitDraftForm(ctx, "POST", "/messages", values) + resp, err := submitDraftForm(ctx, "POST", "/messages", values, csrfToken) if err != nil { return err } @@ -238,12 +263,12 @@ func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft dr } latestEntryID := entries[len(entries)-1].ID + replyForm, err := loadReplyDraftForm(ctx, latestEntryID) + if err != nil { + return err + } if draft.Subject == "" { - subject, err := defaultReplySubject(ctx, latestEntryID) - if err != nil { - return err - } - draft.Subject = subject + draft.Subject = replyForm.Request.Subject } if len(draft.To) == 0 && len(draft.CC) == 0 && len(draft.BCC) == 0 { draft.To = addressed.To @@ -252,7 +277,7 @@ func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft dr } values := draftValues(senderID, draft) - resp, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/entries/%d/replies", latestEntryID), values) + resp, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/entries/%d/replies", latestEntryID), values, replyForm.CSRFToken) if err != nil { return err } @@ -260,19 +285,35 @@ func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft dr return writeDraftSaved(w, resp, "Reply draft created") } -func defaultReplySubject(ctx context.Context, entryID int64) (string, error) { +func loadReplyDraftForm(ctx context.Context, entryID int64) (draftFormState, error) { resp, err := sdk.GetHTML(ctx, fmt.Sprintf("/entries/%d/replies/new", entryID)) if err != nil { - return "", convertSDKError(err) + return draftFormState{}, convertSDKError(err) + } + state := parseDraftForm(string(resp.Data)) + if state.Request.Subject == "" { + return draftFormState{}, output.ErrAPI(0, "could not determine reply draft subject") } - subject := parseMessageSubject(string(resp.Data)) - if subject == "" { - return "", output.ErrAPI(0, "could not determine reply draft subject") + return state, nil +} + +func loadMessageDraft(ctx context.Context, draftID int64) (draftFormState, error) { + resp, err := sdk.GetHTML(ctx, fmt.Sprintf("/messages/%d/edit", draftID)) + if err != nil { + return draftFormState{}, convertSDKError(err) } - return subject, nil + return parseDraftForm(string(resp.Data)), nil } -func updateDraft(ctx context.Context, w io.Writer, draftID int64, draft draftFormRequest) error { +func loadCSRFToken(ctx context.Context, path string) (string, error) { + resp, err := sdk.GetHTML(ctx, path) + if err != nil { + return "", convertSDKError(err) + } + return parseCSRFToken(string(resp.Data)), nil +} + +func updateDraft(ctx context.Context, w io.Writer, draftID int64, draft draftFormRequest, csrfToken string) error { senderID, err := sdk.DefaultSenderID(ctx) if err != nil { return convertSDKError(err) @@ -281,7 +322,7 @@ func updateDraft(ctx context.Context, w io.Writer, draftID int64, draft draftFor values := draftValues(senderID, draft) values.Set("_method", "patch") - resp, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/messages/%d", draftID), values) + resp, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/messages/%d", draftID), values, csrfToken) if err != nil { return err } @@ -295,11 +336,15 @@ func updateDraft(ctx context.Context, w io.Writer, draftID int64, draft draftFor } func deleteDraft(ctx context.Context, w io.Writer, draftID int64) error { + existing, err := loadMessageDraft(ctx, draftID) + if err != nil { + return err + } values := url.Values{} values.Set("_method", "delete") values.Set("status", "drafted") - if _, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/messages/%d", draftID), values); err != nil { + if _, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/messages/%d", draftID), values, existing.CSRFToken); err != nil { return err } @@ -329,7 +374,10 @@ func draftValues(senderID int64, draft draftFormRequest) url.Values { return values } -func submitDraftForm(ctx context.Context, method, path string, values url.Values) (draftResponse, error) { +func submitDraftForm(ctx context.Context, method, path string, values url.Values, csrfToken string) (draftResponse, error) { + if csrfToken != "" { + values.Set("authenticity_token", csrfToken) + } reqURL := strings.TrimRight(cfg.BaseURL, "/") + path req, err := http.NewRequestWithContext(ctx, method, reqURL, strings.NewReader(values.Encode())) if err != nil { @@ -342,8 +390,17 @@ func submitDraftForm(ctx context.Context, method, path string, values url.Values req.Header.Set("Accept", "*/*") req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("X-Requested-With", "XMLHttpRequest") + if csrfToken != "" { + req.Header.Set("X-CSRF-Token", csrfToken) + } - resp, err := http.DefaultClient.Do(req) + client := httpClient + if client == nil { + client = http.DefaultClient + } + start := time.Now() + resp, err := client.Do(req) + trackDraftRequest(time.Since(start)) if err != nil { return draftResponse{}, output.ErrNetwork(err) } @@ -362,6 +419,14 @@ func submitDraftForm(ctx context.Context, method, path string, values url.Values return draftResponseFromLocation(location), nil } +func trackDraftRequest(duration time.Duration) { + if sdkStats == nil { + return + } + sdkStats.requestCount.Add(1) + sdkStats.totalLatency.Add(int64(duration)) +} + func draftResponseFromLocation(location string) draftResponse { if location == "" { return draftResponse{} @@ -387,6 +452,60 @@ func parseMessageSubject(pageHTML string) string { return html.UnescapeString(match[1]) } +func parseDraftForm(pageHTML string) draftFormState { + return draftFormState{ + Request: draftFormRequest{ + Subject: parseMessageSubject(pageHTML), + Content: parseMessageContent(pageHTML), + To: parseSelectedAddresses(pageHTML, "entry[addressed][directly][]"), + CC: parseSelectedAddresses(pageHTML, "entry[addressed][copied][]"), + BCC: parseSelectedAddresses(pageHTML, "entry[addressed][blindcopied][]"), + }, + CSRFToken: parseCSRFToken(pageHTML), + } +} + +func parseMessageContent(pageHTML string) string { + input := messageContentInputRe.FindString(pageHTML) + if input == "" { + return "" + } + match := valueAttrRe.FindStringSubmatch(input) + if match == nil { + return "" + } + return html.UnescapeString(match[1]) +} + +func parseSelectedAddresses(pageHTML, fieldName string) []string { + selectRe := regexp.MustCompile(`(?s)]+name="` + regexp.QuoteMeta(fieldName) + `"[^>]*>(.*?)`) + match := selectRe.FindStringSubmatch(pageHTML) + if match == nil { + return nil + } + + var addresses []string + for _, option := range optionRe.FindAllString(match[1], -1) { + valueMatch := valueAttrRe.FindStringSubmatch(option) + if valueMatch != nil { + addresses = append(addresses, html.UnescapeString(valueMatch[1])) + } + } + return addresses +} + +func parseCSRFToken(pageHTML string) string { + meta := csrfMetaRe.FindString(pageHTML) + if meta == "" { + return "" + } + match := contentAttrRe.FindStringSubmatch(meta) + if match == nil { + return "" + } + return html.UnescapeString(match[1]) +} + func writeDraftSaved(w io.Writer, resp draftResponse, summary string) error { if writer.IsStyled() { if resp.ID > 0 { @@ -400,21 +519,31 @@ func writeDraftSaved(w io.Writer, resp draftResponse, summary string) error { } func draftMessage(inline string) (string, error) { + return draftMessageWithInitial(inline, "", inline != "") +} + +func draftMessageWithInitial(inline, initial string, inlineChanged bool) (string, error) { if inline != "" { return inline, nil } + if inlineChanged { + return "", nil + } if !stdinIsTerminal() { message, err := readStdin() if err != nil { return "", err } if message == "" { + if initial != "" { + return initial, nil + } return "", output.ErrUsage("no message provided (use -m or --message to provide inline, or pipe to stdin)") } return message, nil } - message, err := editor.Open("") + message, err := editor.Open(initial) if err != nil { return "", output.ErrAPI(0, fmt.Sprintf("could not open editor: %v", err)) } diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go index c201db0..5959b51 100644 --- a/internal/cmd/draft_test.go +++ b/internal/cmd/draft_test.go @@ -1,6 +1,9 @@ package cmd -import "testing" +import ( + "reflect" + "testing" +) func TestDraftValues(t *testing.T) { values := draftValues(123, draftFormRequest{ @@ -48,3 +51,41 @@ func TestParseMessageSubject(t *testing.T) { t.Fatalf("subject = %q", got) } } + +func TestParseDraftForm(t *testing.T) { + html := ` + + + + + +` + + state := parseDraftForm(html) + + if state.CSRFToken != "csrf-123" { + t.Fatalf("csrf = %q", state.CSRFToken) + } + if state.Request.Subject != "Hello & welcome" { + t.Fatalf("subject = %q", state.Request.Subject) + } + if state.Request.Content != "Body & more" { + t.Fatalf("content = %q", state.Request.Content) + } + if !reflect.DeepEqual(state.Request.To, []string{"alice@example.com"}) { + t.Fatalf("to = %#v", state.Request.To) + } + if !reflect.DeepEqual(state.Request.CC, []string{"carol@example.com"}) { + t.Fatalf("cc = %#v", state.Request.CC) + } + if !reflect.DeepEqual(state.Request.BCC, []string{"dave@example.com"}) { + t.Fatalf("bcc = %#v", state.Request.BCC) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d1e3e45..bc6dcda 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -33,6 +33,7 @@ var ( baseURL string cfg *config.Config authMgr *auth.Manager + httpClient *http.Client writer *output.Writer ) @@ -76,7 +77,7 @@ func newRootCmd() *cobra.Command { } configDir := config.ConfigDir() - httpClient := &http.Client{Timeout: 30 * time.Second} + httpClient = &http.Client{Timeout: 30 * time.Second} authMgr = auth.NewManager(cfg.BaseURL, httpClient, configDir) initSDK(authMgr, cfg.BaseURL) From 0e3bda615ad053c42fb612f7c4984d2d99a820f6 Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 11:38:23 +0200 Subject: [PATCH 03/13] fix: fail closed on draft form parsing --- internal/cmd/draft.go | 92 ++++++++++++++++++++++++++++---------- internal/cmd/draft_test.go | 14 ++++++ 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index 38726e0..401e5f7 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -167,11 +167,13 @@ func (c *draftUpdateCommand) run(cmd *cobra.Command, args []string) error { draft.BCC = parseAddresses(c.bcc) } - message, err := draftMessageWithInitial(c.message, draft.Content, flags.Changed("message")) - if err != nil { - return err + if flags.Changed("message") || !stdinIsTerminal() { + message, err := draftMessageWithInitial(c.message, draft.Content, flags.Changed("message")) + if err != nil { + return err + } + draft.Content = message } - draft.Content = message return updateDraft(cmd.Context(), cmd.OutOrStdout(), draftID, draft, existing.CSRFToken) } @@ -218,8 +220,13 @@ type draftResponse struct { } type draftFormState struct { - Request draftFormRequest - CSRFToken string + Request draftFormRequest + CSRFToken string + HasSubject bool + HasContent bool + HasTo bool + HasCC bool + HasBCC bool } func createMessageDraft(ctx context.Context, w io.Writer, draft draftFormRequest) error { @@ -291,9 +298,12 @@ func loadReplyDraftForm(ctx context.Context, entryID int64) (draftFormState, err return draftFormState{}, convertSDKError(err) } state := parseDraftForm(string(resp.Data)) - if state.Request.Subject == "" { + if !state.HasSubject || state.Request.Subject == "" { return draftFormState{}, output.ErrAPI(0, "could not determine reply draft subject") } + if state.CSRFToken == "" { + return draftFormState{}, output.ErrAPI(0, "could not determine reply draft authenticity token") + } return state, nil } @@ -302,7 +312,14 @@ func loadMessageDraft(ctx context.Context, draftID int64) (draftFormState, error if err != nil { return draftFormState{}, convertSDKError(err) } - return parseDraftForm(string(resp.Data)), nil + state := parseDraftForm(string(resp.Data)) + if !state.HasSubject || !state.HasContent || !state.HasTo || !state.HasCC || !state.HasBCC { + return draftFormState{}, output.ErrAPI(0, "could not parse draft edit form") + } + if state.CSRFToken == "" { + return draftFormState{}, output.ErrAPI(0, "could not determine draft authenticity token") + } + return state, nil } func loadCSRFToken(ctx context.Context, path string) (string, error) { @@ -310,7 +327,11 @@ func loadCSRFToken(ctx context.Context, path string) (string, error) { if err != nil { return "", convertSDKError(err) } - return parseCSRFToken(string(resp.Data)), nil + token := parseCSRFToken(string(resp.Data)) + if token == "" { + return "", output.ErrAPI(0, "could not determine draft authenticity token") + } + return token, nil } func updateDraft(ctx context.Context, w io.Writer, draftID int64, draft draftFormRequest, csrfToken string) error { @@ -441,47 +462,72 @@ func draftResponseFromLocation(location string) draftResponse { } func parseMessageSubject(pageHTML string) string { + subject, _ := parseMessageSubjectField(pageHTML) + return subject +} + +func parseMessageSubjectField(pageHTML string) (string, bool) { input := messageSubjectInputRe.FindString(pageHTML) if input == "" { - return "" + return "", false } match := valueAttrRe.FindStringSubmatch(input) if match == nil { - return "" + return "", false } - return html.UnescapeString(match[1]) + return html.UnescapeString(match[1]), true } func parseDraftForm(pageHTML string) draftFormState { + subject, hasSubject := parseMessageSubjectField(pageHTML) + content, hasContent := parseMessageContentField(pageHTML) + to, hasTo := parseSelectedAddressesField(pageHTML, "entry[addressed][directly][]") + cc, hasCC := parseSelectedAddressesField(pageHTML, "entry[addressed][copied][]") + bcc, hasBCC := parseSelectedAddressesField(pageHTML, "entry[addressed][blindcopied][]") return draftFormState{ Request: draftFormRequest{ - Subject: parseMessageSubject(pageHTML), - Content: parseMessageContent(pageHTML), - To: parseSelectedAddresses(pageHTML, "entry[addressed][directly][]"), - CC: parseSelectedAddresses(pageHTML, "entry[addressed][copied][]"), - BCC: parseSelectedAddresses(pageHTML, "entry[addressed][blindcopied][]"), + Subject: subject, + Content: content, + To: to, + CC: cc, + BCC: bcc, }, - CSRFToken: parseCSRFToken(pageHTML), + CSRFToken: parseCSRFToken(pageHTML), + HasSubject: hasSubject, + HasContent: hasContent, + HasTo: hasTo, + HasCC: hasCC, + HasBCC: hasBCC, } } func parseMessageContent(pageHTML string) string { + content, _ := parseMessageContentField(pageHTML) + return content +} + +func parseMessageContentField(pageHTML string) (string, bool) { input := messageContentInputRe.FindString(pageHTML) if input == "" { - return "" + return "", false } match := valueAttrRe.FindStringSubmatch(input) if match == nil { - return "" + return "", false } - return html.UnescapeString(match[1]) + return html.UnescapeString(match[1]), true } func parseSelectedAddresses(pageHTML, fieldName string) []string { + addresses, _ := parseSelectedAddressesField(pageHTML, fieldName) + return addresses +} + +func parseSelectedAddressesField(pageHTML, fieldName string) ([]string, bool) { selectRe := regexp.MustCompile(`(?s)]+name="` + regexp.QuoteMeta(fieldName) + `"[^>]*>(.*?)`) match := selectRe.FindStringSubmatch(pageHTML) if match == nil { - return nil + return nil, false } var addresses []string @@ -491,7 +537,7 @@ func parseSelectedAddresses(pageHTML, fieldName string) []string { addresses = append(addresses, html.UnescapeString(valueMatch[1])) } } - return addresses + return addresses, true } func parseCSRFToken(pageHTML string) string { diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go index 5959b51..48c26ae 100644 --- a/internal/cmd/draft_test.go +++ b/internal/cmd/draft_test.go @@ -73,6 +73,9 @@ func TestParseDraftForm(t *testing.T) { if state.CSRFToken != "csrf-123" { t.Fatalf("csrf = %q", state.CSRFToken) } + if !state.HasSubject || !state.HasContent || !state.HasTo || !state.HasCC || !state.HasBCC { + t.Fatalf("field presence = subject:%t content:%t to:%t cc:%t bcc:%t", state.HasSubject, state.HasContent, state.HasTo, state.HasCC, state.HasBCC) + } if state.Request.Subject != "Hello & welcome" { t.Fatalf("subject = %q", state.Request.Subject) } @@ -89,3 +92,14 @@ func TestParseDraftForm(t *testing.T) { t.Fatalf("bcc = %#v", state.Request.BCC) } } + +func TestParseDraftFormMissingFields(t *testing.T) { + state := parseDraftForm(``) + + if state.CSRFToken != "csrf-123" { + t.Fatalf("csrf = %q", state.CSRFToken) + } + if state.HasSubject || state.HasContent || state.HasTo || state.HasCC || state.HasBCC { + t.Fatalf("unexpected field presence = subject:%t content:%t to:%t cc:%t bcc:%t", state.HasSubject, state.HasContent, state.HasTo, state.HasCC, state.HasBCC) + } +} From 943e367d70c27ab7a34c9449d0e1e8594a1e23b8 Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 11:41:46 +0200 Subject: [PATCH 04/13] fix: tighten HEY draft form handling --- internal/cmd/draft.go | 21 +++++++++++++++++---- internal/cmd/draft_test.go | 13 +++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index 401e5f7..e713b6e 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -322,6 +322,18 @@ func loadMessageDraft(ctx context.Context, draftID int64) (draftFormState, error return state, nil } +func loadMessageDraftCSRFToken(ctx context.Context, draftID int64) (string, error) { + resp, err := sdk.GetHTML(ctx, fmt.Sprintf("/messages/%d/edit", draftID)) + if err != nil { + return "", convertSDKError(err) + } + token := parseCSRFToken(string(resp.Data)) + if token == "" { + return "", output.ErrAPI(0, "could not determine draft authenticity token") + } + return token, nil +} + func loadCSRFToken(ctx context.Context, path string) (string, error) { resp, err := sdk.GetHTML(ctx, path) if err != nil { @@ -357,7 +369,7 @@ func updateDraft(ctx context.Context, w io.Writer, draftID int64, draft draftFor } func deleteDraft(ctx context.Context, w io.Writer, draftID int64) error { - existing, err := loadMessageDraft(ctx, draftID) + csrfToken, err := loadMessageDraftCSRFToken(ctx, draftID) if err != nil { return err } @@ -365,7 +377,7 @@ func deleteDraft(ctx context.Context, w io.Writer, draftID int64) error { values.Set("_method", "delete") values.Set("status", "drafted") - if _, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/messages/%d", draftID), values, existing.CSRFToken); err != nil { + if _, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/messages/%d", draftID), values, csrfToken); err != nil { return err } @@ -533,9 +545,10 @@ func parseSelectedAddressesField(pageHTML, fieldName string) ([]string, bool) { var addresses []string for _, option := range optionRe.FindAllString(match[1], -1) { valueMatch := valueAttrRe.FindStringSubmatch(option) - if valueMatch != nil { - addresses = append(addresses, html.UnescapeString(valueMatch[1])) + if valueMatch == nil { + return nil, false } + addresses = append(addresses, html.UnescapeString(valueMatch[1])) } return addresses, true } diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go index 48c26ae..f5f268c 100644 --- a/internal/cmd/draft_test.go +++ b/internal/cmd/draft_test.go @@ -103,3 +103,16 @@ func TestParseDraftFormMissingFields(t *testing.T) { t.Fatalf("unexpected field presence = subject:%t content:%t to:%t cc:%t bcc:%t", state.HasSubject, state.HasContent, state.HasTo, state.HasCC, state.HasBCC) } } + +func TestParseSelectedAddressesFieldRejectsSelectedOptionWithoutValue(t *testing.T) { + html := ` +` + + addresses, ok := parseSelectedAddressesField(html, "entry[addressed][directly][]") + + if ok { + t.Fatalf("expected parser to reject selected option without value, got %#v", addresses) + } +} From aaf5042f4229401dd15af683cc34e26ef3cbc956 Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 11:44:24 +0200 Subject: [PATCH 05/13] refactor: reuse draft csrf loader --- internal/cmd/draft.go | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index e713b6e..2840b90 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -322,18 +322,6 @@ func loadMessageDraft(ctx context.Context, draftID int64) (draftFormState, error return state, nil } -func loadMessageDraftCSRFToken(ctx context.Context, draftID int64) (string, error) { - resp, err := sdk.GetHTML(ctx, fmt.Sprintf("/messages/%d/edit", draftID)) - if err != nil { - return "", convertSDKError(err) - } - token := parseCSRFToken(string(resp.Data)) - if token == "" { - return "", output.ErrAPI(0, "could not determine draft authenticity token") - } - return token, nil -} - func loadCSRFToken(ctx context.Context, path string) (string, error) { resp, err := sdk.GetHTML(ctx, path) if err != nil { @@ -369,7 +357,7 @@ func updateDraft(ctx context.Context, w io.Writer, draftID int64, draft draftFor } func deleteDraft(ctx context.Context, w io.Writer, draftID int64) error { - csrfToken, err := loadMessageDraftCSRFToken(ctx, draftID) + csrfToken, err := loadCSRFToken(ctx, fmt.Sprintf("/messages/%d/edit", draftID)) if err != nil { return err } From 53cba6e7433832bf121ada5cc03814509dacb9d3 Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 12:00:34 +0200 Subject: [PATCH 06/13] feat: add draft mode to compose and reply --- .surface | 2 ++ README.md | 2 ++ internal/cmd/compose.go | 20 +++++++++++++++++++- internal/cmd/draft.go | 20 ++++++++++++++------ internal/cmd/draft_test.go | 12 ++++++++++++ internal/cmd/reply.go | 14 +++++++++++++- skills/hey/SKILL.md | 8 +++++++- 7 files changed, 69 insertions(+), 9 deletions(-) diff --git a/.surface b/.surface index 3a73bb5..f95fd6f 100644 --- a/.surface +++ b/.surface @@ -32,6 +32,7 @@ hey completion hey compose hey compose --bcc hey compose --cc +hey compose --draft hey compose --message hey compose --subject hey compose --thread-id @@ -76,6 +77,7 @@ hey recordings --ends-on hey recordings --limit hey recordings --starts-on hey reply +hey reply --draft hey reply --message hey seen hey setup diff --git a/README.md b/README.md index a21c19f..37e6383 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,9 @@ hey boxes # list mailboxes hey box imbox # list postings in a box (by name or ID) hey threads 123 # read a full email thread hey reply 123 -m "Thanks!" # reply to a thread (or omit -m to open $EDITOR) +hey reply 123 --draft -m "Thanks!" # save a reply draft without sending hey compose --to user@example.com --subject "Hello" # compose a new message +hey compose --draft --to user@example.com --subject "Hello" -m "Draft body" # save without sending hey compose --to user@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello" # with CC/BCC hey draft create --to user@example.com --subject "Hello" -m "Draft body" # save without sending hey draft create --thread-id 123 -m "Thanks!" # save a reply draft without sending diff --git a/internal/cmd/compose.go b/internal/cmd/compose.go index 0add1f4..acbed5d 100644 --- a/internal/cmd/compose.go +++ b/internal/cmd/compose.go @@ -19,6 +19,7 @@ type composeCommand struct { subject string message string threadID string + draft bool } func newComposeCommand() *composeCommand { @@ -27,9 +28,10 @@ func newComposeCommand() *composeCommand { Use: "compose", Short: "Compose a new message", Annotations: map[string]string{ - "agent_notes": "Creates a new email. Requires --subject. Use --to (optionally with --cc/--bcc) for new threads or --thread-id for existing ones.", + "agent_notes": "Creates and sends a new email unless --draft is set. Requires --subject. Use --to (optionally with --cc/--bcc) for new threads or --thread-id for existing ones.", }, Example: ` hey compose --to alice@example.com --subject "Hello" -m "Hi there" + hey compose --draft --to alice@example.com --subject "Hello" -m "Draft body" hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello" -m "Hi" hey compose --subject "Update" --thread-id 12345 -m "Thread reply" echo "Long message" | hey compose --to bob@example.com --subject "Report"`, @@ -42,6 +44,7 @@ func newComposeCommand() *composeCommand { composeCommand.cmd.Flags().StringVar(&composeCommand.subject, "subject", "", "Message subject (required)") composeCommand.cmd.Flags().StringVarP(&composeCommand.message, "message", "m", "", "Message body (or opens $EDITOR)") composeCommand.cmd.Flags().StringVar(&composeCommand.threadID, "thread-id", "", "Thread ID to post message to") + composeCommand.cmd.Flags().BoolVar(&composeCommand.draft, "draft", false, "Save as draft instead of sending") return composeCommand } @@ -85,6 +88,12 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error { if err != nil { return output.ErrUsage(fmt.Sprintf("invalid thread ID: %s", c.threadID)) } + if c.draft { + return createReplyDraft(ctx, cmd.OutOrStdout(), topicID, draftFormRequest{ + Subject: c.subject, + Content: message, + }) + } if err := sdk.Messages().CreateTopicMessage(ctx, topicID, message); err != nil { return convertSDKError(err) } @@ -92,6 +101,15 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error { to := parseAddresses(c.to) cc := parseAddresses(c.cc) bcc := parseAddresses(c.bcc) + if c.draft { + return createMessageDraft(ctx, cmd.OutOrStdout(), draftFormRequest{ + Subject: c.subject, + Content: message, + To: to, + CC: cc, + BCC: bcc, + }) + } if err := sdk.Messages().Create(ctx, c.subject, message, to, cc, bcc); err != nil { return convertSDKError(err) } diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index 2840b90..fc2956e 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -139,21 +139,25 @@ func newDraftUpdateCommand() *cobra.Command { } func (c *draftUpdateCommand) run(cmd *cobra.Command, args []string) error { - if err := requireAuth(); err != nil { - return err - } - draftID, err := strconv.ParseInt(args[0], 10, 64) if err != nil { return output.ErrUsage(fmt.Sprintf("invalid draft ID: %s", args[0])) } + flags := cmd.Flags() + if !draftUpdateHasChanges(flags.Changed("subject"), flags.Changed("to"), flags.Changed("cc"), flags.Changed("bcc"), flags.Changed("message")) { + return output.ErrUsageHint("No update fields specified", "hey draft update --subject -m ") + } + + if err := requireAuth(); err != nil { + return err + } + existing, err := loadMessageDraft(cmd.Context(), draftID) if err != nil { return err } draft := existing.Request - flags := cmd.Flags() if flags.Changed("subject") { draft.Subject = c.subject } @@ -167,7 +171,7 @@ func (c *draftUpdateCommand) run(cmd *cobra.Command, args []string) error { draft.BCC = parseAddresses(c.bcc) } - if flags.Changed("message") || !stdinIsTerminal() { + if flags.Changed("message") { message, err := draftMessageWithInitial(c.message, draft.Content, flags.Changed("message")) if err != nil { return err @@ -178,6 +182,10 @@ func (c *draftUpdateCommand) run(cmd *cobra.Command, args []string) error { return updateDraft(cmd.Context(), cmd.OutOrStdout(), draftID, draft, existing.CSRFToken) } +func draftUpdateHasChanges(subjectChanged, toChanged, ccChanged, bccChanged, messageChanged bool) bool { + return subjectChanged || toChanged || ccChanged || bccChanged || messageChanged +} + type draftDeleteCommand struct{} func newDraftDeleteCommand() *cobra.Command { diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go index f5f268c..c0b88b3 100644 --- a/internal/cmd/draft_test.go +++ b/internal/cmd/draft_test.go @@ -116,3 +116,15 @@ func TestParseSelectedAddressesFieldRejectsSelectedOptionWithoutValue(t *testing t.Fatalf("expected parser to reject selected option without value, got %#v", addresses) } } + +func TestDraftUpdateHasChanges(t *testing.T) { + if draftUpdateHasChanges(false, false, false, false, false) { + t.Fatal("expected no changes when no flags are changed") + } + if !draftUpdateHasChanges(true, false, false, false, false) { + t.Fatal("expected subject flag change to count") + } + if !draftUpdateHasChanges(false, false, false, false, true) { + t.Fatal("expected message flag change to count") + } +} diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index 16d2c20..f2698f4 100644 --- a/internal/cmd/reply.go +++ b/internal/cmd/reply.go @@ -14,6 +14,7 @@ import ( type replyCommand struct { cmd *cobra.Command message string + draft bool } func newReplyCommand() *replyCommand { @@ -22,15 +23,17 @@ func newReplyCommand() *replyCommand { Use: "reply ", Short: "Reply to a thread", Annotations: map[string]string{ - "agent_notes": "Replies to the latest entry in a thread. Accepts message via -m, stdin, or $EDITOR.", + "agent_notes": "Replies to the latest entry in a thread. Accepts message via -m, stdin, or $EDITOR. Use --draft to save without sending.", }, Example: ` hey reply 12345 -m "Thanks!" + hey reply 12345 --draft -m "Draft reply" echo "Detailed reply" | hey reply 12345`, RunE: replyCommand.run, Args: usageExactOneArg(), } replyCommand.cmd.Flags().StringVarP(&replyCommand.message, "message", "m", "", "Reply message (or opens $EDITOR)") + replyCommand.cmd.Flags().BoolVar(&replyCommand.draft, "draft", false, "Save as draft instead of sending") return replyCommand } @@ -90,6 +93,15 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { } } + if c.draft { + return createReplyDraft(ctx, cmd.OutOrStdout(), threadID, draftFormRequest{ + Content: message, + To: addressed.To, + CC: addressed.CC, + BCC: addressed.BCC, + }) + } + if err = sdk.Entries().CreateReply(ctx, latestEntryID, message, addressed.To, addressed.CC, addressed.BCC); err != nil { return convertSDKError(err) } diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index b1508be..275354b 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -87,7 +87,9 @@ CLI for HEY email: mailboxes, email threads, replies, compose, calendars, todos, | List emails in a box | `hey box imbox --json` | | Read email thread | `hey threads --json` | | Reply to email | `hey reply -m "Thanks!"` | +| Save reply draft | `hey reply --draft -m "Thanks!"` | | Compose email | `hey compose --to user@example.com --subject "Hello"` | +| Save compose draft | `hey compose --draft --to user@example.com --subject "Hello" -m "Draft body"` | | Compose with CC/BCC | `hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org --subject "Hello"` | | Create draft | `hey draft create --to user@example.com --subject "Hello" -m "Draft body"` | | Create reply draft | `hey draft create --thread-id 12345 -m "Thanks!"` | @@ -136,9 +138,11 @@ Want to read email? Want to send email? ├── Reply to thread? → hey reply -m "message" │ └── Open editor? → hey reply (omit -m to open $EDITOR) +├── Save reply draft? → hey reply --draft -m "message" ├── Compose new? → hey compose --to --subject "Subject" │ ├── With body? → hey compose --to --subject "Subject" -m "Body" │ ├── With CC? → add --cc +│ ├── Save as draft? → add --draft │ └── With BCC? → add --bcc ├── Save a draft instead? → hey draft create --to --subject "Subject" -m "Body" ├── Save a reply draft? → hey draft create --thread-id -m "Body" @@ -183,9 +187,11 @@ hey threads --html # Read with raw HTML content ```bash hey reply -m "Thanks!" # Reply with inline message +hey reply --draft -m "Thanks!" # Save reply draft without sending hey reply # Reply via $EDITOR hey compose --to user@example.com --subject "Hello" # Compose new (opens $EDITOR) hey compose --to user@example.com --subject "Hi" -m "Body" # With inline body +hey compose --draft --to user@example.com --subject "Hi" -m "Body" # Save draft without sending hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org --subject "Project update" -m "Body" # With CC/BCC hey compose --subject "Update" --thread-id 12345 -m "msg" # Post to existing thread ``` @@ -212,7 +218,7 @@ hey draft delete 67890 # Delete draft ``` Use `hey draft ...` when an agent must prepare mail without sending. `hey compose` -and `hey reply` send immediately. +and `hey reply` send immediately unless `--draft` is set. ### Calendars From 8cde84885ad1cd6d7ca78aa91cc870c7f397a069 Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 12:04:27 +0200 Subject: [PATCH 07/13] refactor: tighten reply draft flow --- internal/cmd/draft.go | 31 ++++++++++++++++--------------- internal/cmd/draft_test.go | 27 ++++++++++++++++++++------- internal/cmd/reply.go | 2 +- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index fc2956e..b2d4344 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -144,7 +144,7 @@ func (c *draftUpdateCommand) run(cmd *cobra.Command, args []string) error { return output.ErrUsage(fmt.Sprintf("invalid draft ID: %s", args[0])) } flags := cmd.Flags() - if !draftUpdateHasChanges(flags.Changed("subject"), flags.Changed("to"), flags.Changed("cc"), flags.Changed("bcc"), flags.Changed("message")) { + if !flags.Changed("subject") && !flags.Changed("to") && !flags.Changed("cc") && !flags.Changed("bcc") && !flags.Changed("message") { return output.ErrUsageHint("No update fields specified", "hey draft update --subject -m ") } @@ -182,10 +182,6 @@ func (c *draftUpdateCommand) run(cmd *cobra.Command, args []string) error { return updateDraft(cmd.Context(), cmd.OutOrStdout(), draftID, draft, existing.CSRFToken) } -func draftUpdateHasChanges(subjectChanged, toChanged, ccChanged, bccChanged, messageChanged bool) bool { - return subjectChanged || toChanged || ccChanged || bccChanged || messageChanged -} - type draftDeleteCommand struct{} func newDraftDeleteCommand() *cobra.Command { @@ -257,11 +253,6 @@ func createMessageDraft(ctx context.Context, w io.Writer, draft draftFormRequest } func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft draftFormRequest) error { - senderID, err := sdk.DefaultSenderID(ctx) - if err != nil { - return convertSDKError(err) - } - topicResp, err := sdk.GetHTML(ctx, fmt.Sprintf("/topics/%d", threadID)) if err != nil { return convertSDKError(err) @@ -278,6 +269,21 @@ func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft dr } latestEntryID := entries[len(entries)-1].ID + if len(draft.To) == 0 && len(draft.CC) == 0 && len(draft.BCC) == 0 { + draft.To = addressed.To + draft.CC = addressed.CC + draft.BCC = addressed.BCC + } + + return createReplyDraftForEntry(ctx, w, latestEntryID, draft) +} + +func createReplyDraftForEntry(ctx context.Context, w io.Writer, latestEntryID int64, draft draftFormRequest) error { + senderID, err := sdk.DefaultSenderID(ctx) + if err != nil { + return convertSDKError(err) + } + replyForm, err := loadReplyDraftForm(ctx, latestEntryID) if err != nil { return err @@ -285,11 +291,6 @@ func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft dr if draft.Subject == "" { draft.Subject = replyForm.Request.Subject } - if len(draft.To) == 0 && len(draft.CC) == 0 && len(draft.BCC) == 0 { - draft.To = addressed.To - draft.CC = addressed.CC - draft.BCC = addressed.BCC - } values := draftValues(senderID, draft) resp, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/entries/%d/replies", latestEntryID), values, replyForm.CSRFToken) diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go index c0b88b3..b080c54 100644 --- a/internal/cmd/draft_test.go +++ b/internal/cmd/draft_test.go @@ -1,8 +1,12 @@ package cmd import ( + "bytes" + "errors" "reflect" "testing" + + "github.com/basecamp/hey-cli/internal/output" ) func TestDraftValues(t *testing.T) { @@ -117,14 +121,23 @@ func TestParseSelectedAddressesFieldRejectsSelectedOptionWithoutValue(t *testing } } -func TestDraftUpdateHasChanges(t *testing.T) { - if draftUpdateHasChanges(false, false, false, false, false) { - t.Fatal("expected no changes when no flags are changed") +func TestDraftUpdateWithoutFieldsFailsBeforeAuth(t *testing.T) { + cmd := newDraftUpdateCommand() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs([]string{"123"}) + + err := cmd.Execute() + + var usageErr *output.Error + if !errors.As(err, &usageErr) { + t.Fatalf("error = %T, want output.Error", err) } - if !draftUpdateHasChanges(true, false, false, false, false) { - t.Fatal("expected subject flag change to count") + if usageErr.Code != "usage" { + t.Fatalf("code = %q, want usage", usageErr.Code) } - if !draftUpdateHasChanges(false, false, false, false, true) { - t.Fatal("expected message flag change to count") + if usageErr.Message != "No update fields specified" { + t.Fatalf("message = %q", usageErr.Message) } } diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index f2698f4..4fc9608 100644 --- a/internal/cmd/reply.go +++ b/internal/cmd/reply.go @@ -94,7 +94,7 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { } if c.draft { - return createReplyDraft(ctx, cmd.OutOrStdout(), threadID, draftFormRequest{ + return createReplyDraftForEntry(ctx, cmd.OutOrStdout(), latestEntryID, draftFormRequest{ Content: message, To: addressed.To, CC: addressed.CC, From 4985a8ed7885abd9b8bdb7e2240fb82b2f2fa03f Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 12:07:11 +0200 Subject: [PATCH 08/13] docs: add draft support implementation note --- DRAFT_SUPPORT_NOTE.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 DRAFT_SUPPORT_NOTE.md diff --git a/DRAFT_SUPPORT_NOTE.md b/DRAFT_SUPPORT_NOTE.md new file mode 100644 index 0000000..5dd5d1f --- /dev/null +++ b/DRAFT_SUPPORT_NOTE.md @@ -0,0 +1,30 @@ +# Draft Support Implementation Note + +## Authorship + +Authored by Mike Gyi with Codex, based on GPT-5. + +## Intent + +The goal of this work is to make HEY draft workflows scriptable from the CLI while keeping Basecamp's CLI taste as the reference point. + +The public Basecamp CLI treats drafts as part of the normal creation flow, for example with a `--draft` option on a create command. This HEY CLI change follows that shape by adding draft mode to the existing mail commands: + +```sh +hey compose --draft --to person@example.com --subject "Hello" -m "Draft body" +hey reply 123 --draft -m "Thanks!" +``` + +Explicit draft commands are still included because they are useful for agent and scripting workflows: + +```sh +hey draft create --to person@example.com --subject "Hello" -m "Draft body" +hey draft update 123 --subject "Updated subject" -m "Updated body" +hey draft delete 123 +``` + +## Implementation Constraint + +The current HEY SDK surface does not expose first-class draft create/update/delete methods. The implementation therefore uses the authenticated HEY web form flow for draft mutations, with CSRF parsing and fail-closed form validation around the fields needed by the CLI. + +If HEY later exposes official draft endpoints in the SDK, the CLI command surface should stay the same and only the internal draft implementation should move to the official API. From 213df75d65dba032676bf496e4e4b52996f58641 Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 12:07:35 +0200 Subject: [PATCH 09/13] docs: clarify draft support authorship --- DRAFT_SUPPORT_NOTE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DRAFT_SUPPORT_NOTE.md b/DRAFT_SUPPORT_NOTE.md index 5dd5d1f..b1b82af 100644 --- a/DRAFT_SUPPORT_NOTE.md +++ b/DRAFT_SUPPORT_NOTE.md @@ -2,7 +2,7 @@ ## Authorship -Authored by Mike Gyi with Codex, based on GPT-5. +Authored by Mike Gyi with Codex 5.5. ## Intent From bc9f1f0d06a1c5d11ab2404ee08dfdb499018ee1 Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 12:21:24 +0200 Subject: [PATCH 10/13] fix: paginate HEY drafts --- internal/cmd/drafts.go | 118 ++++++++++++++++++++++++++++++++---- internal/cmd/drafts_test.go | 112 ++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 internal/cmd/drafts_test.go diff --git a/internal/cmd/drafts.go b/internal/cmd/drafts.go index 9857db7..7418912 100644 --- a/internal/cmd/drafts.go +++ b/internal/cmd/drafts.go @@ -1,7 +1,10 @@ package cmd import ( + "context" "fmt" + "strconv" + "strings" "github.com/spf13/cobra" @@ -42,20 +45,11 @@ func (c *draftsCommand) run(cmd *cobra.Command, args []string) error { } ctx := cmd.Context() - result, err := sdk.Entries().ListDrafts(ctx, nil) + drafts, total, hasMore, err := paginateDrafts(ctx, c.limit, c.all, fetchDraftsPage) if err != nil { - return convertSDKError(err) - } - - var drafts []generated.DraftMessage - if result != nil { - drafts = *result - } - total := len(drafts) - if c.limit > 0 && !c.all && len(drafts) > c.limit { - drafts = drafts[:c.limit] + return err } - notice := output.TruncationNotice(len(drafts), total) + notice := draftsTruncationNotice(len(drafts), total, hasMore, c.all) if writer.IsStyled() { if len(drafts) == 0 { @@ -80,3 +74,103 @@ func (c *draftsCommand) run(cmd *cobra.Command, args []string) error { output.WithNotice(notice), ) } + +type draftPageFetcher func(ctx context.Context, pageURL string) ([]generated.DraftMessage, string, int, error) + +func fetchDraftsPage(ctx context.Context, pageURL string) ([]generated.DraftMessage, string, int, error) { + if strings.HasPrefix(pageURL, "http://") || strings.HasPrefix(pageURL, "https://") { + if err := validateSameOrigin(sdk.Config().BaseURL, pageURL); err != nil { + return nil, "", 0, err + } + } + + resp, err := sdk.Get(ctx, pageURL) + if err != nil { + return nil, "", 0, convertSDKError(err) + } + + var drafts []generated.DraftMessage + if err := resp.UnmarshalData(&drafts); err != nil { + return nil, "", 0, fmt.Errorf("failed to parse drafts response: %w", err) + } + + total := len(drafts) + if totalHeader := resp.Headers.Get("X-Total-Count"); totalHeader != "" { + if parsed, err := strconv.Atoi(totalHeader); err == nil { + total = parsed + } + } + + return drafts, parseNextLinkHeader(resp.Headers.Get("Link")), total, nil +} + +func paginateDrafts(ctx context.Context, limit int, all bool, fetch draftPageFetcher) ([]generated.DraftMessage, int, bool, error) { + if fetch == nil { + return nil, 0, false, fmt.Errorf("paginateDrafts: fetch function is nil") + } + + pageDrafts, nextURL, total, err := fetch(ctx, "/entries/drafts.json") + if err != nil { + return nil, 0, false, err + } + + drafts := append([]generated.DraftMessage(nil), pageDrafts...) + if total == 0 { + total = len(drafts) + } + + needMore := all || (limit > 0 && len(drafts) < limit) + for page := 1; needMore && nextURL != "" && page <= maxAdditionalPages; page++ { + var pageTotal int + pageDrafts, nextURL, pageTotal, err = fetch(ctx, nextURL) + if err != nil { + return nil, 0, false, err + } + if pageTotal > total { + total = pageTotal + } + if len(pageDrafts) == 0 { + nextURL = "" + break + } + + drafts = append(drafts, pageDrafts...) + needMore = all || (limit > 0 && len(drafts) < limit) + } + + hasMore := nextURL != "" + if limit > 0 && !all && len(drafts) > limit { + drafts = drafts[:limit] + hasMore = true + } + if total < len(drafts) { + total = len(drafts) + } + + return drafts, total, hasMore, nil +} + +func draftsTruncationNotice(shown, total int, hasMore, all bool) string { + if hasMore && all { + return fmt.Sprintf("Showing %d of at least %d drafts. Pagination limit reached; not all drafts could be fetched.", shown, total) + } + if hasMore { + return fmt.Sprintf("Showing %d of %d drafts. Use --all to fetch all.", shown, total) + } + return output.TruncationNotice(shown, total) +} + +func parseNextLinkHeader(linkHeader string) string { + for _, part := range strings.Split(linkHeader, ",") { + part = strings.TrimSpace(part) + if !strings.Contains(part, `rel="next"`) { + continue + } + start := strings.Index(part, "<") + end := strings.Index(part, ">") + if start >= 0 && end > start { + return part[start+1 : end] + } + } + return "" +} diff --git a/internal/cmd/drafts_test.go b/internal/cmd/drafts_test.go new file mode 100644 index 0000000..dbb90a5 --- /dev/null +++ b/internal/cmd/drafts_test.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "context" + "reflect" + "testing" + + "github.com/basecamp/hey-sdk/go/pkg/generated" +) + +func TestPaginateDraftsAllFollowsNextLinks(t *testing.T) { + pages := map[string]struct { + drafts []generated.DraftMessage + next string + total int + }{ + "/entries/drafts.json": { + drafts: []generated.DraftMessage{{Id: 1}, {Id: 2}}, + next: "https://app.hey.com/entries/drafts.json?page=2", + total: 3, + }, + "https://app.hey.com/entries/drafts.json?page=2": { + drafts: []generated.DraftMessage{{Id: 3}}, + total: 3, + }, + } + var calls []string + fetch := func(_ context.Context, pageURL string) ([]generated.DraftMessage, string, int, error) { + calls = append(calls, pageURL) + page, ok := pages[pageURL] + if !ok { + t.Fatalf("unexpected page URL %q", pageURL) + } + return page.drafts, page.next, page.total, nil + } + + drafts, total, hasMore, err := paginateDrafts(context.Background(), 0, true, fetch) + if err != nil { + t.Fatalf("paginateDrafts: %v", err) + } + + if got := draftIDs(drafts); !reflect.DeepEqual(got, []int64{1, 2, 3}) { + t.Fatalf("draft ids = %#v", got) + } + if total != 3 { + t.Fatalf("total = %d, want 3", total) + } + if hasMore { + t.Fatal("expected hasMore=false after final page") + } + if !reflect.DeepEqual(calls, []string{"/entries/drafts.json", "https://app.hey.com/entries/drafts.json?page=2"}) { + t.Fatalf("calls = %#v", calls) + } +} + +func TestPaginateDraftsLimitStopsAfterEnoughResults(t *testing.T) { + pages := map[string]struct { + drafts []generated.DraftMessage + next string + total int + }{ + "/entries/drafts.json": { + drafts: []generated.DraftMessage{{Id: 1}, {Id: 2}}, + next: "https://app.hey.com/entries/drafts.json?page=2", + total: 4, + }, + "https://app.hey.com/entries/drafts.json?page=2": { + drafts: []generated.DraftMessage{{Id: 3}, {Id: 4}}, + total: 4, + }, + } + fetch := func(_ context.Context, pageURL string) ([]generated.DraftMessage, string, int, error) { + page, ok := pages[pageURL] + if !ok { + t.Fatalf("unexpected page URL %q", pageURL) + } + return page.drafts, page.next, page.total, nil + } + + drafts, total, hasMore, err := paginateDrafts(context.Background(), 3, false, fetch) + if err != nil { + t.Fatalf("paginateDrafts: %v", err) + } + + if got := draftIDs(drafts); !reflect.DeepEqual(got, []int64{1, 2, 3}) { + t.Fatalf("draft ids = %#v", got) + } + if total != 4 { + t.Fatalf("total = %d, want 4", total) + } + if !hasMore { + t.Fatal("expected hasMore=true when limit truncates fetched results") + } +} + +func TestParseNextLinkHeader(t *testing.T) { + header := `; rel="prev", ; rel="next"` + + got := parseNextLinkHeader(header) + + if got != "https://app.hey.com/entries/drafts.json?page=next" { + t.Fatalf("next link = %q", got) + } +} + +func draftIDs(drafts []generated.DraftMessage) []int64 { + ids := make([]int64, 0, len(drafts)) + for _, draft := range drafts { + ids = append(ids, draft.Id) + } + return ids +} From 18929a6de56117e4d03472db5ed958734e718f1a Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 12:26:51 +0200 Subject: [PATCH 11/13] test: cover draft pagination safeguards --- internal/cmd/drafts_test.go | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/internal/cmd/drafts_test.go b/internal/cmd/drafts_test.go index dbb90a5..e066831 100644 --- a/internal/cmd/drafts_test.go +++ b/internal/cmd/drafts_test.go @@ -3,9 +3,11 @@ package cmd import ( "context" "reflect" + "strings" "testing" "github.com/basecamp/hey-sdk/go/pkg/generated" + hey "github.com/basecamp/hey-sdk/go/pkg/hey" ) func TestPaginateDraftsAllFollowsNextLinks(t *testing.T) { @@ -103,6 +105,63 @@ func TestParseNextLinkHeader(t *testing.T) { } } +func TestFetchDraftsPageRejectsCrossOriginURL(t *testing.T) { + originalSDK := sdk + sdk = hey.NewClient(&hey.Config{BaseURL: "https://app.hey.com"}, nil) + t.Cleanup(func() { sdk = originalSDK }) + + _, _, _, err := fetchDraftsPage(context.Background(), "https://evil.example/entries/drafts.json") + + if err == nil { + t.Fatal("expected cross-origin pagination URL to fail") + } + if !strings.Contains(err.Error(), "does not match base") { + t.Fatalf("error = %q, want origin mismatch", err.Error()) + } +} + +func TestDraftsTruncationNotice(t *testing.T) { + tests := []struct { + name string + shown int + total int + hasMore bool + all bool + want string + }{ + { + name: "default page has more", + shown: 15, + total: 146, + hasMore: true, + want: "Showing 15 of 146 drafts. Use --all to fetch all.", + }, + { + name: "all capped", + shown: 1500, + total: 1600, + hasMore: true, + all: true, + want: "Showing 1500 of at least 1600 drafts. Pagination limit reached; not all drafts could be fetched.", + }, + { + name: "complete result", + shown: 3, + total: 3, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := draftsTruncationNotice(tt.shown, tt.total, tt.hasMore, tt.all) + if got != tt.want { + t.Fatalf("notice = %q, want %q", got, tt.want) + } + }) + } +} + func draftIDs(drafts []generated.DraftMessage) []int64 { ids := make([]int64, 0, len(drafts)) for _, draft := range drafts { From 7ea05c44da5b208f153c4d19660df46a2f52bfb5 Mon Sep 17 00:00:00 2001 From: mikegyi Date: Mon, 1 Jun 2026 12:51:49 +0200 Subject: [PATCH 12/13] fix: preserve safe reply draft formatting --- internal/cmd/draft.go | 55 +++++++++++++++++++++++++++----------- internal/cmd/draft_test.go | 50 ++++++++++++++++++++++++++++++++++ internal/cmd/reply.go | 22 +++++++-------- 3 files changed, 99 insertions(+), 28 deletions(-) diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index b2d4344..e6b44e1 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -253,12 +253,6 @@ func createMessageDraft(ctx context.Context, w io.Writer, draft draftFormRequest } func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft draftFormRequest) error { - topicResp, err := sdk.GetHTML(ctx, fmt.Sprintf("/topics/%d", threadID)) - if err != nil { - return convertSDKError(err) - } - addressed := htmlutil.ParseTopicAddressed(string(topicResp.Data)) - entriesResp, err := sdk.GetHTML(ctx, fmt.Sprintf("/topics/%d/entries", threadID)) if err != nil { return convertSDKError(err) @@ -268,14 +262,7 @@ func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft dr return output.ErrNotFound("entries for thread", fmt.Sprintf("%d", threadID)) } - latestEntryID := entries[len(entries)-1].ID - if len(draft.To) == 0 && len(draft.CC) == 0 && len(draft.BCC) == 0 { - draft.To = addressed.To - draft.CC = addressed.CC - draft.BCC = addressed.BCC - } - - return createReplyDraftForEntry(ctx, w, latestEntryID, draft) + return createReplyDraftForEntry(ctx, w, entries[len(entries)-1].ID, draft) } func createReplyDraftForEntry(ctx context.Context, w io.Writer, latestEntryID int64, draft draftFormRequest) error { @@ -291,6 +278,10 @@ func createReplyDraftForEntry(ctx context.Context, w io.Writer, latestEntryID in if draft.Subject == "" { draft.Subject = replyForm.Request.Subject } + draft = withReplyFormRecipients(draft, replyForm.Request) + if len(draft.To) == 0 && len(draft.CC) == 0 && len(draft.BCC) == 0 { + return output.ErrUsage("could not determine thread recipients") + } values := draftValues(senderID, draft) resp, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/entries/%d/replies", latestEntryID), values, replyForm.CSRFToken) @@ -391,7 +382,7 @@ func draftValues(senderID int64, draft draftFormRequest) url.Values { values.Set("acting_sender_id", fmt.Sprintf("%d", senderID)) values.Set("entry[status]", "drafted") values.Set("message[subject]", draft.Subject) - values.Set("message[content]", draft.Content) + values.Set("message[content]", draftContentHTML(draft.Content)) for _, to := range draft.To { values.Add("entry[addressed][directly][]", to) } @@ -404,6 +395,40 @@ func draftValues(senderID int64, draft draftFormRequest) url.Values { return values } +func withReplyFormRecipients(draft, replyForm draftFormRequest) draftFormRequest { + if len(draft.To) > 0 || len(draft.CC) > 0 || len(draft.BCC) > 0 { + return draft + } + draft.To = replyForm.To + draft.CC = replyForm.CC + draft.BCC = replyForm.BCC + return draft +} + +func draftContentHTML(content string) string { + if looksLikeDraftHTML(content) { + return content + } + + content = strings.ReplaceAll(content, "\r\n", "\n") + content = strings.ReplaceAll(content, "\r", "\n") + lines := strings.Split(content, "\n") + for i, line := range lines { + lines[i] = html.EscapeString(line) + } + return "
" + strings.Join(lines, "
") + "
" +} + +func looksLikeDraftHTML(content string) bool { + trimmed := strings.TrimSpace(strings.ToLower(content)) + return strings.HasPrefix(trimmed, " Date: Mon, 1 Jun 2026 13:37:24 +0200 Subject: [PATCH 13/13] fix: format draft paragraphs as trix blocks --- internal/cmd/draft.go | 8 ++++++-- internal/cmd/draft_test.go | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index e6b44e1..c4c90fa 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -414,9 +414,13 @@ func draftContentHTML(content string) string { content = strings.ReplaceAll(content, "\r", "\n") lines := strings.Split(content, "\n") for i, line := range lines { - lines[i] = html.EscapeString(line) + if line == "" { + lines[i] = "

" + continue + } + lines[i] = "
" + html.EscapeString(line) + "
" } - return "
" + strings.Join(lines, "
") + "
" + return strings.Join(lines, "") } func looksLikeDraftHTML(content string) bool { diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go index 177b5e7..8419cd3 100644 --- a/internal/cmd/draft_test.go +++ b/internal/cmd/draft_test.go @@ -44,7 +44,7 @@ func TestDraftValuesFormatsPlainTextContent(t *testing.T) { To: []string{"chrissie@example.com"}, }) - want := "
Hi Chrissie,

Thanks & all the best.

Mike
" + want := "
Hi Chrissie,

Thanks & all the best.

Mike
" if got := values.Get("message[content]"); got != want { t.Fatalf("content = %q, want %q", got, want) }