Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ hey bulk-reply send --attach
hey bulk-reply send --message
hey bulk-reply send --message-html
hey bulk-reply undo
hey bundle
hey bundle --all
hey bundle --limit
hey bundle --page
hey bundle view
hey bundle view --all
hey bundle view --limit
hey bundle view --page
hey calendar
hey calendar list
hey clip
Expand Down Expand Up @@ -124,6 +132,10 @@ hey contact note set --note-html
hey contact note show
hey contact show
hey contact show-again
hey contact threads
hey contact threads --all
hey contact threads --limit
hey contact threads --page
hey contact unbundle
hey contact update
hey contact update --alias
Expand Down
13 changes: 11 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,8 @@ has one answer in the TUI rather than one per section.

### A mail source reads its own page

`internal/mail` is where a box, a label and a collection stop being three endpoints and
become one `Source` with one `ReadPage`. It follows the shape `internal/folders` and
`internal/mail` is where a box, a label, a collection, a bundle's unseen threads and a
contact's threads stop being five endpoints and become one `Source` with one `ReadPage`. It follows the shape `internal/folders` and
`internal/habit` already set: a domain package taking `client *hey.Client`, imported by
whoever needs it.

Expand Down Expand Up @@ -476,6 +476,15 @@ bundle with several unseen threads (or none) names no topic and answers zero.
`internal/cmd` needs the same answer — `resolvePostingTopicID` in `sdk.go` is a call to
them, not a second copy.

**A bundle row's mail is reached the way the TUI reaches it.** `hey bundle view` lists
the unseen threads a bundle groups (`KindBundle`, the `bundles/unseen` route) and
`hey contact threads` lists every thread with its contact (`KindContact`, the contact
show route's postings page) — a read-through bundle has no unseen threads and no single
topic, so its mail lives only on the contact's list. The likeliest misuse is handing
`hey thread read` a bundle row's own id, which the topic route 404s; `loadThread` checks
a not-found against the bundle route and, when it answers, says what the id really is
instead of letting "not found" read as "no content".

**`mail.Entry` is one message in a thread**, described by `mail.NewEntry` against the
message HEY served for it, because a topic's entry list and a message read on its own
disagree about what they carry: an entry under a bundle has no creator and no timestamp,
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,8 +318,8 @@ hey box list --quiet --jq '.[].id'
Listing commands also answer `--markdown` for a table, `--styled` to force the human
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 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 screener list`, `hey screener history`, `hey calendar 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 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 @@ -363,6 +363,7 @@ or through the direct-form escape (`hey box -- list`).
```bash
hey box list # list mailboxes
hey box view imbox # list email threads in a box (by name or ID)
hey bundle view 456 # list the unseen threads a bundle row groups
hey label list # list labels and their IDs
hey label view 789 --all # list all email threads with a label
hey label add 12345 --to 789 # add a label to a thread
Expand Down Expand Up @@ -396,6 +397,7 @@ hey search --from jane@example.com --date last_30_days # refine a search
hey search filters # list available refinement values
hey contact list # list contacts
hey contact show 12345 # view a contact and private note
hey contact threads 12345 # list every thread with a contact, seen and unseen
hey contact add --name "Jane Doe" --email jane@example.com
hey contact update 12345 --name "Jane Dawson"
hey contact hide 12345 # hide without permanently deleting
Expand Down
3 changes: 2 additions & 1 deletion internal/cmd/box.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ var boxListing = postingsListing{
},
breadcrumbs: []output.Breadcrumb{
{Action: "read", Command: "hey thread read <thread-id>", Description: "Read an email thread"},
{Action: "bundle", Command: "hey bundle view <box-item-id>", Description: "List the unseen threads a bundle row groups"},
{Action: "move", Command: "hey move <box-item-id> --to <box>", Description: "Move an email thread to another box"},
{Action: "compose", Command: "hey compose --to <email> --subject <subject>", Description: "Compose a new message"},
},
Expand Down Expand Up @@ -80,7 +81,7 @@ func newBoxReaderCommand(use, short, long, example string) *boxCommand {
Short: short,
Long: long,
Annotations: map[string]string{
"agent_notes": "Accepts a box name or numeric ID. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. --page continues from the next_page cursor of an earlier listing of the same box.",
"agent_notes": "Accepts a box name or numeric ID. Returns email threads. Use topic_id with hey thread read, reply, and forward; use id with seen, unseen, and move. A row with kind \"bundle\" groups one sender's unseen threads and has no topic_id: list them with hey bundle view <id>, and every thread with that sender via hey contact threads <contact-id>. --page continues from the next_page cursor of an earlier listing of the same box.",
},
Example: example,
RunE: command.run,
Expand Down
139 changes: 139 additions & 0 deletions internal/cmd/bundle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package cmd

import (
"errors"
"fmt"

"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/mail"
"github.com/basecamp/hey-cli/internal/output"
"github.com/basecamp/hey-cli/internal/terminal"
)

type bundleCommand struct {
cmd *cobra.Command
limit int
all bool
page string
}

// bundleOutput is what `hey bundle view --json` answers with: the bundled contact next
// to the unseen postings, because the contact's id is what reads the rest of the
// bundle's mail once these threads are seen (hey contact threads <contact-id>).
type bundleOutput struct {
ID int64 `json:"id"`
Contact generated.Contact `json:"contact"`
Postings []sourcePostingOutput `json:"postings"`
NextPage string `json:"next_page,omitempty"`
}

var bundleListing = postingsListing{
heading: "Bundle",
summary: func(count int, name string) string {
return fmt.Sprintf("%d unseen %s bundled from %s", count, threadNoun(count), name)
},
cursorNotice: func(shown, total int) string {
return fmt.Sprintf("Showing %d remaining results from this cursor (%d unseen threads read).", shown, total)
},
}

func newBundleCommand() *bundleCommand {
command := newBundleReaderCommand(
"bundle",
"List the unseen threads a bundle groups",
` hey bundle view 12345
hey bundle view 12345 --all
hey bundle view 12345 --json`,
)
command.cmd.Annotations[compatibilityUsageAnnotation] = "bundle <box-item-id>"
command.cmd.Args = cobra.MaximumNArgs(1)
command.cmd.AddCommand(newBundleViewCommand().cmd)
return command
}

func newBundleViewCommand() *bundleCommand {
return newBundleReaderCommand(
"view <box-item-id>",
"List the unseen threads a bundle groups",
` hey bundle view 12345
hey bundle view 12345 --page next-cursor
hey bundle view 12345 --all
hey bundle view 12345 --json`,
)
}

func newBundleReaderCommand(use, short, example string) *bundleCommand {
command := &bundleCommand{}
command.cmd = &cobra.Command{
Use: use,
Short: short,
Long: "List the unseen email threads a bundle groups. A bundle is a hey box view row with kind \"bundle\": one sender's mail rolled into a single row instead of a thread apiece.",
Annotations: map[string]string{
"agent_notes": "The ID is a bundle row's own id from hey box view — a row with kind \"bundle\" and no topic_id. Returns the unseen threads the bundle groups, each with topic_id for hey thread read. A bundle read through has no unseen threads; every thread with its sender, seen and unseen, is listed by hey contact threads <contact-id>.",
},
Example: example,
RunE: command.run,
Args: usageExactOneArg(),
}

command.cmd.Flags().IntVar(&command.limit, "limit", 0, "Maximum number of threads to show")
command.cmd.Flags().BoolVar(&command.all, "all", false, "Fetch all results (override --limit)")
command.cmd.Flags().StringVar(&command.page, "page", "", "Continue from a next_page cursor")
return command
}

func (c *bundleCommand) run(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
if err := requireAuth(); err != nil {
return err
}

postingID, err := parsePositiveID(args[0], "bundle")
if err != nil {
return err
}

first, err := sdk.Postings().BundleUnseenPage(cmd.Context(), postingID, c.page)
if err != nil {
return bundleNotFound(args[0], apierr.FromSDK(err))
}
if first == nil {
return apierr.ErrNotFound("bundle", args[0])
}

contact := first.Contact
seed := pageResult[generated.Posting]{Items: first.Postings, Cursor: first.NextPage}
request := pageRequest{Limit: c.limit, All: c.all, MaxPages: maxPostingPages}

listing := bundleListing
listing.emptyNotice = fmt.Sprintf(
"This bundle has no unseen threads — everything in it has been read. List every thread with %s: hey contact threads %d",
terminal.SanitizeLine(contact.Name), contact.Id)
listing.breadcrumbs = []output.Breadcrumb{
{Action: "read", Command: "hey thread read <thread-id>", Description: "Read an email thread"},
{Action: "contact_threads", Command: fmt.Sprintf("hey contact threads %d", contact.Id),
Description: "List every thread with this bundle's sender, seen and unseen"},
}
listing.payload = func(_ mail.Source, postings []sourcePostingOutput, nextPage string, _ int) any {
return bundleOutput{ID: postingID, Contact: contact, Postings: postings, NextPage: nextPage}
}
return listing.write(cmd, mail.BundleSource(postingID, contact), seed, request, c.page != "")
}

// bundleNotFound says what a 404 on the bundle route means: the ID was not a bundle
// row's. The route answers only for postings that are bundles, so a plain thread's box
// item id and a topic id both 404 here.
func bundleNotFound(identifier string, err error) error {
var apiErr *apierr.Error
if errors.As(err, &apiErr) && apiErr.Code == apierr.CodeNotFound {
return apierr.ErrNotFoundHint("bundle", identifier,
"The ID must be a bundle row's own id — a hey box view row with kind \"bundle\".")
}
return err
}
Loading
Loading