Skip to content
Closed
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 @@ -107,6 +107,7 @@ hey compose --message-html
hey compose --subject
hey compose --thread-id
hey compose --to
hey compose --verifiable
hey config
hey config set
hey config show
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ hey box view imbox # threads in a box
hey thread read 12345 # a whole thread, as Markdown
hey reply 12345 -m "Friday works for me."
hey compose --to alice@example.com --subject "Lunch?" -m "Thursday at noon?"
hey compose --to alice@example.com --subject "Customer update" -m "Done." --verifiable
Comment on lines 69 to +70
hey search --from jane@example.com --date last_30_days
hey screener list # first-time senders waiting on you
hey event add "Design review" --starts-on 2026-09-02 --start-time 14:00
Expand Down
7 changes: 7 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 "Customer update" -m "The migration is complete." --verifiable # known-ID send with exact readback
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 @@ -263,6 +264,12 @@ Writing is Markdown too, everywhere text goes in: `-m`, `--content`, `--note`, p

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.

A send answers what it created. `hey compose --json` reports `message_id` — the entry HEY named in its response — `topic_id` and `app_url` where they are known, and a `verification` object holding what reading that message back showed: the `subject` HEY stored, the `sender` address it went out as, the `recipients` it reached, the body as canonical Markdown with its `body_markdown_sha256`, and `matches_sent`, which compares each of those with what was asked for. `status` is `verified` when everything comparable matches, `mismatch` when the message exists but differs — never a reason to send again — and `unverified` when the message could not be read back, with a `reason`. `recipients.bcc_disclosed` says whether HEY served the BCC field at all, not whether anybody was on it: an explicitly empty field is disclosed, while an omitted or null field is not. So `bcc: []` with `bcc_disclosed: true` proves nobody was blind-copied; the same list with `bcc_disclosed: false` proves nothing. A send whose outcome cannot be established is neither reported as success nor retried: it exits `ambiguous` (exit 8) saying the message may have been sent, because a retry on this endpoint — which carries no idempotency key — can deliver it twice. That covers a send HEY accepted but named no message for, a connection that died with the request already on it, an answer that could not be read, and any 5xx: none proves HEY did not act. Only a failure before the request went out, or a status that is itself a refusal — 401, 403, 404, 409, 422, 429 and 4xx generally — keeps its own code. When you see exit 8, read the thread back rather than sending again. `--styled` keeps the ordinary one-line confirmation.

For automation that requires a reconcilable send, `hey compose --verifiable` first saves a draft to obtain a stable `message_id`, reads that exact draft back through its edit endpoint, sends it exactly once only after the draft matches, and then reads only that ID back as a delivered message. It succeeds only when the delivery readback carries that ID plus an exact `/topics/` followed by a positive numeric ID as its URL path, and an explicitly present `sender` ID, subject, To, CC, explicitly disclosed BCC (including an explicit empty list), and body all match. Missing or mismatched proof exits `ambiguous` (exit 8) with the known ID and bounded boolean reconciliation checks; it never searches by subject or time and never retries the send. Its Markdown input must contain no raw HTML or Action Text attachment markup, and `--verifiable` cannot be combined with `--draft`, `--thread-id`, `--attach`, or `--message-html`.

`hey thread read --json` carries the same envelope per entry, off the same message record: `subject`, `sender` (the identity the message went out as, next to `creator`, who wrote it) and `addressed` with `to`, `cc`, `bcc` and `bcc_disclosed`, with the same field-presence meaning as on a send. Recipient lists are read from what HEY served, never inferred from a position or from the body, and are cut at a hundred addresses per line with `truncated` saying so. `--count` and `--ids-only` read no messages, so they carry no envelope rather than an invented one.

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

Search accepts free text plus `--required`, `--any`, `--none`, `--exact`, `--from`, `--to`, `--subject`, `--date`, `--in`, `--label`, and `--attachment`. `--in`, `--date`, `--label` and `--attachment` take one of the values `hey search filters` lists — the attachment kinds are `any`, `images`, `pdfs`, `calendar_invites`, `documents`, `spreadsheets`, `presentations`, `media` and `zip_files`, so it is `--attachment pdfs` rather than `pdf`, and an unrecognized `--in`, `--date` or `--attachment` is refused with the values it accepts before anything is sent. Use `--page` for one page or `--all` to fetch up to 100 pages; capped searches report the next page for continuation. Search results include `topic_id` for reading the thread and the matching message summaries. Results with an active box item also include `id` for organization actions.
Expand Down
9 changes: 9 additions & 0 deletions internal/apierr/apierr.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ func ErrAmbiguous(resource string, matches []string) *Error {
}
}

// ErrAmbiguousOutcome is a write the server accepted and then named nothing for. It is
// neither a success nor a failure: the change may well have happened, so a caller must
// go and look rather than either reporting it done or trying again. It is a code of its
// own — not api, not network — because those two both read as "it did not happen", and
// this one does not.
func ErrAmbiguousOutcome(msg, hint string) *Error {
return &Error{Code: CodeAmbiguous, Message: msg, Hint: hint}
}

func AsError(err error) *Error {
var e *Error
if errors.As(err, &e) {
Expand Down
3 changes: 3 additions & 0 deletions internal/cmd/accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ func TestSelectedAccountUsesMatchingSenderAndUser(t *testing.T) {
}
_ = json.NewDecoder(r.Body).Decode(&body)
actingSenderID = body.ActingSenderID
// HEY names the entry it created in Location, which is what compose reads
// back; the readback itself 404s here, and an unverified send still stands.
w.Header().Set("Location", "https://app.hey.com/messages/9101")
w.WriteHeader(http.StatusCreated)
case "/contacts.json":
contactAccount = r.URL.Query().Get("filtered_account_id")
Expand Down
80 changes: 80 additions & 0 deletions internal/cmd/addressed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cmd

import (
"github.com/basecamp/hey-sdk/go/pkg/generated"
)

// maxRetainedRecipients is how many addresses of one kind are kept from a message HEY
// served. A message addressed to a mailing list carries as many recipients as somebody
// else decided to put on it, and every one of them is a name and an address this
// program would otherwise hold and print; the bound is what keeps a read of one message
// from retaining an address book. It is well past any list a person writes by hand.
const maxRetainedRecipients = 100

// addressedEnvelope is who a message reached, as HEY served it back: the To, CC and BCC
// lines as plain addresses. It is what lets a caller prove a message went where it was
// meant to and nowhere else, so it is read off the message HEY answered with rather
// than inferred from a position in a list or found in the body.
//
// BCCDisclosed is the honest part, and it answers "did HEY tell us the BCC line" rather
// than "was anybody on it". Those are different questions, and reading the second as the
// first left a caller unable to prove a message's exact destinations: an empty BCC HEY
// served — which is proof that nobody was blind-copied — had the same shape as one HEY
// withheld, which proves nothing at all.
//
// So it is presence, not population: true when the blindcopied field arrived, an
// explicitly empty array included, and false when it was omitted, null, or there was no
// addressing at all. A caller that must not accept an unexpected recipient reads it
// before treating an empty BCC as settled — false still means unknown.
type addressedEnvelope struct {
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
BCCDisclosed bool `json:"bcc_disclosed"`
// Truncated says a list was longer than maxRetainedRecipients and was cut to it.
Truncated bool `json:"truncated,omitempty"`
}

// addressedFrom describes HEY's addressing in the CLI's shape, within the bound.
//
// Disclosure is read from the decoded slice's nil-ness, which is where the presence of
// the field survives: encoding/json leaves an omitted or null array nil and makes an
// explicit `[]` non-nil, generated.Message declares no unmarshaler of its own to flatten
// the two, and threadload's retained copies the slice header rather than rebuilding it.
// It is deliberately not read from len(bcc) — that would report a line HEY served as
// empty as withheld — nor from what the caller asked to send, which is not evidence of
// anything the server did. TestBlindcopiedPresenceSurvivesTheSDKDecode pins the
// invariant through a real HTTP response, since it is the kind that would otherwise
// break in silence.
func addressedFrom(addressed generated.Addressed) addressedEnvelope {
to, toCut := boundedEmails(addressed.Directly)
cc, ccCut := boundedEmails(addressed.Copied)
bcc, bccCut := boundedEmails(addressed.Blindcopied)
return addressedEnvelope{
To: to,
CC: cc,
BCC: bcc,
BCCDisclosed: addressed.Blindcopied != nil,
Truncated: toCut || ccCut || bccCut,
}
}

// boundedEmails answers the contacts' addresses, marking the envelope incomplete when a
// contact has no address or when the bound is reached. The addresses are HEY's own, so
// they are carried verbatim;
// whatever prints one sanitizes it there, as every other read of somebody else's text
// does.
func boundedEmails(contacts []generated.Contact) (emails []string, truncated bool) {
emails = make([]string, 0, min(len(contacts), maxRetainedRecipients))
for _, contact := range contacts {
if contact.EmailAddress == "" {
truncated = true
continue
}
if len(emails) == maxRetainedRecipients {
return emails, true
}
emails = append(emails, contact.EmailAddress)
}
return emails, truncated
}
170 changes: 170 additions & 0 deletions internal/cmd/addressed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package cmd

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/basecamp/hey-sdk/go/pkg/generated"
)

// messageServingAddressed answers one message whose `addressed` object is exactly the
// JSON given, so a test can say what HEY put on the wire rather than what a Go struct
// looks like after somebody built it by hand.
func messageServingAddressed(t *testing.T, addressedJSON string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/messages/") {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.RequestURI())
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"id":9101,"subject":"Inovo Customer Update — Week 12",
"content":"<p>Hi Alice,</p>",
"sender":{"id":42,"email_address":"nova@example.com"}%s}`, addressedJSON)
}))
t.Cleanup(server.Close)
return server
}

// bcc_disclosed rests on one fact about the decode: `encoding/json` leaves a field HEY
// omitted — or served as null — nil, and makes an explicitly empty array non-nil, and
// nothing between HEY's JSON and generated.Message flattens the two together. That is
// asserted here through the real path — an HTTP response read by the SDK — because a
// struct built by hand would prove nothing about the decoder, and this is the invariant
// that would break silently if the SDK ever changed decoders.
func TestBlindcopiedPresenceSurvivesTheSDKDecode(t *testing.T) {
tests := []struct {
name string
addressed string
wantNonNil bool
wantContacts int
}{
{
name: "the addressed object itself is omitted",
addressed: ``,
},
{
name: "blindcopied is omitted",
addressed: `,"addressed":{"directly":[{"id":100,"email_address":"alice@example.com"}]}`,
},
{
name: "blindcopied is null",
addressed: `,"addressed":{"directly":[{"id":100,"email_address":"alice@example.com"}],"blindcopied":null}`,
},
{
name: "blindcopied is an explicitly empty array",
addressed: `,"addressed":{"directly":[{"id":100,"email_address":"alice@example.com"}],"blindcopied":[]}`,
wantNonNil: true,
},
{
name: "blindcopied carries addresses",
addressed: `,"addressed":{"blindcopied":[{"id":102,"email_address":"carol@example.org"}]}`,
wantNonNil: true,
wantContacts: 1,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := messageServingAddressed(t, tt.addressed)
withSDKPointedAt(t, server)

message, err := sdk.Messages().Get(context.Background(), 9101)
if err != nil {
t.Fatalf("read the message: %v", err)
}
if got := message.Addressed.Blindcopied != nil; got != tt.wantNonNil {
t.Errorf("blindcopied non-nil = %v, want %v", got, tt.wantNonNil)
}
if len(message.Addressed.Blindcopied) != tt.wantContacts {
t.Errorf("blindcopied = %d contacts, want %d",
len(message.Addressed.Blindcopied), tt.wantContacts)
}
})
}
}

// bcc_disclosed answers "did HEY tell us the BCC line", not "was anybody on it". Those
// are different questions, and reading the second as the first is what left a caller
// unable to prove a message's exact destinations: an empty BCC that HEY served and an
// empty BCC that HEY withheld had the same shape.
func TestAddressedFromReportsWhetherHEYServedABCCLineAtAll(t *testing.T) {
tests := []struct {
name string
blindcopied []generated.Contact
wantDisclosed bool
wantBCC []string
}{
{
name: "no blindcopied field at all",
blindcopied: nil,
wantDisclosed: false,
wantBCC: []string{},
},
{
name: "an explicitly empty blindcopied line",
blindcopied: []generated.Contact{},
wantDisclosed: true,
wantBCC: []string{},
},
{
name: "a blindcopied line with addresses",
blindcopied: []generated.Contact{{Id: 102, EmailAddress: "carol@example.org"}},
wantDisclosed: true,
wantBCC: []string{"carol@example.org"},
},
{
// A contact HEY named without an address is still HEY answering the
// question: the line was served, it just carries nothing this can print.
name: "a blindcopied line whose only contact has no address",
blindcopied: []generated.Contact{{Id: 102}},
wantDisclosed: true,
wantBCC: []string{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
envelope := addressedFrom(generated.Addressed{Blindcopied: tt.blindcopied})
if envelope.BCCDisclosed != tt.wantDisclosed {
t.Errorf("bcc_disclosed = %v, want %v", envelope.BCCDisclosed, tt.wantDisclosed)
}
if !equalStrings(envelope.BCC, tt.wantBCC) {
t.Errorf("bcc = %v, want %v", envelope.BCC, tt.wantBCC)
}
if envelope.BCC == nil {
t.Error("bcc must marshal as [] rather than null")
}
})
}
}

// Disclosure and the bound are independent: a line long enough to be cut was plainly
// served, so it is disclosed and truncated at once.
func TestAddressedFromKeepsDisclosureWhenALineIsCut(t *testing.T) {
contacts := make([]generated.Contact, 0, maxRetainedRecipients+5)
for i := range maxRetainedRecipients + 5 {
contacts = append(contacts, generated.Contact{
Id: int64(200 + i), EmailAddress: fmt.Sprintf("reader%d@example.com", i),
})
}

envelope := addressedFrom(generated.Addressed{Blindcopied: contacts})
if !envelope.BCCDisclosed {
t.Error("a line HEY served in full is disclosed however much of it is kept")
}
if !envelope.Truncated {
t.Error("a cut list must say it was cut")
}
if len(envelope.BCC) != maxRetainedRecipients {
t.Errorf("bcc = %d addresses, want the bound of %d", len(envelope.BCC), maxRetainedRecipients)
}
if envelope.BCC[0] != "reader0@example.com" {
t.Errorf("bcc[0] = %q, want the first address verbatim", envelope.BCC[0])
}
}
11 changes: 11 additions & 0 deletions internal/cmd/attachments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func attachmentServer(t *testing.T) (*httptest.Server, *attachmentServerState) {
state.sentContents = append(state.sentContents, body.Message.Content)
state.events = append(state.events, "send")
state.mu.Unlock()
w.Header().Set("Location", "https://app.hey.com/messages/9101")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodPost && r.URL.Path == "/messages.json":
Expand All @@ -117,8 +118,18 @@ func attachmentServer(t *testing.T) (*httptest.Server, *attachmentServerState) {
state.sentContents = append(state.sentContents, body.Message.Content)
state.events = append(state.events, "send")
state.mu.Unlock()
w.Header().Set("Location", "https://app.hey.com/messages/9101")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodGet && r.URL.Path == "/messages/9101.json":
state.mu.Lock()
content := ""
if len(state.sentContents) > 0 {
content = state.sentContents[len(state.sentContents)-1]
}
state.mu.Unlock()
payload, _ := json.Marshal(map[string]any{"id": 9101, "content": content})
_, _ = w.Write(payload)
default:
t.Logf("unhandled attachment test request: %s %s", r.Method, r.URL.RequestURI())
http.Error(w, "not found", http.StatusNotFound)
Expand Down
Loading