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