From c961c549a2e78f9d20e34c8cd4b1833f5c4b2ac7 Mon Sep 17 00:00:00 2001 From: "J.R. Lillard" Date: Tue, 25 Aug 2026 11:45:42 -0500 Subject: [PATCH 1/5] Add HTML file input for email commands --- .surface | 5 ++ README.md | 8 ++- internal/cmd/attachments_test.go | 52 +++++++++++++++- internal/cmd/bulk_reply.go | 27 +++++--- internal/cmd/bulk_reply_test.go | 28 +++++++++ internal/cmd/compose.go | 40 ++++++++---- internal/cmd/compose_test.go | 20 ++++++ internal/cmd/draft.go | 27 +++++--- internal/cmd/draft_test.go | 24 ++++++++ internal/cmd/forward.go | 36 +++++++---- internal/cmd/forward_test.go | 22 +++++++ internal/cmd/message_html_file.go | 50 +++++++++++++++ internal/cmd/message_html_file_test.go | 85 ++++++++++++++++++++++++++ internal/cmd/reply.go | 32 +++++++--- internal/cmd/thread_reply_test.go | 19 ++++++ skills/hey/SKILL.md | 27 +++++--- 16 files changed, 440 insertions(+), 62 deletions(-) create mode 100644 internal/cmd/message_html_file.go create mode 100644 internal/cmd/message_html_file_test.go diff --git a/.surface b/.surface index bf681bab..dee2a8bd 100644 --- a/.surface +++ b/.surface @@ -60,6 +60,7 @@ hey bulk-reply send hey bulk-reply send --attach hey bulk-reply send --message hey bulk-reply send --message-html +hey bulk-reply send --message-html-file hey bulk-reply undo hey calendar hey calendar list @@ -96,6 +97,7 @@ hey compose --cc hey compose --draft hey compose --message hey compose --message-html +hey compose --message-html-file hey compose --subject hey compose --thread-id hey compose --to @@ -137,6 +139,7 @@ hey draft edit --bcc hey draft edit --cc hey draft edit --message hey draft edit --message-html +hey draft edit --message-html-file hey draft edit --subject hey draft edit --to hey draft list @@ -198,6 +201,7 @@ hey forward --bcc hey forward --cc hey forward --message hey forward --message-html +hey forward --message-html-file hey forward --to hey habit hey habit complete @@ -259,6 +263,7 @@ hey reply --attach hey reply --draft hey reply --message hey reply --message-html +hey reply --message-html-file hey screener hey screener approve hey screener approve --box diff --git a/README.md b/README.md index 191dc0a8..5c077243 100644 --- a/README.md +++ b/README.md @@ -433,11 +433,13 @@ hey compose --to alice@example.com --subject "Q3 revenue report" -m "The numbers hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org --subject "Kitchen remodel timeline" # with CC/BCC hey compose --to alice@example.com --subject "Sprint recap" -m "We **shipped** the pagination fix." hey compose --to alice@example.com --subject "Newsletter draft" --message-html "

March

What we shipped.

" +hey compose --subject "Client invoice" --message-html-file ./invoice-email.html --attach ./invoice.pdf --draft hey compose --subject "Board update" -m "Numbers to follow." --draft # save a draft instead of sending hey reply 123 -m "Drafting a longer answer." --draft # save a reply draft hey draft list # list drafts (--all and --page follow HEY's cursor) hey draft show 12345 # read a draft back hey draft edit 12345 --to alice@example.com --subject "Board update (v2)" +hey draft edit 12345 --message-html-file ./revised-message.html hey draft send 12345 # deliver it hey draft delete 12345 # trash it hey seen 12345 # mark a thread as seen @@ -459,9 +461,9 @@ hey stop-ignoring 12345 # resume attention for a thread Email bodies come back as Markdown. `hey thread read` and the TUI render that Markdown for the terminal — headings, emphasis, lists, quotes, tables and code survive, and links keep their URLs and stay clickable where the terminal supports it. `--json` carries the same Markdown in `body`, so an agent reading a thread sees the structure a human sees rather than a flattened wall of text. `--html` still returns HEY's original HTML. -Writing is Markdown too, everywhere text goes in: `-m`, `--content`, `--note`, positional content, stdin, and `$EDITOR` (which opens prefilled with the existing entry or note as Markdown). Every such flag has a raw-HTML twin — `--message-html`, `--content-html`, `--note-html` — for sending markup verbatim; each pair is mutually exclusive. The TUI's compose and bulk-reply forms convert Markdown the same way, and the compose editor renders it live as you type — `**bold**` turns bold, markers and all. A fenced code block's language (` ```ruby `) is carried the way HEY's own editor stores it, so the web app syntax-highlights it. +Writing is Markdown too, everywhere text goes in: `-m`, `--content`, `--note`, positional content, stdin, and `$EDITOR` (which opens prefilled with the existing entry or note as Markdown). Every such flag has a raw-HTML twin — `--message-html`, `--content-html`, `--note-html` — for sending markup verbatim. Email commands that accept `--message-html` (`compose`, `reply`, `forward`, `bulk-reply send`, and `draft edit`) also accept `--message-html-file ` and read the file directly as raw HTML, which avoids shell quoting and argument-size problems for generated messages. Markdown, inline HTML, and HTML-file inputs are mutually exclusive. Compose, reply, and draft edit preserve the file bytes exactly; forward and bulk reply use their existing HTML-prefix join, which trims surrounding whitespace before appending quoted or Name Tag content. The TUI's compose and bulk-reply forms convert Markdown the same way, and the compose editor renders it live as you type — `**bold**` turns bold, markers and all. A fenced code block's language (` ```ruby `) is carried the way HEY's own editor stores it, so the web app syntax-highlights it. -Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown, `hey draft edit` revises it (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the API cannot yet name an exact instant — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides. +Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown, `hey draft edit` revises it (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), and `--message-html-file` replaces the complete body with the exact HTML read from a file. `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the API cannot yet name an exact instant — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides. `hey share ` gets a sharing link for a thread. Anyone with the link can see the entire thread and future emails or replies sent to it. `hey unshare ` turns off the sharing link. @@ -471,7 +473,7 @@ Contact updates preserve omitted name, email, and alias fields. Supplying `--ali The Screener is where first-time senders wait. `hey screener list` returns clearance IDs — not contact IDs — with the sender and the subject of what they sent, plus `topic_id` for reading the thread before deciding. `--count` asks for the number alone, which is a far cheaper request than the queue, and prints it as a bare number like every other command's `--count`, so `n=$(hey screener list --count)` reads it directly. Approving delivers everything the sender has waiting; denying hides it. Either is reversible with the opposite command, and `hey screener history` shows what was already decided. Both listings page the way `hey box view` does: `--all` follows HEY's cursor to the end of the queue (up to 100 pages), and a single-page or capped read reports `next_page` in its JSON meta, which `--page ` continues from — the cursor is opaque, so a page *number* does not name a position. `hey screener list` also reports `total_count`, the whole queue's size, next to what the read returned. `--box` and `--seen` approve one sender at a time; several IDs go through HEY's bulk endpoint, which takes neither. `--spam` also trains HEY's filter, which is harder to undo than denying. `hey screener clear` empties the queue without deciding anything — those senders reappear on their next email. -`hey bulk-reply preview` is read-only and resolves each posting to its latest replyable entry. `hey bulk-reply send` resolves the selection again, skips threads without a replyable entry, keeps HEY's server-provided name tag, and returns the exact reply count, delivery ID, delayed state, undo URL, and undo command. Posting IDs must be positive and unique. The message can come from `-m`, stdin, or `$EDITOR`; `--attach` is repeatable. +`hey bulk-reply preview` is read-only and resolves each posting to its latest replyable entry. `hey bulk-reply send` resolves the selection again, skips threads without a replyable entry, keeps HEY's server-provided name tag, and returns the exact reply count, delivery ID, delayed state, undo URL, and undo command. Posting IDs must be positive and unique. The message can come from `-m`, stdin, `$EDITOR`, inline `--message-html`, or `--message-html-file`; `--attach` is repeatable. `--attach` is repeatable on `hey compose`, `hey reply`, and `hey bulk-reply send`, and attachment-only messages are supported. The CLI validates and uploads every file before sending the email. `hey attachment list ` returns stable message-and-position IDs such as `456:1`; pass an ID to `hey attachment save`. Saving uses the original filename by default, accepts `--output` for a file or directory, and preserves existing files unless `--force` is set. diff --git a/internal/cmd/attachments_test.go b/internal/cmd/attachments_test.go index 40fb3913..d6d12b1c 100644 --- a/internal/cmd/attachments_test.go +++ b/internal/cmd/attachments_test.go @@ -111,12 +111,24 @@ func attachmentServer(t *testing.T) (*httptest.Server, *attachmentServerState) { Message struct { Content string `json:"content"` } `json:"message"` + Entry struct { + Status string `json:"status"` + } `json:"entry"` } _ = json.NewDecoder(r.Body).Decode(&body) + event := "send" + if body.Entry.Status == "drafted" { + event = "draft" + } state.mu.Lock() state.sentContents = append(state.sentContents, body.Message.Content) - state.events = append(state.events, "send") + state.events = append(state.events, event) state.mu.Unlock() + if event == "draft" { + w.Header().Set("Location", "/messages/12345") + w.WriteHeader(http.StatusNoContent) + return + } w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{}`)) default: @@ -351,6 +363,44 @@ func TestComposeUploadsAttachmentsBeforeSending(t *testing.T) { } } +func TestComposeDraftReadsHTMLFileAndIncludesAttachment(t *testing.T) { + server, state := attachmentServer(t) + directory := t.TempDir() + htmlPath := filepath.Join(directory, "invoice-email.html") + htmlBody := "

Invoice 1042

\n

Please see the attached invoice.

\n" + if err := os.WriteFile(htmlPath, []byte(htmlBody), 0o600); err != nil { + t.Fatal(err) + } + attachmentPath := filepath.Join(directory, "invoice-1042.pdf") + if err := os.WriteFile(attachmentPath, []byte("invoice contents"), 0o600); err != nil { + t.Fatal(err) + } + + stdout, err := runAttachmentCommand(t, server, + "compose", "--subject", "Client invoice", "--message-html-file", htmlPath, + "--attach", attachmentPath, "--draft", + ) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout, `"id": 12345`) { + t.Errorf("draft output = %s, want saved draft ID", stdout) + } + + state.mu.Lock() + defer state.mu.Unlock() + if state.directUploads != 1 || state.storageUploads != 1 || len(state.sentContents) != 1 { + t.Fatalf("state = %+v", state) + } + if strings.Join(state.events, ",") != "reserve,upload,draft" { + t.Errorf("events = %v", state.events) + } + content := state.sentContents[0] + if !strings.HasPrefix(content, htmlBody+"
") || !strings.Contains(content, `action-text-attachment sgid="sgid-upload"`) || !strings.Contains(content, `filename="invoice-1042.pdf"`) { + t.Errorf("draft content = %q", content) + } +} + func TestComposeReadsPipedBodyWithAttachments(t *testing.T) { server, state := attachmentServer(t) path := filepath.Join(t.TempDir(), "quarterly-report.pdf") diff --git a/internal/cmd/bulk_reply.go b/internal/cmd/bulk_reply.go index 5db5448e..a8a83b5c 100644 --- a/internal/cmd/bulk_reply.go +++ b/internal/cmd/bulk_reply.go @@ -26,10 +26,11 @@ type bulkReplyPreviewCommand struct { } type bulkReplySendCommand struct { - cmd *cobra.Command - message string - messageHTML string - attachments []string + cmd *cobra.Command + message string + messageHTML string + messageHTMLFile string + attachments []string } type bulkReplyUndoCommand struct { @@ -138,18 +139,20 @@ func newBulkReplySendCommand() *bulkReplySendCommand { Use: "send ...", Short: "Send one reply to multiple threads", Annotations: map[string]string{ - "agent_notes": "Mutating. Preview first. Accepts a message via -m, stdin, or $EDITOR and repeatable --attach files. HEY's server-provided name-tag content is preserved.", + "agent_notes": "Mutating. Preview first. Accepts a message via -m, stdin, or $EDITOR; use --message-html for inline raw HTML or --message-html-file to read raw HTML from a file. Repeatable --attach files are supported, and HEY's server-provided name-tag content is preserved.", }, Example: ` hey bulk-reply send 12345 67890 -m "Thanks for the update." echo "Thanks for the update." | hey bulk-reply send 12345 67890 + hey bulk-reply send 12345 67890 --message-html-file ./reply.html hey bulk-reply send 12345 67890 -m "The report is attached." --attach ./report.pdf`, RunE: sendCommand.run, Args: usageMinOneArg(), } sendCommand.cmd.Flags().StringVarP(&sendCommand.message, "message", "m", "", "Reply message as Markdown (or opens $EDITOR)") sendCommand.cmd.Flags().StringVar(&sendCommand.messageHTML, "message-html", "", "Reply message as raw HTML instead of Markdown") + sendCommand.cmd.Flags().StringVar(&sendCommand.messageHTMLFile, "message-html-file", "", "Read the raw HTML reply body from this file") sendCommand.cmd.Flags().StringArrayVar(&sendCommand.attachments, "attach", nil, "File to attach (repeatable)") - sendCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html") + sendCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html", "message-html-file") return sendCommand } @@ -162,7 +165,17 @@ func (c *bulkReplySendCommand) run(cmd *cobra.Command, args []string) error { return err } message := c.messageHTML - if message == "" { + messageHTMLFileProvided := cmd.Flags().Changed("message-html-file") + if messageHTMLFileProvided { + message, err = readMessageHTMLFile(c.messageHTMLFile) + if err != nil { + return err + } + if message == "" && len(c.attachments) == 0 { + return apierr.ErrUsage(fmt.Sprintf("HTML message file %q is empty; attach a file or provide HTML content", c.messageHTMLFile)) + } + } + if message == "" && !messageHTMLFileProvided { markdownMessage, readErr := c.readMessage() if readErr != nil { return readErr diff --git a/internal/cmd/bulk_reply_test.go b/internal/cmd/bulk_reply_test.go index eaba110f..7e296065 100644 --- a/internal/cmd/bulk_reply_test.go +++ b/internal/cmd/bulk_reply_test.go @@ -247,6 +247,34 @@ func TestBulkReplySendConvertsMarkdownMessage(t *testing.T) { } } +func TestBulkReplySendReadsRawHTMLFromFile(t *testing.T) { + server, state := bulkReplyServer(t) + path := filepath.Join(t.TempDir(), "reply.html") + message := "

Thanks everyone.

\n" + if err := os.WriteFile(path, []byte(message), 0o600); err != nil { + t.Fatal(err) + } + + _, err := runBulkReply(t, server, []string{"--json"}, []string{"send", "101", "202", "--message-html-file", path}) + if err != nil { + t.Fatal(err) + } + + requests := state.snapshot() + var request struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } + if err := json.Unmarshal(requests[len(requests)-1].Body, &request); err != nil { + t.Fatal(err) + } + want := strings.TrimSpace(message) + "
Signing off with a tag!
" + if request.Message.Content != want { + t.Errorf("content = %q, want file HTML plus name tag %q", request.Message.Content, want) + } +} + func TestBulkReplySendReportsImmediateDeliveryWithoutUndo(t *testing.T) { server, state := bulkReplyServer(t) state.delivery = `{"id":901,"entries_count":2,"delayed":false}` diff --git a/internal/cmd/compose.go b/internal/cmd/compose.go index 5967f94e..f5e0463f 100644 --- a/internal/cmd/compose.go +++ b/internal/cmd/compose.go @@ -16,16 +16,17 @@ import ( ) type composeCommand struct { - cmd *cobra.Command - to string - cc string - bcc string - subject string - message string - messageHTML string - threadID string - attachments []string - draft bool + cmd *cobra.Command + to string + cc string + bcc string + subject string + message string + messageHTML string + messageHTMLFile string + threadID string + attachments []string + draft bool } func newComposeCommand() *composeCommand { @@ -34,7 +35,7 @@ func newComposeCommand() *composeCommand { Use: "compose", Short: "Write and send a new email", Annotations: map[string]string{ - "agent_notes": "Starts a new thread with --to (optionally --cc/--bcc), which requires --subject, or replies to an existing one with --thread-id, which does not. Repeatable --attach files are uploaded before sending and can be sent without body text. The body is Markdown; use --message-html to send raw HTML instead. --draft saves instead of sending — recipients become optional — and answers the draft ID for hey draft show/edit/send/delete.", + "agent_notes": "Starts a new thread with --to (optionally --cc/--bcc), which requires --subject, or replies to an existing one with --thread-id, which does not. Repeatable --attach files are uploaded before sending and can be sent without body text. The body is Markdown; use --message-html for inline raw HTML or --message-html-file to read raw HTML verbatim from a file. --draft saves instead of sending — recipients become optional — and answers the draft ID for hey draft show/edit/send/delete.", }, Example: ` hey compose --to alice@example.com --subject "Lunch plans" -m "Are you free Friday?" hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org --subject "Kitchen remodel timeline" -m "Cabinets land the week of the 14th." @@ -42,6 +43,7 @@ func newComposeCommand() *composeCommand { hey compose --thread-id 12345 -m "Confirmed — see you then." --attach ./diagram.png hey compose --to alice@example.com --subject "Sprint recap" -m "We **shipped** the pagination fix." hey compose --to alice@example.com --subject "Newsletter draft" --message-html "

March

What we shipped.

" + hey compose --subject "Client invoice" --message-html-file ./invoice-email.html --attach ./invoice.pdf --draft echo "Notes from the offsite" | hey compose --to bob@example.com --subject "Offsite recap" hey compose --subject "Board update" -m "Numbers to follow." --draft # save a draft; add recipients later`, RunE: composeCommand.run, @@ -53,10 +55,11 @@ func newComposeCommand() *composeCommand { composeCommand.cmd.Flags().StringVar(&composeCommand.subject, "subject", "", "Message subject (required for a new message)") composeCommand.cmd.Flags().StringVarP(&composeCommand.message, "message", "m", "", "Message body as Markdown (or opens $EDITOR)") composeCommand.cmd.Flags().StringVar(&composeCommand.messageHTML, "message-html", "", "Message body as raw HTML instead of Markdown") + composeCommand.cmd.Flags().StringVar(&composeCommand.messageHTMLFile, "message-html-file", "", "Read the raw HTML message body from this file") composeCommand.cmd.Flags().StringVar(&composeCommand.threadID, "thread-id", "", "Reply to this thread instead of starting a new one") composeCommand.cmd.Flags().StringArrayVar(&composeCommand.attachments, "attach", nil, "File to attach (repeatable)") composeCommand.cmd.Flags().BoolVar(&composeCommand.draft, "draft", false, "Save as a draft instead of sending") - composeCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html") + composeCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html", "message-html-file") return composeCommand } @@ -72,7 +75,18 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error { } message := c.messageHTML - if message == "" { + messageHTMLFileProvided := cmd.Flags().Changed("message-html-file") + if messageHTMLFileProvided { + var err error + message, err = readMessageHTMLFile(c.messageHTMLFile) + if err != nil { + return err + } + if message == "" && len(c.attachments) == 0 { + return apierr.ErrUsage(fmt.Sprintf("HTML message file %q is empty; attach a file or provide HTML content", c.messageHTMLFile)) + } + } + if message == "" && !messageHTMLFileProvided { markdownMessage := c.message if markdownMessage == "" && !stdinIsTerminal() { var err error diff --git a/internal/cmd/compose_test.go b/internal/cmd/compose_test.go index f8508e62..52c3b7ff 100644 --- a/internal/cmd/compose_test.go +++ b/internal/cmd/compose_test.go @@ -2,6 +2,8 @@ package cmd import ( "errors" + "os" + "path/filepath" "strings" "testing" @@ -130,6 +132,24 @@ func TestComposeSendsRawHTMLVerbatim(t *testing.T) { } } +func TestComposeReadsRawHTMLFromFileVerbatim(t *testing.T) { + server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12) + path := filepath.Join(t.TempDir(), "message.html") + want := "

March

\n

What we shipped.

\n" + if err := os.WriteFile(path, []byte(want), 0o600); err != nil { + t.Fatal(err) + } + + err := runCLI(t, server, "--account", "8", "compose", "--thread-id", "7", + "--message-html-file", path) + if err != nil { + t.Fatalf("compose failed: %v", err) + } + if sent.Content != want { + t.Errorf("content = %q, want exact file bytes %q", sent.Content, want) + } +} + func TestComposeRefusesMessageAndMessageHTMLTogether(t *testing.T) { server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12) diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index 49813271..c88177d9 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -179,13 +179,14 @@ func (c *draftShowCommand) run(cmd *cobra.Command, args []string) error { // --- edit --- type draftEditCommand struct { - cmd *cobra.Command - subject string - to string - cc string - bcc string - message string - messageHTML string + cmd *cobra.Command + subject string + to string + cc string + bcc string + message string + messageHTML string + messageHTMLFile string } func newDraftEditCommand() *draftEditCommand { @@ -194,11 +195,12 @@ func newDraftEditCommand() *draftEditCommand { Use: "edit ", Short: "Change a draft", Annotations: map[string]string{ - "agent_notes": "Each flag replaces its field and an omitted flag keeps what the draft has — --to/--cc/--bcc replace that whole recipient kind (an explicit empty value clears it). With no field flags the body opens in $EDITOR as Markdown. A scheduled delivery is preserved.", + "agent_notes": "Each flag replaces its field and an omitted flag keeps what the draft has — --to/--cc/--bcc replace that whole recipient kind (an explicit empty value clears it). --message-html-file reads a complete raw HTML replacement verbatim from a local file. With no field flags the body opens in $EDITOR as Markdown. A scheduled delivery is preserved.", }, Example: ` hey draft edit 12345 --subject "Quarterly planning (v2)" hey draft edit 12345 --to maria@example.com --cc finance@example.com hey draft edit 12345 -m "Rewritten agenda: budget first, hiring second." + hey draft edit 12345 --message-html-file ./revised-message.html hey draft edit 12345 # open the body in $EDITOR`, RunE: editCommand.run, Args: usageExactOneArg(), @@ -209,7 +211,8 @@ func newDraftEditCommand() *draftEditCommand { editCommand.cmd.Flags().StringVar(&editCommand.bcc, "bcc", "", "Replace the BCC recipients (comma separated; empty clears)") editCommand.cmd.Flags().StringVarP(&editCommand.message, "message", "m", "", "Replace the body with this Markdown") editCommand.cmd.Flags().StringVar(&editCommand.messageHTML, "message-html", "", "Replace the body with raw HTML instead of Markdown") - editCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html") + editCommand.cmd.Flags().StringVar(&editCommand.messageHTMLFile, "message-html-file", "", "Replace the body with raw HTML read from this file") + editCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html", "message-html-file") return editCommand } @@ -254,6 +257,12 @@ func (c *draftEditCommand) run(cmd *cobra.Command, args []string) error { fieldFlagged = true } switch { + case flags.Changed("message-html-file"): + messageHTML, readErr := readMessageHTMLFile(c.messageHTMLFile) + if readErr != nil { + return readErr + } + content.Content = messageHTML case flags.Changed("message-html"): content.Content = c.messageHTML case flags.Changed("message"): diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go index e3e3dbcc..ce5c5f96 100644 --- a/internal/cmd/draft_test.go +++ b/internal/cmd/draft_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "io" "net/http" + "os" + "path/filepath" "strings" "testing" ) @@ -170,6 +172,28 @@ func TestDraftEditReplacesOnlyTheFlaggedFields(t *testing.T) { } } +func TestDraftEditReadsRawHTMLFromFileVerbatim(t *testing.T) { + path := filepath.Join(t.TempDir(), "revised-message.html") + want := "

Revised agenda

\n
  1. Budget
  2. Hiring
\n" + if err := os.WriteFile(path, []byte(want), 0o600); err != nil { + t.Fatal(err) + } + + var writes []draftWrite + _, err := runJSONCommand(t, draftLifecycleServer(t, draftEditJSON, &writes), + "draft", "edit", "12345", "--message-html-file", path) + if err != nil { + t.Fatalf("draft edit --message-html-file: %v", err) + } + if len(writes) != 1 { + t.Fatalf("writes = %+v", writes) + } + message, _ := writes[0].Body["message"].(map[string]any) + if message["content"] != want { + t.Errorf("content = %q, want exact file bytes %q", message["content"], want) + } +} + func TestDraftEditClearsARecipientKindWithAnEmptyValue(t *testing.T) { var writes []draftWrite _, err := runJSONCommand(t, draftLifecycleServer(t, draftEditJSON, &writes), diff --git a/internal/cmd/forward.go b/internal/cmd/forward.go index 51f96a12..cd6f37fe 100644 --- a/internal/cmd/forward.go +++ b/internal/cmd/forward.go @@ -11,12 +11,13 @@ import ( ) type forwardCommand struct { - cmd *cobra.Command - to string - cc string - bcc string - message string - messageHTML string + cmd *cobra.Command + to string + cc string + bcc string + message string + messageHTML string + messageHTMLFile string } func newForwardCommand() *forwardCommand { @@ -25,10 +26,11 @@ func newForwardCommand() *forwardCommand { Use: "forward ", Short: "Forward the latest message in a thread", Annotations: map[string]string{ - "agent_notes": "Forwards the latest entry in a thread with HEY's quoted content. Accepts comma-separated recipients and an optional note via -m.", + "agent_notes": "Forwards the latest entry in a thread with HEY's quoted content. Accepts comma-separated recipients and an optional Markdown note via -m; use --message-html for inline raw HTML or --message-html-file to read the raw HTML note from a file.", }, Example: ` hey forward 12345 --to alice@example.com - hey forward 12345 --to alice@example.com --cc bob@example.org -m "For your review"`, + hey forward 12345 --to alice@example.com --cc bob@example.org -m "For your review" + hey forward 12345 --to alice@example.com --message-html-file ./forward-note.html`, RunE: forwardCommand.run, Args: usageExactOneArg(), } @@ -38,7 +40,8 @@ func newForwardCommand() *forwardCommand { forwardCommand.cmd.Flags().StringVar(&forwardCommand.bcc, "bcc", "", "BCC recipient email address(es)") forwardCommand.cmd.Flags().StringVarP(&forwardCommand.message, "message", "m", "", "Optional Markdown note above the forwarded message") forwardCommand.cmd.Flags().StringVar(&forwardCommand.messageHTML, "message-html", "", "The note as raw HTML instead of Markdown") - forwardCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html") + forwardCommand.cmd.Flags().StringVar(&forwardCommand.messageHTMLFile, "message-html-file", "", "Read the raw HTML note from this file") + forwardCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html", "message-html-file") return forwardCommand } @@ -60,6 +63,17 @@ func (c *forwardCommand) run(cmd *cobra.Command, args []string) error { return apierr.ErrUsageHint("at least one recipient is required", "hey forward --to ") } + note := c.messageHTML + messageHTMLFileProvided := cmd.Flags().Changed("message-html-file") + if messageHTMLFileProvided { + note, err = readMessageHTMLFile(c.messageHTMLFile) + if err != nil { + return err + } + } else if note == "" { + note = htmlutil.FromMarkdown(c.message) + } + ctx := cmd.Context() topic, err := rootSDK.Topics().Get(ctx, threadID) if err != nil { @@ -82,10 +96,6 @@ func (c *forwardCommand) run(cmd *cobra.Command, args []string) error { return apierr.ErrNotFound("forward draft for thread", args[0]) } - note := c.messageHTML - if note == "" { - note = htmlutil.FromMarkdown(c.message) - } content := htmlutil.PrependHTML(draft.Content, note) if err := forwardSDK.Messages().Create(ctx, draft.Subject, content, to, cc, bcc); err != nil { return apierr.FromSDK(err) diff --git a/internal/cmd/forward_test.go b/internal/cmd/forward_test.go index ec05a8c1..43f89372 100644 --- a/internal/cmd/forward_test.go +++ b/internal/cmd/forward_test.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -157,6 +159,26 @@ func TestForwardSendsARawHTMLNoteVerbatim(t *testing.T) { } } +func TestForwardReadsARawHTMLNoteFromFile(t *testing.T) { + server, sent := forwardServer(t, `[{"id":12}]`) + path := filepath.Join(t.TempDir(), "forward-note.html") + note := "
For your review
\n" + if err := os.WriteFile(path, []byte(note), 0o600); err != nil { + t.Fatal(err) + } + + err := runCLI(t, server, "--account", "8", "forward", "7", + "--to", "alice@example.com", "--message-html-file", path) + if err != nil { + t.Fatalf("forward failed: %v", err) + } + + wantContent := strings.TrimSpace(note) + `
Quoted message
` + if sent.Content != wantContent { + t.Errorf("content = %q, want %q", sent.Content, wantContent) + } +} + func TestForwardRequiresRecipient(t *testing.T) { server, sent := forwardServer(t, `[{"id":12}]`) diff --git a/internal/cmd/message_html_file.go b/internal/cmd/message_html_file.go new file mode 100644 index 00000000..ebac67d0 --- /dev/null +++ b/internal/cmd/message_html_file.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "unicode/utf8" + + "github.com/basecamp/hey-cli/internal/apierr" +) + +// readMessageHTMLFile reads the exact bytes from a user-selected local file. Empty +// files are allowed here because an attachment-only message or an intentional draft +// body replacement can be valid; each command applies its own empty-body rule. +func readMessageHTMLFile(path string) (string, error) { + if path == "" { + return "", apierr.ErrUsage("--message-html-file requires a path") + } + + pathInfo, err := os.Stat(path) + if err != nil { + return "", apierr.ErrUsage(fmt.Sprintf("could not inspect HTML message file %q: %v", path, err)) + } + if !pathInfo.Mode().IsRegular() { + return "", apierr.ErrUsage(fmt.Sprintf("HTML message file %q is not a regular file", path)) + } + + file, err := os.Open(path) // #nosec G304 -- the user explicitly selected this local HTML message file + if err != nil { + return "", apierr.ErrUsage(fmt.Sprintf("could not open HTML message file %q: %v", path, err)) + } + defer func() { _ = file.Close() }() + + info, err := file.Stat() + if err != nil { + return "", apierr.ErrUsage(fmt.Sprintf("could not inspect HTML message file %q: %v", path, err)) + } + if !info.Mode().IsRegular() { + return "", apierr.ErrUsage(fmt.Sprintf("HTML message file %q is not a regular file", path)) + } + + data, err := io.ReadAll(file) + if err != nil { + return "", apierr.ErrUsage(fmt.Sprintf("could not read HTML message file %q: %v", path, err)) + } + if !utf8.Valid(data) { + return "", apierr.ErrUsage(fmt.Sprintf("HTML message file %q is not valid UTF-8", path)) + } + return string(data), nil +} diff --git a/internal/cmd/message_html_file_test.go b/internal/cmd/message_html_file_test.go new file mode 100644 index 00000000..820c79f6 --- /dev/null +++ b/internal/cmd/message_html_file_test.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadMessageHTMLFileReadsExactContent(t *testing.T) { + path := filepath.Join(t.TempDir(), "message.html") + want := "

Quarterly results

\n

Revenue grew 12%.

\n" + if err := os.WriteFile(path, []byte(want), 0o600); err != nil { + t.Fatal(err) + } + + got, err := readMessageHTMLFile(path) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Errorf("content = %q, want exact file bytes %q", got, want) + } +} + +func TestReadMessageHTMLFileRejectsInvalidPaths(t *testing.T) { + invalidUTF8 := filepath.Join(t.TempDir(), "invalid-utf8.html") + if err := os.WriteFile(invalidUTF8, []byte{0xff, 0xfe}, 0o600); err != nil { + t.Fatal(err) + } + + for _, test := range []struct { + name string + path string + want string + }{ + {name: "empty path", path: "", want: "requires a path"}, + {name: "missing file", path: filepath.Join(t.TempDir(), "missing.html"), want: "could not inspect HTML message file"}, + {name: "directory", path: t.TempDir(), want: "is not a regular file"}, + {name: "invalid UTF-8", path: invalidUTF8, want: "is not valid UTF-8"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := readMessageHTMLFile(test.path); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want to contain %q", err, test.want) + } + }) + } +} + +func TestMessageHTMLFileIsExclusiveWithEveryOtherMessageSource(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + http.NotFound(w, r) + })) + defer server.Close() + + for _, test := range []struct { + name string + args []string + }{ + {name: "compose Markdown", args: []string{"compose", "--thread-id", "7", "-m", "Hello", "--message-html-file", "message.html"}}, + {name: "compose inline HTML", args: []string{"compose", "--thread-id", "7", "--message-html", "

Hello

", "--message-html-file", "message.html"}}, + {name: "reply Markdown", args: []string{"reply", "7", "-m", "Hello", "--message-html-file", "message.html"}}, + {name: "reply inline HTML", args: []string{"reply", "7", "--message-html", "

Hello

", "--message-html-file", "message.html"}}, + {name: "forward Markdown", args: []string{"forward", "7", "--to", "alex@example.com", "-m", "Hello", "--message-html-file", "message.html"}}, + {name: "forward inline HTML", args: []string{"forward", "7", "--to", "alex@example.com", "--message-html", "

Hello

", "--message-html-file", "message.html"}}, + {name: "bulk reply Markdown", args: []string{"bulk-reply", "send", "7", "-m", "Hello", "--message-html-file", "message.html"}}, + {name: "bulk reply inline HTML", args: []string{"bulk-reply", "send", "7", "--message-html", "

Hello

", "--message-html-file", "message.html"}}, + {name: "draft edit Markdown", args: []string{"draft", "edit", "7", "-m", "Hello", "--message-html-file", "message.html"}}, + {name: "draft edit inline HTML", args: []string{"draft", "edit", "7", "--message-html", "

Hello

", "--message-html-file", "message.html"}}, + } { + t.Run(test.name, func(t *testing.T) { + err := runCLI(t, server, test.args...) + if err == nil || !strings.Contains(err.Error(), "none of the others can be") { + t.Fatalf("error = %v, want mutually exclusive flags refused", err) + } + }) + } + if requests != 0 { + t.Errorf("mutually exclusive input made %d server requests, want 0", requests) + } +} diff --git a/internal/cmd/reply.go b/internal/cmd/reply.go index 67f6a4ac..f2dd4645 100644 --- a/internal/cmd/reply.go +++ b/internal/cmd/reply.go @@ -13,11 +13,12 @@ import ( ) type replyCommand struct { - cmd *cobra.Command - message string - messageHTML string - attachments []string - draft bool + cmd *cobra.Command + message string + messageHTML string + messageHTMLFile string + attachments []string + draft bool } func newReplyCommand() *replyCommand { @@ -31,10 +32,11 @@ The reply is addressed the way HEY's own web app addresses one: everyone that en addressed to, with whoever wrote it on the To line. HEY saves an unaddressed reply as a draft rather than sending it, so the command fails when it cannot work the recipients out.`, Annotations: map[string]string{ - "agent_notes": "Replies to the latest entry in a thread, addressed the way HEY addresses a reply: everyone that entry was addressed to, plus its sender on the To line, minus the acting user's own addresses. Accepts message via -m, stdin, or $EDITOR, plus repeatable --attach files; an attachment can be sent without body text. The message is Markdown; use --message-html to send raw HTML instead. --draft saves the reply as a draft — carrying those recipients — and answers the draft ID for hey draft show/edit/send/delete.", + "agent_notes": "Replies to the latest entry in a thread, addressed the way HEY addresses a reply: everyone that entry was addressed to, plus its sender on the To line, minus the acting user's own addresses. Accepts message via -m, stdin, or $EDITOR, plus repeatable --attach files; an attachment can be sent without body text. The message is Markdown; use --message-html for inline raw HTML or --message-html-file to read raw HTML verbatim from a file. --draft saves the reply as a draft — carrying those recipients — and answers the draft ID for hey draft show/edit/send/delete.", }, Example: ` hey reply 12345 -m "Friday works for me — I'll send an agenda." hey reply 12345 -m "Attached is the report." --attach ./report.pdf + hey reply 12345 --message-html-file ./reply.html hey reply 12345 -m "Drafting a longer answer — sending tomorrow." --draft echo "Longer reply from a file or a heredoc" | hey reply 12345`, RunE: replyCommand.run, @@ -43,9 +45,10 @@ draft rather than sending it, so the command fails when it cannot work the recip replyCommand.cmd.Flags().StringVarP(&replyCommand.message, "message", "m", "", "Reply message as Markdown (or opens $EDITOR)") replyCommand.cmd.Flags().StringVar(&replyCommand.messageHTML, "message-html", "", "Reply message as raw HTML instead of Markdown") + replyCommand.cmd.Flags().StringVar(&replyCommand.messageHTMLFile, "message-html-file", "", "Read the raw HTML reply body from this file") replyCommand.cmd.Flags().StringArrayVar(&replyCommand.attachments, "attach", nil, "File to attach (repeatable)") replyCommand.cmd.Flags().BoolVar(&replyCommand.draft, "draft", false, "Save as a draft instead of sending") - replyCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html") + replyCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html", "message-html-file") return replyCommand } @@ -54,6 +57,18 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { if err := requireAuth(); err != nil { return err } + message := c.messageHTML + messageHTMLFileProvided := cmd.Flags().Changed("message-html-file") + if messageHTMLFileProvided { + var readErr error + message, readErr = readMessageHTMLFile(c.messageHTMLFile) + if readErr != nil { + return readErr + } + if message == "" && len(c.attachments) == 0 { + return apierr.ErrUsage(fmt.Sprintf("HTML message file %q is empty; attach a file or provide HTML content", c.messageHTMLFile)) + } + } threadID, err := strconv.ParseInt(args[0], 10, 64) if err != nil { @@ -68,8 +83,7 @@ func (c *replyCommand) run(cmd *cobra.Command, args []string) error { } replySDK := target.client - message := c.messageHTML - if message == "" { + if message == "" && !messageHTMLFileProvided { markdownMessage := c.message if markdownMessage == "" && !stdinIsTerminal() { markdownMessage, err = readStdin() diff --git a/internal/cmd/thread_reply_test.go b/internal/cmd/thread_reply_test.go index 6447d9e3..a5686dff 100644 --- a/internal/cmd/thread_reply_test.go +++ b/internal/cmd/thread_reply_test.go @@ -8,6 +8,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "reflect" "strings" "testing" @@ -336,6 +338,23 @@ func TestReplySendsRawHTMLVerbatim(t *testing.T) { } } +func TestReplyReadsRawHTMLFromFileVerbatim(t *testing.T) { + server, sent := threadReplyServer(t, messageAddressedToJane, 11, 12) + path := filepath.Join(t.TempDir(), "reply.html") + want := "

Confirmed — Tuesday.

\n" + if err := os.WriteFile(path, []byte(want), 0o600); err != nil { + t.Fatal(err) + } + + err := runCLI(t, server, "--account", "8", "reply", "7", "--message-html-file", path) + if err != nil { + t.Fatalf("reply failed: %v", err) + } + if sent.Content != want { + t.Errorf("content = %q, want exact file bytes %q", sent.Content, want) + } +} + // runCLI drives a command the way the binary does — through the root command, so the // output writer and auth are set up — against a test server. func runCLI(t *testing.T, server *httptest.Server, args ...string) error { diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 9ab1d68e..0b408f95 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -190,6 +190,7 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box | Reply to email | `hey reply -m "Friday works for me."` | | Forward email | `hey forward --to alice@example.com -m "For your review"` | | Compose email | `hey compose --to alice@example.com --subject "Lunch plans" -m "Are you free Friday?"` | +| Compose from an HTML file | `hey compose --subject "Client invoice" --message-html-file ./invoice-email.html --attach ./invoice.pdf --draft` | | Compose with CC/BCC | `hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org --subject "Kitchen remodel timeline"` | | List drafts | `hey draft list --json` (`--all`/`--page` follow the cursor) | | Draft an email for human review | `hey compose --to alice@example.com --subject "Lunch plans" -m "Free Friday?" --draft` | @@ -285,6 +286,7 @@ Want to read email? Want to send email? ├── Reply to thread? → hey reply -m "message" │ ├── Open editor? → hey reply (omit -m to open $EDITOR) +│ ├── Use generated HTML? → add --message-html-file ./reply.html │ └── Attach files? → add --attach ./report.pdf (repeatable) ├── Reply to many threads at once? → hey bulk-reply preview ... first, then send │ └── Sent by mistake? → hey bulk-reply undo (while the window is open) @@ -292,6 +294,7 @@ Want to send email? │ └── Add a note? → add -m "note" ├── Compose new? → hey compose --to --subject "Subject" │ ├── With body? → hey compose --to --subject "Subject" -m "Body" +│ ├── With generated HTML? → add --message-html-file ./message.html │ ├── With files? → add --attach ./report.pdf (repeatable; body is optional) │ ├── With CC? → add --cc │ └── With BCC? → add --bcc @@ -299,7 +302,7 @@ Want to send email? │ └── Save one? → hey attachment save [--output ] ├── Draft instead of sending (human reviews in HEY)? → add --draft to compose or reply; the answer carries the draft id │ ├── Read it back? → hey draft show --json -│ ├── Change it? → hey draft edit --subject/--to/--cc/--bcc/-m (flags replace; omitted fields are kept) +│ ├── Change it? → hey draft edit --subject/--to/--cc/--bcc/-m/--message-html-file (flags replace; omitted fields are kept) │ ├── Deliver it? → hey draft send (recipients required) │ └── Discard it? → hey draft delete └── Check drafts? → hey draft list --json @@ -442,6 +445,7 @@ hey reply # Reply via $EDITOR hey reply -m "Here is the wiring diagram." --attach ./diagram.png hey forward --to alice@example.com # Forward the latest message hey forward --to alice@example.com -m "Please review before Thursday." +hey forward --to alice@example.com --message-html-file ./forward-note.html hey compose --to alice@example.com --subject "Lunch plans" # Body from $EDITOR hey compose --to alice@example.com --subject "Lunch plans" -m "Are you free Friday?" hey compose --to alice@example.com --subject "Q3 revenue report" --attach ./report.pdf # Attachment-only message @@ -450,6 +454,7 @@ hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org hey compose --thread-id 12345 -m "Confirmed — see you then." # Reply into an existing thread (no subject: it carries the thread's) hey compose --to alice@example.com --subject "Sprint recap" -m "We **shipped** the pagination fix." hey compose --to alice@example.com --subject "Newsletter draft" --message-html "

March

What we shipped.

" +hey compose --subject "Client invoice" --message-html-file ./invoice-email.html --attach ./invoice.pdf --draft ``` `hey reply` answers the thread's **latest** entry. HEY addresses the reply the way its own @@ -460,10 +465,14 @@ rather than guessing when it cannot work out the recipients. Everything you send is Markdown by default — `-m`, `--content`, `--note`, positional content, stdin, and `$EDITOR` alike — and is converted to rich text on the way out. To send raw HTML instead, use the flag's HTML twin: `--message-html` on `compose`, `reply`, -`forward`, and `bulk-reply send`; `--content-html` on `journal write` and -`snippet create`/`update`; `--note-html` on `contacts note set`. Each pair is mutually -exclusive. A fenced code block's language (` ```ruby `) survives the conversion, and -HEY's web app syntax-highlights it. +`forward`, `bulk-reply send`, and `draft edit`; `--content-html` on `journal write` and +`snippet create`/`update`; `--note-html` on `contacts note set`. Those five email commands +also accept `--message-html-file `, reading the file directly as raw HTML to avoid +shell quoting and argument-size problems for generated messages. Markdown, inline HTML, +and HTML-file inputs are mutually exclusive. Compose, reply, and draft edit preserve the +file bytes exactly; forward and bulk reply trim surrounding whitespace when joining the +file content to HEY's quoted or Name Tag content. A fenced code block's language +(` ```ruby `) survives the conversion, and HEY's web app syntax-highlights it. ### Email - The Screener @@ -495,6 +504,7 @@ those senders are asked about again on their next email. ```bash hey bulk-reply preview 12345 67890 --json # Read-only: threads and exact recipients hey bulk-reply send 12345 67890 -m "Thanks for the update — noted." +hey bulk-reply send 12345 67890 --message-html-file ./reply.html hey bulk-reply undo 98765 # Recall a delayed bulk reply ``` @@ -606,10 +616,12 @@ otherwise — and both take over stdout. ```bash hey compose --subject "Board update" -m "Numbers to follow." --draft # save instead of sending; answers the draft id +hey compose --subject "Client invoice" --message-html-file ./invoice-email.html --attach ./invoice.pdf --draft hey reply -m "Drafting this." --draft # save a reply draft, addressed like a real reply hey draft list --json # List drafts; --all and --page follow the next_page cursor hey draft show --json # The draft's editable state; body is Markdown hey draft edit --to alice@example.com # Each flag replaces its field; omitted flags keep the draft's +hey draft edit --message-html-file ./revised-message.html hey draft send # Deliver now (through HEY's undo window) hey draft delete [...] # Trash drafts ``` @@ -621,8 +633,9 @@ recipient requirement. **An edit is a revision, not a patch.** The CLI reads the draft first and resends the whole of it, so an omitted flag keeps that field. `--to`/`--cc`/`--bcc` replace their -entire recipient kind; an explicit empty value (`--cc ""`) clears it. Any scheduled -delivery is preserved through edits. +entire recipient kind; an explicit empty value (`--cc ""`) clears it. +`--message-html-file` replaces the complete body with the exact HTML read from a file. +Any scheduled delivery is preserved through edits. **Scheduled deliveries.** Scheduling is done in a HEY app for now; the CLI cannot set a schedule (HEY's API cannot yet name an exact instant). A draft scheduled in an app stays From b24b9e3d4016d94466c6cd47e9ecb1df7667f0fe Mon Sep 17 00:00:00 2001 From: "J.R. Lillard" Date: Tue, 25 Aug 2026 16:56:07 -0500 Subject: [PATCH 2/5] Add raw HTML output for saved drafts --- README.md | 20 +++++++------- internal/cmd/contact_note_show.go | 2 +- internal/cmd/contacts_show.go | 13 +-------- internal/cmd/draft.go | 12 ++++++--- internal/cmd/draft_test.go | 41 +++++++++++++++++++++++++++++ internal/cmd/html.go | 16 +++++++++++ internal/cmd/html_test.go | 14 +++++----- internal/cmd/journal.go | 6 +---- internal/cmd/root.go | 3 ++- internal/cmd/topic.go | 6 ++--- internal/output/writer.go | 6 ++--- skills/hey/SKILL.md | 9 ++++--- tests/smoke/draft_lifecycle_test.go | 10 +++++++ 13 files changed, 110 insertions(+), 48 deletions(-) create mode 100644 internal/cmd/html.go diff --git a/README.md b/README.md index 5c077243..a8b0384f 100644 --- a/README.md +++ b/README.md @@ -330,11 +330,11 @@ pipeable. `hey clip list --ids-only` and `--count` cover the newest page only be released SDK does not expose HEY's cursor for older clip pages. `--html` writes the original HTML, for the commands that hold some: `hey thread read`, -`hey journal read`, `hey contact show` and `hey contact note show`. It is a format of -its own — it cannot be combined with the other output flags (`--stats` included: there is -no envelope to carry stats), every other command refuses it, and it is meant for a file or -a pipe: on a terminal it is refused with the redirect spelled out, since markup on a -terminal is neither readable nor safe. +`hey draft show`, `hey journal read`, `hey contact show` and `hey contact note show`. It +is a format of its own — it cannot be combined with the other output flags (`--stats` +included: there is no envelope to carry stats), every other command refuses it, and it is +meant for a file or a pipe: on a terminal it is refused with the redirect spelled out, +since markup on a terminal is neither readable nor safe. A thread is written as one HTML5 document, so a downstream tool can parse it rather than split it: ``, ``, a `` with `` @@ -348,9 +348,10 @@ and was empty. A thread that could only be read in part is refused as for every format; with `--allow-partial` the document ends with the notice in an HTML comment (``) just before ``, and the notice goes to stderr as well. -A single body — a journal entry, a contact's note — is written as a fragment instead: the -HTML as HEY served it, nothing for an empty one. A thread has entries to frame; one body -is what gets pasted into something else. +A single body — a draft, journal entry, or contact note — is written as a fragment +instead: the HTML as HEY served it, nothing for an empty one. A draft fragment includes +the complete editable body, including attachment markup. A thread has entries to frame; +one body is what gets pasted into something else. ### Email @@ -438,6 +439,7 @@ hey compose --subject "Board update" -m "Numbers to follow." --draft # save a d hey reply 123 -m "Drafting a longer answer." --draft # save a reply draft hey draft list # list drafts (--all and --page follow HEY's cursor) hey draft show 12345 # read a draft back +hey draft show 12345 --html > draft.html # complete stored HTML, including attachment markup hey draft edit 12345 --to alice@example.com --subject "Board update (v2)" hey draft edit 12345 --message-html-file ./revised-message.html hey draft send 12345 # deliver it @@ -463,7 +465,7 @@ Email bodies come back as Markdown. `hey thread read` and the TUI render that Ma Writing is Markdown too, everywhere text goes in: `-m`, `--content`, `--note`, positional content, stdin, and `$EDITOR` (which opens prefilled with the existing entry or note as Markdown). Every such flag has a raw-HTML twin — `--message-html`, `--content-html`, `--note-html` — for sending markup verbatim. Email commands that accept `--message-html` (`compose`, `reply`, `forward`, `bulk-reply send`, and `draft edit`) also accept `--message-html-file ` and read the file directly as raw HTML, which avoids shell quoting and argument-size problems for generated messages. Markdown, inline HTML, and HTML-file inputs are mutually exclusive. Compose, reply, and draft edit preserve the file bytes exactly; forward and bulk reply use their existing HTML-prefix join, which trims surrounding whitespace before appending quoted or Name Tag content. The TUI's compose and bulk-reply forms convert Markdown the same way, and the compose editor renders it live as you type — `**bold**` turns bold, markers and all. A fenced code block's language (` ```ruby `) is carried the way HEY's own editor stores it, so the web app syntax-highlights it. -Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown, `hey draft edit` revises it (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), and `--message-html-file` replaces the complete body with the exact HTML read from a file. `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the API cannot yet name an exact instant — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides. +Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown; `--html` writes the complete stored HTML fragment, including attachment markup, to a file or pipe. `hey draft edit` revises the draft (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), and `--message-html-file` replaces the complete body with the exact HTML read from a file. `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the API cannot yet name an exact instant — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides. `hey share ` gets a sharing link for a thread. Anyone with the link can see the entire thread and future emails or replies sent to it. `hey unshare ` turns off the sharing link. diff --git a/internal/cmd/contact_note_show.go b/internal/cmd/contact_note_show.go index fd61fbd0..264114a0 100644 --- a/internal/cmd/contact_note_show.go +++ b/internal/cmd/contact_note_show.go @@ -42,7 +42,7 @@ func (c *contactNoteShowCommand) run(cmd *cobra.Command, args []string) error { return apierr.ErrNotFound("contact note", args[0]) } if writer.EffectiveFormat() == output.FormatHTML { - return writeNoteHTML(cmd.OutOrStdout(), note.NoteHtml) + return writeHTMLFragment(cmd.OutOrStdout(), note.NoteHtml) } if writer.IsStyled() { fmt.Fprintln(cmd.OutOrStdout(), renderedNote(note.Note, note.NoteHtml)) diff --git a/internal/cmd/contacts_show.go b/internal/cmd/contacts_show.go index 298731ce..98df479e 100644 --- a/internal/cmd/contacts_show.go +++ b/internal/cmd/contacts_show.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "io" "strings" "github.com/spf13/cobra" @@ -84,7 +83,7 @@ func (c *contactsShowCommand) run(cmd *cobra.Command, args []string) error { result.NoteHTML = note.NoteHtml } if writer.EffectiveFormat() == output.FormatHTML { - return writeNoteHTML(cmd.OutOrStdout(), result.NoteHTML) + return writeHTMLFragment(cmd.OutOrStdout(), result.NoteHTML) } if writer.IsStyled() { printContactDetails(cmd, result) @@ -99,16 +98,6 @@ func (c *contactsShowCommand) run(cmd *cobra.Command, args []string) error { ) } -// writeNoteHTML is what --html writes for a contact: the note's original HTML, and -// nothing at all when there is no note. -func writeNoteHTML(w io.Writer, noteHTML string) error { - if noteHTML == "" { - return nil - } - _, err := fmt.Fprintln(w, noteHTML) - return err -} - func printContactDetails(cmd *cobra.Command, result contactShowResult) { w := cmd.OutOrStdout() fmt.Fprintf(w, "%s\n", terminal.SanitizeLine(result.Name)) diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index c88177d9..339dbd96 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -19,8 +19,8 @@ import ( "github.com/basecamp/hey-cli/internal/terminal" ) -// draftOutput is what hey draft show answers with: the draft's editable state, its body -// as Markdown the way every email body leaves this CLI. +// draftOutput is what hey draft show answers with outside --html: the draft's editable +// state, its body as Markdown the way every email body leaves this CLI. type draftOutput struct { ID int64 `json:"id"` Subject string `json:"subject,omitempty"` @@ -121,10 +121,11 @@ func newDraftShowCommand() *draftShowCommand { Use: "show ", Short: "Read a draft back", Annotations: map[string]string{ - "agent_notes": "Draft IDs come from `hey draft list` or from saving with `hey compose --draft`. The body is Markdown.", + "agent_notes": "Draft IDs come from `hey draft list` or from saving with `hey compose --draft`. The body is Markdown by default; --html writes the complete stored HTML fragment instead, including Name Tag and attachment markup, and must be redirected to a file or pipe.", }, Example: ` hey draft show 12345 - hey draft show 12345 --json`, + hey draft show 12345 --json + hey draft show 12345 --html > draft.html`, RunE: showCommand.run, Args: usageExactOneArg(), } @@ -147,6 +148,9 @@ func (c *draftShowCommand) run(cmd *cobra.Command, args []string) error { if edit == nil { return apierr.ErrNotFound("draft", args[0]) } + if writer.EffectiveFormat() == output.FormatHTML { + return writeHTMLFragment(cmd.OutOrStdout(), edit.Content) + } out := draftOutputFor(draftID, edit) if writer.IsStyled() { diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go index ce5c5f96..4cb31c59 100644 --- a/internal/cmd/draft_test.go +++ b/internal/cmd/draft_test.go @@ -4,8 +4,10 @@ import ( "encoding/json" "io" "net/http" + "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "testing" ) @@ -136,6 +138,45 @@ func TestDraftShowAnswersTheEditableState(t *testing.T) { } } +func TestDraftShowHTMLAnswersTheCompleteStoredBody(t *testing.T) { + stdoutTerminal(t, false) + + for _, test := range []struct { + name string + content string + want string + }{ + { + name: "rich body with attachment markup", + content: `
Quarterly planning
`, + want: `
Quarterly planning
` + "\n", + }, + {name: "empty body"}, + } { + t.Run(test.name, func(t *testing.T) { + editJSON := `{"id":12345,"subject":"Quarterly planning","content":` + strconv.Quote(test.content) + `, + "sender":{"id":77,"email_address":"projects@example.org"},"addressed":{}}` + var writes []draftWrite + server := httptest.NewServer(draftLifecycleServer(t, editJSON, &writes)) + t.Cleanup(server.Close) + + stdout, stderr, err := runCLIRaw(t, server, "draft", "show", "12345", "--html") + if err != nil { + t.Fatalf("draft show --html: %v", err) + } + if stdout != test.want { + t.Errorf("stdout = %q, want stored HTML %q", stdout, test.want) + } + if stderr != "" { + t.Errorf("stderr = %q, want empty", stderr) + } + if len(writes) != 0 { + t.Errorf("show must not write, wrote %+v", writes) + } + }) + } +} + // An edit is a revision of the whole draft, so what is not flagged is read first and // sent back unchanged. func TestDraftEditReplacesOnlyTheFlaggedFields(t *testing.T) { diff --git a/internal/cmd/html.go b/internal/cmd/html.go new file mode 100644 index 00000000..0f1cf075 --- /dev/null +++ b/internal/cmd/html.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "fmt" + "io" +) + +// writeHTMLFragment writes one body exactly as HEY served it, with the CLI's +// trailing record newline. An absent body has no fragment and writes nothing. +func writeHTMLFragment(w io.Writer, body string) error { + if body == "" { + return nil + } + _, err := fmt.Fprintln(w, body) + return err +} diff --git a/internal/cmd/html_test.go b/internal/cmd/html_test.go index 43aad524..dab5b1f6 100644 --- a/internal/cmd/html_test.go +++ b/internal/cmd/html_test.go @@ -213,15 +213,15 @@ func TestHTMLCommentsCannotBeEndedByWhatTheyHold(t *testing.T) { } } -// A single body — a contact's note, a journal entry — is a fragment, not a document: -// the HTML as HEY served it, and nothing at all when there is none. -func TestNoteHTMLWritesNothingForAnEmptyNote(t *testing.T) { +// A single body — a draft, contact note, or journal entry — is a fragment, not a +// document: the HTML as HEY served it, and nothing at all when there is none. +func TestHTMLFragmentWritesNothingForAnEmptyBody(t *testing.T) { var out bytes.Buffer - if err := writeNoteHTML(&out, ""); err != nil || out.Len() != 0 { - t.Errorf("writeNoteHTML = %q, %v", out.String(), err) + if err := writeHTMLFragment(&out, ""); err != nil || out.Len() != 0 { + t.Errorf("writeHTMLFragment = %q, %v", out.String(), err) } - if err := writeNoteHTML(&out, "
call back
"); err != nil || out.String() != "
call back
\n" { - t.Errorf("writeNoteHTML = %q, %v", out.String(), err) + if err := writeHTMLFragment(&out, "
call back
"); err != nil || out.String() != "
call back
\n" { + t.Errorf("writeHTMLFragment = %q, %v", out.String(), err) } } diff --git a/internal/cmd/journal.go b/internal/cmd/journal.go index 6c192c44..50b36097 100644 --- a/internal/cmd/journal.go +++ b/internal/cmd/journal.go @@ -163,11 +163,7 @@ func (c *journalReadCommand) run(cmd *cobra.Command, args []string) error { // --html writes the entry's HTML and, for a day without one, nothing at all. if writer.EffectiveFormat() == output.FormatHTML { - if content == "" { - return nil - } - _, err := fmt.Fprintln(cmd.OutOrStdout(), content) - return err + return writeHTMLFragment(cmd.OutOrStdout(), content) } if content == "" { diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a6b2e245..a4dc6679 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -162,7 +162,7 @@ func newRootCmd() *cobra.Command { root.CompletionOptions.HiddenDefaultCmd = true root.PersistentFlags().BoolVar(&jsonFlag, "json", false, "Output JSON with metadata") - root.PersistentFlags().BoolVar(&htmlOutput, "html", false, "Write the original HTML to a pipe or file (thread read, journal read, contact show, contact note show)") + root.PersistentFlags().BoolVar(&htmlOutput, "html", false, "Write the original HTML to a pipe or file (thread read, draft show, journal read, contact show, contact note show)") root.PersistentFlags().BoolVar(&quietFlag, "quiet", false, "Output result data only") root.PersistentFlags().BoolVar(&idsOnly, "ids-only", false, "Output only IDs, one per line") root.PersistentFlags().BoolVar(&countFlag, "count", false, "Output only the count of results") @@ -308,6 +308,7 @@ func htmlRequested(args []string) bool { // ones --html means anything to. var htmlCommands = map[string]bool{ "hey thread read": true, + "hey draft show": true, "hey journal read": true, "hey contact show": true, "hey contact note show": true, diff --git a/internal/cmd/topic.go b/internal/cmd/topic.go index b315f128..8482bdc9 100644 --- a/internal/cmd/topic.go +++ b/internal/cmd/topic.go @@ -239,9 +239,9 @@ func writeThreadMarkdown(w io.Writer, threadID int64, entries []threadEntry, not // through as it does for every other format, ends with the notice in a comment before // , alongside the copy on stderr. // -// A thread is a document because it has entries to frame; the single-body reads — -// journal read, contacts show, contacts note show — write a fragment instead (see -// writeNoteHTML), one body as HEY served it and nothing for an empty one, because one +// A thread is a document because it has entries to frame; the single-body reads — draft +// show, journal read, contact show, contact note show — write a fragment instead (see +// writeHTMLFragment), one body as HEY served it and nothing for an empty one, because one // body is what gets pasted into something else. --stats is refused with --html like every // other selector: there is no envelope here to carry stats. // diff --git a/internal/output/writer.go b/internal/output/writer.go index 1f02b91b..0c26706a 100644 --- a/internal/output/writer.go +++ b/internal/output/writer.go @@ -31,9 +31,9 @@ const ( // FormatHTML is the raw writer behind --html: the original HTML of the one thing a // command reads, written to a pipe or a file. A thread is written as an HTML document // with an
per entry, since it has entries to frame; a single body — a - // journal entry, a contact's note — is written as a fragment, as HEY served it, since - // one body is what gets pasted elsewhere. It carries no envelope, so OK refuses it - // and --stats has nowhere to go; the commands that support it write the HTML + // draft, journal entry, or contact note — is written as a fragment, as HEY served it, + // since one body is what gets pasted elsewhere. It carries no envelope, so OK refuses + // it and --stats has nowhere to go; the commands that support it write the HTML // themselves. FormatHTML ) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 0b408f95..baf1921b 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -195,7 +195,7 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box | List drafts | `hey draft list --json` (`--all`/`--page` follow the cursor) | | Draft an email for human review | `hey compose --to alice@example.com --subject "Lunch plans" -m "Free Friday?" --draft` | | Draft a reply for human review | `hey reply -m "Drafting this." --draft` | -| Read a draft back | `hey draft show --json` | +| Read a draft back | `hey draft show --json` (`--html > draft.html` writes complete stored markup) | | Change a draft | `hey draft edit --to alice@example.com --subject "New subject"` | | Send a draft | `hey draft send ` | | Trash drafts | `hey draft delete ...` | @@ -301,7 +301,7 @@ Want to send email? ├── List files in a thread? → hey attachment list --json │ └── Save one? → hey attachment save [--output ] ├── Draft instead of sending (human reviews in HEY)? → add --draft to compose or reply; the answer carries the draft id -│ ├── Read it back? → hey draft show --json +│ ├── Read it back? → hey draft show --json (or --html > draft.html for stored markup) │ ├── Change it? → hey draft edit --subject/--to/--cc/--bcc/-m/--message-html-file (flags replace; omitted fields are kept) │ ├── Deliver it? → hey draft send (recipients required) │ └── Discard it? → hey draft delete @@ -620,6 +620,7 @@ hey compose --subject "Client invoice" --message-html-file ./invoice-email.html hey reply -m "Drafting this." --draft # save a reply draft, addressed like a real reply hey draft list --json # List drafts; --all and --page follow the next_page cursor hey draft show --json # The draft's editable state; body is Markdown +hey draft show --html > draft.html # Complete stored HTML, including attachment markup hey draft edit --to alice@example.com # Each flag replaces its field; omitted flags keep the draft's hey draft edit --message-html-file ./revised-message.html hey draft send # Deliver now (through HEY's undo window) @@ -629,7 +630,9 @@ hey draft delete [...] # Trash drafts This is the review-before-send lane: an agent prepares the email as a draft, a person reviews and sends it from any HEY app (or the agent sends it later with `hey draft send`). A draft needs no recipients until it is sent; `--draft` on `hey compose` lifts the -recipient requirement. +recipient requirement. `draft show` returns Markdown by default; `--html` writes the +complete stored body fragment, including attachment markup, and must be redirected to a +file or pipe. **An edit is a revision, not a patch.** The CLI reads the draft first and resends the whole of it, so an omitted flag keeps that field. `--to`/`--cc`/`--bcc` replace their diff --git a/tests/smoke/draft_lifecycle_test.go b/tests/smoke/draft_lifecycle_test.go index fb4e0f34..18c76f9b 100644 --- a/tests/smoke/draft_lifecycle_test.go +++ b/tests/smoke/draft_lifecycle_test.go @@ -83,6 +83,16 @@ func TestDraftLifecycle(t *testing.T) { t.Errorf("a fresh draft should have no recipients, got %v", state.To) } + // Raw output reads the same stored body without converting it to Markdown or + // wrapping it in a response envelope. + rawHTML, stderr, code := hey(t, "draft", "show", fmt.Sprintf("%d", id), "--html") + if code != 0 { + t.Fatalf("draft show --html failed (exit %d): %s", code, stderr) + } + if !strings.Contains(rawHTML, "Smoke test draft body") || !strings.Contains(rawHTML, "<") { + t.Errorf("draft show --html = %q, want the stored HTML fragment", rawHTML) + } + // Edit adds a recipient and rewrites the subject; unflagged fields survive. newSubject := subject + " (v2)" stdout, stderr, code := hey(t, "draft", "edit", fmt.Sprintf("%d", id), From c225cf36ee6a6f0a813839faaf0e6984e9556117 Mon Sep 17 00:00:00 2001 From: "J.R. Lillard" Date: Tue, 25 Aug 2026 22:21:31 -0500 Subject: [PATCH 3/5] feat: show draft attachment metadata --- README.md | 3 +- internal/cmd/draft.go | 80 +++++++++++++++++++++++++++++--------- internal/cmd/draft_test.go | 77 ++++++++++++++++++++++++++++++++++++ skills/hey/SKILL.md | 13 ++++--- 4 files changed, 149 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index a8b0384f..c030a3d0 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,7 @@ hey compose --subject "Board update" -m "Numbers to follow." --draft # save a d hey reply 123 -m "Drafting a longer answer." --draft # save a reply draft hey draft list # list drafts (--all and --page follow HEY's cursor) hey draft show 12345 # read a draft back +hey draft show 12345 --jq '.data.attachments' # filename plus available type/size metadata hey draft show 12345 --html > draft.html # complete stored HTML, including attachment markup hey draft edit 12345 --to alice@example.com --subject "Board update (v2)" hey draft edit 12345 --message-html-file ./revised-message.html @@ -465,7 +466,7 @@ Email bodies come back as Markdown. `hey thread read` and the TUI render that Ma Writing is Markdown too, everywhere text goes in: `-m`, `--content`, `--note`, positional content, stdin, and `$EDITOR` (which opens prefilled with the existing entry or note as Markdown). Every such flag has a raw-HTML twin — `--message-html`, `--content-html`, `--note-html` — for sending markup verbatim. Email commands that accept `--message-html` (`compose`, `reply`, `forward`, `bulk-reply send`, and `draft edit`) also accept `--message-html-file ` and read the file directly as raw HTML, which avoids shell quoting and argument-size problems for generated messages. Markdown, inline HTML, and HTML-file inputs are mutually exclusive. Compose, reply, and draft edit preserve the file bytes exactly; forward and bulk reply use their existing HTML-prefix join, which trims surrounding whitespace before appending quoted or Name Tag content. The TUI's compose and bulk-reply forms convert Markdown the same way, and the compose editor renders it live as you type — `**bold**` turns bold, markers and all. A fenced code block's language (` ```ruby `) is carried the way HEY's own editor stores it, so the web app syntax-highlights it. -Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown; `--html` writes the complete stored HTML fragment, including attachment markup, to a file or pipe. `hey draft edit` revises the draft (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), and `--message-html-file` replaces the complete body with the exact HTML read from a file. `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the API cannot yet name an exact instant — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides. +Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown. Its structured output has an `attachments` array with each downloadable file's name and available content type/byte size, while the styled view lists the same safe metadata; internal download URLs and signed IDs stay private. `--html` writes the complete stored HTML fragment, including attachment markup, to a file or pipe. `hey draft edit` revises the draft (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), and `--message-html-file` replaces the complete body with the exact HTML read from a file. `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the API cannot yet name an exact instant — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides. `hey share ` gets a sharing link for a thread. Anyone with the link can see the entire thread and future emails or replies sent to it. `hey unshare ` turns off the sharing link. diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index 339dbd96..1e099c72 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -19,29 +19,38 @@ import ( "github.com/basecamp/hey-cli/internal/terminal" ) +type draftAttachmentOutput struct { + Filename string `json:"filename"` + ContentType string `json:"content_type,omitempty"` + ByteSize *int64 `json:"byte_size,omitempty"` +} + // draftOutput is what hey draft show answers with outside --html: the draft's editable -// state, its body as Markdown the way every email body leaves this CLI. +// state, its body as Markdown the way every email body leaves this CLI, and safe +// metadata for the downloadable attachments in that complete stored body. type draftOutput struct { - ID int64 `json:"id"` - Subject string `json:"subject,omitempty"` - Body htmlutil.Markdown `json:"body"` - To []string `json:"to,omitempty"` - CC []string `json:"cc,omitempty"` - BCC []string `json:"bcc,omitempty"` - IsReply bool `json:"is_reply,omitempty"` - ScheduledDeliveryAt *time.Time `json:"scheduled_delivery_at,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id"` + Subject string `json:"subject,omitempty"` + Body htmlutil.Markdown `json:"body"` + Attachments []draftAttachmentOutput `json:"attachments"` + To []string `json:"to,omitempty"` + CC []string `json:"cc,omitempty"` + BCC []string `json:"bcc,omitempty"` + IsReply bool `json:"is_reply,omitempty"` + ScheduledDeliveryAt *time.Time `json:"scheduled_delivery_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` } func draftOutputFor(id int64, edit *generated.MessageEditState) draftOutput { out := draftOutput{ - ID: id, - Subject: edit.Subject, - Body: htmlutil.ToMarkdown(edit.Content), - To: addressEmails(edit.Addressed.Directly), - CC: addressEmails(edit.Addressed.Copied), - BCC: addressEmails(edit.Addressed.Blindcopied), - IsReply: edit.IsReply, + ID: id, + Subject: edit.Subject, + Body: htmlutil.ToMarkdown(edit.Content), + Attachments: draftAttachments(edit.Content), + To: addressEmails(edit.Addressed.Directly), + CC: addressEmails(edit.Addressed.Copied), + BCC: addressEmails(edit.Addressed.Blindcopied), + IsReply: edit.IsReply, } if !edit.ScheduledDeliveryAt.IsZero() { at := edit.ScheduledDeliveryAt @@ -54,6 +63,19 @@ func draftOutputFor(id int64, edit *generated.MessageEditState) draftOutput { return out } +func draftAttachments(content string) []draftAttachmentOutput { + attachments := htmlutil.ExtractAttachments(content) + out := make([]draftAttachmentOutput, len(attachments)) + for index, attachment := range attachments { + out[index] = draftAttachmentOutput{ + Filename: attachment.Filename, + ContentType: attachment.ContentType, + ByteSize: attachment.ByteSize, + } + } + return out +} + func addressEmails(contacts []generated.Contact) []string { var emails []string for _, contact := range contacts { @@ -121,10 +143,11 @@ func newDraftShowCommand() *draftShowCommand { Use: "show ", Short: "Read a draft back", Annotations: map[string]string{ - "agent_notes": "Draft IDs come from `hey draft list` or from saving with `hey compose --draft`. The body is Markdown by default; --html writes the complete stored HTML fragment instead, including Name Tag and attachment markup, and must be redirected to a file or pipe.", + "agent_notes": "Draft IDs come from `hey draft list` or from saving with `hey compose --draft`. Structured and styled output lists each downloadable attachment's filename and available type/size metadata without exposing its internal URL or signed ID. The body is Markdown by default; --html writes the complete stored HTML fragment instead, including attachment markup, and must be redirected to a file or pipe.", }, Example: ` hey draft show 12345 hey draft show 12345 --json + hey draft show 12345 --jq '.data.attachments' hey draft show 12345 --html > draft.html`, RunE: showCommand.run, Args: usageExactOneArg(), @@ -167,6 +190,12 @@ func (c *draftShowCommand) run(cmd *cobra.Command, args []string) error { if out.ScheduledDeliveryAt != nil { fmt.Fprintf(w, "Scheduled: %s\n", out.ScheduledDeliveryAt.Local().Format("2006-01-02 15:04")) } + if len(out.Attachments) > 0 { + fmt.Fprintln(w, "Attachments:") + for _, attachment := range out.Attachments { + fmt.Fprintf(w, " %s\n", formatDraftAttachment(attachment)) + } + } fmt.Fprintln(w) fmt.Fprintln(w, markdown.Render(out.Body, stdoutWidth())) return nil @@ -180,6 +209,21 @@ func (c *draftShowCommand) run(cmd *cobra.Command, args []string) error { ) } +func formatDraftAttachment(attachment draftAttachmentOutput) string { + filename := terminal.SanitizeLine(attachment.Filename) + var details []string + if attachment.ContentType != "" { + details = append(details, terminal.SanitizeLine(attachment.ContentType)) + } + if attachment.ByteSize != nil { + details = append(details, formatByteSize(*attachment.ByteSize)) + } + if len(details) == 0 { + return filename + } + return fmt.Sprintf("%s (%s)", filename, strings.Join(details, ", ")) +} + // --- edit --- type draftEditCommand struct { diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go index 4cb31c59..cc6493bc 100644 --- a/internal/cmd/draft_test.go +++ b/internal/cmd/draft_test.go @@ -133,6 +133,83 @@ func TestDraftShowAnswersTheEditableState(t *testing.T) { if len(to) != 1 || to[0] != "maria@example.com" { t.Errorf("to = %v", data["to"]) } + attachments, ok := data["attachments"].([]any) + if !ok || len(attachments) != 0 { + t.Errorf("attachments = %#v, want an empty array", data["attachments"]) + } + if len(writes) != 0 { + t.Errorf("show must not write, wrote %+v", writes) + } +} + +func TestDraftShowIncludesAttachmentMetadataWithoutInternalLocators(t *testing.T) { + content := `
Agenda to follow.
+ +
+
` + editJSON := `{"id":12345,"subject":"Quarterly planning","content":` + strconv.Quote(content) + `, + "sender":{"id":77,"email_address":"projects@example.org"},"addressed":{}}` + var writes []draftWrite + response, err := runJSONCommand(t, draftLifecycleServer(t, editJSON, &writes), + "draft", "show", "12345") + if err != nil { + t.Fatalf("draft show: %v", err) + } + + data, _ := response.Data.(map[string]any) + attachments, _ := data["attachments"].([]any) + if len(attachments) != 3 { + t.Fatalf("attachments = %#v, want three files", data["attachments"]) + } + first, _ := attachments[0].(map[string]any) + if first["filename"] != "quarterly-report.pdf" || first["content_type"] != "application/pdf" || first["byte_size"] != float64(128) { + t.Errorf("first attachment = %#v", first) + } + second, _ := attachments[1].(map[string]any) + if second["filename"] != "empty.txt" || second["byte_size"] != float64(0) { + t.Errorf("empty attachment = %#v", second) + } + third, _ := attachments[2].(map[string]any) + if third["filename"] != "unknown.bin" || third["content_type"] != "application/octet-stream" { + t.Errorf("unknown-size attachment = %#v", third) + } + if _, exists := third["byte_size"]; exists { + t.Errorf("unknown-size attachment unexpectedly has byte_size: %#v", third) + } + for index, value := range attachments { + attachment, _ := value.(map[string]any) + if _, exists := attachment["url"]; exists { + t.Errorf("attachment %d exposes url: %#v", index, attachment) + } + if _, exists := attachment["sgid"]; exists { + t.Errorf("attachment %d exposes sgid: %#v", index, attachment) + } + } + if len(writes) != 0 { + t.Errorf("show must not write, wrote %+v", writes) + } +} + +func TestDraftShowStyledListsAttachmentMetadata(t *testing.T) { + content := `
Agenda to follow.
` + editJSON := `{"id":12345,"subject":"Quarterly planning","content":` + strconv.Quote(content) + `, + "sender":{"id":77,"email_address":"projects@example.org"},"addressed":{}}` + var writes []draftWrite + server := httptest.NewServer(draftLifecycleServer(t, editJSON, &writes)) + t.Cleanup(server.Close) + + stdout, stderr, err := runCLIRaw(t, server, "--styled", "draft", "show", "12345") + if err != nil { + t.Fatalf("draft show --styled: %v", err) + } + for _, want := range []string{"Attachments:", "quarterly-report.pdf (application/pdf, 128 B)"} { + if !strings.Contains(stdout, want) { + t.Errorf("stdout = %q, want %q", stdout, want) + } + } + if stderr != "" { + t.Errorf("stderr = %q, want empty", stderr) + } if len(writes) != 0 { t.Errorf("show must not write, wrote %+v", writes) } diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index baf1921b..169c599a 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -195,7 +195,7 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box | List drafts | `hey draft list --json` (`--all`/`--page` follow the cursor) | | Draft an email for human review | `hey compose --to alice@example.com --subject "Lunch plans" -m "Free Friday?" --draft` | | Draft a reply for human review | `hey reply -m "Drafting this." --draft` | -| Read a draft back | `hey draft show --json` (`--html > draft.html` writes complete stored markup) | +| Read a draft back | `hey draft show --json` (`attachments` has safe file metadata; `--html > draft.html` writes complete stored markup) | | Change a draft | `hey draft edit --to alice@example.com --subject "New subject"` | | Send a draft | `hey draft send ` | | Trash drafts | `hey draft delete ...` | @@ -301,7 +301,7 @@ Want to send email? ├── List files in a thread? → hey attachment list --json │ └── Save one? → hey attachment save [--output ] ├── Draft instead of sending (human reviews in HEY)? → add --draft to compose or reply; the answer carries the draft id -│ ├── Read it back? → hey draft show --json (or --html > draft.html for stored markup) +│ ├── Read it back? → hey draft show --json (attachments has file metadata; --html > draft.html writes stored markup) │ ├── Change it? → hey draft edit --subject/--to/--cc/--bcc/-m/--message-html-file (flags replace; omitted fields are kept) │ ├── Deliver it? → hey draft send (recipients required) │ └── Discard it? → hey draft delete @@ -620,6 +620,7 @@ hey compose --subject "Client invoice" --message-html-file ./invoice-email.html hey reply -m "Drafting this." --draft # save a reply draft, addressed like a real reply hey draft list --json # List drafts; --all and --page follow the next_page cursor hey draft show --json # The draft's editable state; body is Markdown +hey draft show --jq '.data.attachments' # Filename plus available type/size metadata hey draft show --html > draft.html # Complete stored HTML, including attachment markup hey draft edit --to alice@example.com # Each flag replaces its field; omitted flags keep the draft's hey draft edit --message-html-file ./revised-message.html @@ -630,9 +631,11 @@ hey draft delete [...] # Trash drafts This is the review-before-send lane: an agent prepares the email as a draft, a person reviews and sends it from any HEY app (or the agent sends it later with `hey draft send`). A draft needs no recipients until it is sent; `--draft` on `hey compose` lifts the -recipient requirement. `draft show` returns Markdown by default; `--html` writes the -complete stored body fragment, including attachment markup, and must be redirected to a -file or pipe. +recipient requirement. `draft show` returns Markdown by default. Its structured output +has an `attachments` array with each downloadable file's name and available content +type/byte size, and the styled view lists the same safe metadata; internal download URLs +and signed IDs stay private. `--html` writes the complete stored body fragment, including +attachment markup, and must be redirected to a file or pipe. **An edit is a revision, not a patch.** The CLI reads the draft first and resends the whole of it, so an omitted flag keeps that field. `--to`/`--cc`/`--bcc` replace their From bea163d4ab01b97eb243fc8a0acf85a1f2e64201 Mon Sep 17 00:00:00 2001 From: "J.R. Lillard" Date: Tue, 25 Aug 2026 23:19:23 -0500 Subject: [PATCH 4/5] feat: export complete draft bundles --- .surface | 3 + README.md | 3 +- internal/cmd/draft_export.go | 480 ++++++++++++++++++++ internal/cmd/draft_export_commit_darwin.go | 9 + internal/cmd/draft_export_commit_linux.go | 9 + internal/cmd/draft_export_commit_other.go | 17 + internal/cmd/draft_export_commit_windows.go | 17 + internal/cmd/draft_export_test.go | 289 ++++++++++++ internal/cmd/drafts.go | 3 +- skills/hey/SKILL.md | 12 +- tests/smoke/draft_lifecycle_test.go | 21 + 11 files changed, 860 insertions(+), 3 deletions(-) create mode 100644 internal/cmd/draft_export.go create mode 100644 internal/cmd/draft_export_commit_darwin.go create mode 100644 internal/cmd/draft_export_commit_linux.go create mode 100644 internal/cmd/draft_export_commit_other.go create mode 100644 internal/cmd/draft_export_commit_windows.go create mode 100644 internal/cmd/draft_export_test.go diff --git a/.surface b/.surface index dee2a8bd..7ed8c203 100644 --- a/.surface +++ b/.surface @@ -142,6 +142,9 @@ hey draft edit --message-html hey draft edit --message-html-file hey draft edit --subject hey draft edit --to +hey draft export +hey draft export --force +hey draft export --output hey draft list hey draft list --all hey draft list --limit diff --git a/README.md b/README.md index c030a3d0..99b5af58 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,7 @@ hey draft list # list drafts (--all and --page follow HEY's hey draft show 12345 # read a draft back hey draft show 12345 --jq '.data.attachments' # filename plus available type/size metadata hey draft show 12345 --html > draft.html # complete stored HTML, including attachment markup +hey draft export 12345 --output ./draft-12345 # HTML, safe JSON manifest, and downloaded attachments hey draft edit 12345 --to alice@example.com --subject "Board update (v2)" hey draft edit 12345 --message-html-file ./revised-message.html hey draft send 12345 # deliver it @@ -466,7 +467,7 @@ Email bodies come back as Markdown. `hey thread read` and the TUI render that Ma Writing is Markdown too, everywhere text goes in: `-m`, `--content`, `--note`, positional content, stdin, and `$EDITOR` (which opens prefilled with the existing entry or note as Markdown). Every such flag has a raw-HTML twin — `--message-html`, `--content-html`, `--note-html` — for sending markup verbatim. Email commands that accept `--message-html` (`compose`, `reply`, `forward`, `bulk-reply send`, and `draft edit`) also accept `--message-html-file ` and read the file directly as raw HTML, which avoids shell quoting and argument-size problems for generated messages. Markdown, inline HTML, and HTML-file inputs are mutually exclusive. Compose, reply, and draft edit preserve the file bytes exactly; forward and bulk reply use their existing HTML-prefix join, which trims surrounding whitespace before appending quoted or Name Tag content. The TUI's compose and bulk-reply forms convert Markdown the same way, and the compose editor renders it live as you type — `**bold**` turns bold, markers and all. A fenced code block's language (` ```ruby `) is carried the way HEY's own editor stores it, so the web app syntax-highlights it. -Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown. Its structured output has an `attachments` array with each downloadable file's name and available content type/byte size, while the styled view lists the same safe metadata; internal download URLs and signed IDs stay private. `--html` writes the complete stored HTML fragment, including attachment markup, to a file or pipe. `hey draft edit` revises the draft (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), and `--message-html-file` replaces the complete body with the exact HTML read from a file. `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the API cannot yet name an exact instant — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides. +Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown. Its structured output has an `attachments` array with each downloadable file's name and available content type/byte size, while the styled view lists the same safe metadata; internal download URLs and signed IDs stay private. `--html` writes the complete stored HTML fragment, including attachment markup, to a file or pipe. `hey draft export --output ` reads the same exact draft into a private local bundle: byte-exact `draft.html`, safe `draft.json`, and downloaded originals under `attachments/`, with collision-safe filenames, actual byte counts, and SHA-256 hashes. The command stages and verifies the whole bundle before publishing it, never changes HEY, and preserves any existing destination. `--force` only replaces a complete export of the same draft and refuses a directory with unrecognized files. `hey draft edit` revises the draft (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), and `--message-html-file` replaces the complete body with the exact HTML read from a file. `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the API cannot yet name an exact instant — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides. `hey share ` gets a sharing link for a thread. Anyone with the link can see the entire thread and future emails or replies sent to it. `hey unshare ` turns off the sharing link. diff --git a/internal/cmd/draft_export.go b/internal/cmd/draft_export.go new file mode 100644 index 00000000..5a752fb2 --- /dev/null +++ b/internal/cmd/draft_export.go @@ -0,0 +1,480 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/apierr" + attachmentfiles "github.com/basecamp/hey-cli/internal/attachments" + "github.com/basecamp/hey-cli/internal/htmlutil" + "github.com/basecamp/hey-cli/internal/output" + "github.com/basecamp/hey-cli/internal/terminal" +) + +const ( + draftExportFormat = "hey-draft-export/v1" + draftExportManifestMaxBytes = 1 << 20 +) + +type draftExportCommand struct { + cmd *cobra.Command + output string + force bool +} + +type draftExportAttachment struct { + Position int `json:"position"` + Filename string `json:"filename"` + Path string `json:"path"` + ContentType string `json:"content_type,omitempty"` + ByteSize int64 `json:"byte_size"` + SHA256 string `json:"sha256"` +} + +type draftExportManifest struct { + Format string `json:"format"` + ExportedAt time.Time `json:"exported_at"` + DraftID int64 `json:"draft_id"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + HTMLPath string `json:"html_path"` + Attachments []draftExportAttachment `json:"attachments"` + To []string `json:"to,omitempty"` + CC []string `json:"cc,omitempty"` + BCC []string `json:"bcc,omitempty"` + From string `json:"from,omitempty"` + IsReply bool `json:"is_reply,omitempty"` + ScheduledDeliveryAt *time.Time `json:"scheduled_delivery_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` +} + +type draftExportResult struct { + DraftID int64 `json:"draft_id"` + Path string `json:"path"` + HTMLPath string `json:"html_path"` + ManifestPath string `json:"manifest_path"` + Attachments []draftExportAttachment `json:"attachments"` +} + +func newDraftExportCommand() *draftExportCommand { + exportCommand := &draftExportCommand{} + exportCommand.cmd = &cobra.Command{ + Use: "export ", + Short: "Export a complete draft bundle", + Annotations: map[string]string{ + "agent_notes": "Reads one exact draft into a private local directory containing draft.html, draft.json, and downloaded attachments. HEY is not changed. The destination must not exist; --force only replaces a complete export of the same draft. Files are staged first, and an incomplete download publishes nothing.", + }, + Example: ` hey draft export 12345 --output ./draft-12345 + hey draft export 12345 --output ./draft-12345 --force + hey draft export 12345 --output ./draft-12345 --json`, + RunE: exportCommand.run, + Args: usageExactOneArg(), + } + exportCommand.cmd.Flags().StringVarP(&exportCommand.output, "output", "o", "", "New directory for the exported draft bundle") + exportCommand.cmd.Flags().BoolVar(&exportCommand.force, "force", false, "Replace a complete export of this same draft") + _ = exportCommand.cmd.MarkFlagRequired("output") + return exportCommand +} + +func (c *draftExportCommand) run(cmd *cobra.Command, args []string) error { + if writer.RequestedFormat() == output.FormatHTML { + return apierr.ErrUsage("--html is not supported by draft export; the bundle already contains draft.html") + } + draftID, err := parseDraftID(args[0]) + if err != nil { + return err + } + destination, existed, err := preflightDraftExportDestination(c.output, draftID, c.force) + if err != nil { + return err + } + if authErr := requireAuth(); authErr != nil { + return authErr + } + + edit, err := sdk.Messages().GetEdit(cmd.Context(), draftID) + if err != nil { + return apierr.FromSDK(err) + } + if edit == nil { + return apierr.ErrNotFound("draft", args[0]) + } + + result, err := exportDraftBundle(cmd.Context(), destination, draftID, edit, existed) + if err != nil { + return err + } + if writer.IsStyled() { + fmt.Fprintf(cmd.OutOrStdout(), "Draft %d exported to %s\n", result.DraftID, terminal.SanitizeLine(result.Path)) + fmt.Fprintf(cmd.OutOrStdout(), " draft.html\n draft.json\n attachments: %d\n", len(result.Attachments)) + return nil + } + return writeOK(result, output.WithSummary(fmt.Sprintf("Draft %d exported", result.DraftID))) +} + +func preflightDraftExportDestination(outputPath string, draftID int64, force bool) (string, bool, error) { + if strings.TrimSpace(outputPath) == "" { + return "", false, apierr.ErrUsage("--output must name a new draft export directory") + } + destination, err := filepath.Abs(filepath.Clean(outputPath)) + if err != nil { + return "", false, apierr.ErrAPI(0, fmt.Sprintf("could not resolve output path: %v", err)) + } + parent := filepath.Dir(destination) + parentInfo, err := os.Stat(parent) + if err != nil { + return "", false, apierr.ErrAPI(0, fmt.Sprintf("could not inspect output parent: %v", err)) + } + if !parentInfo.IsDir() { + return "", false, apierr.ErrUsage(fmt.Sprintf("output parent is not a directory: %s", parent)) + } + + _, err = os.Lstat(destination) + switch { + case errors.Is(err, fs.ErrNotExist): + return destination, false, nil + case err != nil: + return "", false, apierr.ErrAPI(0, fmt.Sprintf("could not inspect output destination: %v", err)) + case !force: + return "", true, apierr.ErrUsage(fmt.Sprintf("destination already exists: %s (use --force to replace a complete export of this draft)", destination)) + } + if err := validateExistingDraftExport(destination, draftID); err != nil { + return "", true, err + } + return destination, true, nil +} + +func exportDraftBundle(ctx context.Context, destination string, draftID int64, edit *generated.MessageEditState, replace bool) (draftExportResult, error) { + parent := filepath.Dir(destination) + staging, err := os.MkdirTemp(parent, ".hey-draft-export-*") + if err != nil { + return draftExportResult{}, apierr.ErrAPI(0, fmt.Sprintf("could not create export staging directory: %v", err)) + } + published := false + defer func() { + if !published { + _ = os.RemoveAll(staging) + } + }() + stagingRoot, err := os.OpenRoot(staging) + if err != nil { + return draftExportResult{}, apierr.ErrAPI(0, fmt.Sprintf("could not open export staging directory: %v", err)) + } + rootOpen := true + defer func() { + if rootOpen { + _ = stagingRoot.Close() + } + }() + + if mkdirErr := stagingRoot.Mkdir("attachments", 0o700); mkdirErr != nil { + return draftExportResult{}, apierr.ErrAPI(0, fmt.Sprintf("could not create attachment staging directory: %v", mkdirErr)) + } + if writeErr := writePrivateExportFile(stagingRoot, "draft.html", []byte(edit.Content)); writeErr != nil { + return draftExportResult{}, writeErr + } + + sourceAttachments := htmlutil.ExtractAttachments(edit.Content) + exportedAttachments := make([]draftExportAttachment, 0, len(sourceAttachments)) + usedFilenames := make(map[string]struct{}, len(sourceAttachments)) + for index, attachment := range sourceAttachments { + exported, exportErr := exportDraftAttachment(ctx, stagingRoot, index+1, attachment, usedFilenames) + if exportErr != nil { + return draftExportResult{}, exportErr + } + exportedAttachments = append(exportedAttachments, exported) + } + + out := draftOutputFor(draftID, edit) + manifest := draftExportManifest{ + Format: draftExportFormat, + ExportedAt: time.Now().UTC(), + DraftID: draftID, + Subject: out.Subject, + Body: out.Body.String(), + HTMLPath: "draft.html", + Attachments: exportedAttachments, + To: out.To, + CC: out.CC, + BCC: out.BCC, + From: edit.Sender.EmailAddress, + IsReply: out.IsReply, + ScheduledDeliveryAt: out.ScheduledDeliveryAt, + UpdatedAt: out.UpdatedAt, + } + manifestJSON, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return draftExportResult{}, apierr.ErrAPI(0, fmt.Sprintf("could not encode draft manifest: %v", err)) + } + if err := writePrivateExportFile(stagingRoot, "draft.json", append(manifestJSON, '\n')); err != nil { + return draftExportResult{}, err + } + if err := stagingRoot.Close(); err != nil { + return draftExportResult{}, apierr.ErrAPI(0, fmt.Sprintf("could not close export staging directory: %v", err)) + } + rootOpen = false + + if replace { + if err := replaceDraftExportDirectory(staging, destination, draftID); err != nil { + return draftExportResult{}, err + } + } else if err := commitDraftExportDirectory(staging, destination); err != nil { + return draftExportResult{}, err + } + published = true + + return draftExportResult{ + DraftID: draftID, + Path: destination, + HTMLPath: filepath.Join(destination, "draft.html"), + ManifestPath: filepath.Join(destination, "draft.json"), + Attachments: exportedAttachments, + }, nil +} + +func exportDraftAttachment(ctx context.Context, root *os.Root, position int, attachment htmlutil.Attachment, used map[string]struct{}) (draftExportAttachment, error) { + filename, err := uniqueDraftExportFilename(attachment.Filename, used) + if err != nil { + return draftExportAttachment{}, err + } + relativePath := filepath.Join("attachments", filename) + written, err := downloadDraftExportAttachment(ctx, root, relativePath, attachment.URL) + if err != nil { + return draftExportAttachment{}, err + } + if attachment.ByteSize != nil && written != *attachment.ByteSize { + return draftExportAttachment{}, apierr.ErrAPI(0, fmt.Sprintf( + "attachment %q downloaded %d bytes; HEY reported %d", terminal.SanitizeLine(attachment.Filename), written, *attachment.ByteSize)) + } + digest, err := sha256File(root, relativePath) + if err != nil { + return draftExportAttachment{}, err + } + return draftExportAttachment{ + Position: position, + Filename: attachment.Filename, + Path: filepath.ToSlash(filepath.Join("attachments", filename)), + ContentType: attachment.ContentType, + ByteSize: written, + SHA256: digest, + }, nil +} + +func uniqueDraftExportFilename(filename string, used map[string]struct{}) (string, error) { + portable, err := attachmentfiles.PortableFilename(filename) + if err != nil { + return "", err + } + extension := filepath.Ext(portable) + stem := strings.TrimSuffix(portable, extension) + if stem == "" { + stem = portable + extension = "" + } + for sequence := 1; ; sequence++ { + candidate := portable + if sequence > 1 { + candidate = fmt.Sprintf("%s-%d%s", stem, sequence, extension) + } + key := strings.ToLower(candidate) + if _, exists := used[key]; exists { + continue + } + used[key] = struct{}{} + return candidate, nil + } +} + +func downloadDraftExportAttachment(ctx context.Context, root *os.Root, path, sourceURL string) (int64, error) { + file, err := root.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return 0, apierr.ErrAPI(0, fmt.Sprintf("could not create exported attachment: %v", err)) + } + written, _, downloadErr := sdk.DownloadBlob(ctx, sourceURL, file) + closeErr := file.Close() + if downloadErr != nil { + return written, apierr.FromSDK(downloadErr) + } + if closeErr != nil { + return written, apierr.ErrAPI(0, fmt.Sprintf("could not close exported attachment: %v", closeErr)) + } + return written, nil +} + +func writePrivateExportFile(root *os.Root, path string, data []byte) error { + file, err := root.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return apierr.ErrAPI(0, fmt.Sprintf("could not create export file: %v", err)) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + return apierr.ErrAPI(0, fmt.Sprintf("could not write export file: %v", err)) + } + if err := file.Close(); err != nil { + return apierr.ErrAPI(0, fmt.Sprintf("could not close export file: %v", err)) + } + return nil +} + +func sha256File(root *os.Root, path string) (string, error) { + file, err := root.Open(path) + if err != nil { + return "", apierr.ErrAPI(0, fmt.Sprintf("could not verify exported attachment: %v", err)) + } + hash := sha256.New() + _, copyErr := io.Copy(hash, file) + closeErr := file.Close() + if copyErr != nil { + return "", apierr.ErrAPI(0, fmt.Sprintf("could not verify exported attachment: %v", copyErr)) + } + if closeErr != nil { + return "", apierr.ErrAPI(0, fmt.Sprintf("could not close exported attachment: %v", closeErr)) + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func commitDraftExportDirectory(staging, destination string) error { + if err := commitDraftExportDirectoryNoReplace(staging, destination); err != nil { + if errors.Is(err, fs.ErrExist) { + return apierr.ErrUsage(fmt.Sprintf("destination already exists: %s (use --force to replace a complete export of this draft)", destination)) + } + return apierr.ErrAPI(0, fmt.Sprintf("could not publish draft export: %v", err)) + } + return nil +} + +func replaceDraftExportDirectory(staging, destination string, draftID int64) error { + if err := validateExistingDraftExport(destination, draftID); err != nil { + return err + } + backup, err := os.MkdirTemp(filepath.Dir(destination), ".hey-draft-export-previous-*") + if err != nil { + return apierr.ErrAPI(0, fmt.Sprintf("could not reserve prior-export path: %v", err)) + } + if err := os.Remove(backup); err != nil { + return apierr.ErrAPI(0, fmt.Sprintf("could not prepare prior-export path: %v", err)) + } + if err := commitDraftExportDirectoryNoReplace(destination, backup); err != nil { + return apierr.ErrAPI(0, fmt.Sprintf("could not preserve prior export: %v", err)) + } + if err := validateExistingDraftExport(backup, draftID); err != nil { + rollbackErr := commitDraftExportDirectoryNoReplace(backup, destination) + if rollbackErr != nil { + return apierr.ErrAPI(0, fmt.Sprintf("the destination changed while export replacement began; the moved directory remains at %s because rollback failed: %v", backup, rollbackErr)) + } + return apierr.ErrUsage("the destination changed while export replacement began; it was restored and nothing was replaced") + } + if err := commitDraftExportDirectoryNoReplace(staging, destination); err != nil { + rollbackErr := commitDraftExportDirectoryNoReplace(backup, destination) + if rollbackErr != nil { + return apierr.ErrAPI(0, fmt.Sprintf("could not publish draft export: %v; prior export remains at %s because rollback failed: %v", err, backup, rollbackErr)) + } + return apierr.ErrAPI(0, fmt.Sprintf("could not publish draft export: %v", err)) + } + if err := os.RemoveAll(backup); err != nil { + return apierr.ErrAPI(0, fmt.Sprintf("draft export was published at %s, but the prior export could not be removed from %s: %v", destination, backup, err)) + } + return nil +} + +func validateExistingDraftExport(destination string, draftID int64) error { + info, err := os.Lstat(destination) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return invalidExistingDraftExport(destination) + } + root, err := os.OpenRoot(destination) + if err != nil { + return invalidExistingDraftExport(destination) + } + defer func() { _ = root.Close() }() + manifestData, err := readLimitedRegularFile(root, "draft.json", draftExportManifestMaxBytes) + if err != nil { + return invalidExistingDraftExport(destination) + } + var manifest draftExportManifest + if unmarshalErr := json.Unmarshal(manifestData, &manifest); unmarshalErr != nil || manifest.Format != draftExportFormat || manifest.DraftID != draftID || manifest.HTMLPath != "draft.html" { + return invalidExistingDraftExport(destination) + } + + rootEntries, err := fs.ReadDir(root.FS(), ".") + if err != nil || len(rootEntries) != 3 { + return invalidExistingDraftExport(destination) + } + wantedRoot := map[string]bool{"draft.html": false, "draft.json": false, "attachments": true} + for _, entry := range rootEntries { + wantDirectory, ok := wantedRoot[entry.Name()] + if !ok { + return invalidExistingDraftExport(destination) + } + entryInfo, infoErr := entry.Info() + if infoErr != nil || entryInfo.Mode()&os.ModeSymlink != 0 || entryInfo.IsDir() != wantDirectory || (!wantDirectory && !entryInfo.Mode().IsRegular()) { + return invalidExistingDraftExport(destination) + } + } + + expectedAttachments := make(map[string]struct{}, len(manifest.Attachments)) + for _, attachment := range manifest.Attachments { + localPath := filepath.FromSlash(attachment.Path) + if filepath.IsAbs(localPath) || filepath.Dir(localPath) != "attachments" || filepath.Base(localPath) == "." || filepath.Base(localPath) == ".." { + return invalidExistingDraftExport(destination) + } + filename := filepath.Base(localPath) + if _, duplicate := expectedAttachments[filename]; duplicate { + return invalidExistingDraftExport(destination) + } + expectedAttachments[filename] = struct{}{} + } + attachmentEntries, err := fs.ReadDir(root.FS(), "attachments") + if err != nil || len(attachmentEntries) != len(expectedAttachments) { + return invalidExistingDraftExport(destination) + } + for _, entry := range attachmentEntries { + _, expected := expectedAttachments[entry.Name()] + entryInfo, infoErr := entry.Info() + if !expected || infoErr != nil || !entryInfo.Mode().IsRegular() || entryInfo.Mode()&os.ModeSymlink != 0 { + return invalidExistingDraftExport(destination) + } + } + return nil +} + +func readLimitedRegularFile(root *os.Root, path string, limit int64) ([]byte, error) { + info, err := root.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > limit { + return nil, fs.ErrInvalid + } + file, err := root.Open(path) + if err != nil { + return nil, err + } + data, readErr := io.ReadAll(io.LimitReader(file, limit+1)) + closeErr := file.Close() + if readErr != nil { + return nil, readErr + } + if closeErr != nil { + return nil, closeErr + } + if int64(len(data)) > limit { + return nil, fs.ErrInvalid + } + return data, nil +} + +func invalidExistingDraftExport(destination string) error { + return apierr.ErrUsage(fmt.Sprintf("--force only replaces a complete %s export of this same draft: %s", draftExportFormat, destination)) +} diff --git a/internal/cmd/draft_export_commit_darwin.go b/internal/cmd/draft_export_commit_darwin.go new file mode 100644 index 00000000..ba6d0daa --- /dev/null +++ b/internal/cmd/draft_export_commit_darwin.go @@ -0,0 +1,9 @@ +//go:build darwin + +package cmd + +import "golang.org/x/sys/unix" + +func commitDraftExportDirectoryNoReplace(source, destination string) error { + return unix.RenamexNp(source, destination, unix.RENAME_EXCL) +} diff --git a/internal/cmd/draft_export_commit_linux.go b/internal/cmd/draft_export_commit_linux.go new file mode 100644 index 00000000..9e670aee --- /dev/null +++ b/internal/cmd/draft_export_commit_linux.go @@ -0,0 +1,9 @@ +//go:build linux + +package cmd + +import "golang.org/x/sys/unix" + +func commitDraftExportDirectoryNoReplace(source, destination string) error { + return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE) +} diff --git a/internal/cmd/draft_export_commit_other.go b/internal/cmd/draft_export_commit_other.go new file mode 100644 index 00000000..71d143ef --- /dev/null +++ b/internal/cmd/draft_export_commit_other.go @@ -0,0 +1,17 @@ +//go:build !darwin && !linux && !windows + +package cmd + +import ( + "io/fs" + "os" +) + +func commitDraftExportDirectoryNoReplace(source, destination string) error { + if _, err := os.Lstat(destination); err == nil { + return fs.ErrExist + } else if !os.IsNotExist(err) { + return err + } + return os.Rename(source, destination) +} diff --git a/internal/cmd/draft_export_commit_windows.go b/internal/cmd/draft_export_commit_windows.go new file mode 100644 index 00000000..bd9a8b0c --- /dev/null +++ b/internal/cmd/draft_export_commit_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package cmd + +import "golang.org/x/sys/windows" + +func commitDraftExportDirectoryNoReplace(source, destination string) error { + from, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFile(from, to) +} diff --git a/internal/cmd/draft_export_test.go b/internal/cmd/draft_export_test.go new file mode 100644 index 00000000..bcd0a852 --- /dev/null +++ b/internal/cmd/draft_export_test.go @@ -0,0 +1,289 @@ +package cmd + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +type draftExportBlob struct { + data []byte + status int +} + +func draftExportServer(t *testing.T, editJSON string, blobs map[string]draftExportBlob, writes *[]draftWrite) http.Handler { + t.Helper() + draftHandler := draftLifecycleServer(t, editJSON, writes) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if blob, ok := blobs[r.URL.Path]; ok { + if r.Method != http.MethodGet { + t.Errorf("attachment request = %s %s, want GET", r.Method, r.URL.Path) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + status := blob.status + if status == 0 { + status = http.StatusOK + } + w.WriteHeader(status) + if status >= http.StatusOK && status < http.StatusMultipleChoices { + _, _ = w.Write(blob.data) + } + return + } + draftHandler.ServeHTTP(w, r) + }) +} + +func draftExportEditJSON(content string) string { + return `{"id":12345,"subject":"Quarterly planning","content":` + strconv.Quote(content) + `, + "updated_at":"2026-08-24T10:00:00Z", + "sender":{"id":77,"email_address":"projects@example.org"}, + "addressed":{"directly":[{"email_address":"maria@example.com"}], + "copied":[{"email_address":"priya@example.com"}]}}` +} + +func TestDraftExportWritesCompletePrivateBundleWithoutMailboxWrites(t *testing.T) { + firstBlob := []byte("first report") + secondBlob := []byte("second report") + content := `
Quarterly planning
+ +
` + var writes []draftWrite + handler := draftExportServer(t, draftExportEditJSON(content), map[string]draftExportBlob{ + "/rails/blobs/first": {data: firstBlob}, + "/rails/blobs/second": {data: secondBlob}, + }, &writes) + destination := filepath.Join(t.TempDir(), "draft-12345") + + response, err := runJSONCommand(t, handler, "draft", "export", "12345", "--output", destination) + if err != nil { + t.Fatalf("draft export: %v", err) + } + if len(writes) != 0 { + t.Fatalf("draft export wrote to HEY: %+v", writes) + } + data, _ := response.Data.(map[string]any) + if data["draft_id"] != float64(12345) || data["path"] != destination { + t.Errorf("result = %#v", response.Data) + } + + htmlBody, err := os.ReadFile(filepath.Join(destination, "draft.html")) + if err != nil || string(htmlBody) != content { + t.Fatalf("draft.html = %q, error = %v", htmlBody, err) + } + manifestBytes, err := os.ReadFile(filepath.Join(destination, "draft.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(manifestBytes), "/rails/blobs/") || strings.Contains(string(manifestBytes), "secret-one") || strings.Contains(string(manifestBytes), "secret-two") { + t.Errorf("draft.json exposes an internal attachment locator: %s", manifestBytes) + } + var manifest draftExportManifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + t.Fatalf("decode draft.json: %v", err) + } + if manifest.Format != draftExportFormat || manifest.DraftID != 12345 || manifest.HTMLPath != "draft.html" || manifest.ExportedAt.IsZero() { + t.Errorf("manifest header = %+v", manifest) + } + if manifest.Subject != "Quarterly planning" || !strings.Contains(manifest.Body, "Quarterly planning") || manifest.From != "projects@example.org" { + t.Errorf("manifest draft metadata = %+v", manifest) + } + if len(manifest.To) != 1 || manifest.To[0] != "maria@example.com" || len(manifest.CC) != 1 || manifest.CC[0] != "priya@example.com" { + t.Errorf("manifest recipients = to:%v cc:%v", manifest.To, manifest.CC) + } + if len(manifest.Attachments) != 2 { + t.Fatalf("attachments = %+v", manifest.Attachments) + } + for index, want := range []struct { + filename string + path string + data []byte + }{ + {filename: "../../Report.PDF", path: "attachments/Report.PDF", data: firstBlob}, + {filename: "report.pdf", path: "attachments/report-2.pdf", data: secondBlob}, + } { + attachment := manifest.Attachments[index] + digest := fmt.Sprintf("%x", sha256.Sum256(want.data)) + if attachment.Position != index+1 || attachment.Filename != want.filename || attachment.Path != want.path || attachment.ByteSize != int64(len(want.data)) || attachment.SHA256 != digest { + t.Errorf("attachment %d = %+v", index, attachment) + } + contents, readErr := os.ReadFile(filepath.Join(destination, filepath.FromSlash(want.path))) + if readErr != nil || string(contents) != string(want.data) { + t.Errorf("saved attachment %d = %q, error = %v", index, contents, readErr) + } + } + + for path, wantMode := range map[string]os.FileMode{ + destination: 0o700, + filepath.Join(destination, "attachments"): 0o700, + filepath.Join(destination, "draft.html"): 0o600, + filepath.Join(destination, "draft.json"): 0o600, + filepath.Join(destination, "attachments", "Report.PDF"): 0o600, + } { + info, statErr := os.Stat(path) + if statErr != nil { + t.Errorf("stat %s: %v", path, statErr) + continue + } + if info.Mode().Perm() != wantMode { + t.Errorf("mode for %s = %v; want %v", path, info.Mode().Perm(), wantMode) + } + } +} + +func TestDraftExportPublishesNothingWhenAnAttachmentFails(t *testing.T) { + content := ` +` + var writes []draftWrite + handler := draftExportServer(t, draftExportEditJSON(content), map[string]draftExportBlob{ + "/rails/blobs/first": {data: []byte("first")}, + "/rails/blobs/second": {status: http.StatusInternalServerError}, + }, &writes) + parent := t.TempDir() + destination := filepath.Join(parent, "draft-12345") + + if _, err := runJSONCommand(t, handler, "draft", "export", "12345", "--output", destination); err == nil { + t.Fatal("failed attachment download should fail the export") + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Errorf("failed export published a destination: %v", err) + } + entries, err := os.ReadDir(parent) + if err != nil || len(entries) != 0 { + t.Errorf("failed export left staging files: %v, error = %v", entries, err) + } + if len(writes) != 0 { + t.Errorf("failed export wrote to HEY: %+v", writes) + } +} + +func TestDraftExportRejectsReportedAttachmentSizeMismatch(t *testing.T) { + content := `` + var writes []draftWrite + handler := draftExportServer(t, draftExportEditJSON(content), map[string]draftExportBlob{ + "/rails/blobs/report": {data: []byte("short")}, + }, &writes) + parent := t.TempDir() + destination := filepath.Join(parent, "draft-12345") + + _, err := runJSONCommand(t, handler, "draft", "export", "12345", "--output", destination) + if err == nil || !strings.Contains(err.Error(), "HEY reported 99") { + t.Fatalf("size mismatch error = %v", err) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Errorf("size mismatch published a destination: %v", err) + } + entries, _ := os.ReadDir(parent) + if len(entries) != 0 { + t.Errorf("size mismatch left staging files: %v", entries) + } +} + +func TestDraftExportPreservesUnrecognizedExistingDestination(t *testing.T) { + requests := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + t.Errorf("destination preflight should not request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + }) + destination := filepath.Join(t.TempDir(), "existing") + if err := os.Mkdir(destination, 0o700); err != nil { + t.Fatal(err) + } + keep := filepath.Join(destination, "keep.txt") + if err := os.WriteFile(keep, []byte("keep me"), 0o600); err != nil { + t.Fatal(err) + } + + for _, extra := range [][]string{nil, {"--force"}} { + args := append([]string{"draft", "export", "12345", "--output", destination}, extra...) + if _, err := runJSONCommand(t, handler, args...); err == nil { + t.Errorf("existing destination with flags %v should fail", extra) + } + contents, err := os.ReadFile(keep) + if err != nil || string(contents) != "keep me" { + t.Errorf("existing destination changed to %q, error = %v", contents, err) + } + } + if requests != 0 { + t.Errorf("destination preflight made %d requests", requests) + } +} + +func TestDraftExportForceOnlyReplacesAnExactCompleteExport(t *testing.T) { + content := `
Complete body.
` + var writes []draftWrite + handler := draftExportServer(t, draftExportEditJSON(content), map[string]draftExportBlob{ + "/rails/blobs/report": {data: []byte("report")}, + }, &writes) + destination := filepath.Join(t.TempDir(), "draft-12345") + if _, err := runJSONCommand(t, handler, "draft", "export", "12345", "--output", destination); err != nil { + t.Fatal(err) + } + if _, err := runJSONCommand(t, handler, "draft", "export", "12346", "--output", destination, "--force"); err == nil { + t.Fatal("--force should refuse an export created from a different draft ID") + } + if htmlBody, err := os.ReadFile(filepath.Join(destination, "draft.html")); err != nil || string(htmlBody) != content { + t.Errorf("different-ID force changed the existing export to %q, error = %v", htmlBody, err) + } + + extraPath := filepath.Join(destination, "personal-notes.txt") + if err := os.WriteFile(extraPath, []byte("keep this"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := runJSONCommand(t, handler, "draft", "export", "12345", "--output", destination, "--force"); err == nil { + t.Fatal("--force should refuse an export directory with an unexpected file") + } + if contents, err := os.ReadFile(extraPath); err != nil || string(contents) != "keep this" { + t.Errorf("refused force changed extra file to %q, error = %v", contents, err) + } + if err := os.Remove(extraPath); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(destination, "draft.html"), []byte("old local copy"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := runJSONCommand(t, handler, "draft", "export", "12345", "--output", destination, "--force"); err != nil { + t.Fatalf("force exact export: %v", err) + } + htmlBody, err := os.ReadFile(filepath.Join(destination, "draft.html")) + if err != nil || string(htmlBody) != content { + t.Errorf("forced export body = %q, error = %v", htmlBody, err) + } + parentEntries, err := os.ReadDir(filepath.Dir(destination)) + if err != nil || len(parentEntries) != 1 || parentEntries[0].Name() != filepath.Base(destination) { + t.Errorf("force left prior/staging directories: %v, error = %v", parentEntries, err) + } + if len(writes) != 0 { + t.Errorf("force export wrote to HEY: %+v", writes) + } +} + +func TestDraftExportRejectsHTMLFormatBeforeReadingHEY(t *testing.T) { + requests := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + http.NotFound(w, r) + }) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + destination := filepath.Join(t.TempDir(), "draft-12345") + _, _, err := runCLIRaw(t, server, "draft", "export", "12345", "--output", destination, "--html") + if err == nil || !strings.Contains(err.Error(), "--html is not supported") { + t.Fatalf("--html error = %v", err) + } + if requests != 0 { + t.Errorf("--html refusal made %d requests", requests) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Errorf("--html refusal created destination: %v", err) + } +} diff --git a/internal/cmd/drafts.go b/internal/cmd/drafts.go index a0438e19..8200839f 100644 --- a/internal/cmd/drafts.go +++ b/internal/cmd/drafts.go @@ -27,11 +27,12 @@ func newDraftCommand() *cobra.Command { Use: "draft", Short: "Manage unsent drafts", Annotations: map[string]string{ - "agent_notes": "Subcommands: list, show, edit, send, delete. Draft IDs come from `hey draft list` or from `hey compose --draft`/`hey reply --draft`. list's --page continues from the next_page cursor of an earlier listing.", + "agent_notes": "Subcommands: list, show, export, edit, send, delete. Draft IDs come from `hey draft list` or from `hey compose --draft`/`hey reply --draft`. export reads one exact draft into a private local bundle without changing HEY. list's --page continues from the next_page cursor of an earlier listing.", }, } draft.AddCommand(newDraftsCommand().cmd) draft.AddCommand(newDraftShowCommand().cmd) + draft.AddCommand(newDraftExportCommand().cmd) draft.AddCommand(newDraftEditCommand().cmd) draft.AddCommand(newDraftSendCommand().cmd) draft.AddCommand(newDraftDeleteCommand().cmd) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 169c599a..01a871dc 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -27,6 +27,7 @@ triggers: - hey compose - hey draft list - hey draft show + - hey draft export - hey draft edit - hey draft send - hey draft delete @@ -196,6 +197,7 @@ notice on stderr. Both need list data, so they work on `hey box list`, `hey box | Draft an email for human review | `hey compose --to alice@example.com --subject "Lunch plans" -m "Free Friday?" --draft` | | Draft a reply for human review | `hey reply -m "Drafting this." --draft` | | Read a draft back | `hey draft show --json` (`attachments` has safe file metadata; `--html > draft.html` writes complete stored markup) | +| Export a complete draft bundle | `hey draft export --output ./draft-` (read-only to HEY; writes HTML, safe JSON, and attachments locally) | | Change a draft | `hey draft edit --to alice@example.com --subject "New subject"` | | Send a draft | `hey draft send ` | | Trash drafts | `hey draft delete ...` | @@ -302,6 +304,7 @@ Want to send email? │ └── Save one? → hey attachment save [--output ] ├── Draft instead of sending (human reviews in HEY)? → add --draft to compose or reply; the answer carries the draft id │ ├── Read it back? → hey draft show --json (attachments has file metadata; --html > draft.html writes stored markup) +│ ├── Export it locally? → hey draft export --output ./draft- │ ├── Change it? → hey draft edit --subject/--to/--cc/--bcc/-m/--message-html-file (flags replace; omitted fields are kept) │ ├── Deliver it? → hey draft send (recipients required) │ └── Discard it? → hey draft delete @@ -622,6 +625,7 @@ hey draft list --json # List drafts; --all and --pag hey draft show --json # The draft's editable state; body is Markdown hey draft show --jq '.data.attachments' # Filename plus available type/size metadata hey draft show --html > draft.html # Complete stored HTML, including attachment markup +hey draft export --output ./draft- # Private local HTML/JSON/attachment bundle hey draft edit --to alice@example.com # Each flag replaces its field; omitted flags keep the draft's hey draft edit --message-html-file ./revised-message.html hey draft send # Deliver now (through HEY's undo window) @@ -635,7 +639,13 @@ recipient requirement. `draft show` returns Markdown by default. Its structured has an `attachments` array with each downloadable file's name and available content type/byte size, and the styled view lists the same safe metadata; internal download URLs and signed IDs stay private. `--html` writes the complete stored body fragment, including -attachment markup, and must be redirected to a file or pipe. +attachment markup, and must be redirected to a file or pipe. `draft export` is read-only +to HEY and writes a private local directory containing byte-exact `draft.html`, safe +`draft.json`, and downloaded originals under `attachments/`. It stages and verifies +every file before publishing the directory, resolves filename collisions portably, +records actual byte counts and SHA-256 hashes, and exposes no download URL or SGID. The +destination must not exist. `--force` only replaces a complete export of the same draft +and refuses unexpected files. **An edit is a revision, not a patch.** The CLI reads the draft first and resends the whole of it, so an omitted flag keeps that field. `--to`/`--cc`/`--bcc` replace their diff --git a/tests/smoke/draft_lifecycle_test.go b/tests/smoke/draft_lifecycle_test.go index 18c76f9b..fb71ca96 100644 --- a/tests/smoke/draft_lifecycle_test.go +++ b/tests/smoke/draft_lifecycle_test.go @@ -3,6 +3,8 @@ package smoke_test import ( "encoding/json" "fmt" + "os" + "path/filepath" "strings" "testing" ) @@ -93,6 +95,25 @@ func TestDraftLifecycle(t *testing.T) { t.Errorf("draft show --html = %q, want the stored HTML fragment", rawHTML) } + // Export reads the same draft into a complete local bundle without changing HEY. + exportDirectory := filepath.Join(t.TempDir(), "draft-export") + _, stderr, code = hey(t, "draft", "export", fmt.Sprintf("%d", id), "--output", exportDirectory, "--json") + if code != 0 { + t.Fatalf("draft export failed (exit %d): %s", code, stderr) + } + exportedHTML, err := os.ReadFile(filepath.Join(exportDirectory, "draft.html")) + if err != nil || !strings.Contains(string(exportedHTML), "Smoke test draft body") { + t.Errorf("exported draft.html = %q, error = %v", exportedHTML, err) + } + manifest, err := os.ReadFile(filepath.Join(exportDirectory, "draft.json")) + if err != nil || !strings.Contains(string(manifest), `"format": "hey-draft-export/v1"`) { + t.Errorf("exported draft.json = %q, error = %v", manifest, err) + } + attachmentEntries, err := os.ReadDir(filepath.Join(exportDirectory, "attachments")) + if err != nil || len(attachmentEntries) != 0 { + t.Errorf("fresh draft export attachments = %v, error = %v", attachmentEntries, err) + } + // Edit adds a recipient and rewrites the subject; unflagged fields survive. newSubject := subject + " (v2)" stdout, stderr, code := hey(t, "draft", "edit", fmt.Sprintf("%d", id), From 510126f3ae508ed2dc5b4e7b0b4cd9cc626302ae Mon Sep 17 00:00:00 2001 From: "J.R. Lillard" Date: Wed, 26 Aug 2026 09:58:41 -0500 Subject: [PATCH 5/5] Harden draft export fidelity and replacement --- README.md | 2 +- go.mod | 2 +- internal/cmd/draft.go | 2 +- internal/cmd/draft_export.go | 113 ++++++++++++++++--- internal/cmd/draft_export_commit_darwin.go | 14 ++- internal/cmd/draft_export_commit_linux.go | 14 ++- internal/cmd/draft_export_commit_other.go | 4 + internal/cmd/draft_export_commit_windows.go | 4 + internal/cmd/draft_export_test.go | 115 ++++++++++++++++++++ internal/cmd/draft_test.go | 7 +- internal/cmd/html.go | 8 ++ skills/hey/SKILL.md | 5 +- 12 files changed, 265 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 99b5af58..d3eee8c1 100644 --- a/README.md +++ b/README.md @@ -467,7 +467,7 @@ Email bodies come back as Markdown. `hey thread read` and the TUI render that Ma Writing is Markdown too, everywhere text goes in: `-m`, `--content`, `--note`, positional content, stdin, and `$EDITOR` (which opens prefilled with the existing entry or note as Markdown). Every such flag has a raw-HTML twin — `--message-html`, `--content-html`, `--note-html` — for sending markup verbatim. Email commands that accept `--message-html` (`compose`, `reply`, `forward`, `bulk-reply send`, and `draft edit`) also accept `--message-html-file ` and read the file directly as raw HTML, which avoids shell quoting and argument-size problems for generated messages. Markdown, inline HTML, and HTML-file inputs are mutually exclusive. Compose, reply, and draft edit preserve the file bytes exactly; forward and bulk reply use their existing HTML-prefix join, which trims surrounding whitespace before appending quoted or Name Tag content. The TUI's compose and bulk-reply forms convert Markdown the same way, and the compose editor renders it live as you type — `**bold**` turns bold, markers and all. A fenced code block's language (` ```ruby `) is carried the way HEY's own editor stores it, so the web app syntax-highlights it. -Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown. Its structured output has an `attachments` array with each downloadable file's name and available content type/byte size, while the styled view lists the same safe metadata; internal download URLs and signed IDs stay private. `--html` writes the complete stored HTML fragment, including attachment markup, to a file or pipe. `hey draft export --output ` reads the same exact draft into a private local bundle: byte-exact `draft.html`, safe `draft.json`, and downloaded originals under `attachments/`, with collision-safe filenames, actual byte counts, and SHA-256 hashes. The command stages and verifies the whole bundle before publishing it, never changes HEY, and preserves any existing destination. `--force` only replaces a complete export of the same draft and refuses a directory with unrecognized files. `hey draft edit` revises the draft (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), and `--message-html-file` replaces the complete body with the exact HTML read from a file. `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the API cannot yet name an exact instant — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides. +Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --draft`) saves instead of sending — recipients optional on a draft — and answers the draft's ID. `hey draft show` reads it back with the body as Markdown. Its structured output has an `attachments` array with each downloadable file's name and available content type/byte size, while the styled view lists the same safe metadata; internal download URLs and signed IDs stay private. `--html` writes the byte-exact stored HTML fragment, including attachment markup, to a file or pipe. `hey draft export --output ` reads the same exact draft into a private local bundle: byte-exact `draft.html`, safe `draft.json`, and downloaded originals under `attachments/`, with collision-safe filenames, actual byte counts, and SHA-256 hashes. The command stages and verifies the whole bundle before publishing it, never changes HEY, and preserves any existing destination. `--force` only replaces a complete export of the same draft and refuses a directory with unrecognized files; it uses atomic directory exchange where the platform supports it and otherwise repairs an interrupted replacement on the next invocation before proceeding. `hey draft edit` revises the draft (each flag replaces its field; what is not flagged is kept, by reading the draft and resending the whole of it, since a revision is not a patch on HEY's side), and `--message-html-file` replaces the complete body with the exact HTML read from a file. `hey draft send` delivers through HEY's undo window, and `hey draft delete` trashes it. Scheduling a delivery is done in a HEY app for now — the API cannot yet name an exact instant — and a schedule set there survives CLI edits untouched. A draft prepared here is reviewed and sent from any HEY app, which is the workflow this is for: an agent writes, a person decides. `hey share ` gets a sharing link for a thread. Anyone with the link can see the entire thread and future emails or replies sent to it. `hey unshare ` turns off the sharing link. diff --git a/go.mod b/go.mod index 86649ba8..44487403 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 + golang.org/x/text v0.41.0 ) require ( @@ -111,7 +112,6 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect google.golang.org/grpc v1.82.1 // indirect diff --git a/internal/cmd/draft.go b/internal/cmd/draft.go index 1e099c72..953eff2e 100644 --- a/internal/cmd/draft.go +++ b/internal/cmd/draft.go @@ -172,7 +172,7 @@ func (c *draftShowCommand) run(cmd *cobra.Command, args []string) error { return apierr.ErrNotFound("draft", args[0]) } if writer.EffectiveFormat() == output.FormatHTML { - return writeHTMLFragment(cmd.OutOrStdout(), edit.Content) + return writeExactHTMLFragment(cmd.OutOrStdout(), edit.Content) } out := draftOutputFor(draftID, edit) diff --git a/internal/cmd/draft_export.go b/internal/cmd/draft_export.go index 5a752fb2..59102e15 100644 --- a/internal/cmd/draft_export.go +++ b/internal/cmd/draft_export.go @@ -15,6 +15,8 @@ import ( "time" "github.com/spf13/cobra" + "golang.org/x/text/cases" + "golang.org/x/text/unicode/norm" "github.com/basecamp/hey-sdk/go/pkg/generated" @@ -142,6 +144,9 @@ func preflightDraftExportDestination(outputPath string, draftID int64, force boo if !parentInfo.IsDir() { return "", false, apierr.ErrUsage(fmt.Sprintf("output parent is not a directory: %s", parent)) } + if recoveryErr := recoverDraftExportReplacement(destination, draftID); recoveryErr != nil { + return "", false, recoveryErr + } _, err = os.Lstat(destination) switch { @@ -216,7 +221,7 @@ func exportDraftBundle(ctx context.Context, destination string, draftID int64, e ScheduledDeliveryAt: out.ScheduledDeliveryAt, UpdatedAt: out.UpdatedAt, } - manifestJSON, err := json.MarshalIndent(manifest, "", " ") + manifestJSON, err := output.MarshalIndentJSON(manifest) if err != nil { return draftExportResult{}, apierr.ErrAPI(0, fmt.Sprintf("could not encode draft manifest: %v", err)) } @@ -290,7 +295,7 @@ func uniqueDraftExportFilename(filename string, used map[string]struct{}) (strin if sequence > 1 { candidate = fmt.Sprintf("%s-%d%s", stem, sequence, extension) } - key := strings.ToLower(candidate) + key := draftExportFilenameKey(candidate) if _, exists := used[key]; exists { continue } @@ -299,6 +304,14 @@ func uniqueDraftExportFilename(filename string, used map[string]struct{}) (strin } } +func draftExportFilenameKey(filename string) string { + // Portable bundles must survive case-insensitive and normalization-insensitive + // filesystems. Full Unicode folding is deliberately conservative: assigning a + // suffix where one filesystem would allow both names is safer than discovering + // the collision while the second attachment is being created. + return norm.NFC.String(cases.Fold().String(norm.NFC.String(filename))) +} + func downloadDraftExportAttachment(ctx context.Context, root *os.Root, path, sourceURL string) (int64, error) { file, err := root.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { @@ -361,32 +374,104 @@ func replaceDraftExportDirectory(staging, destination string, draftID int64) err if err := validateExistingDraftExport(destination, draftID); err != nil { return err } - backup, err := os.MkdirTemp(filepath.Dir(destination), ".hey-draft-export-previous-*") + + exchanged, err := exchangeDraftExportDirectories(staging, destination) if err != nil { - return apierr.ErrAPI(0, fmt.Sprintf("could not reserve prior-export path: %v", err)) + return apierr.ErrAPI(0, fmt.Sprintf("could not atomically replace draft export: %v", err)) + } + if exchanged { + // The prior export is now at staging. Revalidate it after the exchange so a + // destination changed between preflight and publication is restored rather + // than silently discarded. + if err := validateExistingDraftExport(staging, draftID); err != nil { + restored, rollbackErr := exchangeDraftExportDirectories(staging, destination) + if rollbackErr != nil || !restored { + return apierr.ErrAPI(0, fmt.Sprintf( + "the destination changed while export replacement began; atomic rollback failed: %v", rollbackErr)) + } + return apierr.ErrUsage("the destination changed while export replacement began; it was restored and nothing was replaced") + } + if err := os.RemoveAll(staging); err != nil { + return apierr.ErrAPI(0, fmt.Sprintf( + "draft export was published at %s, but the prior export could not be removed from %s: %v", destination, staging, err)) + } + return nil } - if err := os.Remove(backup); err != nil { - return apierr.ErrAPI(0, fmt.Sprintf("could not prepare prior-export path: %v", err)) + + return replaceDraftExportDirectoryRecoverable(staging, destination, draftID) +} + +func replaceDraftExportDirectoryRecoverable(staging, destination string, draftID int64) error { + if err := recoverDraftExportReplacement(destination, draftID); err != nil { + return err } - if err := commitDraftExportDirectoryNoReplace(destination, backup); err != nil { + recovery := draftExportRecoveryPath(destination) + if err := commitDraftExportDirectoryNoReplace(destination, recovery); err != nil { return apierr.ErrAPI(0, fmt.Sprintf("could not preserve prior export: %v", err)) } - if err := validateExistingDraftExport(backup, draftID); err != nil { - rollbackErr := commitDraftExportDirectoryNoReplace(backup, destination) + if err := validateExistingDraftExport(recovery, draftID); err != nil { + rollbackErr := commitDraftExportDirectoryNoReplace(recovery, destination) if rollbackErr != nil { - return apierr.ErrAPI(0, fmt.Sprintf("the destination changed while export replacement began; the moved directory remains at %s because rollback failed: %v", backup, rollbackErr)) + return apierr.ErrAPI(0, fmt.Sprintf("the destination changed while export replacement began; the moved directory remains at %s because rollback failed: %v", recovery, rollbackErr)) } return apierr.ErrUsage("the destination changed while export replacement began; it was restored and nothing was replaced") } if err := commitDraftExportDirectoryNoReplace(staging, destination); err != nil { - rollbackErr := commitDraftExportDirectoryNoReplace(backup, destination) + rollbackErr := commitDraftExportDirectoryNoReplace(recovery, destination) if rollbackErr != nil { - return apierr.ErrAPI(0, fmt.Sprintf("could not publish draft export: %v; prior export remains at %s because rollback failed: %v", err, backup, rollbackErr)) + return apierr.ErrAPI(0, fmt.Sprintf("could not publish draft export: %v; prior export remains at %s because rollback failed: %v", err, recovery, rollbackErr)) } return apierr.ErrAPI(0, fmt.Sprintf("could not publish draft export: %v", err)) } - if err := os.RemoveAll(backup); err != nil { - return apierr.ErrAPI(0, fmt.Sprintf("draft export was published at %s, but the prior export could not be removed from %s: %v", destination, backup, err)) + if err := os.RemoveAll(recovery); err != nil { + return apierr.ErrAPI(0, fmt.Sprintf("draft export was published at %s, but the prior export could not be removed from %s: %v", destination, recovery, err)) + } + return nil +} + +func draftExportRecoveryPath(destination string) string { + digest := sha256.Sum256([]byte(destination)) + return filepath.Join(filepath.Dir(destination), fmt.Sprintf(".hey-draft-export-recovery-%x", digest[:8])) +} + +// recoverDraftExportReplacement repairs the two durable states a non-atomic +// platform can leave behind: the prior export moved aside before publication, +// or both the published replacement and the prior export present before cleanup. +// The recovery path is deterministic so the next invocation finds it without a +// directory scan. Nothing unrecognized is removed. +func recoverDraftExportReplacement(destination string, draftID int64) error { + recovery := draftExportRecoveryPath(destination) + if _, err := os.Lstat(recovery); errors.Is(err, fs.ErrNotExist) { + return nil + } else if err != nil { + return apierr.ErrAPI(0, fmt.Sprintf("could not inspect draft export replacement recovery: %v", err)) + } + + _, destinationErr := os.Lstat(destination) + switch { + case errors.Is(destinationErr, fs.ErrNotExist): + if err := validateExistingDraftExport(recovery, draftID); err != nil { + return apierr.ErrUsage(fmt.Sprintf( + "an interrupted draft export replacement left no destination and the recovery at %s is not a complete export of this draft; nothing was changed", recovery)) + } + if err := commitDraftExportDirectoryNoReplace(recovery, destination); err != nil { + return apierr.ErrAPI(0, fmt.Sprintf("could not restore the prior draft export from %s: %v", recovery, err)) + } + return nil + case destinationErr != nil: + return apierr.ErrAPI(0, fmt.Sprintf("could not inspect output destination during replacement recovery: %v", destinationErr)) + } + + if err := validateExistingDraftExport(destination, draftID); err != nil { + return apierr.ErrUsage(fmt.Sprintf( + "an interrupted draft export replacement left recovery data at %s, but the destination is not a complete export of this draft; nothing was changed", recovery)) + } + if err := validateExistingDraftExport(recovery, draftID); err != nil { + return apierr.ErrUsage(fmt.Sprintf( + "an interrupted draft export replacement left unrecognized recovery data at %s; nothing was removed", recovery)) + } + if err := os.RemoveAll(recovery); err != nil { + return apierr.ErrAPI(0, fmt.Sprintf("could not remove the recovered prior draft export at %s: %v", recovery, err)) } return nil } diff --git a/internal/cmd/draft_export_commit_darwin.go b/internal/cmd/draft_export_commit_darwin.go index ba6d0daa..63d7d819 100644 --- a/internal/cmd/draft_export_commit_darwin.go +++ b/internal/cmd/draft_export_commit_darwin.go @@ -2,8 +2,20 @@ package cmd -import "golang.org/x/sys/unix" +import ( + "errors" + + "golang.org/x/sys/unix" +) func commitDraftExportDirectoryNoReplace(source, destination string) error { return unix.RenamexNp(source, destination, unix.RENAME_EXCL) } + +func exchangeDraftExportDirectories(source, destination string) (bool, error) { + err := unix.RenamexNp(source, destination, unix.RENAME_SWAP) + if errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOSYS) { + return false, nil + } + return err == nil, err +} diff --git a/internal/cmd/draft_export_commit_linux.go b/internal/cmd/draft_export_commit_linux.go index 9e670aee..f0babd43 100644 --- a/internal/cmd/draft_export_commit_linux.go +++ b/internal/cmd/draft_export_commit_linux.go @@ -2,8 +2,20 @@ package cmd -import "golang.org/x/sys/unix" +import ( + "errors" + + "golang.org/x/sys/unix" +) func commitDraftExportDirectoryNoReplace(source, destination string) error { return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE) } + +func exchangeDraftExportDirectories(source, destination string) (bool, error) { + err := unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_EXCHANGE) + if errors.Is(err, unix.EOPNOTSUPP) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOSYS) { + return false, nil + } + return err == nil, err +} diff --git a/internal/cmd/draft_export_commit_other.go b/internal/cmd/draft_export_commit_other.go index 71d143ef..2707e1f1 100644 --- a/internal/cmd/draft_export_commit_other.go +++ b/internal/cmd/draft_export_commit_other.go @@ -15,3 +15,7 @@ func commitDraftExportDirectoryNoReplace(source, destination string) error { } return os.Rename(source, destination) } + +func exchangeDraftExportDirectories(_, _ string) (bool, error) { + return false, nil +} diff --git a/internal/cmd/draft_export_commit_windows.go b/internal/cmd/draft_export_commit_windows.go index bd9a8b0c..1d22d78f 100644 --- a/internal/cmd/draft_export_commit_windows.go +++ b/internal/cmd/draft_export_commit_windows.go @@ -15,3 +15,7 @@ func commitDraftExportDirectoryNoReplace(source, destination string) error { } return windows.MoveFile(from, to) } + +func exchangeDraftExportDirectories(_, _ string) (bool, error) { + return false, nil +} diff --git a/internal/cmd/draft_export_test.go b/internal/cmd/draft_export_test.go index bcd0a852..95705652 100644 --- a/internal/cmd/draft_export_test.go +++ b/internal/cmd/draft_export_test.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "crypto/sha256" "encoding/json" "fmt" @@ -139,6 +140,40 @@ func TestDraftExportWritesCompletePrivateBundleWithoutMailboxWrites(t *testing.T } } +func TestDraftExportManifestEscapesC1Controls(t *testing.T) { + subject := "Quarterly \u009b planning" + content := "
Agenda.
" + editJSON := `{"id":12345,"subject":` + strconv.Quote(subject) + `,"content":` + strconv.Quote(content) + `, + "sender":{"id":77,"email_address":"projects@example.org"},"addressed":{}}` + var writes []draftWrite + destination := filepath.Join(t.TempDir(), "draft-12345") + + if _, err := runJSONCommand(t, draftExportServer(t, editJSON, nil, &writes), + "draft", "export", "12345", "--output", destination); err != nil { + t.Fatal(err) + } + manifestBytes, err := os.ReadFile(filepath.Join(destination, "draft.json")) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(manifestBytes, []byte{0xc2, 0x9b}) { + t.Fatalf("draft.json contains a raw C1 control: %q", manifestBytes) + } + if !bytes.Contains(manifestBytes, []byte(`\u009b`)) { + t.Fatalf("draft.json = %q, want an escaped C1 control", manifestBytes) + } + var manifest draftExportManifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + t.Fatal(err) + } + if manifest.Subject != subject { + t.Errorf("decoded subject = %q, want lossless %q", manifest.Subject, subject) + } + if len(writes) != 0 { + t.Errorf("draft export wrote to HEY: %+v", writes) + } +} + func TestDraftExportPublishesNothingWhenAnAttachmentFails(t *testing.T) { content := ` ` @@ -187,6 +222,28 @@ func TestDraftExportRejectsReportedAttachmentSizeMismatch(t *testing.T) { } } +func TestUniqueDraftExportFilenameNormalizesPortableCollisions(t *testing.T) { + used := make(map[string]struct{}) + tests := []struct { + filename string + want string + }{ + {filename: "Café.txt", want: "Café.txt"}, + {filename: "Cafe\u0301.txt", want: "Cafe\u0301-2.txt"}, + {filename: "STRASSE.txt", want: "STRASSE.txt"}, + {filename: "straße.txt", want: "straße-2.txt"}, + } + for _, tt := range tests { + got, err := uniqueDraftExportFilename(tt.filename, used) + if err != nil { + t.Fatalf("unique filename for %q: %v", tt.filename, err) + } + if got != tt.want { + t.Errorf("unique filename for %q = %q, want %q", tt.filename, got, tt.want) + } + } +} + func TestDraftExportPreservesUnrecognizedExistingDestination(t *testing.T) { requests := 0 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -267,6 +324,64 @@ func TestDraftExportForceOnlyReplacesAnExactCompleteExport(t *testing.T) { } } +func TestDraftExportPreflightRecoversInterruptedForceReplacement(t *testing.T) { + var writes []draftWrite + parent := t.TempDir() + destination := filepath.Join(parent, "draft-12345") + replacement := filepath.Join(parent, "replacement-12345") + priorContent := "
Prior complete body.
" + replacementContent := "
Replacement complete body.
" + + if _, err := runJSONCommand(t, draftExportServer(t, draftExportEditJSON(priorContent), nil, &writes), + "draft", "export", "12345", "--output", destination); err != nil { + t.Fatal(err) + } + if _, err := runJSONCommand(t, draftExportServer(t, draftExportEditJSON(replacementContent), nil, &writes), + "draft", "export", "12345", "--output", replacement); err != nil { + t.Fatal(err) + } + + recovery := draftExportRecoveryPath(destination) + if err := commitDraftExportDirectoryNoReplace(destination, recovery); err != nil { + t.Fatal(err) + } + if _, _, err := preflightDraftExportDestination(destination, 12345, false); err == nil { + t.Fatal("a recovered existing destination should still require --force") + } + if err := validateExistingDraftExport(destination, 12345); err != nil { + t.Fatalf("missing destination was not restored: %v", err) + } + if body, err := os.ReadFile(filepath.Join(destination, "draft.html")); err != nil || string(body) != priorContent { + t.Fatalf("restored body = %q, error = %v", body, err) + } + if _, err := os.Lstat(recovery); !os.IsNotExist(err) { + t.Fatalf("restored replacement left recovery data: %v", err) + } + + if err := commitDraftExportDirectoryNoReplace(destination, recovery); err != nil { + t.Fatal(err) + } + if err := commitDraftExportDirectoryNoReplace(replacement, destination); err != nil { + t.Fatal(err) + } + resolved, existed, err := preflightDraftExportDestination(destination, 12345, true) + if err != nil { + t.Fatalf("clean published replacement recovery: %v", err) + } + if resolved != destination || !existed { + t.Errorf("preflight = %q, existed %v", resolved, existed) + } + if body, err := os.ReadFile(filepath.Join(destination, "draft.html")); err != nil || string(body) != replacementContent { + t.Fatalf("published replacement body = %q, error = %v", body, err) + } + if _, err := os.Lstat(recovery); !os.IsNotExist(err) { + t.Fatalf("published replacement left recovery data: %v", err) + } + if len(writes) != 0 { + t.Errorf("draft export wrote to HEY: %+v", writes) + } +} + func TestDraftExportRejectsHTMLFormatBeforeReadingHEY(t *testing.T) { requests := 0 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/cmd/draft_test.go b/internal/cmd/draft_test.go index cc6493bc..b401fe0c 100644 --- a/internal/cmd/draft_test.go +++ b/internal/cmd/draft_test.go @@ -221,13 +221,12 @@ func TestDraftShowHTMLAnswersTheCompleteStoredBody(t *testing.T) { for _, test := range []struct { name string content string - want string }{ { name: "rich body with attachment markup", content: `
Quarterly planning
`, - want: `
Quarterly planning
` + "\n", }, + {name: "stored trailing newline", content: "
Agenda.
\n"}, {name: "empty body"}, } { t.Run(test.name, func(t *testing.T) { @@ -241,8 +240,8 @@ func TestDraftShowHTMLAnswersTheCompleteStoredBody(t *testing.T) { if err != nil { t.Fatalf("draft show --html: %v", err) } - if stdout != test.want { - t.Errorf("stdout = %q, want stored HTML %q", stdout, test.want) + if stdout != test.content { + t.Errorf("stdout = %q, want byte-exact stored HTML %q", stdout, test.content) } if stderr != "" { t.Errorf("stderr = %q, want empty", stderr) diff --git a/internal/cmd/html.go b/internal/cmd/html.go index 0f1cf075..5375d29e 100644 --- a/internal/cmd/html.go +++ b/internal/cmd/html.go @@ -14,3 +14,11 @@ func writeHTMLFragment(w io.Writer, body string) error { _, err := fmt.Fprintln(w, body) return err } + +// writeExactHTMLFragment writes one body byte-for-byte as HEY served it. Draft +// HTML is also the source for exact local exports, so it carries no record +// newline beyond any newline already present in the stored body. +func writeExactHTMLFragment(w io.Writer, body string) error { + _, err := io.WriteString(w, body) + return err +} diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 01a871dc..f4fef705 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -638,14 +638,15 @@ A draft needs no recipients until it is sent; `--draft` on `hey compose` lifts t recipient requirement. `draft show` returns Markdown by default. Its structured output has an `attachments` array with each downloadable file's name and available content type/byte size, and the styled view lists the same safe metadata; internal download URLs -and signed IDs stay private. `--html` writes the complete stored body fragment, including +and signed IDs stay private. `--html` writes the byte-exact stored body fragment, including attachment markup, and must be redirected to a file or pipe. `draft export` is read-only to HEY and writes a private local directory containing byte-exact `draft.html`, safe `draft.json`, and downloaded originals under `attachments/`. It stages and verifies every file before publishing the directory, resolves filename collisions portably, records actual byte counts and SHA-256 hashes, and exposes no download URL or SGID. The destination must not exist. `--force` only replaces a complete export of the same draft -and refuses unexpected files. +and refuses unexpected files. Replacement uses atomic directory exchange where the +platform supports it and otherwise repairs an interrupted replacement on the next run. **An edit is a revision, not a patch.** The CLI reads the draft first and resends the whole of it, so an omitted flag keeps that field. `--to`/`--cc`/`--bcc` replace their