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
8 changes: 8 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ hey doctor
hey drafts
hey drafts --all
hey drafts --limit
hey everything
hey everything --page
hey forward
hey forward --bcc
hey forward --cc
Expand Down Expand Up @@ -113,10 +115,14 @@ hey search --subject
hey search --to
hey search filters
hey seen
hey sent
hey sent --page
hey setup
hey skill
hey skill install
hey spam
hey spammed
hey spammed --page
hey stop-ignoring
hey threads
hey timetrack
Expand All @@ -137,5 +143,7 @@ hey todo list --all
hey todo list --limit
hey todo uncomplete
hey trash
hey trashed
hey trashed --page
hey tui
hey unseen
4 changes: 4 additions & 0 deletions API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ The remaining HTML-reading gaps use the SDK's authenticated HTML helper and are
| `/asidebox.json` | GET | SDK `Boxes().GetAsidebox` | `hey box asidebox` | covered |
| `/laterbox.json` | GET | SDK `Boxes().GetLaterbox` | `hey box laterbox` | covered |
| `/bubblebox.json` | GET | SDK `Boxes().GetBubblebox` | `hey box bubblebox` | covered |
| `/topics/sent.json` | GET | SDK `Topics().GetSent` | `hey sent` | covered |
| `/topics/spam.json` | GET | SDK `Topics().GetSpam` | `hey spammed` | covered |
| `/topics/trash.json` | GET | SDK `Topics().GetTrash` | `hey trashed` | covered |
| `/topics/everything.json` | GET | SDK `Topics().GetEverything` | `hey everything` | covered |
| `/advanced_search.json` | GET | SDK `Search().Search` | `hey search`, TUI `/` | covered |
| `/advanced_search_filters.json` | GET | SDK `Search().Filters` | `hey search filters` | covered |
| `/contacts.json` | GET | SDK `Contacts().List` | `hey contacts list`, Contacts TUI | covered |
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ All commands support `--json` for raw JSON output and `--base-url` to override t
```bash
hey boxes # list mailboxes
hey box imbox # list email threads in a box (by name or ID)
hey sent # list sent emails
hey spammed # list emails in Spam without changing them
hey trashed # list emails in Trash without changing them
hey everything # list all email in HEY's Everything view
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 @@ -102,6 +106,8 @@ hey ignore 12345 # ignore future activity on a thread
hey stop-ignoring 12345 # resume attention for a thread
```

The system-view commands are read-only. `hey spammed` lists Spam while `hey spam <id>` marks an email as spam. `hey trashed` lists Trash while `hey trash <id>` moves an email to Trash. Each result returns a thread ID for `hey threads`.

Search accepts free text plus `--required`, `--any`, `--none`, `--exact`, `--from`, `--to`, `--subject`, `--date`, `--in`, `--label`, and `--attachment`. 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.
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: "EMAIL",
names: []string{"boxes", "box", "search", "contacts", "threads", "attachments", "compose", "reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"},
names: []string{"boxes", "box", "sent", "spammed", "trashed", "everything", "search", "contacts", "threads", "attachments", "compose", "reply", "forward", "drafts", "seen", "unseen", "move", "trash", "spam", "ignore", "stop-ignoring"},
},
{
heading: "CALENDAR & TASKS",
Expand Down
4 changes: 4 additions & 0 deletions internal/cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ USAGE
EMAIL
boxes List your HEY boxes
box List email threads in a box
sent List sent emails
spammed List emails in Spam
trashed List emails in Trash
everything List all email
search Search email threads and messages
contacts Manage contacts
threads Read a thread
Expand Down
4 changes: 4 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newAuthCommand().cmd)
root.AddCommand(newBoxesCommand().cmd)
root.AddCommand(newBoxCommand().cmd)
root.AddCommand(newSentCommand().cmd)
root.AddCommand(newSpammedCommand().cmd)
root.AddCommand(newTrashedCommand().cmd)
root.AddCommand(newEverythingCommand().cmd)
root.AddCommand(newSearchCommand().cmd)
root.AddCommand(newContactsCommand().cmd)
root.AddCommand(newThreadsCommand().cmd)
Expand Down
177 changes: 177 additions & 0 deletions internal/cmd/topic_views.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package cmd

import (
"context"
"fmt"
"strconv"
"time"

"github.com/spf13/cobra"

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

"github.com/basecamp/hey-cli/internal/output"
)

type topicViewFetcher func(context.Context, *string) (*generated.TopicListResponse, error)

type topicViewCommand struct {
cmd *cobra.Command
page int
title string
fetch topicViewFetcher
emptyMsg string
}

func newSentCommand() *topicViewCommand {
return newTopicViewCommand(
"sent",
"List sent emails",
"Sent",
"Returns sent email topics. Use a topic ID with hey threads to read the full conversation.",
func(ctx context.Context, page *string) (*generated.TopicListResponse, error) {
return sdk.Topics().GetSent(ctx, &generated.GetSentTopicsParams{Page: page})
},
)
}

func newSpammedCommand() *topicViewCommand {
return newTopicViewCommand(
"spammed",
"List emails in Spam",
"Spam",
"Returns topics in Spam. This command is read-only; hey spam <id> marks a thread as spam.",
func(ctx context.Context, page *string) (*generated.TopicListResponse, error) {
return sdk.Topics().GetSpam(ctx, &generated.GetSpamTopicsParams{Page: page})
},
)
}

func newTrashedCommand() *topicViewCommand {
return newTopicViewCommand(
"trashed",
"List emails in Trash",
"Trash",
"Returns topics in Trash. This command is read-only; hey trash <id> moves a thread to Trash.",
func(ctx context.Context, page *string) (*generated.TopicListResponse, error) {
return sdk.Topics().GetTrash(ctx, &generated.GetTrashTopicsParams{Page: page})
},
)
}

func newEverythingCommand() *topicViewCommand {
return newTopicViewCommand(
"everything",
"List all email",
"Everything",
"Returns topics from HEY's Everything view. Use a topic ID with hey threads to read the full conversation.",
func(ctx context.Context, page *string) (*generated.TopicListResponse, error) {
return sdk.Topics().GetEverything(ctx, &generated.GetEverythingTopicsParams{Page: page})
},
)
}

func newTopicViewCommand(name, short, title, agentNotes string, fetch topicViewFetcher) *topicViewCommand {
viewCommand := &topicViewCommand{
title: title,
fetch: fetch,
emptyMsg: fmt.Sprintf("No emails in %s.", title),
}
viewCommand.cmd = &cobra.Command{
Use: name,
Short: short,
Annotations: map[string]string{
"agent_notes": agentNotes,
},
Example: fmt.Sprintf(" hey %s\n hey %s --page 2\n hey %s --json", name, name, name),
RunE: viewCommand.run,
Args: cobra.NoArgs,
}
viewCommand.cmd.Flags().IntVar(&viewCommand.page, "page", 1, "Result page (starting at 1)")
return viewCommand
}

func (c *topicViewCommand) run(cmd *cobra.Command, _ []string) error {
if c.page < 1 {
return output.ErrUsage("--page must be at least 1")
}
if err := requireAuth(); err != nil {
return err
}

var page *string
if c.page > 1 {
value := strconv.Itoa(c.page)
page = &value
}
result, err := c.fetch(cmd.Context(), page)
if err != nil {
return convertSDKError(err)
}
if result == nil {
result = &generated.TopicListResponse{}
}
topics := result.Topics
if topics == nil {
topics = make([]generated.Topic, 0)
}
title := result.Title
if title == "" {
title = c.title
}

if writer.IsStyled() {
if len(topics) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), c.emptyMsg)
return nil
}

fmt.Fprintf(cmd.OutOrStdout(), "%s\n\n", terminalSafeText(title))
table := newTable(cmd.OutOrStdout())
table.addRow([]string{"Thread", "Subject", "From", "Date"})
for _, topic := range topics {
table.addRow([]string{
fmt.Sprintf("%d", topic.Id),
truncate(terminalSafeText(topic.Name), 48),
terminalSafeText(topicViewSender(topic)),
formatDate(topicViewDate(topic)),
})
}
table.print()
return nil
}

return writeOK(topics,
output.WithSummary(topicViewSummary(len(topics), title)),
output.WithBreadcrumbs(output.Breadcrumb{
Action: "read",
Command: "hey threads <id>",
Description: "Read an email thread",
}),
)
}

func topicViewSummary(count int, title string) string {
noun := "emails"
if count == 1 {
noun = "email"
}
return fmt.Sprintf("%d %s in %s", count, noun, title)
}

func topicViewSender(topic generated.Topic) string {
if topic.Creator.Name != "" {
return topic.Creator.Name
}
return topic.Creator.EmailAddress
}

func topicViewDate(topic generated.Topic) time.Time {
if !topic.ActiveAt.IsZero() {
return topic.ActiveAt
}
if !topic.UpdatedAt.IsZero() {
return topic.UpdatedAt
}
return topic.CreatedAt
}
Loading