Skip to content

fix(self-hosted): real conversations, honest source scopes, and a --read filter that pages#59

Merged
andrei-hasna merged 2 commits into
mainfrom
audit/remote-thread-and-source-projection
Jul 25, 2026
Merged

fix(self-hosted): real conversations, honest source scopes, and a --read filter that pages#59
andrei-hasna merged 2 commits into
mainfrom
audit/remote-thread-and-source-projection

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

What was wrong

Five read-path projections in the self_hosted MailDataSource returned a confident wrong answer instead of the truth or an error. Each one is shaped so an operator cannot distinguish it from a legitimately empty result.

# Symptom in self_hosted Was
1 emails email replies <sent-id> prints No replies. / {"replies":[],"total":0,"has_more":false} for a sent message that did get replies; email thread / conversation always render (1 message) getConversation() / getConversationBodies() returned [the message itself]
2 thread_id permanently null everywhere, including emails reply's output v1ToTuiMessage hardcoded thread_id/provider_thread_id to null
3 inbox list --source <id> prints No mail found; inbox mailboxes --source <id> prints all-zero folder counts — over a full store any scope without an address/domain narrowed to nothing
4 inbox sources --search x --limit n returns the unfiltered list listMailboxSources(_opts) ignored both
5 inbox list --limit 20 --read can return fewer than 20 rows while more matching rows sit on the next page --read post-filtered an already-paged response

Item 3 was a self-inflicted trap: listMailboxSources() fabricates a source with id self_hosted, and inbox.remote.ts tells the operator to pass exactly that id from emails inbox sources. Doing so returned an empty mailbox.

What changed

Conversations are real. The self-hosted store has no thread_id column; its one server-side notion of a conversation is the normalized (Re:/Fwd:-stripped, lower-cased) subject that GET /v1/messages/threads groups by (store.listThreads). threadKeyOfSubject() mirrors that SQL expression-for-expression, and conversationRows() groups by it plus the RFC 5322 Message-ID/In-Reply-To links every row carries, so a reply survives a rewritten subject. Candidates are narrowed server-side with ?subject=, so one conversation read costs one or two requests — not the full-store scan the label/oldest paths pay. A message with an empty subject has no conversation key and stands alone: collapsing every empty-subject message into one thread would be a fabrication, not a thread.

thread_id is the server's conversation key, derived from the subject the row already carries — no extra round-trip, present on list rows and detail reads alike. provider_thread_id stays null because the store genuinely has no such column.

Source scopes are honest. self_hosted (and local mode's all) resolve to the whole store, so the id emails inbox sources prints round-trips into --source. A scope the /v1 store cannot express — provider, S3 bucket, legacy, unknown id — is refused with a message naming what is supported, instead of silently reporting an empty mailbox. clear() resolves the scope before scanning, so it can neither delete nothing while reporting success nor widen to the whole store.

--read is part of the query (MailboxListOptions.read), applied inside the paging walk so a page fills.

Every user-facing string touched here was re-verified:

  • thread headers show the whole conversation key (Thread "declaratii tva 06.2026" (3 of 3 messages)) instead of Thread declarat, an 8-char slice of a subject that named nothing;
  • an empty page of a non-empty thread no longer claims the thread is empty;
  • replies is now described as what it returns;
  • --provider / --source help text says what self_hosted actually supports;
  • the empty-mailbox hint no longer suggests emails refreshthat command is not registered in any mode.

--limit/--offset on email thread / conversation were accepted and ignored; they now page the thread and the payload reports total / has_more.

Deliberate semantic note

replies returns the whole inbound side of the conversation, not local mode's strict depth-1 in_reply_to_email_id children (the /v1 rows expose the conversation, not a parent-child edge per message). The response shape is identical to local's ({ replies, total, limit, offset, has_more }), and under-reporting a reply is precisely the failure this replaces.

Tests

New tests fail before this change and pass after — 22 of them, verified by stashing the source changes and re-running:

  • src/lib/self-hosted-mail-data-source.test.ts — 10 fail before / 0 after (conversation, multi-hop reference chain, empty-subject non-grouping, thread key on list rows, body windowing, source round-trip, all alias, refusal for provider/S3/legacy/unknown, clear refusal, sources --search/--limit, --read paging)
  • src/cli/commands/email-log.test.ts + src/cli/commands/inbox.test.ts — 12 fail before / 0 after, driving the real commands against the out-of-process /v1 stub

Two stale test titles that encoded the bug were corrected, and reply.test.ts's expect(data.thread_id).toBeNull() now asserts the real conversation key.

bun test          2068 pass, 85 skip, 0 fail   (baseline on origin/main: 2044 pass, 0 fail)
bunx tsc --noEmit exit 0
bun run build     ok

Also re-run under CI's environment (HOME=<tmp> EMAILS_MODE=local EMAILS_DB_PATH=:memory:): 2068 pass, 0 fail.

Rebase note

Branched from origin/main @ 1ac2d15 (verified via gh api). PR #53 is still open and also edits src/lib/self-hosted-mail-data-source.ts and src/cli/commands/email-log.remote.ts. Its hunks are the status/send_state ones, but two will collide textually: v1ToTuiMessage (it adds fields next to the thread_id/provider_thread_id lines this PR rewrites) and mail-types.ts TuiMessage vs this PR's MailboxListOptions. Rebase this branch after #53 lands. Do not merge before then.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…s instead of confident guesses

Five self_hosted read-path projections in the remote MailDataSource answered
with a confident wrong value instead of the truth. All of them are shaped so an
operator cannot tell the wrong answer from a legitimately empty one.

1. getConversation()/getConversationBodies() returned only the message they were
   handed, so `emails email replies <sent-id>` printed "No replies." and
   {"replies":[],"total":0} for a sent message that had them, and every thread
   header read "(1 message)". They now build the real conversation from data the
   serve already returns: the normalized subject key GET /v1/messages/threads
   groups by (store.listThreads), plus the Message-ID/In-Reply-To links on every
   row so a reply survives a rewritten subject. Candidates are narrowed
   server-side by ?subject=, so one conversation read costs one or two requests.

2. v1ToTuiMessage hardcoded thread_id: null, so the thread id was permanently
   absent from every row and from `emails reply`'s output. It now carries the
   server's own conversation key. provider_thread_id stays null: the store has no
   such column, and that is the truthful projection.

3. Any --source/--provider scope the /v1 store cannot express (provider, S3,
   legacy, unknown id) silently narrowed to nothing: `inbox list --source <id>`
   printed "No mail found" and `inbox mailboxes --source <id>` printed all-zero
   folder counts over a full store. The trap was self-inflicted — inbox sources
   publishes id "self_hosted" and the CLI told the operator to pass exactly that.
   That id (and local mode's "all") now resolves to the whole store, and a scope
   the store genuinely cannot express is refused with a message naming what IS
   supported. `clear` resolves the scope before scanning, so it can neither
   silently delete nothing nor widen.

4. listMailboxSources ignored the --search/--limit the CLI parses and handed it.

5. `inbox list --read` post-filtered an already-paged response, so
   `--limit 20 --read` could return fewer than 20 rows while more matching rows
   sat on the next page. `read` is now part of the list query.

Every user-facing string touched here was re-checked: thread headers show the
whole conversation key instead of an 8-character slice of a subject, an empty
page of a non-empty thread no longer claims the thread is empty, `replies` is
described as what it returns, and the empty-mailbox hint no longer suggests
`emails refresh`, which is not a registered command in any mode.
@andrei-hasna
andrei-hasna merged commit 34bc318 into main Jul 25, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant