= 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)
`)
+ 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