Skip to content

Reply to the last emailed message, not a note after it - #517

Merged
robzolkos merged 3 commits into
mainfrom
reply-skips-notes
Oct 1, 2026
Merged

robzolkos merged 3 commits into
mainfrom
reply-skips-notes

Conversation

@robzolkos

@robzolkos robzolkos commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

On HEY for Domains a thread can hold internal notes (kind: "comment") and share notices (kind: "access_notice", posted by "Share with…"). Neither is emailed. But hey reply, hey forward, hey compose --thread-id and the TUI's reply and forward all answered the thread's last entry. When that was a note, the reply was addressed from the note — on a local HEY, hey reply <thread> --dry-run came back To: david@basecamp.com (the teammate who wrote the note) under a bare Re: instead of to the prospect. A forward was worse: it would quote the team's internal note to whoever it went to.

HEY's web app answers Topic#last_replyable_entry — the newest active entry whose entryable says replyable?, which only Message, Announcement and SignUp::Message do. The forward and bulk-reply paths in HEY use the same entry.

What changes

  • mail.ReplyTarget (internal/mail/reply_target.go) mirrors that: it picks the replyable entry with the highest id (HEY's reverse_order on an unordered relation is id-descending) from the topic's own page, and only when that page has none walks Topics().GetEntriesPage newest first, bounded at 20 pages. A thread with nothing replyable is refused (not_found, "thread N has no emailed message to reply to") rather than answered from a note. An entry with no kind is treated as a message, as every entry was before HEY served one.
  • Every path that picked "the latest entry" now asks it: hey reply (send, draft and --dry-run), hey compose --thread-id, hey forward, and the TUI's loadReplyContext and loadForwardContext. The reply prefill (Entries().NewReply) and the forward draft are requested for that entry only. hey bulk-reply was already right: HEY resolves its entries server-side with last_replyable_entry.
  • Text output labels internal entries. --markdown, styled output, --html and the TUI thread view head a note "Note by Priya Raman" and a share notice "Marcus Lee shared this thread", each tagged not emailed, instead of a From: line. --html also carries each entry's kind as data-kind. JSON is unchanged — it already carried kind.
  • Docs: hey reply / hey thread read help and agent notes, docs/cli.md, skills/hey/SKILL.md, AGENTS.md.

Seen on a local HEY (dry run only)

Before:

"entry_id": 1019247219, "subject": "Re:", "to": ["david@basecamp.com"]

After:

"entry_id": 1019247213, "subject": "Re: Quick question about Northwind's onboarding flow", "to": ["priya.shah@example.com"]

hey thread read --markdown, after:

## From: David Heinemeier Hansson — 2026-09-28T23:02 (#1019247213)
…
## David Heinemeier Hansson shared this thread — 2026-09-28T23:03 (#1019247214)

*(share notice, not emailed)*
…
## Note by Jason Fried — 2026-09-28T23:04 (#1019247217)

*(internal note, not emailed)*

hey thread read --json is byte-for-byte the same before and after.

One gap in HEY's JSON

A share notice's web view says who was added ("David added Andrea to this thread"), but neither the entry nor the message JSON carries those contacts (Access::Notice#contacts_with_access), so the label names only who shared. Also, HEY's last_replyable_entry looks only at active entries, while the entry index serves spam entries too and does not say which are which; the CLI cannot exclude a spam-marked message inside an otherwise active thread.

Independent of #383 (hey thread comment), which touches some of the same docs; the doc edits here are kept small and separate so either rebases cleanly onto the other.


Summary by cubic

Reply and forward now answer a thread's last emailed message — as HEY's web app does — instead of its last entry. A note or share notice posted after it used to be replied to (going to the teammate who wrote it) or forwarded out (leaking an internal note); a thread holding nothing else is refused.

  • mail.ReplyTarget picks HEY's last replyable entry (message, announcement, or sign-up message) from the topic's own page, walking the entry index only when that page has none; a missing entry page is treated as a malformed response.
  • Reply, forward, compose-into-thread, and the TUI's reply and forward all use it.
  • --markdown, styled, --html, and the TUI thread view head notes ("Note by …") and share notices ("… shared this thread") as never emailed instead of with a From line; HTML carries each entry's kind as data-kind. JSON is unchanged.

Written for commit a70d2e4. Summary will update on new commits.

Review in cubic

Copilot AI balanced review requested due to automatic review settings September 29, 2026 00:05
@robzolkos
robzolkos requested a review from a team as a code owner September 29, 2026 00:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Nil page handling can produce a misleading not-found result, and required help/API documentation remains incomplete.

Review effort: Balanced
Findings: 1 Medium severity · 2 Low severity

Open (3)
What changed in this PR

Updates reply and forward flows to target the latest replyable emailed entry, avoiding internal notes and share notices.

Changes:

  • Adds centralized reply-target selection across CLI and TUI flows.
  • Labels internal entries distinctly in terminal, Markdown, HTML, and TUI output.
  • Adds documentation and regression coverage.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
AGENTS.md Documents reply-target and entry-label behavior.
docs/​cli.md Updates thread and reply documentation.
skills/​hey/​SKILL.md Updates agent guidance.
internal/​mail/​reply_target.go Implements replyable-entry selection and labels.
internal/​mail/​reply_target_test.go Tests target selection and kinds.
internal/​mail/​entry.go Preserves entry kinds for presentation.
internal/​cmd/​thread_reply.go Uses the shared target resolver.
internal/​cmd/​forward.go Forwards the latest emailed message.
internal/​cmd/​reply.go Updates reply help text.
internal/​cmd/​topic.go Labels internal entries across output formats.
internal/​cmd/​html_test.go Updates HTML expectations.
internal/​cmd/​thread_internal_entries_test.go Adds CLI regression tests.
internal/​tui/​compose.go Applies reply-target selection in TUI flows.
internal/​tui/​mail.go Labels internal entries in the TUI.
internal/​tui/​compose_internal_entries_test.go Adds TUI regression tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/mail/reply_target.go Outdated
Comment thread docs/cli.md
Comment thread internal/mail/reply_target.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Ordinary forward help and TUI documentation do not yet describe the new targeting and refusal behavior.

Review effort: Balanced
Findings: 2 Low severity

Open (2)
Resolved since last review (3)

Comment thread internal/cmd/forward.go
Comment thread internal/tui/compose.go
On HEY for Domains a thread also holds notes (kind "comment") and share
notices (kind "access_notice"). Neither is emailed, but `hey reply`, `hey
forward`, `hey compose --thread-id` and the TUI's reply and forward all
answered the thread's last entry. When that was a note, the reply went to
the teammate who wrote it under a bare "Re:", and a forward handed an
outsider what the team wrote to itself.

They now answer HEY's Topic#last_replyable_entry through mail.ReplyTarget:
the newest message, announcement or sign-up message, found on the topic's
own page or further down the entry index. A thread with none is refused.

The text formats and the TUI's thread view head a note "Note by …" and a
share notice "… shared this thread", each tagged as not emailed, instead
of a From line; --html carries each entry's kind as data-kind. JSON is
unchanged: it already carried kind.
A nil page from the entry index is a malformed response, as threadload
treats it, not a thread without a message, so it no longer surfaces as
not_found. hey compose --thread-id and hey forward say in their help
that they answer the latest emailed message, and API-COVERAGE.md lists
the entry index's new readers.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approved

The targeting, pagination, account routing, output safety, documentation, and affected command paths are consistently implemented and tested.

Review effort: Balanced
Findings: None

Resolved since last review (2)

@robzolkos
robzolkos merged commit 83ea97d into main Oct 1, 2026
26 checks passed
@robzolkos
robzolkos deleted the reply-skips-notes branch October 1, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants