diff --git a/.surface b/.surface
index 7d35c367..4ebe280a 100644
--- a/.surface
+++ b/.surface
@@ -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
diff --git a/docs/cli.md b/docs/cli.md
index 830e78bf..3e66e59a 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -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 "
March
What we shipped.
"
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
@@ -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 ` 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 ` 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 ` 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.
diff --git a/internal/cmd/attachments_test.go b/internal/cmd/attachments_test.go
index 4aa4a25c..d35becfd 100644
--- a/internal/cmd/attachments_test.go
+++ b/internal/cmd/attachments_test.go
@@ -29,6 +29,7 @@ type attachmentServerState struct {
events []string
blobStatus int
nilMessage bool
+ nameTag string
}
func attachmentServer(t *testing.T) (*httptest.Server, *attachmentServerState) {
@@ -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":
@@ -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 = "Maria Delgado
"
+ 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">
Maria Delgado
`) {
+ t.Errorf("message %d does not end with the attachment and then the name tag: %q", i, content)
+ }
+ }
+ if !strings.HasPrefix(state.sentContents[0], "Attached.
" + 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)
diff --git a/internal/cmd/compose_test.go b/internal/cmd/compose_test.go
index dee0ad43..0ce3cf15 100644
--- a/internal/cmd/compose_test.go
+++ b/internal/cmd/compose_test.go
@@ -1,7 +1,10 @@
package cmd
import (
+ "encoding/json"
"errors"
+ "fmt"
+ "net/http"
"strings"
"testing"
@@ -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":"Not this one
"}],"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 = "Maria Delgado
Chief of Staff
"
+ want := "Numbers to follow.
" + 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, "Maria Delgado
", &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), "Numbers to follow.
"; 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), "Numbers to follow.
"; 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":"Maria, at home
"},{"id":43,"account_id":2,"name_tag":"Maria Delgado
Chief of Staff
"}],
+ "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), "Numbers to follow.
Maria Delgado
Chief of Staff
"; 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)
+ }
+}
diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md
index 747ffe67..09251518 100644
--- a/skills/hey/SKILL.md
+++ b/skills/hey/SKILL.md
@@ -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 -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