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
11 changes: 11 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ hey box view
hey box view --all
hey box view --limit
hey box view --page
hey bubble
hey bubble list
hey bubble list --all
hey bubble list --limit
hey bubble pop
hey bubble up
hey bubble up --next-week
hey bubble up --now
hey bubble up --on
hey bubble up --tomorrow
hey bubble up --weekend
hey bulk-reply
hey bulk-reply preview
hey bulk-reply send
Expand Down
6 changes: 5 additions & 1 deletion API-COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ which is what `Entries().ListDraftsPage` and `hey draft list --page` exist for.
| `/trailbox.json` | GET | SDK `Boxes().GetTrailbox` | `hey box view trailbox` | covered |
| `/asidebox.json` | GET | SDK `Boxes().GetAsidebox` | `hey box view asidebox` | covered |
| `/laterbox.json` | GET | SDK `Boxes().GetLaterbox` | `hey box view laterbox` | covered |
| `/bubblebox.json` | GET | SDK `Boxes().GetBubblebox` | `hey box view bubblebox` | covered |
| `/bubblebox.json` | GET | SDK `Boxes().GetBubblebox` | `hey box view bubblebox`, `hey bubble list` (scheduled bucket) | covered |
| `/imbox/bubbled_up` | GET | — | — | not served: HTML only; the Imbox JSON orders bubbled-up threads first, so `hey bubble list` reads that prefix instead |
| `/my/navigation.json` | GET | SDK `Identity().GetNavigation` | `hey label list`, Mail TUI navigation | covered |
| `/folders/{id}.json` | GET | SDK `Folders().GetPage` | `hey label view <id>`, Mail TUI labels | covered |
| `/postings/filings.json` | POST | SDK `Postings().File` | `hey label add`, TUI `b/B` | covered |
Expand Down Expand Up @@ -81,6 +82,9 @@ which is what `Entries().ListDraftsPage` and `hey draft list --page` exist for.
| `/postings/spam.json` | POST | SDK `Postings().MarkSpam` | `hey spam <id>`, TUI `!` | covered |
| `/postings/mutings.json` | POST | SDK `Postings().Mute` | `hey ignore <id>`, TUI `-` | covered |
| `/postings/mutings.json` | DELETE | SDK `Postings().Unmute` | `hey stop-ignoring <id>`, TUI `+` | covered |
| `/postings/bulk_bubble_up_now.json` | POST | SDK `Postings().BubbleUpNow` | `hey bubble up <id> --now` | covered |
| `/postings/bubble_up.json` | POST | SDK `Postings().ScheduleBubbleUp` | `hey bubble up <id> --on <date>` | covered |
| `/postings/bubble_up.json` | DELETE | SDK `Postings().CancelBubbleUp` | `hey bubble pop <id>` | covered |
| `/calendar/events.json` | POST | SDK `CalendarEvents().Create` | `hey event add`, Calendar TUI `a` | covered |
| `/calendar/events/{id}.json` | PATCH | SDK `CalendarEvents().Update` | `hey event edit <id>`, Calendar TUI `e` | covered: a write replaces rather than patches, so the caller reads the event and sends back what it keeps |
| `/calendar/events/{id}` | DELETE | SDK `CalendarEvents().Delete` | `hey event delete <id>`, Calendar TUI `x` | covered |
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,11 @@ hey seen 12345 # mark a thread as seen
hey unseen 12345 67890 # mark threads as unseen
hey move 12345 --to feed # move a thread to another box
hey move 12345 67890 --to "paper trail" # move multiple threads
hey bubble up 12345 --now # bubble a thread up to the top of the Imbox
hey bubble up 12345 --on 2026-09-04 # bubble a thread up on a date
hey bubble up 12345 --weekend # bubble a thread up Saturday morning
hey bubble list # list bubbled-up and scheduled threads
hey bubble pop 12345 # cancel a thread's bubble-up
hey trash 12345 # move a thread to Trash
hey spam 12345 # mark a thread as spam
hey ignore 12345 # ignore future activity on a thread
Expand Down Expand Up @@ -457,7 +462,7 @@ Snippets are named reusable email content, separate from clips saved out of rece

`hey box view <name|id>`, `hey label view <id>` and `hey collection view <id>` list the same postings and answer the same formats: `--json`, `--styled`, `--markdown`, `--ids-only`, and `--count`. The data-only formats print the pagination notice and any `next_page` cursor on stderr, so the IDs on stdout stay pipeable. `--json` differs only in what wraps the postings: a box answers with HEY's box payload, a label and a collection with the source and its `total_count`.

Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Bubble Up requires a scheduled date and is not available through `hey move`. Trashing a shared thread removes your access instead of deleting it for everyone. Ignored threads remain in their box and can be restored with `hey stop-ignoring`.
Move destinations are Imbox, The Feed, Set Aside, Reply Later, or Paper Trail. Bubble Up has its own commands: `hey bubble up` raises a thread right away with `--now`, on a date with `--on` (HEY resurfaces it at its morning hour of that day, or its evening hour when the date is today), or at the morning hour of tomorrow, Saturday, or next Monday with `--tomorrow`, `--weekend`, and `--next-week`; `hey bubble pop` cancels one. `hey bubble list` shows both buckets — the threads back in the Imbox after bubbling up and the ones still scheduled, each with when it resurfaces. Trashing a shared thread removes your access instead of deleting it for everyone. Ignored threads remain in their box and can be restored with `hey stop-ignoring`.

### Watching for changes

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 v0.0.0-20260824145920-822e6cf08655
github.com/basecamp/hey-sdk/go v0.23.0
github.com/basecamp/hey-sdk/go v0.24.0
github.com/charmbracelet/x/ansi v0.11.8
github.com/fsnotify/fsnotify v1.10.1
github.com/gofrs/flock v0.13.0
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 v0.0.0-20260824145920-822e6cf08655 h1:zz0WUSEmjURj0T+soXuTtgX291nYouqa+UoyYY3Xxk8=
github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs=
github.com/basecamp/hey-sdk/go v0.23.0 h1:NjM4XvZTdjfL0bKNwHDM1ro3nAOZzaLppCeBczCi15U=
github.com/basecamp/hey-sdk/go v0.23.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk=
github.com/basecamp/hey-sdk/go v0.24.0 h1:yEKHXAcX2yGhiphe6NetBLlfxtjJoM8r8pat3N1lYxs=
github.com/basecamp/hey-sdk/go v0.24.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
Expand Down
173 changes: 173 additions & 0 deletions internal/cmd/bubble.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package cmd

import (
"fmt"
"time"

"github.com/spf13/cobra"

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

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

type bubbleCommand struct {
cmd *cobra.Command
}

func newBubbleCommand() *bubbleCommand {
bubbleCommand := &bubbleCommand{}
bubbleCommand.cmd = &cobra.Command{
Use: "bubble",
Short: "Bubble email threads up in the Imbox",
Long: "Bubble email threads up to the top of the Imbox, or cancel a bubble-up.",
}

bubbleCommand.cmd.AddCommand(newBubbleListCommand().cmd)
bubbleCommand.cmd.AddCommand(newBubbleUpCommand().cmd)
bubbleCommand.cmd.AddCommand(newBubblePopCommand().cmd)

return bubbleCommand
}

type bubbleUpCommand struct {
cmd *cobra.Command
now bool
on string
tomorrow bool
weekend bool
nextWeek bool
}

func newBubbleUpCommand() *bubbleUpCommand {
bubbleUpCommand := &bubbleUpCommand{}
bubbleUpCommand.cmd = &cobra.Command{
Use: "up <box-item-id>... (--now | --on <date> | --tomorrow | --weekend | --next-week)",
Short: "Bubble email threads up",
Long: "Bubble one or more email threads up to the top of the Imbox: right away with --now, at HEY's morning hour of a date with --on, or at its morning hour of tomorrow, Saturday, or next Monday with the named flags. Today's date under --on schedules HEY's Later today slot instead — its evening hour (18:00) — since this morning has already passed.",
Example: ` hey bubble up 12345 --now
hey bubble up 12345 67890 --now
hey bubble up 12345 --on 2026-09-04
hey bubble up 12345 --weekend`,
Annotations: map[string]string{
"agent_notes": "Accepts one or more box item IDs from hey box view output. Exactly one of --now, --on, --tomorrow, --weekend and --next-week is required. --on takes a YYYY-MM-DD date; HEY bubbles the threads up at its morning hour of that day, or at its evening hour (18:00) when the date is today. --tomorrow, --weekend and --next-week land at the morning hour of tomorrow, the coming Saturday, and next Monday.",
},
RunE: bubbleUpCommand.run,
Args: usageMinOneArg(),
}

bubbleUpCommand.cmd.Flags().BoolVar(&bubbleUpCommand.now, "now", false, "Bubble the threads up right away")
bubbleUpCommand.cmd.Flags().StringVar(&bubbleUpCommand.on, "on", "", "Bubble the threads up on a date (YYYY-MM-DD)")
bubbleUpCommand.cmd.Flags().BoolVar(&bubbleUpCommand.tomorrow, "tomorrow", false, "Bubble the threads up tomorrow morning")
bubbleUpCommand.cmd.Flags().BoolVar(&bubbleUpCommand.weekend, "weekend", false, "Bubble the threads up Saturday morning")
bubbleUpCommand.cmd.Flags().BoolVar(&bubbleUpCommand.nextWeek, "next-week", false, "Bubble the threads up Monday morning")

return bubbleUpCommand
}

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

ids, err := parseIntArgs(args)
if err != nil {
return err
}

if c.now {
if err = sdk.Postings().BubbleUpNow(cmd.Context(), ids...); err != nil {
return apierr.FromSDK(err)
}
return writeMutation(cmd, fmt.Sprintf("%d %s bubbled up", len(ids), threadNoun(len(ids))), nil)
}

switch {
case c.tomorrow:
return c.scheduleFor(cmd, hey.BubbleUpTomorrow, "tomorrow morning", ids)
case c.weekend:
return c.scheduleFor(cmd, hey.BubbleUpThisWeekend, "Saturday morning", ids)
case c.nextWeek:
return c.scheduleFor(cmd, hey.BubbleUpNextWeek, "Monday morning", ids)
}

on, err := parseDateArg("on date", c.on)
if err != nil {
return err
}

if on.Format(dateLayout) == time.Now().Format(dateLayout) {
return c.scheduleFor(cmd, hey.BubbleUpLaterToday, "this evening", ids)
}

if err = sdk.Postings().ScheduleBubbleUp(cmd.Context(), on.Format(dateLayout), ids...); err != nil {
return apierr.FromSDK(err)
}
return writeMutation(cmd, fmt.Sprintf("%d %s will bubble up on %s", len(ids), threadNoun(len(ids)), on.Format(dateLayout)), nil)
}

func (c *bubbleUpCommand) requireOneSchedule() error {
chosen := 0
for _, picked := range []bool{c.now, c.on != "", c.tomorrow, c.weekend, c.nextWeek} {
if picked {
chosen++
}
}

if chosen > 1 {
return apierr.ErrUsage("--now, --on, --tomorrow, --weekend and --next-week are mutually exclusive")
}
if chosen == 0 {
return apierr.ErrUsage("one of --now, --on <date>, --tomorrow, --weekend or --next-week is required")
}
return nil
}

func (c *bubbleUpCommand) scheduleFor(cmd *cobra.Command, slot hey.BubbleUpSlot, when string, ids []int64) error {
if err := sdk.Postings().ScheduleBubbleUpFor(cmd.Context(), slot, ids...); err != nil {
return apierr.FromSDK(err)
}
return writeMutation(cmd, fmt.Sprintf("%d %s will bubble up %s", len(ids), threadNoun(len(ids)), when), nil)
}

type bubblePopCommand struct {
cmd *cobra.Command
}

func newBubblePopCommand() *bubblePopCommand {
bubblePopCommand := &bubblePopCommand{}
bubblePopCommand.cmd = &cobra.Command{
Use: "pop <box-item-id>...",
Short: "Cancel bubble-ups",
Long: "Cancel the bubble-up on one or more email threads.",
Example: ` hey bubble pop 12345
hey bubble pop 12345 67890`,
Annotations: map[string]string{
"agent_notes": "Accepts one or more box item IDs from hey box view output. Cancels each thread's bubble-up.",
},
RunE: bubblePopCommand.run,
Args: usageMinOneArg(),
}

return bubblePopCommand
}

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

ids, err := parseIntArgs(args)
if err != nil {
return err
}

if err := sdk.Postings().CancelBubbleUp(cmd.Context(), ids...); err != nil {
return apierr.FromSDK(err)
}

return writeMutation(cmd, fmt.Sprintf("%d %s no longer bubbled up", len(ids), threadNoun(len(ids))), nil)
}
Loading
Loading