`hey move` silently relocates a bundle row, unbundling that sender's mail
- Dominant language
- Go
- Stars
- 368
- Forks
- 44
- Avg merge
- 15h 15m
- Merged PRs (30d)
- 189
Description
`hey move` accepts a bundle row's box item ID and moves it like any thread, with no warning. A bundle is the *container* for one sender's mail in the box that sender is delivered to, so moving it means their next email arrives as a separate thread instead of joining the bundle. The command reports `"1 thread moved to The Feed"` — calling a bundle a thread — so nothing in the CLI signals what happened.
This is easy to hit when filing in bulk: `hey box view` returns bundle rows and thread rows in one list, and an agent batching the IDs it just listed will move bundles without ever knowing they were there.
## Impact
On a real account, a batch of 31 IDs taken from `hey box view imbox` contained 13 bundle rows, silently unbundling all 13 senders.
## Reproduction
Verified end-to-end against a live account, with a sender that had no prior mail:
1. Send 2 emails from a new sender; approve into the Imbox
2. `hey contact bundle ` — a `kind: "bundle"` row appears, and the two thread rows leave the box listing
3. Send a 3rd email — it joins the bundle (`active_at` advances, no new row appears)
4. `hey move --to feed` — accepted, reported as `"1 thread moved to The Feed"`
5. Send a 4th email — **it arrives as a loose thread in the Imbox** while the bundle sits in The Feed
## Expected vs actual
**Expected:** `hey move` refuses a bundle row.
HEY's own web app already does. Selecting a bundle offers only **Mark Seen**, **Note** and **Ignore** — there is no move, Set Aside, Reply Later or Trash action for a bundle row, whereas a thread has the full set. The CLI is exposing an operation the first-party client deliberately withholds.
**Actual:** the move succeeds silently and is described as a thread move.
## Recovery semantics (also verified)
- `hey move --to imbox` **does** restore grouping — a 5th email sent afterwards rejoined the bundle.
- Threads delivered while the bundle was away **never** merge back in; they stay loose permanently.
So it is recoverable, but only by moving the bundle row itself back. The intuitive repair — moving the visible loose threads back — does nothing, because the object that needs moving is not in the box you are looking at.
## Other commands on the same path
Every command that takes box item IDs shares the same ID space, and none of them check `kind`. What the API then does varies by operation, which makes the gap easy to miss:
| Command | Bundle accepted? | Effect |
|---|---|---|
| `hey move` | yes | **relocates the container, unbundling the sender** (verified) |
| `hey trash` | yes — reports `"1 thread moved to Trash"` | **no-op: nothing is trashed** (verified) |
| `hey bubble up` / `bubble pop` | yes — reports `"1 thread bubbled up"` | bundle bubbles within its own box; collection unaffected (verified) |
| `hey spam` | untested | — |
| `hey label create` / `add` / `remove` | untested | — |
| `hey seen` / `unseen`, `hey ignore` / `stop-ignoring` | yes | legitimate — the web app offers Mark Seen and Ignore on a bundle |
Only `hey move` does damage, because it is the only one that takes the container out of the box its sender is delivered to. But `hey trash` reporting a move that never happened, and all of them describing a bundle as "1 thread", suggest the check belongs in one shared place rather than in `move.go` alone.
## Detecting a bundle from an ID
The `bundles/unseen` route discriminates reliably, so a pre-flight check needs no new endpoint:
- a bundle's posting ID returns `ok` with its contact
- a thread's box item ID or a topic ID returns `not_found`
- a bundle that has been read through still returns `ok`, with zero postings
## Suggested fix
Refuse `kind: "bundle"` postings in `hey move`, with an error pointing at `hey contact bundle|unbundle ` for changing a sender's grouping and `hey bundle view ` for reading one.
Since the web app offers no way to move a bundle at all, an opt-in override flag would be inventing an escape hatch HEY itself does not provide — a plain refusal keeps the CLI in line with the product and adds no new CLI surface.
Given the table above, a shared helper used by `move`, `trash`, `spam`, `bubble` and `label` would likely serve better than a check in `move.go` alone.
## Environment
- `hey` v1.2.1, macOS
- At `main` (313a811), none of `internal/cmd/move.go`, `trash.go`, `spam.go`, `bubble.go` or `label.go` check a posting's `kind`, so this applies to current `main`
## Related
- #156 / #378 added bundle *reading*; this is the same blind spot on the write side
- #180 (box designations unreadable from the CLI) makes this harder to detect, since the CLI cannot read back which box a sender is delivered to
Contributor guide
Research direction
Start with internal/cmd/move.go and compare the bundle handling paths in trash.go, spam.go, bubble.go, and label.go; inspect the bundles/unseen route for how it distinguishes bundle postings from thread or topic IDs. Done means bundle inputs are refused where appropriate, with guidance for bundle operations, and no longer reported as thread actions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100