diff --git a/.surface b/.surface
index bf681bab..7ed8c203 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,8 +139,12 @@ 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 export
+hey draft export --force
+hey draft export --output
hey draft list
hey draft list --all
hey draft list --limit
@@ -198,6 +204,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 +266,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..d3eee8c1 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
@@ -433,11 +434,16 @@ 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 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
hey draft delete 12345 # trash it
hey seen 12345 # mark a thread as seen
@@ -459,9 +465,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. 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.
@@ -471,7 +477,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/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/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 := "
"
+ 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/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 49813271..953eff2e 100644
--- a/internal/cmd/draft.go
+++ b/internal/cmd/draft.go
@@ -19,29 +19,38 @@ 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.
+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, 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,12 @@ 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`. 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 --json
+ hey draft show 12345 --jq '.data.attachments'
+ hey draft show 12345 --html > draft.html`,
RunE: showCommand.run,
Args: usageExactOneArg(),
}
@@ -147,6 +171,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 writeExactHTMLFragment(cmd.OutOrStdout(), edit.Content)
+ }
out := draftOutputFor(draftID, edit)
if writer.IsStyled() {
@@ -163,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
@@ -176,16 +209,32 @@ 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 {
- 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 +243,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 +259,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 +305,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_export.go b/internal/cmd/draft_export.go
new file mode 100644
index 00000000..59102e15
--- /dev/null
+++ b/internal/cmd/draft_export.go
@@ -0,0 +1,565 @@
+package cmd
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/unicode/norm"
+
+ "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))
+ }
+ if recoveryErr := recoverDraftExportReplacement(destination, draftID); recoveryErr != nil {
+ return "", false, recoveryErr
+ }
+
+ _, 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 := output.MarshalIndentJSON(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 := draftExportFilenameKey(candidate)
+ if _, exists := used[key]; exists {
+ continue
+ }
+ used[key] = struct{}{}
+ return candidate, nil
+ }
+}
+
+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 {
+ 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
+ }
+
+ exchanged, err := exchangeDraftExportDirectories(staging, destination)
+ if err != nil {
+ 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
+ }
+
+ return replaceDraftExportDirectoryRecoverable(staging, destination, draftID)
+}
+
+func replaceDraftExportDirectoryRecoverable(staging, destination string, draftID int64) error {
+ if err := recoverDraftExportReplacement(destination, draftID); err != nil {
+ return err
+ }
+ 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(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", 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(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, recovery, rollbackErr))
+ }
+ return apierr.ErrAPI(0, fmt.Sprintf("could not publish draft export: %v", 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
+}
+
+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..63d7d819
--- /dev/null
+++ b/internal/cmd/draft_export_commit_darwin.go
@@ -0,0 +1,21 @@
+//go:build darwin
+
+package cmd
+
+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
new file mode 100644
index 00000000..f0babd43
--- /dev/null
+++ b/internal/cmd/draft_export_commit_linux.go
@@ -0,0 +1,21 @@
+//go:build linux
+
+package cmd
+
+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
new file mode 100644
index 00000000..2707e1f1
--- /dev/null
+++ b/internal/cmd/draft_export_commit_other.go
@@ -0,0 +1,21 @@
+//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)
+}
+
+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
new file mode 100644
index 00000000..1d22d78f
--- /dev/null
+++ b/internal/cmd/draft_export_commit_windows.go
@@ -0,0 +1,21 @@
+//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)
+}
+
+func exchangeDraftExportDirectories(_, _ string) (bool, error) {
+ return false, nil
+}
diff --git a/internal/cmd/draft_export_test.go b/internal/cmd/draft_export_test.go
new file mode 100644
index 00000000..95705652
--- /dev/null
+++ b/internal/cmd/draft_export_test.go
@@ -0,0 +1,404 @@
+package cmd
+
+import (
+ "bytes"
+ "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 := `
`
+ 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/html.go b/internal/cmd/html.go
new file mode 100644
index 00000000..5375d29e
--- /dev/null
+++ b/internal/cmd/html.go
@@ -0,0 +1,24 @@
+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
+}
+
+// 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/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, "
", "--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/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/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/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 9ab1d68e..f4fef705 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
@@ -190,11 +191,13 @@ 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` |
| 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` (`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 ...` |
@@ -285,6 +288,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,14 +296,16 @@ 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
├── 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
-│ ├── Change it? → hey draft edit --subject/--to/--cc/--bcc/-m (flags replace; omitted fields are kept)
+│ ├── 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
└── Check drafts? → hey draft list --json
@@ -442,6 +448,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 +457,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 +468,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 +507,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 +619,15 @@ 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 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)
hey draft delete [...] # Trash drafts
```
@@ -617,12 +635,24 @@ 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. 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 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. 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
-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
diff --git a/tests/smoke/draft_lifecycle_test.go b/tests/smoke/draft_lifecycle_test.go
index fb4e0f34..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"
)
@@ -83,6 +85,35 @@ 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)
+ }
+
+ // 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),