From d58f7af082169ea4705012d49d2677b42cc2e30d Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 20 Sep 2026 14:06:13 -0400 Subject: [PATCH 1/8] Add sent mail listing --- .surface | 4 + docs/cli.md | 6 +- go.mod | 2 +- go.sum | 4 +- internal/cmd/help.go | 2 +- internal/cmd/help_test.go | 1 + internal/cmd/root.go | 1 + internal/cmd/sent.go | 257 +++++++++++++++++++++++ internal/cmd/sent_test.go | 201 ++++++++++++++++++ internal/mcpserver/model/PROVENANCE.json | 9 +- internal/mcpserver/model/openapi.json | 15 +- tests/smoke/sent_test.go | 55 +++++ 12 files changed, 548 insertions(+), 9 deletions(-) create mode 100644 internal/cmd/sent.go create mode 100644 internal/cmd/sent_test.go create mode 100644 tests/smoke/sent_test.go diff --git a/.surface b/.surface index 9dee36fb..ec09927b 100644 --- a/.surface +++ b/.surface @@ -316,6 +316,10 @@ hey search --subject hey search --to hey search filters hey seen +hey sent +hey sent --all +hey sent --limit +hey sent --page hey set-aside hey set-aside group hey set-aside group add diff --git a/docs/cli.md b/docs/cli.md index 72cbb684..aca42303 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -112,7 +112,7 @@ Listing commands also answer `--markdown` for a table, `--styled` to force the h rendering when the output is piped, `--ids-only` for one ID per line, and `--count` for a bare number. `--ids-only` and `--count` need list data, so they work on `hey box list`, `hey box view`, `hey bundle view`, `hey label list`, `hey label view`, `hey collection list`, `hey collection view`, -`hey workflow list`, `hey workflow view`, `hey clip list`, `hey snippet list`, `hey draft list`, `hey search`, `hey contact list`, `hey contact threads`, `hey screener list`, `hey screener history`, `hey calendar list`, +`hey workflow list`, `hey workflow view`, `hey clip list`, `hey snippet list`, `hey draft list`, `hey sent`, `hey search`, `hey contact list`, `hey contact threads`, `hey screener list`, `hey screener history`, `hey calendar list`, `hey event list`, `hey event day`, `hey event week`, `hey todo list`, `hey habit list`, `hey timetrack list` and `hey journal list`. The @@ -193,6 +193,8 @@ hey snippet list # list reusable email snippets hey snippet create --name "Scheduling reply" --content "Tuesday works for me." hey snippet update 44 --content "Wednesday works for me." hey snippet delete 44 +hey sent # list the latest sent message in each thread +hey sent --all --json # exact To, CC, BCC, sent time, and app URL hey search "quarterly planning" # search threads and matching messages hey search --from jane@example.com --date last_30_days # refine a search hey search filters # list available refinement values @@ -270,6 +272,8 @@ Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply -- `hey share ` 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 ` turns off the sharing link. +`hey sent` lists the latest message you sent in each thread, newest first. Its `id` is the thread ID accepted by `hey thread read`; `sent_at` is when HEY delivered that message, not when an earlier draft was created. JSON separates exact recipients into `to`, `cc`, and `bcc` arrays and includes the subject, summary, and `app_url`. Styled and Markdown output use HEY's compact `Me → first recipient + N` summary. One page arrives by default; `--limit` reads enough pages for the requested count, `--all` follows HEY's next-page links for up to 100 pages, and `--page` continues from a reported page. + 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. 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. Bundling groups a contact's mail into one row without merging or deleting the underlying threads; unbundling lists those threads separately again. HEY applies bundling when the contact's current delivery setting supports bundles. diff --git a/go.mod b/go.mod index f1cadb43..a4782198 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 github.com/basecamp/actioncable-go v1.1.0 - github.com/basecamp/hey-sdk/go v0.31.1 + github.com/basecamp/hey-sdk/go v0.31.2-0.20260920180340-2d40721c770f github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d github.com/charmbracelet/x/ansi v0.11.8 github.com/fsnotify/fsnotify v1.10.1 diff --git a/go.sum b/go.sum index 656af9d5..79a05b6c 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v1.1.0 h1:AizmCxoKvmUMdqwMQCCLQrFaVbjp8RhkneWidxygc7E= github.com/basecamp/actioncable-go v1.1.0/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/hey-sdk/go v0.31.1 h1:dFlCe4LMmLAHgRB05QRHaE26XPPkZVIMp0qCGEanVQU= -github.com/basecamp/hey-sdk/go v0.31.1/go.mod h1:eCJOhTLOpi2FCQUXireHZQ7DzRs2TUqvYhRDTfVLoDY= +github.com/basecamp/hey-sdk/go v0.31.2-0.20260920180340-2d40721c770f h1:pmgQYWhGNX7W6CAxrfKNTN+BEvfganLC2mgR87Uv5hs= +github.com/basecamp/hey-sdk/go v0.31.2-0.20260920180340-2d40721c770f/go.mod h1:eCJOhTLOpi2FCQUXireHZQ7DzRs2TUqvYhRDTfVLoDY= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d h1:zEQVGq1x1nhKMZ2TudFAcSJ32CHT8richI1vQakIKz4= github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d/go.mod h1:Ee2c/q1/pg+5T5741PIuA3s6VJMQC7I0XBNXIHIujzA= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 8c044833..63ffac5a 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -17,7 +17,7 @@ var curatedCategories = []struct { }{ { heading: "CORE COMMANDS", - names: []string{"tui", "box", "thread", "reply", "compose", "search", "contact", "calendar", "journal"}, + names: []string{"tui", "box", "thread", "reply", "compose", "sent", "search", "contact", "calendar", "journal"}, }, { heading: "MAIL", diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index a09d6a83..be316459 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -110,6 +110,7 @@ CORE COMMANDS thread Read email threads reply Reply to a thread compose Write and send a new email + sent List sent email search Search email threads and messages contact Manage contacts calendar Browse your calendars diff --git a/internal/cmd/root.go b/internal/cmd/root.go index eba54d9c..bee6c012 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -206,6 +206,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newClipCommand().cmd) root.AddCommand(newSnippetCommand().cmd) root.AddCommand(newSearchCommand().cmd) + root.AddCommand(newSentCommand().cmd) root.AddCommand(newContactsCommand().cmd) root.AddCommand(newScreenerCommand().cmd) root.AddCommand(newThreadCommand()) diff --git a/internal/cmd/sent.go b/internal/cmd/sent.go new file mode 100644 index 00000000..9dde3fba --- /dev/null +++ b/internal/cmd/sent.go @@ -0,0 +1,257 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-sdk/go/pkg/generated" + + "github.com/basecamp/hey-cli/internal/apierr" + "github.com/basecamp/hey-cli/internal/output" +) + +// maxSentPages bounds one invocation even if the server returns a bad pagination loop. +const maxSentPages = 100 + +type sentCommand struct { + cmd *cobra.Command + limit int + all bool + page int +} + +type sentRecipient struct { + Name string `json:"name,omitempty"` + EmailAddress string `json:"email_address"` +} + +type sentMessage struct { + ID int64 `json:"id"` + Subject string `json:"subject"` + To []sentRecipient `json:"to"` + CC []sentRecipient `json:"cc"` + BCC []sentRecipient `json:"bcc"` + Summary string `json:"summary,omitempty"` + SentAt time.Time `json:"sent_at"` + AppURL string `json:"app_url"` +} + +type sentTableRow struct { + ID int64 `json:"id"` + Subject string `json:"subject"` + Recipients string `json:"recipients"` + Summary string `json:"summary,omitempty"` + Sent string `json:"sent"` + AppURL string `json:"app_url"` +} + +func newSentCommand() *sentCommand { + sentCommand := &sentCommand{} + sentCommand.cmd = &cobra.Command{ + Use: "sent", + Short: "List sent email", + Long: `List the latest message you sent in each thread, newest first. + +One page arrives by default. --limit reads pages until it has enough messages, and +--all reads up to 100 pages. Recipient lists in JSON preserve To, CC, and BCC separately.`, + Annotations: map[string]string{ + "agent_notes": "Returns outbound mail with thread IDs, exact To/CC/BCC recipients, sent times, and HEY app URLs. IDs are thread IDs for `hey thread read `.", + }, + Example: ` hey sent + hey sent --limit 10 --json + hey sent --all --json + hey sent --ids-only`, + RunE: sentCommand.run, + Args: cobra.NoArgs, + } + + sentCommand.cmd.Flags().IntVar(&sentCommand.limit, "limit", 0, "Maximum number of sent messages to show") + sentCommand.cmd.Flags().BoolVar(&sentCommand.all, "all", false, "Fetch up to 100 results pages (override --limit)") + sentCommand.cmd.Flags().IntVar(&sentCommand.page, "page", 1, "Results page") + return sentCommand +} + +func (c *sentCommand) run(cmd *cobra.Command, _ []string) error { + if err := requireAuth(); err != nil { + return err + } + if c.page < 1 { + return apierr.ErrUsage("--page must be at least 1") + } + if c.limit < 0 { + return apierr.ErrUsage("--limit must be at least 0") + } + + startPage := "" + if c.page > 1 { + startPage = strconv.Itoa(c.page) + } + first, err := readSentPage(cmd.Context(), startPage) + if err != nil { + return err + } + collected, err := collectPages(cmd.Context(), first, pageRequest{Limit: c.limit, All: c.all, MaxPages: maxSentPages}, readSentPage) + if err != nil { + return err + } + + messages := makeSentMessages(collected.Items) + nextPage := collected.Cursor + notice := sentListingNotice(len(messages), collected.Read, nextPage, collected.Truncated) + if c.limit > 0 && !c.all && len(messages) > c.limit { + messages = messages[:c.limit] + nextPage = "" + notice = output.TruncationNotice(len(messages), len(collected.Items)) + } + + switch writer.EffectiveFormat() { + case output.FormatStyled: + return writeSentStyled(cmd, messages, notice) + case output.FormatMarkdown: + if stderrNotice := paginationNoticeForStderr(writer.EffectiveFormat(), notice); stderrNotice != "" { + fmt.Fprintln(cmd.ErrOrStderr(), stderrNotice) + } + return writeOK(makeSentTableRows(messages)) + default: + if stderrNotice := paginationNoticeForStderr(writer.EffectiveFormat(), notice); stderrNotice != "" { + fmt.Fprintln(cmd.ErrOrStderr(), stderrNotice) + } + opts := []output.ResponseOption{ + output.WithSummary(fmt.Sprintf("%d %s", len(messages), sentMessageNoun(len(messages)))), + output.WithNotice(notice), + output.WithMeta("page", c.page), + output.WithMeta("pages_fetched", collected.Read), + output.WithBreadcrumbs(output.Breadcrumb{ + Action: "read", + Command: "hey thread read ", + Description: "Read a sent email thread", + }), + } + if nextPage != "" { + opts = append(opts, output.WithMeta("next_page", nextPage)) + } + return writeOK(messages, opts...) + } +} + +func readSentPage(ctx context.Context, cursor string) (pageResult[generated.Topic], error) { + page, err := sdk.Topics().GetSentPage(ctx, cursor) + if err != nil { + return pageResult[generated.Topic]{}, apierr.FromSDK(err) + } + if page == nil { + return pageResult[generated.Topic]{}, nil + } + return pageResult[generated.Topic]{Items: page.Topics, Cursor: page.NextPage}, nil +} + +func makeSentMessages(topics []generated.Topic) []sentMessage { + messages := make([]sentMessage, 0, len(topics)) + for _, topic := range topics { + entry := topic.LatestEntry + sentAt := entry.ActiveAt + if sentAt.IsZero() { + sentAt = entry.CreatedAt + } + messages = append(messages, sentMessage{ + ID: topic.Id, + Subject: topic.Name, + To: makeSentRecipients(entry.Addressed.Directly), + CC: makeSentRecipients(entry.Addressed.Copied), + BCC: makeSentRecipients(entry.Addressed.Blindcopied), + Summary: entry.Summary, + SentAt: sentAt, + AppURL: topic.AppUrl, + }) + } + return messages +} + +func makeSentRecipients(contacts []generated.Contact) []sentRecipient { + recipients := make([]sentRecipient, len(contacts)) + for i, contact := range contacts { + recipients[i] = sentRecipient{Name: contact.Name, EmailAddress: contact.EmailAddress} + } + return recipients +} + +func writeSentStyled(cmd *cobra.Command, messages []sentMessage, notice string) error { + if len(messages) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No sent messages.") + return nil + } + + table := newTable(cmd.OutOrStdout()) + table.addRow([]string{"Thread", "Subject", "Recipients", "Summary", "Sent"}) + for _, message := range messages { + table.addRow([]string{ + strconv.FormatInt(message.ID, 10), + truncate(message.Subject, 42), + truncate(sentRecipientSummary(message), 32), + truncate(message.Summary, 52), + formatTimestamp(message.SentAt.Local()), + }) + } + table.print() + if notice != "" { + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", notice) + } + return nil +} + +func makeSentTableRows(messages []sentMessage) []sentTableRow { + rows := make([]sentTableRow, len(messages)) + for i, message := range messages { + rows[i] = sentTableRow{ + ID: message.ID, + Subject: message.Subject, + Recipients: sentRecipientSummary(message), + Summary: message.Summary, + Sent: formatTimestamp(message.SentAt.Local()), + AppURL: message.AppURL, + } + } + return rows +} + +func sentRecipientSummary(message sentMessage) string { + recipients := make([]sentRecipient, 0, len(message.To)+len(message.CC)+len(message.BCC)) + recipients = append(recipients, message.To...) + recipients = append(recipients, message.CC...) + recipients = append(recipients, message.BCC...) + if len(recipients) == 0 { + return "Me" + } + + name := strings.TrimSpace(recipients[0].Name) + if name == "" { + name = recipients[0].EmailAddress + } + if len(recipients) == 1 { + return "Me → " + name + } + return fmt.Sprintf("Me → %s + %d", name, len(recipients)-1) +} + +func sentListingNotice(shown, pages int, nextPage string, truncated bool) string { + switch { + case truncated: + return fmt.Sprintf("Sent listing stopped after %d pages. Continue with --page %s.", pages, nextPage) + case nextPage != "": + return fmt.Sprintf("Showing %d %s. Use --all to see everything.", shown, sentMessageNoun(shown)) + default: + return "" + } +} + +func sentMessageNoun(count int) string { + if count == 1 { + return "sent message" + } + return "sent messages" +} diff --git a/internal/cmd/sent_test.go b/internal/cmd/sent_test.go new file mode 100644 index 00000000..38562368 --- /dev/null +++ b/internal/cmd/sent_test.go @@ -0,0 +1,201 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" +) + +const sentTopicsJSON = `{ + "title":"Sent Mail", + "topics":[{ + "id":42, + "name":"Quarterly planning follow-up", + "app_url":"https://app.hey.com/topics/42/entries/99", + "latest_entry":{ + "id":99, + "subject":"Quarterly planning follow-up", + "summary":"Here are the decisions and owners from today's planning session.", + "created_at":"2026-09-18T09:00:00Z", + "active_at":"2026-09-19T14:30:00Z", + "addressed":{ + "directly":[{"id":1,"name":"Sarah Chen","email_address":"sarah@example.com"}], + "copied":[{"id":2,"name":"Jamal Reed","email_address":"jamal@example.org"}], + "blindcopied":[{"id":3,"name":"Pat Quinn","email_address":"pat@example.com"}] + } + } + }] +}` + +type sentTestRecipient struct { + Name string `json:"name"` + EmailAddress string `json:"email_address"` +} + +type sentTestRow struct { + ID int64 `json:"id"` + Subject string `json:"subject"` + To []sentTestRecipient `json:"to"` + CC []sentTestRecipient `json:"cc"` + BCC []sentTestRecipient `json:"bcc"` + SentAt time.Time `json:"sent_at"` + AppURL string `json:"app_url"` +} + +func decodeSentData[T any](t *testing.T, data any) T { + t.Helper() + encoded, err := json.Marshal(data) + if err != nil { + t.Fatalf("encode response data: %v", err) + } + var decoded T + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("decode response data: %v", err) + } + return decoded +} + +func TestSentCommandReturnsStableOutboundRows(t *testing.T) { + response, err := runJSONCommand(t, sentTopicsHandler(t, sentTopicsJSON), "sent") + if err != nil { + t.Fatalf("execute sent: %v", err) + } + + rows := decodeSentData[[]sentTestRow](t, response.Data) + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + row := rows[0] + if row.ID != 42 || row.Subject != "Quarterly planning follow-up" { + t.Errorf("row identity = %#v", row) + } + if len(row.To) != 1 || row.To[0].Name != "Sarah Chen" || row.To[0].EmailAddress != "sarah@example.com" { + t.Errorf("to = %#v", row.To) + } + if len(row.CC) != 1 || row.CC[0].EmailAddress != "jamal@example.org" { + t.Errorf("cc = %#v", row.CC) + } + if len(row.BCC) != 1 || row.BCC[0].EmailAddress != "pat@example.com" { + t.Errorf("bcc = %#v", row.BCC) + } + if got := row.SentAt.Format(time.RFC3339); got != "2026-09-19T14:30:00Z" { + t.Errorf("sent_at = %q", got) + } + if row.AppURL != "https://app.hey.com/topics/42/entries/99" { + t.Errorf("app_url = %q", row.AppURL) + } + if response.Summary != "1 sent message" { + t.Errorf("summary = %q", response.Summary) + } +} + +func TestSentCommandStyledMatchesHEYRecipientSummary(t *testing.T) { + stdout, err := runStyledCommand(t, sentTopicsHandler(t, sentTopicsJSON), "sent") + if err != nil { + t.Fatalf("execute sent --styled: %v", err) + } + for _, want := range []string{ + "Quarterly planning follow-up", + "Me → Sarah Chen + 2", + "Here are the decisions and owners", + "2026-09-19", + } { + if !strings.Contains(stdout, want) { + t.Errorf("styled output missing %q:\n%s", want, stdout) + } + } +} + +func TestSentCommandAllFollowsNextPageLink(t *testing.T) { + var mu sync.Mutex + var pages []string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/topics/sent.json" { + http.NotFound(w, r) + return + } + page := r.URL.Query().Get("page") + mu.Lock() + pages = append(pages, page) + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + switch page { + case "": + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, sentTopicsJSON) + case "2": + _, _ = io.WriteString(w, strings.ReplaceAll(sentTopicsJSON, `"id":42`, `"id":43`)) + default: + t.Errorf("unexpected page %q", page) + http.Error(w, "unexpected page", http.StatusBadRequest) + } + }) + + response, err := runJSONCommand(t, handler, "sent", "--all") + if err != nil { + t.Fatalf("execute sent --all: %v", err) + } + rows := decodeSentData[[]sentTestRow](t, response.Data) + if len(rows) != 2 || rows[0].ID != 42 || rows[1].ID != 43 { + t.Fatalf("rows = %#v", rows) + } + mu.Lock() + gotPages := strings.Join(pages, ",") + mu.Unlock() + if gotPages != ",2" { + t.Errorf("pages = %q, want first page then 2", gotPages) + } +} + +func TestSentCommandIDsAndCountUseThreadIDs(t *testing.T) { + handler := sentTopicsHandler(t, sentTopicsJSON) + + ids, err := runFormattedCommand(t, handler, []string{"--ids-only"}, "sent") + if err != nil { + t.Fatalf("execute sent --ids-only: %v", err) + } + if ids != "42\n" { + t.Errorf("ids = %q, want thread ID", ids) + } + + count, err := runFormattedCommand(t, handler, []string{"--count"}, "sent") + if err != nil { + t.Fatalf("execute sent --count: %v", err) + } + if count != "1\n" { + t.Errorf("count = %q", count) + } +} + +func TestSentCommandRejectsInvalidPage(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request: %s", r.URL.String()) + }), "sent", "--page", "0") + if err == nil || !strings.Contains(err.Error(), "--page must be at least 1") { + t.Fatalf("error = %v", err) + } +} + +func sentTopicsHandler(t *testing.T, body string) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/topics/sent.json" { + t.Errorf("request = %s %s, want GET /topics/sent.json", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + if page := r.URL.Query().Get("page"); page != "" && page != "1" { + t.Errorf("page = %q, want first page", page) + } + w.Header().Set("Content-Type", "application/json") + if _, err := fmt.Fprint(w, body); err != nil { + t.Errorf("write response: %v", err) + } + }) +} diff --git a/internal/mcpserver/model/PROVENANCE.json b/internal/mcpserver/model/PROVENANCE.json index c32e64dc..61f85986 100644 --- a/internal/mcpserver/model/PROVENANCE.json +++ b/internal/mcpserver/model/PROVENANCE.json @@ -1,7 +1,10 @@ { "source": "github.com/basecamp/hey-sdk", - "commit": "41585abf862677fd40375c23cd858cc6eeac8a02", - "ref": "go/v0.31.1", - "files": ["behavior-model.json", "openapi.json"], + "commit": "2d40721c770f6ad2330757f064d20ccae99d25c0", + "ref": "go/v0.31.2-0.20260920180340-2d40721c770f", + "files": [ + "behavior-model.json", + "openapi.json" + ], "synced_by": "scripts/sync-mcp-model.sh" } diff --git a/internal/mcpserver/model/openapi.json b/internal/mcpserver/model/openapi.json index 6e2b7c4b..9b3e5e07 100644 --- a/internal/mcpserver/model/openapi.json +++ b/internal/mcpserver/model/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "HEY", - "version": "2026-09-16", + "version": "2026-09-20", "description": "HEY API", "contact": { "name": "Basecamp", @@ -12193,6 +12193,16 @@ }, "x-omitzero": true }, + "active_at": { + "type": "string", + "description": "Delivery time. Present on the latest entry in Sent topics.", + "format": "date-time", + "x-go-type": "time.Time", + "x-go-type-import": { + "path": "time" + }, + "x-omitzero": true + }, "creator": { "$ref": "#/components/schemas/Contact" }, @@ -12214,6 +12224,9 @@ "topic_id": { "type": "integer", "format": "int64" + }, + "addressed": { + "$ref": "#/components/schemas/Addressed" } }, "required": [ diff --git a/tests/smoke/sent_test.go b/tests/smoke/sent_test.go new file mode 100644 index 00000000..18ff59d3 --- /dev/null +++ b/tests/smoke/sent_test.go @@ -0,0 +1,55 @@ +package smoke_test + +import ( + "strconv" + "strings" + "testing" + "time" +) + +type smokeSentRecipient struct { + Name string `json:"name"` + EmailAddress string `json:"email_address"` +} + +type smokeSentMessage struct { + ID int64 `json:"id"` + Subject string `json:"subject"` + To []smokeSentRecipient `json:"to"` + CC []smokeSentRecipient `json:"cc"` + BCC []smokeSentRecipient `json:"bcc"` + SentAt time.Time `json:"sent_at"` + AppURL string `json:"app_url"` +} + +func TestSentListing(t *testing.T) { + resp := heyJSON(t, "sent", "--all") + messages := dataAs[[]smokeSentMessage](t, resp) + for _, message := range messages { + if message.ID <= 0 { + t.Errorf("sent thread id = %d, want positive", message.ID) + } + if strings.TrimSpace(message.Subject) == "" { + t.Errorf("sent thread %d has no subject", message.ID) + } + if message.SentAt.IsZero() { + t.Errorf("sent thread %d has no sent_at", message.ID) + } + if strings.TrimSpace(message.AppURL) == "" { + t.Errorf("sent thread %d has no app_url", message.ID) + } + if message.To == nil || message.CC == nil || message.BCC == nil { + t.Errorf("sent thread %d recipient arrays = to:%v cc:%v bcc:%v, want arrays even when empty", message.ID, message.To, message.CC, message.BCC) + } + } + + ids := strings.Fields(heyOK(t, "sent", "--ids-only")) + countText := strings.TrimSpace(heyOK(t, "sent", "--count")) + count, err := strconv.Atoi(countText) + if err != nil { + t.Fatalf("sent --count = %q: %v", countText, err) + } + if len(ids) != count { + t.Errorf("sent --ids-only returned %d ids, --count returned %d", len(ids), count) + } +} From 1ed1d4370cfb1c485d527dc9c885495e6a909863 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 20 Sep 2026 14:10:50 -0400 Subject: [PATCH 2/8] Test sent listing limits --- internal/cmd/sent_test.go | 43 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/internal/cmd/sent_test.go b/internal/cmd/sent_test.go index 38562368..0c74432d 100644 --- a/internal/cmd/sent_test.go +++ b/internal/cmd/sent_test.go @@ -153,6 +153,41 @@ func TestSentCommandAllFollowsNextPageLink(t *testing.T) { } } +func TestSentCommandLimitReadsEnoughPagesAndTrims(t *testing.T) { + var pages []string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + pages = append(pages, page) + w.Header().Set("Content-Type", "application/json") + switch page { + case "": + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, sentTopicsPage(42)) + case "2": + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, sentTopicsPage(43, 44)) + default: + t.Errorf("unexpected page %q", page) + http.Error(w, "unexpected page", http.StatusBadRequest) + } + }) + + response, err := runJSONCommand(t, handler, "sent", "--limit", "2") + if err != nil { + t.Fatalf("execute sent --limit 2: %v", err) + } + rows := decodeSentData[[]sentTestRow](t, response.Data) + if len(rows) != 2 || rows[0].ID != 42 || rows[1].ID != 43 { + t.Fatalf("rows = %#v", rows) + } + if got := strings.Join(pages, ","); got != ",2" { + t.Errorf("pages = %q, want first page then 2", got) + } + if response.Notice != "Showing 2 of 3 results. Use --all to see everything." { + t.Errorf("notice = %q", response.Notice) + } +} + func TestSentCommandIDsAndCountUseThreadIDs(t *testing.T) { handler := sentTopicsHandler(t, sentTopicsJSON) @@ -182,6 +217,14 @@ func TestSentCommandRejectsInvalidPage(t *testing.T) { } } +func sentTopicsPage(ids ...int64) string { + rows := make([]string, len(ids)) + for i, id := range ids { + rows[i] = fmt.Sprintf(`{"id":%d,"name":"Planning follow-up %d","app_url":"https://app.hey.com/topics/%d/entries/99","latest_entry":{"id":99,"summary":"Decisions and owners.","active_at":"2026-09-19T14:30:00Z","addressed":{"directly":[],"copied":[],"blindcopied":[]}}}`, id, id, id) + } + return `{"title":"Sent Mail","topics":[` + strings.Join(rows, ",") + `]}` +} + func sentTopicsHandler(t *testing.T, body string) http.Handler { t.Helper() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From c102fc792aa5f847179d40bebf3a69571626f58c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 20 Sep 2026 14:13:45 -0400 Subject: [PATCH 3/8] Keep sent pagination notices consistent --- internal/cmd/sent.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/cmd/sent.go b/internal/cmd/sent.go index 9dde3fba..ca0257f4 100644 --- a/internal/cmd/sent.go +++ b/internal/cmd/sent.go @@ -109,18 +109,17 @@ func (c *sentCommand) run(cmd *cobra.Command, _ []string) error { notice = output.TruncationNotice(len(messages), len(collected.Items)) } - switch writer.EffectiveFormat() { + format := writer.EffectiveFormat() + if stderrNotice := paginationNoticeForStderr(format, notice); stderrNotice != "" { + fmt.Fprintln(cmd.ErrOrStderr(), stderrNotice) + } + + switch format { case output.FormatStyled: return writeSentStyled(cmd, messages, notice) case output.FormatMarkdown: - if stderrNotice := paginationNoticeForStderr(writer.EffectiveFormat(), notice); stderrNotice != "" { - fmt.Fprintln(cmd.ErrOrStderr(), stderrNotice) - } return writeOK(makeSentTableRows(messages)) default: - if stderrNotice := paginationNoticeForStderr(writer.EffectiveFormat(), notice); stderrNotice != "" { - fmt.Fprintln(cmd.ErrOrStderr(), stderrNotice) - } opts := []output.ResponseOption{ output.WithSummary(fmt.Sprintf("%d %s", len(messages), sentMessageNoun(len(messages)))), output.WithNotice(notice), From dfa55c886fa3f59a9ee0da28cc8ea251529ea391 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 20 Sep 2026 14:36:35 -0400 Subject: [PATCH 4/8] Harden sent pagination and delivery metadata --- docs/cli.md | 2 +- internal/cmd/sent.go | 36 +++++++++++++------------- internal/cmd/sent_test.go | 53 +++++++++++++++++++++++++++++++++------ nix/package.nix | 2 +- 4 files changed, 67 insertions(+), 26 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index aca42303..2d4212ca 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -272,7 +272,7 @@ Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply -- `hey share ` 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 ` turns off the sharing link. -`hey sent` lists the latest message you sent in each thread, newest first. Its `id` is the thread ID accepted by `hey thread read`; `sent_at` is when HEY delivered that message, not when an earlier draft was created. JSON separates exact recipients into `to`, `cc`, and `bcc` arrays and includes the subject, summary, and `app_url`. Styled and Markdown output use HEY's compact `Me → first recipient + N` summary. One page arrives by default; `--limit` reads enough pages for the requested count, `--all` follows HEY's next-page links for up to 100 pages, and `--page` continues from a reported page. +`hey sent` lists the latest message you sent in each thread, newest first. Its `id` is the thread ID accepted by `hey thread read`; `sent_at` is when HEY delivered that message, not when an earlier draft was created. If an older server omits the delivery time, JSON reports `sent_at` as `null` and human output says `Unavailable`. JSON separates exact recipients into `to`, `cc`, and `bcc` arrays and includes the subject, summary, and `app_url`. Styled and Markdown output use HEY's compact `Me → first recipient + N` summary. One page arrives by default; `--limit` reads enough pages for the requested count, `--all` follows HEY's next-page links for up to 100 pages, and `--page` continues from a reported page cursor. 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. diff --git a/internal/cmd/sent.go b/internal/cmd/sent.go index ca0257f4..3893da98 100644 --- a/internal/cmd/sent.go +++ b/internal/cmd/sent.go @@ -13,6 +13,7 @@ import ( "github.com/basecamp/hey-cli/internal/apierr" "github.com/basecamp/hey-cli/internal/output" + "github.com/basecamp/hey-cli/internal/terminal" ) // maxSentPages bounds one invocation even if the server returns a bad pagination loop. @@ -22,7 +23,7 @@ type sentCommand struct { cmd *cobra.Command limit int all bool - page int + page string } type sentRecipient struct { @@ -37,7 +38,7 @@ type sentMessage struct { CC []sentRecipient `json:"cc"` BCC []sentRecipient `json:"bcc"` Summary string `json:"summary,omitempty"` - SentAt time.Time `json:"sent_at"` + SentAt *time.Time `json:"sent_at"` AppURL string `json:"app_url"` } @@ -72,7 +73,7 @@ One page arrives by default. --limit reads pages until it has enough messages, a sentCommand.cmd.Flags().IntVar(&sentCommand.limit, "limit", 0, "Maximum number of sent messages to show") sentCommand.cmd.Flags().BoolVar(&sentCommand.all, "all", false, "Fetch up to 100 results pages (override --limit)") - sentCommand.cmd.Flags().IntVar(&sentCommand.page, "page", 1, "Results page") + sentCommand.cmd.Flags().StringVar(&sentCommand.page, "page", "", "Results page cursor") return sentCommand } @@ -80,18 +81,11 @@ func (c *sentCommand) run(cmd *cobra.Command, _ []string) error { if err := requireAuth(); err != nil { return err } - if c.page < 1 { - return apierr.ErrUsage("--page must be at least 1") - } if c.limit < 0 { return apierr.ErrUsage("--limit must be at least 0") } - startPage := "" - if c.page > 1 { - startPage = strconv.Itoa(c.page) - } - first, err := readSentPage(cmd.Context(), startPage) + first, err := readSentPage(cmd.Context(), c.page) if err != nil { return err } @@ -153,9 +147,10 @@ func makeSentMessages(topics []generated.Topic) []sentMessage { messages := make([]sentMessage, 0, len(topics)) for _, topic := range topics { entry := topic.LatestEntry - sentAt := entry.ActiveAt - if sentAt.IsZero() { - sentAt = entry.CreatedAt + var sentAt *time.Time + if !entry.ActiveAt.IsZero() { + activeAt := entry.ActiveAt + sentAt = &activeAt } messages = append(messages, sentMessage{ ID: topic.Id, @@ -193,7 +188,7 @@ func writeSentStyled(cmd *cobra.Command, messages []sentMessage, notice string) truncate(message.Subject, 42), truncate(sentRecipientSummary(message), 32), truncate(message.Summary, 52), - formatTimestamp(message.SentAt.Local()), + formatSentTimestamp(message.SentAt), }) } table.print() @@ -211,7 +206,7 @@ func makeSentTableRows(messages []sentMessage) []sentTableRow { Subject: message.Subject, Recipients: sentRecipientSummary(message), Summary: message.Summary, - Sent: formatTimestamp(message.SentAt.Local()), + Sent: formatSentTimestamp(message.SentAt), AppURL: message.AppURL, } } @@ -237,10 +232,17 @@ func sentRecipientSummary(message sentMessage) string { return fmt.Sprintf("Me → %s + %d", name, len(recipients)-1) } +func formatSentTimestamp(sentAt *time.Time) string { + if sentAt == nil { + return "Unavailable" + } + return formatTimestamp(sentAt.Local()) +} + func sentListingNotice(shown, pages int, nextPage string, truncated bool) string { switch { case truncated: - return fmt.Sprintf("Sent listing stopped after %d pages. Continue with --page %s.", pages, nextPage) + return fmt.Sprintf("Sent listing stopped after %d pages. Continue with --page %s.", pages, terminal.SanitizeLine(nextPage)) case nextPage != "": return fmt.Sprintf("Showing %d %s. Use --all to see everything.", shown, sentMessageNoun(shown)) default: diff --git a/internal/cmd/sent_test.go b/internal/cmd/sent_test.go index 0c74432d..a50a3bd7 100644 --- a/internal/cmd/sent_test.go +++ b/internal/cmd/sent_test.go @@ -43,7 +43,7 @@ type sentTestRow struct { To []sentTestRecipient `json:"to"` CC []sentTestRecipient `json:"cc"` BCC []sentTestRecipient `json:"bcc"` - SentAt time.Time `json:"sent_at"` + SentAt *time.Time `json:"sent_at"` AppURL string `json:"app_url"` } @@ -83,6 +83,9 @@ func TestSentCommandReturnsStableOutboundRows(t *testing.T) { if len(row.BCC) != 1 || row.BCC[0].EmailAddress != "pat@example.com" { t.Errorf("bcc = %#v", row.BCC) } + if row.SentAt == nil { + t.Fatal("sent_at is nil") + } if got := row.SentAt.Format(time.RFC3339); got != "2026-09-19T14:30:00Z" { t.Errorf("sent_at = %q", got) } @@ -208,12 +211,48 @@ func TestSentCommandIDsAndCountUseThreadIDs(t *testing.T) { } } -func TestSentCommandRejectsInvalidPage(t *testing.T) { - _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Errorf("unexpected request: %s", r.URL.String()) - }), "sent", "--page", "0") - if err == nil || !strings.Contains(err.Error(), "--page must be at least 1") { - t.Fatalf("error = %v", err) +func TestSentCommandPreservesOpaquePageCursor(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if page := r.URL.Query().Get("page"); page != "cursor-2" { + t.Errorf("page = %q, want cursor-2", page) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, sentTopicsJSON) + }) + + response, err := runJSONCommand(t, handler, "sent", "--page", "cursor-2") + if err != nil { + t.Fatalf("execute sent --page cursor-2: %v", err) + } + if got := response.Meta["page"]; got != "cursor-2" { + t.Errorf("page metadata = %#v", got) + } + if got := response.Meta["next_page"]; got != "cursor-3" { + t.Errorf("next_page metadata = %#v", got) + } +} + +func TestSentCommandDoesNotUseCreationTimeAsDeliveryTime(t *testing.T) { + body := strings.Replace(sentTopicsJSON, `"active_at":"2026-09-19T14:30:00Z",`, "", 1) + response, err := runJSONCommand(t, sentTopicsHandler(t, body), "sent") + if err != nil { + t.Fatalf("execute sent: %v", err) + } + + rows := decodeSentData[[]sentTestRow](t, response.Data) + if rows[0].SentAt != nil { + t.Errorf("sent_at = %v, want null", rows[0].SentAt) + } +} + +func TestSentListingNoticeSanitizesCursor(t *testing.T) { + notice := sentListingNotice(100, 100, "cursor-\x1b[31mnext", true) + if strings.ContainsRune(notice, '\x1b') { + t.Errorf("notice contains escape byte: %q", notice) + } + if !strings.Contains(notice, "cursor-next") { + t.Errorf("notice = %q", notice) } } diff --git a/nix/package.nix b/nix/package.nix index 1fd2d6e6..5c76094f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-ZfLot6LMYS4yL+5A4Jb9qzNVRobgUuVqXkuWfnX751k="; + vendorHash = "sha256-za+/pNZGLU2zXIRO9DjvW4KAXUvwi8k9WSWAKUZw1zk="; subPackages = [ "cmd/hey" ]; From 1f7a2c251dbf49042ee97d78d51316cd06518888 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 20 Sep 2026 15:04:01 -0400 Subject: [PATCH 5/8] Complete sent output edge-case coverage --- internal/cmd/sent.go | 3 +++ internal/cmd/sent_test.go | 35 +++++++++++++++++++++++++++++++++++ tests/smoke/sent_test.go | 6 +++--- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/internal/cmd/sent.go b/internal/cmd/sent.go index 3893da98..1702b016 100644 --- a/internal/cmd/sent.go +++ b/internal/cmd/sent.go @@ -177,6 +177,9 @@ func makeSentRecipients(contacts []generated.Contact) []sentRecipient { func writeSentStyled(cmd *cobra.Command, messages []sentMessage, notice string) error { if len(messages) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "No sent messages.") + if notice != "" { + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", notice) + } return nil } diff --git a/internal/cmd/sent_test.go b/internal/cmd/sent_test.go index a50a3bd7..48b2ba37 100644 --- a/internal/cmd/sent_test.go +++ b/internal/cmd/sent_test.go @@ -114,6 +114,41 @@ func TestSentCommandStyledMatchesHEYRecipientSummary(t *testing.T) { } } +func TestSentCommandMarkdownReportsRecipientsURLAndUnavailableDeliveryTime(t *testing.T) { + body := strings.Replace(sentTopicsJSON, `"active_at":"2026-09-19T14:30:00Z",`, "", 1) + markdown, err := runFormattedCommand(t, sentTopicsHandler(t, body), []string{"--markdown"}, "sent") + if err != nil { + t.Fatalf("execute sent --markdown: %v", err) + } + for _, want := range []string{ + "Me → Sarah Chen + 2", + "https://app.hey.com/topics/42/entries/99", + "Unavailable", + } { + if !strings.Contains(markdown, want) { + t.Errorf("Markdown output missing %q:\n%s", want, markdown) + } + } +} + +func TestSentCommandStyledShowsContinuationAfterAnEmptyPage(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, `{"title":"Sent Mail","topics":[]}`) + }) + + stdout, err := runStyledCommand(t, handler, "sent") + if err != nil { + t.Fatalf("execute sent --styled: %v", err) + } + for _, want := range []string{"No sent messages.", "Use --all to see everything."} { + if !strings.Contains(stdout, want) { + t.Errorf("styled output missing %q:\n%s", want, stdout) + } + } +} + func TestSentCommandAllFollowsNextPageLink(t *testing.T) { var mu sync.Mutex var pages []string diff --git a/tests/smoke/sent_test.go b/tests/smoke/sent_test.go index 18ff59d3..d32ec2ec 100644 --- a/tests/smoke/sent_test.go +++ b/tests/smoke/sent_test.go @@ -18,7 +18,7 @@ type smokeSentMessage struct { To []smokeSentRecipient `json:"to"` CC []smokeSentRecipient `json:"cc"` BCC []smokeSentRecipient `json:"bcc"` - SentAt time.Time `json:"sent_at"` + SentAt *time.Time `json:"sent_at"` AppURL string `json:"app_url"` } @@ -32,8 +32,8 @@ func TestSentListing(t *testing.T) { if strings.TrimSpace(message.Subject) == "" { t.Errorf("sent thread %d has no subject", message.ID) } - if message.SentAt.IsZero() { - t.Errorf("sent thread %d has no sent_at", message.ID) + if message.SentAt != nil && message.SentAt.IsZero() { + t.Errorf("sent thread %d has an invalid sent_at", message.ID) } if strings.TrimSpace(message.AppURL) == "" { t.Errorf("sent thread %d has no app_url", message.ID) From bc05bbfe54ba1d72302d764a4768827309ee6d76 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 20 Sep 2026 15:21:05 -0400 Subject: [PATCH 6/8] Stabilize sent output and pagination notices --- internal/cmd/sent.go | 10 +++++++--- internal/cmd/sent_test.go | 23 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/internal/cmd/sent.go b/internal/cmd/sent.go index 1702b016..5b1b8b99 100644 --- a/internal/cmd/sent.go +++ b/internal/cmd/sent.go @@ -37,7 +37,7 @@ type sentMessage struct { To []sentRecipient `json:"to"` CC []sentRecipient `json:"cc"` BCC []sentRecipient `json:"bcc"` - Summary string `json:"summary,omitempty"` + Summary string `json:"summary"` SentAt *time.Time `json:"sent_at"` AppURL string `json:"app_url"` } @@ -46,7 +46,7 @@ type sentTableRow struct { ID int64 `json:"id"` Subject string `json:"subject"` Recipients string `json:"recipients"` - Summary string `json:"summary,omitempty"` + Summary string `json:"summary"` Sent string `json:"sent"` AppURL string `json:"app_url"` } @@ -100,7 +100,11 @@ func (c *sentCommand) run(cmd *cobra.Command, _ []string) error { if c.limit > 0 && !c.all && len(messages) > c.limit { messages = messages[:c.limit] nextPage = "" - notice = output.TruncationNotice(len(messages), len(collected.Items)) + if collected.Cursor != "" { + notice = sentListingNotice(len(messages), collected.Read, collected.Cursor, false) + } else { + notice = output.TruncationNotice(len(messages), len(collected.Items)) + } } format := writer.EffectiveFormat() diff --git a/internal/cmd/sent_test.go b/internal/cmd/sent_test.go index 48b2ba37..48017f72 100644 --- a/internal/cmd/sent_test.go +++ b/internal/cmd/sent_test.go @@ -131,6 +131,27 @@ func TestSentCommandMarkdownReportsRecipientsURLAndUnavailableDeliveryTime(t *te } } +func TestSentCommandPreservesEmptySummaryFields(t *testing.T) { + body := strings.Replace(sentTopicsJSON, `"summary":"Here are the decisions and owners from today's planning session."`, `"summary":""`, 1) + + response, err := runJSONCommand(t, sentTopicsHandler(t, body), "sent") + if err != nil { + t.Fatalf("execute sent --json: %v", err) + } + rows := decodeSentData[[]map[string]any](t, response.Data) + if summary, ok := rows[0]["summary"]; !ok || summary != "" { + t.Errorf("summary = %#v, present = %v", summary, ok) + } + + markdown, err := runFormattedCommand(t, sentTopicsHandler(t, body), []string{"--markdown"}, "sent") + if err != nil { + t.Fatalf("execute sent --markdown: %v", err) + } + if !strings.Contains(markdown, " summary |") { + t.Errorf("Markdown output has no summary column:\n%s", markdown) + } +} + func TestSentCommandStyledShowsContinuationAfterAnEmptyPage(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -221,7 +242,7 @@ func TestSentCommandLimitReadsEnoughPagesAndTrims(t *testing.T) { if got := strings.Join(pages, ","); got != ",2" { t.Errorf("pages = %q, want first page then 2", got) } - if response.Notice != "Showing 2 of 3 results. Use --all to see everything." { + if response.Notice != "Showing 2 sent messages. Use --all to see everything." { t.Errorf("notice = %q", response.Notice) } } From 75fe8f3076d2051eafa8b1c852a87db10fd03695 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 20 Sep 2026 15:27:10 -0400 Subject: [PATCH 7/8] Document sent API coverage --- API-COVERAGE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/API-COVERAGE.md b/API-COVERAGE.md index 1480e1db..ecd10d6a 100644 --- a/API-COVERAGE.md +++ b/API-COVERAGE.md @@ -55,6 +55,7 @@ which is what `Entries().ListDraftsPage` and `hey draft list --page` exist for. | `/contacts/{id}/note.json` | DELETE | SDK `Contacts().DeleteNote` | `hey contact note delete`, Contacts TUI | covered | | `/calendars.json` | GET | SDK `Calendars().List` | `hey calendar list` | covered | | `/calendars/{id}/recordings.json` | GET | SDK `Calendars().GetRecordings` | `hey event list`, `hey event edit ` (reading the event back; with `--occurrence`, the occurrence's day and then the series' first day for its `Calendar::Countdown`), `hey todo list`, `hey journal list` | covered | +| `/topics/sent.json` | GET | SDK `Topics().GetSentPage` | `hey sent` | covered | | `/topics/{id}/entries.json` | GET | SDK `Topics().GetEntries` | `hey thread read `, `hey attachment list ` | covered, but see the paging note below | | `/topics/{id}/publication` | POST | SDK `Publications().Create` | `hey share ` | covered | | `/topics/{id}/publication.json` | GET | SDK `Publications().Create` readback | `hey share ` | covered | From 8624becc51c8d2f4ca013ca3049d15f28d76b5ee Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Sun, 20 Sep 2026 16:55:17 -0400 Subject: [PATCH 8/8] Handle sent pagination and timestamp fallbacks --- docs/cli.md | 2 +- internal/cmd/sent.go | 14 +++++++---- internal/cmd/sent_test.go | 50 +++++++++++++++++++++++++++++++++++---- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 2d4212ca..b61cfff7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -272,7 +272,7 @@ Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply -- `hey share ` 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 ` turns off the sharing link. -`hey sent` lists the latest message you sent in each thread, newest first. Its `id` is the thread ID accepted by `hey thread read`; `sent_at` is when HEY delivered that message, not when an earlier draft was created. If an older server omits the delivery time, JSON reports `sent_at` as `null` and human output says `Unavailable`. JSON separates exact recipients into `to`, `cc`, and `bcc` arrays and includes the subject, summary, and `app_url`. Styled and Markdown output use HEY's compact `Me → first recipient + N` summary. One page arrives by default; `--limit` reads enough pages for the requested count, `--all` follows HEY's next-page links for up to 100 pages, and `--page` continues from a reported page cursor. +`hey sent` lists the latest message you sent in each thread, newest first. Its `id` is the thread ID accepted by `hey thread read`; `sent_at` uses HEY's delivery time when present and otherwise its creation time, matching the web view. If a server omits both times, JSON reports `sent_at` as `null` and human output says `Unavailable`. JSON separates exact recipients into `to`, `cc`, and `bcc` arrays and includes the subject, summary, and `app_url`. Styled and Markdown output use HEY's compact `Me → first recipient + N` summary. One page arrives by default; `--limit` reads enough pages for the requested count, `--all` follows HEY's next-page links for up to 100 pages, and `--page` continues from a reported page cursor. 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. diff --git a/internal/cmd/sent.go b/internal/cmd/sent.go index 5b1b8b99..6bcf7690 100644 --- a/internal/cmd/sent.go +++ b/internal/cmd/sent.go @@ -96,12 +96,12 @@ func (c *sentCommand) run(cmd *cobra.Command, _ []string) error { messages := makeSentMessages(collected.Items) nextPage := collected.Cursor - notice := sentListingNotice(len(messages), collected.Read, nextPage, collected.Truncated) + notice := sentListingNotice(len(messages), collected.Read, nextPage, collected.Truncated, c.all) if c.limit > 0 && !c.all && len(messages) > c.limit { messages = messages[:c.limit] nextPage = "" if collected.Cursor != "" { - notice = sentListingNotice(len(messages), collected.Read, collected.Cursor, false) + notice = sentListingNotice(len(messages), collected.Read, collected.Cursor, false, false) } else { notice = output.TruncationNotice(len(messages), len(collected.Items)) } @@ -152,9 +152,13 @@ func makeSentMessages(topics []generated.Topic) []sentMessage { for _, topic := range topics { entry := topic.LatestEntry var sentAt *time.Time - if !entry.ActiveAt.IsZero() { + switch { + case !entry.ActiveAt.IsZero(): activeAt := entry.ActiveAt sentAt = &activeAt + case !entry.CreatedAt.IsZero(): + createdAt := entry.CreatedAt + sentAt = &createdAt } messages = append(messages, sentMessage{ ID: topic.Id, @@ -246,10 +250,12 @@ func formatSentTimestamp(sentAt *time.Time) string { return formatTimestamp(sentAt.Local()) } -func sentListingNotice(shown, pages int, nextPage string, truncated bool) string { +func sentListingNotice(shown, pages int, nextPage string, truncated, all bool) string { switch { case truncated: return fmt.Sprintf("Sent listing stopped after %d pages. Continue with --page %s.", pages, terminal.SanitizeLine(nextPage)) + case all && nextPage != "": + return fmt.Sprintf("Showing %d %s. Continue with --page %s.", shown, sentMessageNoun(shown), terminal.SanitizeLine(nextPage)) case nextPage != "": return fmt.Sprintf("Showing %d %s. Use --all to see everything.", shown, sentMessageNoun(shown)) default: diff --git a/internal/cmd/sent_test.go b/internal/cmd/sent_test.go index 48017f72..b9ab436d 100644 --- a/internal/cmd/sent_test.go +++ b/internal/cmd/sent_test.go @@ -102,11 +102,12 @@ func TestSentCommandStyledMatchesHEYRecipientSummary(t *testing.T) { if err != nil { t.Fatalf("execute sent --styled: %v", err) } + wantDate := time.Date(2026, time.September, 19, 14, 30, 0, 0, time.UTC).Local().Format("2006-01-02") for _, want := range []string{ "Quarterly planning follow-up", "Me → Sarah Chen + 2", "Here are the decisions and owners", - "2026-09-19", + wantDate, } { if !strings.Contains(stdout, want) { t.Errorf("styled output missing %q:\n%s", want, stdout) @@ -116,6 +117,7 @@ func TestSentCommandStyledMatchesHEYRecipientSummary(t *testing.T) { func TestSentCommandMarkdownReportsRecipientsURLAndUnavailableDeliveryTime(t *testing.T) { body := strings.Replace(sentTopicsJSON, `"active_at":"2026-09-19T14:30:00Z",`, "", 1) + body = strings.Replace(body, `"created_at":"2026-09-18T09:00:00Z",`, "", 1) markdown, err := runFormattedCommand(t, sentTopicsHandler(t, body), []string{"--markdown"}, "sent") if err != nil { t.Fatalf("execute sent --markdown: %v", err) @@ -170,6 +172,41 @@ func TestSentCommandStyledShowsContinuationAfterAnEmptyPage(t *testing.T) { } } +func TestSentCommandAllContinuesAfterAnEmptyPageWithACursor(t *testing.T) { + var pages []string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + pages = append(pages, page) + w.Header().Set("Content-Type", "application/json") + + switch page { + case "": + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, sentTopicsJSON) + case "cursor-2": + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, `{"title":"Sent Mail","topics":[]}`) + default: + t.Errorf("unexpected page %q", page) + http.Error(w, "unexpected page", http.StatusBadRequest) + } + }) + + response, err := runJSONCommand(t, handler, "sent", "--all") + if err != nil { + t.Fatalf("execute sent --all: %v", err) + } + if got := strings.Join(pages, ","); got != ",cursor-2" { + t.Errorf("pages = %q, want first page then cursor-2", got) + } + if response.Notice != "Showing 1 sent message. Continue with --page cursor-3." { + t.Errorf("notice = %q", response.Notice) + } + if got := response.Meta["next_page"]; got != "cursor-3" { + t.Errorf("next_page = %#v", got) + } +} + func TestSentCommandAllFollowsNextPageLink(t *testing.T) { var mu sync.Mutex var pages []string @@ -289,7 +326,7 @@ func TestSentCommandPreservesOpaquePageCursor(t *testing.T) { } } -func TestSentCommandDoesNotUseCreationTimeAsDeliveryTime(t *testing.T) { +func TestSentCommandFallsBackToCreationTimeWhenDeliveryTimeIsMissing(t *testing.T) { body := strings.Replace(sentTopicsJSON, `"active_at":"2026-09-19T14:30:00Z",`, "", 1) response, err := runJSONCommand(t, sentTopicsHandler(t, body), "sent") if err != nil { @@ -297,13 +334,16 @@ func TestSentCommandDoesNotUseCreationTimeAsDeliveryTime(t *testing.T) { } rows := decodeSentData[[]sentTestRow](t, response.Data) - if rows[0].SentAt != nil { - t.Errorf("sent_at = %v, want null", rows[0].SentAt) + if rows[0].SentAt == nil { + t.Fatal("sent_at is nil") + } + if got := rows[0].SentAt.Format(time.RFC3339); got != "2026-09-18T09:00:00Z" { + t.Errorf("sent_at = %q", got) } } func TestSentListingNoticeSanitizesCursor(t *testing.T) { - notice := sentListingNotice(100, 100, "cursor-\x1b[31mnext", true) + notice := sentListingNotice(100, 100, "cursor-\x1b[31mnext", true, false) if strings.ContainsRune(notice, '\x1b') { t.Errorf("notice contains escape byte: %q", notice) }