diff --git a/.surface b/.surface index c688616..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 @@ -40,6 +41,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 @@ -61,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/DRAFT_SUPPORT_NOTE.md b/DRAFT_SUPPORT_NOTE.md new file mode 100644 index 0000000..b1b82af --- /dev/null +++ b/DRAFT_SUPPORT_NOTE.md @@ -0,0 +1,30 @@ +# Draft Support Implementation Note + +## Authorship + +Authored by Mike Gyi with Codex 5.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. diff --git a/README.md b/README.md index 8525306..37e6383 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,14 @@ 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 +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/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 new file mode 100644 index 0000000..c4c90fa --- /dev/null +++ b/internal/cmd/draft.go @@ -0,0 +1,639 @@ +package cmd + +import ( + "context" + "fmt" + "html" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "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\]"[^>]*>`) + 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 { + 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: "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, + } + + 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)") + + return cmd +} + +func (c *draftUpdateCommand) run(cmd *cobra.Command, args []string) error { + 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 !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 + + 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) + } + + if flags.Changed("message") { + 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, draft, existing.CSRFToken) +} + +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"` +} + +type draftFormState struct { + 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 { + 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, csrfToken) + if err != nil { + return err + } + + return writeDraftSaved(w, resp, "Draft created") +} + +func createReplyDraft(ctx context.Context, w io.Writer, threadID int64, draft draftFormRequest) error { + 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)) + } + + return createReplyDraftForEntry(ctx, w, entries[len(entries)-1].ID, 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 + } + 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) + if err != nil { + return err + } + + return writeDraftSaved(w, resp, "Reply draft created") +} + +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 draftFormState{}, convertSDKError(err) + } + state := parseDraftForm(string(resp.Data)) + 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 +} + +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) + } + 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) { + resp, err := sdk.GetHTML(ctx, path) + 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 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) + } + + values := draftValues(senderID, draft) + values.Set("_method", "patch") + + resp, err := submitDraftForm(ctx, "POST", fmt.Sprintf("/messages/%d", draftID), values, csrfToken) + 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 { + csrfToken, err := loadCSRFToken(ctx, fmt.Sprintf("/messages/%d/edit", 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, csrfToken); 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]", draftContentHTML(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 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 { + if line == "" { + lines[i] = "

" + continue + } + lines[i] = "
" + html.EscapeString(line) + "
" + } + return strings.Join(lines, "") +} + +func looksLikeDraftHTML(content string) bool { + trimmed := strings.TrimSpace(strings.ToLower(content)) + return strings.HasPrefix(trimmed, "= 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 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{} + } + 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 { + subject, _ := parseMessageSubjectField(pageHTML) + return subject +} + +func parseMessageSubjectField(pageHTML string) (string, bool) { + input := messageSubjectInputRe.FindString(pageHTML) + if input == "" { + return "", false + } + match := valueAttrRe.FindStringSubmatch(input) + if match == nil { + return "", false + } + 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: subject, + Content: content, + To: to, + CC: cc, + BCC: bcc, + }, + 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 "", false + } + match := valueAttrRe.FindStringSubmatch(input) + if match == nil { + return "", false + } + 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, false + } + + var addresses []string + for _, option := range optionRe.FindAllString(match[1], -1) { + valueMatch := valueAttrRe.FindStringSubmatch(option) + if valueMatch == nil { + return nil, false + } + addresses = append(addresses, html.UnescapeString(valueMatch[1])) + } + return addresses, true +} + +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 { + 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) { + 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(initial) + 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..8419cd3 --- /dev/null +++ b/internal/cmd/draft_test.go @@ -0,0 +1,193 @@ +package cmd + +import ( + "bytes" + "errors" + "reflect" + "testing" + + "github.com/basecamp/hey-cli/internal/output" +) + +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 TestDraftValuesFormatsPlainTextContent(t *testing.T) { + values := draftValues(123, draftFormRequest{ + Subject: "Hello", + Content: "Hi Chrissie,\n\nThanks & all the best.\n\nMike", + To: []string{"chrissie@example.com"}, + }) + + want := "
Hi Chrissie,

Thanks & all the best.

Mike
" + if got := values.Get("message[content]"); got != want { + t.Fatalf("content = %q, want %q", got, want) + } +} + +func TestWithReplyFormRecipientsUsesReplyFormDefaults(t *testing.T) { + got := withReplyFormRecipients(draftFormRequest{ + Content: "Thanks", + }, draftFormRequest{ + To: []string{"chrissie@example.com"}, + CC: []string{"friend@example.com"}, + BCC: nil, + }) + + want := draftFormRequest{ + Content: "Thanks", + To: []string{"chrissie@example.com"}, + CC: []string{"friend@example.com"}, + BCC: nil, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("draft = %#v, want %#v", got, want) + } +} + +func TestWithReplyFormRecipientsKeepsExplicitRecipients(t *testing.T) { + got := withReplyFormRecipients(draftFormRequest{ + Content: "Thanks", + To: []string{"selected@example.com"}, + }, draftFormRequest{ + To: []string{"form@example.com"}, + BCC: []string{"hidden@example.com"}, + }) + + if !reflect.DeepEqual(got.To, []string{"selected@example.com"}) { + t.Fatalf("to = %#v", got.To) + } + if len(got.BCC) != 0 { + t.Fatalf("bcc = %#v, want empty explicit recipient state to be preserved", got.BCC) + } +} + +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) + } +} + +func TestParseDraftForm(t *testing.T) { + html := ` + + + + + +` + + state := parseDraftForm(html) + + 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) + } + 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) + } +} + +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) + } +} + +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) + } +} + +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 usageErr.Code != "usage" { + t.Fatalf("code = %q, want usage", usageErr.Code) + } + if usageErr.Message != "No update fields specified" { + t.Fatalf("message = %q", usageErr.Message) + } +} 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..e066831 --- /dev/null +++ b/internal/cmd/drafts_test.go @@ -0,0 +1,171 @@ +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) { + 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 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 { + ids = append(ids, draft.Id) + } + return ids +} 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/reply.go b/internal/cmd/reply.go index 16d2c20..4d9eb12 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 } @@ -47,16 +50,6 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - // Fetch topic page to extract recipients (To/CC/BCC). - topicResp, err := sdk.GetHTML(ctx, fmt.Sprintf("/topics/%d", threadID)) - if err != nil { - return convertSDKError(err) - } - addressed := htmlutil.ParseTopicAddressed(string(topicResp.Data)) - if len(addressed.To) == 0 && len(addressed.CC) == 0 && len(addressed.BCC) == 0 { - return output.ErrUsage("could not determine thread recipients") - } - // Fetch entries to find the latest entry ID for the reply. entriesResp, err := sdk.GetHTML(ctx, fmt.Sprintf("/topics/%d/entries", threadID)) if err != nil { @@ -90,6 +83,21 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { } } + if c.draft { + return createReplyDraftForEntry(ctx, cmd.OutOrStdout(), latestEntryID, draftFormRequest{ + Content: message, + }) + } + + replyForm, err := loadReplyDraftForm(ctx, latestEntryID) + if err != nil { + return err + } + addressed := replyForm.Request + if len(addressed.To) == 0 && len(addressed.CC) == 0 && len(addressed.BCC) == 0 { + return output.ErrUsage("could not determine thread recipients") + } + if err = sdk.Entries().CreateReply(ctx, latestEntryID, message, addressed.To, addressed.CC, addressed.BCC); err != nil { return convertSDKError(err) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 568b3a2..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) @@ -122,6 +123,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..275354b 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 @@ -86,8 +87,14 @@ 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!"` | +| 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` | @@ -131,10 +138,14 @@ 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" └── Check drafts? → hey drafts --json ``` @@ -176,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 ``` @@ -198,8 +211,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 unless `--draft` is set. + ### Calendars ```bash