Skip to content
Merged
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
1 change: 1 addition & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ hey compose --cc
hey compose --draft
hey compose --message
hey compose --message-html
hey compose --no-name-tag
hey compose --subject
hey compose --thread-id
hey compose --to
Expand Down
3 changes: 3 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ hey compose --to alice@example.com --cc bob@example.com --bcc carol@example.org
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 "Board update" -m "Numbers to follow." --draft # save a draft instead of sending
hey compose --to alice@example.com --subject "Sprint recap" -m "Shipped." --no-name-tag # leave your HEY name tag off
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
Expand Down Expand Up @@ -273,6 +274,8 @@ The Screener is where first-time senders wait. `hey screener list` returns clear

`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.

A new message from `hey compose` — sent or saved with `--draft` — ends with the sender's HEY name tag, appended the way HEY's own compose form does; HEY puts the tag into the form rather than onto the saved message, so the CLI carries it itself. `--no-name-tag` leaves it off. A reply does not carry one yet.

`--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 every named downloadable file, including named inline images. Direct files keep stable message-and-position IDs such as `456:1`; files inside embedded HTML receive opaque IDs scoped to their message. Pass either returned 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.

Organization actions take the `id` values returned by `hey box view --json`, `hey label view --json`, or `hey search --json`. Reading, replying to, and forwarding a thread take its `topic_id` instead, which `hey box view --json`, `hey label view --json`, `hey collection view --json` and `hey search --json` all carry alongside `id`. `hey box view` also returns `next_page` and accepts `--page <next_page>` to continue a box listing; it keeps `next_history_url` for the sync clients that read it, and `--page` accepts that URL as readily as the cursor inside it. Label IDs come from `hey label list`; `hey label view` returns `next_page` and `total_count`, accepts `--page <next_page>` for continuation, and supports `--all` for complete traversal. HEY creates a label while adding it to at least one thread, so `hey label create` requires thread item IDs.
Expand Down
44 changes: 43 additions & 1 deletion internal/cmd/attachments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type attachmentServerState struct {
events []string
blobStatus int
nilMessage bool
nameTag string
}

func attachmentServer(t *testing.T) (*httptest.Server, *attachmentServerState) {
Expand Down Expand Up @@ -88,7 +89,10 @@ func attachmentServer(t *testing.T) (*httptest.Server, *attachmentServerState) {
if got := r.URL.Query().Get("filtered_account_id"); got != "" {
t.Errorf("identity account = %q, want unscoped", got)
}
_, _ = w.Write([]byte(`{"id":1,"accounts":[{"id":9,"status":"active"}],"senders":[{"id":42,"account_id":9,"default":true}]}`))
state.mu.Lock()
nameTag, _ := json.Marshal(state.nameTag)
state.mu.Unlock()
fmt.Fprintf(w, `{"id":1,"accounts":[{"id":9,"status":"active"}],"senders":[{"id":42,"account_id":9,"default":true,"name_tag":%s}]}`, nameTag)
case r.Method == http.MethodGet && r.URL.Path == "/topics/7.json":
_, _ = w.Write([]byte(`{"id":7,"account_id":9,"entries":[{"id":11},{"id":12}]}`))
case r.Method == http.MethodGet && r.URL.Path == "/messages/12.json":
Expand Down Expand Up @@ -529,6 +533,44 @@ func TestComposeUploadsAttachmentsBeforeSending(t *testing.T) {
}
}

// The name tag is the last thing in the message, after the attachments, as a signature is.
func TestComposeEndsAnAttachedMessageWithTheNameTag(t *testing.T) {
server, state := attachmentServer(t)
state.mu.Lock()
state.nameTag = "<div>Maria Delgado</div>"
state.mu.Unlock()
path := filepath.Join(t.TempDir(), "quarterly-report.pdf")
if err := os.WriteFile(path, []byte("report contents"), 0o600); err != nil {
t.Fatal(err)
}

for _, args := range [][]string{
{"compose", "--to", "alice@example.com", "--subject", "Quarterly report", "-m", "Attached.", "--attach", path},
{"compose", "--to", "alice@example.com", "--subject", "Quarterly report", "--attach", path},
} {
if _, err := runAttachmentCommand(t, server, args...); err != nil {
t.Fatal(err)
}
}

state.mu.Lock()
defer state.mu.Unlock()
if len(state.sentContents) != 2 {
t.Fatalf("sent %d messages, want 2", len(state.sentContents))
}
for i, content := range state.sentContents {
if !strings.HasSuffix(content, `filename="quarterly-report.pdf" filesize="15"></action-text-attachment><br><div>Maria Delgado</div>`) {
t.Errorf("message %d does not end with the attachment and then the name tag: %q", i, content)
}
}
if !strings.HasPrefix(state.sentContents[0], "<p>Attached.</p><br><action-text-attachment") {
t.Errorf("message does not start with the body: %q", state.sentContents[0])
}
if !strings.HasPrefix(state.sentContents[1], "<action-text-attachment") {
t.Errorf("attachment-only message does not start with the attachment: %q", state.sentContents[1])
}
}

func TestComposeReadsPipedBodyWithAttachments(t *testing.T) {
server, state := attachmentServer(t)
path := filepath.Join(t.TempDir(), "quarterly-report.pdf")
Expand Down
37 changes: 36 additions & 1 deletion internal/cmd/compose.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"context"
"fmt"
"strconv"
"strings"
Expand All @@ -26,6 +27,7 @@ type composeCommand struct {
threadID string
attachments []string
draft bool
noNameTag bool
}

func newComposeCommand() *composeCommand {
Expand All @@ -34,7 +36,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 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. A new message ends with the sender's HEY name tag, as one composed in HEY does; --no-name-tag leaves it out.",
},
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."
Expand All @@ -56,6 +58,7 @@ func newComposeCommand() *composeCommand {
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.Flags().BoolVar(&composeCommand.noNameTag, "no-name-tag", false, "Leave the sender's HEY name tag off a new message")
composeCommand.cmd.MarkFlagsMutuallyExclusive("message", "message-html")

return composeCommand
Expand Down Expand Up @@ -136,6 +139,12 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error {
if attachErr != nil {
return attachErr
}
if !c.noNameTag {
var tagErr error
if messageWithAttachments, tagErr = appendSenderNameTag(ctx, messageWithAttachments); tagErr != nil {
return tagErr
}
}
if c.draft {
draftID, draftErr := sdk.Messages().CreateDraft(ctx, hey.DraftContent{
Subject: c.subject, Content: messageWithAttachments, To: to, CC: cc, BCC: bcc,
Expand All @@ -153,6 +162,32 @@ func (c *composeCommand) run(cmd *cobra.Command, args []string) error {
return writeMutation(cmd, sentWithAttachmentsSummary("Message sent", len(c.attachments)), nil)
}

// appendSenderNameTag ends a new message — attachments included — with the sender's name
// tag the way HEY's own compose form does. HEY applies the tag in the form it prefills, not on the message it
// saves, so a message written here has to carry its own — otherwise a draft or a send from
// the CLI goes out unsigned while the same message written in HEY would not. The tag is the
// one HEY serves for the sender the message is filed under; a sender without one leaves the
// message alone.
func appendSenderNameTag(ctx context.Context, message string) (string, error) {
senderID, err := sdk.DefaultSenderID(ctx)
if err != nil {
return "", apierr.FromSDK(err)
}
identity, err := rootSDK.Identity().GetIdentity(ctx)
Comment thread
Copilot marked this conversation as resolved.
if err != nil {
return "", apierr.FromSDK(err)
}
if identity == nil {
return message, nil
}
for _, sender := range identity.Senders {
if sender.Id == senderID && sender.NameTag != "" {
return message + "<br>" + sender.NameTag, nil
}
}
return message, nil
}

// writeDraftSaved confirms a saved draft, naming the id every draft verb takes.
func writeDraftSaved(cmd *cobra.Command, draftID int64, attachments int) error {
summary := sentWithAttachmentsSummary("Draft saved", attachments)
Expand Down
104 changes: 104 additions & 0 deletions internal/cmd/compose_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package cmd

import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"

Expand Down Expand Up @@ -155,3 +158,104 @@ func TestComposeRefusesMessageAndMessageHTMLTogether(t *testing.T) {
t.Errorf("nothing should have been sent, got %q", sent.Content)
}
}

// nameTaggedServer is draftLifecycleServer with a name tag on the default sender, which
// is where HEY serves it: the identity's senders carry name_tag as sanitized HTML.
func nameTaggedServer(t *testing.T, nameTag string, writes *[]draftWrite) http.Handler {
t.Helper()
tag, _ := json.Marshal(nameTag)
return composeIdentityServer(t, fmt.Sprintf(`{"id":1,"senders":[{"id":42,"default":true,"name_tag":%s},{"id":43,"name_tag":"<div>Not this one</div>"}],"primary_contact":{"id":42}}`, tag), writes)
}

func composeIdentityServer(t *testing.T, identityJSON string, writes *[]draftWrite) http.Handler {
t.Helper()
inner := draftLifecycleServer(t, draftEditJSON, writes)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "identity") {
inner.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, identityJSON)
})
}

func sentContent(t *testing.T, writes []draftWrite) string {
t.Helper()
if len(writes) != 1 || writes[0].Path != "/messages.json" {
t.Fatalf("writes = %+v, want one POST /messages.json", writes)
}
message, _ := writes[0].Body["message"].(map[string]any)
content, _ := message["content"].(string)
return content
}

// HEY puts the sender's name tag into the compose form, not onto the saved message, so a
// message written here has to end with it itself — on a draft and on a send alike.
func TestComposeEndsANewMessageWithTheSendersNameTag(t *testing.T) {
const nameTag = "<div>Maria Delgado<br>Chief of Staff</div>"
want := "<p>Numbers to follow.</p><br>" + nameTag

var drafted []draftWrite
if _, err := runJSONCommand(t, nameTaggedServer(t, nameTag, &drafted),
"compose", "--subject", "Board update", "-m", "Numbers to follow.", "--draft"); err != nil {
t.Fatalf("compose --draft: %v", err)
}
if got := sentContent(t, drafted); got != want {
t.Errorf("draft content = %q, want %q", got, want)
}

var sent []draftWrite
if _, err := runJSONCommand(t, nameTaggedServer(t, nameTag, &sent),
"compose", "--to", "alice@example.com", "--subject", "Board update", "-m", "Numbers to follow."); err != nil {
t.Fatalf("compose: %v", err)
}
if got := sentContent(t, sent); got != want {
t.Errorf("sent content = %q, want %q", got, want)
}
}

func TestComposeNoNameTagLeavesTheMessageAlone(t *testing.T) {
var writes []draftWrite
if _, err := runJSONCommand(t, nameTaggedServer(t, "<div>Maria Delgado</div>", &writes),
"compose", "--subject", "Board update", "-m", "Numbers to follow.", "--draft", "--no-name-tag"); err != nil {
t.Fatalf("compose --draft --no-name-tag: %v", err)
}
if got, want := sentContent(t, writes), "<p>Numbers to follow.</p>"; got != want {
t.Errorf("content = %q, want %q", got, want)
}
}

// A sender with no name tag configured has nothing to append: the message goes as written,
// without a stray break at the end.
func TestComposeWithoutANameTagSendsTheMessageAsWritten(t *testing.T) {
var writes []draftWrite
if _, err := runJSONCommand(t, nameTaggedServer(t, "", &writes),
"compose", "--subject", "Board update", "-m", "Numbers to follow.", "--draft"); err != nil {
t.Fatalf("compose --draft: %v", err)
}
if got, want := sentContent(t, writes), "<p>Numbers to follow.</p>"; got != want {
t.Errorf("content = %q, want %q", got, want)
}
}

// The sender is the one the SDK files the message under, which --account changes: the
// tag has to be that account's sender's, not the identity-wide default's.
func TestComposeUsesTheSelectedAccountsSendersNameTag(t *testing.T) {
const identity = `{"id":1,
"accounts":[{"id":1,"name":"Personal","purpose":"home","status":"active"},{"id":2,"name":"Work","purpose":"work","status":"active"}],
"senders":[{"id":42,"account_id":1,"default":true,"name_tag":"<div>Maria, at home</div>"},{"id":43,"account_id":2,"name_tag":"<div>Maria Delgado<br>Chief of Staff</div>"}],
"primary_contact":{"id":42}}`

var writes []draftWrite
if _, err := runJSONCommand(t, composeIdentityServer(t, identity, &writes),
"--account", "2", "compose", "--subject", "Board update", "-m", "Numbers to follow.", "--draft"); err != nil {
t.Fatalf("compose --account 2 --draft: %v", err)
}
if got, want := sentContent(t, writes), "<p>Numbers to follow.</p><br><div>Maria Delgado<br>Chief of Staff</div>"; got != want {
t.Errorf("content = %q, want %q", got, want)
}
if got := writes[0].Body["acting_sender_id"]; got != float64(43) {
t.Errorf("acting_sender_id = %v, want the work account's sender 43", got)
}
}
1 change: 1 addition & 0 deletions skills/hey/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ 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 --to alice@example.com --subject "Sprint recap" -m "Shipped." --no-name-tag # leave the sender's HEY name tag off
hey reply <topic_id> -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 <draft_id> --json # The draft's editable state; body is Markdown
Expand Down
Loading