Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` (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 <id>`, `hey attachment list <topic-id>` | covered, but see the paging note below |
| `/topics/{id}/publication` | POST | SDK `Publications().Create` | `hey share <thread-id>` | covered |
| `/topics/{id}/publication.json` | GET | SDK `Publications().Create` readback | `hey share <thread-id>` | covered |
Expand Down
6 changes: 5 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -270,6 +272,8 @@ Drafts are the review-before-send lane: `hey compose --draft` (and `hey reply --

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

`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.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flagged for human review - this dependency pin must remain until SDK PR #222 is merged and released. The PR description records the required release replacement and dependency order.

github.com/basecamp/mcp v0.0.0-20260828100356-2d6f44b51e9d
github.com/charmbracelet/x/ansi v0.11.8
github.com/fsnotify/fsnotify v1.10.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
271 changes: 271 additions & 0 deletions internal/cmd/sent.go
Original file line number Diff line number Diff line change
@@ -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 <id>`.",
},
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))
Comment thread
robzolkos marked this conversation as resolved.
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 <id>",
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)
Comment thread
robzolkos marked this conversation as resolved.
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"
}
Loading
Loading