Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 17 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<!doctype html>`, `<html lang="en">`, a `<head>` with `<meta charset="utf-8">`
Expand All @@ -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
(`<!-- notice: … -->`) just before `</body>`, 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

Expand Down Expand Up @@ -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 "<h1>March</h1><p>What we shipped.</p>"
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
Expand All @@ -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 <path>` 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 <id> --output <directory>` 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 <thread_id>` 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 <thread_id>` turns off the sharing link.

Expand All @@ -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 <next_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 <thread-id>` 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.

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
52 changes: 51 additions & 1 deletion internal/cmd/attachments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 := "<h1>Invoice 1042</h1>\n<p>Please see the attached invoice.</p>\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+"<br>") || !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")
Expand Down
27 changes: 20 additions & 7 deletions internal/cmd/bulk_reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -138,18 +139,20 @@ func newBulkReplySendCommand() *bulkReplySendCommand {
Use: "send <box-item-id>...",
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
}

Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions internal/cmd/bulk_reply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := "<p>Thanks <strong>everyone</strong>.</p>\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) + "<br><div>Signing off with a tag!</div>"
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}`
Expand Down
Loading
Loading