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/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 | diff --git a/docs/cli.md b/docs/cli.md index 72cbb684..b61cfff7 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` 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. 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..6bcf7690 --- /dev/null +++ b/internal/cmd/sent.go @@ -0,0 +1,271 @@ +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" + "github.com/basecamp/hey-cli/internal/terminal" +) + +// 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 string +} + +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"` + 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"` + 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().StringVar(&sentCommand.page, "page", "", "Results page cursor") + return sentCommand +} + +func (c *sentCommand) run(cmd *cobra.Command, _ []string) error { + if err := requireAuth(); err != nil { + return err + } + if c.limit < 0 { + return apierr.ErrUsage("--limit must be at least 0") + } + + first, err := readSentPage(cmd.Context(), c.page) + 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, 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, false) + } else { + notice = output.TruncationNotice(len(messages), len(collected.Items)) + } + } + + 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: + return writeOK(makeSentTableRows(messages)) + default: + 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 + var sentAt *time.Time + 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, + 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.") + if notice != "" { + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", notice) + } + 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), + formatSentTimestamp(message.SentAt), + }) + } + 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: formatSentTimestamp(message.SentAt), + 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 formatSentTimestamp(sentAt *time.Time) string { + if sentAt == nil { + return "Unavailable" + } + return formatTimestamp(sentAt.Local()) +} + +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: + 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..b9ab436d --- /dev/null +++ b/internal/cmd/sent_test.go @@ -0,0 +1,379 @@ +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 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) + } + 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) + } + 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", + wantDate, + } { + if !strings.Contains(stdout, want) { + t.Errorf("styled output missing %q:\n%s", want, stdout) + } + } +} + +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) + } + 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 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") + 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 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 + 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 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 sent messages. Use --all to see everything." { + t.Errorf("notice = %q", response.Notice) + } +} + +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 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 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 { + t.Fatalf("execute sent: %v", err) + } + + rows := decodeSentData[[]sentTestRow](t, response.Data) + 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, false) + if strings.ContainsRune(notice, '\x1b') { + t.Errorf("notice contains escape byte: %q", notice) + } + if !strings.Contains(notice, "cursor-next") { + t.Errorf("notice = %q", notice) + } +} + +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) { + 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/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" ]; diff --git a/tests/smoke/sent_test.go b/tests/smoke/sent_test.go new file mode 100644 index 00000000..d32ec2ec --- /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 != 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) + } + 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) + } +}