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
2 changes: 2 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ hey recordings --limit
hey recordings --starts-on
hey reply
hey reply --attach
hey reply --expect-entry
hey reply --message
hey reply --preview
hey search
hey search --all
hey search --any
Expand Down
5 changes: 3 additions & 2 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ The remaining HTML-reading gaps use the SDK's authenticated HTML helper and are
| signed Active Storage upload URL | PUT | SDK `Attachments().Upload` | `hey compose --attach`, `hey reply --attach` | covered |
| signed Active Storage blob URL | GET | SDK `DownloadBlob` | `hey attachments save <id>` | covered |
| `/messages.json` | POST | SDK `Messages().Create` | `hey compose`, `hey forward <topic-id>` | covered |
| `/entries/{id}/replies` | POST | SDK `Entries().CreateReply` | `hey reply <topic-id>` | covered |
| `/topics/{id}.json` | GET | SDK `Topics().Get` | `hey forward <topic-id>` | covered |
| `/entries/{id}/replies/new` | GET (HTML) | SDK `GetHTML` | `hey reply <topic-id>`, `hey compose --thread-id` | gap: resolves the live reply envelope |
| `/entries/{id}/replies.json` | POST | SDK `Entries().CreateReply` | `hey reply <topic-id>` | covered |
| `/topics/{id}.json` | GET | SDK `Topics().Get` | `hey forward <topic-id>`, `hey reply <topic-id>` | covered |
| `/entries/{id}/forwards/new.json` | GET | SDK `Entries().NewForward` | `hey forward <topic-id>` | covered |
| `/postings/moves.json` | POST | SDK `Postings().Move` | `hey move <id> --to <box>`, TUI `m` | covered |
| `/postings/trash.json` | POST | SDK `Postings().MoveToTrash` | `hey trash <id>`, TUI `t` | covered |
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ hey contacts note delete 12345
hey threads 123 # read a full email thread
hey attachments 123 # list files attached to the thread
hey attachments save 456:1 # save a file using its attachment ID
hey reply 123 -m "Thanks!" # reply to a thread (or omit -m to open $EDITOR)
hey reply 123 -m "Attached." --attach ./diagram.png
hey reply 123 -m "Thanks!" --preview # preview recipients, subject, body, and files
hey reply 123 -m "Thanks!" --expect-entry 456 # send only to the previewed entry
hey reply 123 -m "Attached." --attach ./diagram.png --preview
hey forward 123 --to alice@example.com -m "For your review" # forward the latest message
hey compose --to user@example.com --subject "Hello" # compose a new message
hey compose --to user@example.com --subject "Report" -m "Attached." --attach ./report.pdf
Expand All @@ -106,7 +107,7 @@ Search accepts free text plus `--required`, `--any`, `--none`, `--exact`, `--fro

Contact updates preserve omitted name, email, and alias fields. Supplying `--alias` replaces the complete alias list; `--alias=` clears it. Contact notes accept positional content, `--note`, stdin, or `$EDITOR`. HEY hides contacts rather than permanently deleting them; hidden contacts leave lists, autocomplete, and search, and can be shown again by ID.

`--attach` is repeatable on `hey compose` and `hey reply`, and attachment-only messages are supported. The CLI validates and uploads every file before sending the email. `hey attachments <topic_id>` returns stable message-and-position IDs such as `456:1`; pass an ID to `hey attachments save`. Saving uses the original filename by default, accepts `--output` for a file or directory, and preserves existing files unless `--force` is set.
`--attach` is repeatable on `hey compose` and `hey reply`, and attachment-only messages are supported. `hey reply --preview` inspects attachment files locally but does not upload or send anything. The preview returns an `entry_id`; pass it to `--expect-entry` when sending. If a newer entry arrived after the preview, the send stops and asks you to preview again. Sending validates and uploads every file before delivering the email. `hey attachments <topic_id>` returns stable message-and-position IDs such as `456:1`; pass an ID to `hey attachments 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 --json` or `hey search --json`. Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Bubble Up requires a scheduled date and is not available through `hey move`. Trashing a shared thread removes your access instead of deleting it for everyone. Ignored threads remain in their box and can be restored with `hey stop-ignoring`.

Expand Down
85 changes: 70 additions & 15 deletions internal/cmd/attachments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ import (
)

type attachmentServerState struct {
mu sync.Mutex
directUploads int
storageUploads int
sentContents []string
events []string
blobStatus int
nilMessage bool
mu sync.Mutex
directUploads int
storageUploads int
sentContents []string
events []string
blobStatus int
nilMessage bool
advanceOnUpload bool
threadAdvanced bool
}

func attachmentServer(t *testing.T) (*httptest.Server, *attachmentServerState) {
Expand Down Expand Up @@ -77,16 +79,47 @@ func attachmentServer(t *testing.T) (*httptest.Server, *attachmentServerState) {
state.mu.Lock()
state.storageUploads++
state.events = append(state.events, "upload")
if state.advanceOnUpload {
state.threadAdvanced = true
}
state.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodGet && r.URL.Path == "/identity.json":
_, _ = w.Write([]byte(`{"id":1,"senders":[{"id":42,"default":true}]}`))
case r.Method == http.MethodGet && r.URL.Path == "/topics/7":
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(topicWithRecipients))
case r.Method == http.MethodGet && r.URL.Path == "/topics/7/entries":
case r.Method == http.MethodGet && (r.URL.Path == "/topics/7" || r.URL.Path == "/topics/7.json"):
state.mu.Lock()
sentOrAdvanced := len(state.sentContents) > 0 || state.threadAdvanced
state.mu.Unlock()
latestEntryID := 12
if sentOrAdvanced {
latestEntryID = 13
}
_, _ = fmt.Fprintf(w, `{"id":7,"name":"Project update","latest_entry":{"id":%d}}`, latestEntryID)
case r.Method == http.MethodGet && r.URL.Path == "/topics/7/entries.json":
state.mu.Lock()
sent := len(state.sentContents) > 0
state.mu.Unlock()
if sent && r.URL.Query().Get("page") == "1" {
_, _ = w.Write([]byte(`[{"id":13,"kind":"message","creator":{"id":42}}]`))
} else {
_, _ = w.Write([]byte(`[]`))
}
case r.Method == http.MethodGet && r.URL.Path == "/messages/13.json":
state.mu.Lock()
content := ""
if len(state.sentContents) > 0 {
content = state.sentContents[len(state.sentContents)-1]
}
state.mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 13,
"content": content,
"creator": map[string]any{"id": 42},
"sender": map[string]any{"id": 42},
})
case r.Method == http.MethodGet && r.URL.Path == "/entries/12/replies/new":
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(topicEntries))
_, _ = w.Write([]byte(replyForm))
case r.Method == http.MethodPost && r.URL.Path == "/entries/12/replies.json":
var body struct {
Message struct {
Expand Down Expand Up @@ -320,7 +353,7 @@ func TestReplyUploadsAttachmentsBeforeSending(t *testing.T) {
if err := os.WriteFile(path, []byte("report contents"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := runAttachmentCommand(t, server, "reply", "7", "-m", "Attached.", "--attach", path); err != nil {
if _, err := runAttachmentCommand(t, server, "reply", "7", "-m", "Attached.", "--attach", path, "--expect-entry", "12"); err != nil {
t.Fatal(err)
}

Expand All @@ -331,13 +364,35 @@ func TestReplyUploadsAttachmentsBeforeSending(t *testing.T) {
}
}

func TestReplyRechecksPreviewedEntryAfterAttachmentUpload(t *testing.T) {
server, state := attachmentServer(t)
state.mu.Lock()
state.advanceOnUpload = true
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)
}

_, err := runAttachmentCommand(t, server, "reply", "7", "-m", "Attached.", "--attach", path, "--expect-entry", "12")
if err == nil || !strings.Contains(err.Error(), "thread changed after preview") {
t.Fatalf("error = %v, want stale preview rejection", err)
}

state.mu.Lock()
defer state.mu.Unlock()
if len(state.sentContents) != 0 || strings.Contains(strings.Join(state.events, ","), "send") {
t.Errorf("stale reply was sent: %+v", state)
}
}

func TestReplySupportsAttachmentOnlyMessages(t *testing.T) {
server, state := attachmentServer(t)
path := filepath.Join(t.TempDir(), "quarterly-report.pdf")
if err := os.WriteFile(path, []byte("report contents"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := runAttachmentCommand(t, server, "reply", "7", "--attach", path); err != nil {
if _, err := runAttachmentCommand(t, server, "reply", "7", "--attach", path, "--expect-entry", "12"); err != nil {
t.Fatal(err)
}

Expand All @@ -355,7 +410,7 @@ func TestReplyReadsPipedBodyWithAttachments(t *testing.T) {
t.Fatal(err)
}
if _, err := runAttachmentCommandWithStdin(t, server, "Piped reply body\n",
"reply", "7", "--attach", path,
"reply", "7", "--attach", path, "--expect-entry", "12",
); err != nil {
t.Fatal(err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func TestParseAddresses(t *testing.T) {
// A reply carries the thread's subject with it, so --subject is only wanted when
// starting a new thread. Requiring it either way made people pass one that HEY ignores.
func TestComposeSubjectRequiredOnlyForANewMessage(t *testing.T) {
server, sent := threadReplyServer(t, topicWithRecipients, topicEntries)
server, sent := threadReplyServer(t, replyForm)

err := runCLI(t, server, "compose", "-m", "body")
var cliErr *apierr.Error
Expand Down
Loading