fix(self-hosted): real conversations, honest source scopes, and a --read filter that pages#59
Merged
Conversation
…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.
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
Five read-path projections in the self_hosted
MailDataSourcereturned 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.self_hostedemails email replies <sent-id>printsNo replies./{"replies":[],"total":0,"has_more":false}for a sent message that did get replies;email thread/conversationalways render(1 message)getConversation()/getConversationBodies()returned[the message itself]thread_idpermanentlynulleverywhere, includingemails reply's outputv1ToTuiMessagehardcodedthread_id/provider_thread_idtonullinbox list --source <id>printsNo mail found;inbox mailboxes --source <id>prints all-zero folder counts — over a full storeinbox sources --search x --limit nreturns the unfiltered listlistMailboxSources(_opts)ignored bothinbox list --limit 20 --readcan return fewer than 20 rows while more matching rows sit on the next page--readpost-filtered an already-paged responseItem 3 was a self-inflicted trap:
listMailboxSources()fabricates a source with idself_hosted, andinbox.remote.tstells the operator to pass exactly that id fromemails inbox sources. Doing so returned an empty mailbox.What changed
Conversations are real. The self-hosted store has no
thread_idcolumn; its one server-side notion of a conversation is the normalized (Re:/Fwd:-stripped, lower-cased) subject thatGET /v1/messages/threadsgroups by (store.listThreads).threadKeyOfSubject()mirrors that SQL expression-for-expression, andconversationRows()groups by it plus the RFC 5322Message-ID/In-Reply-Tolinks 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_idis 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_idstaysnullbecause the store genuinely has no such column.Source scopes are honest.
self_hosted(and local mode'sall) resolve to the whole store, so the idemails inbox sourcesprints 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.--readis 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 "declaratii tva 06.2026" (3 of 3 messages)) instead ofThread declarat, an 8-char slice of a subject that named nothing;repliesis now described as what it returns;--provider/--sourcehelp text says what self_hosted actually supports;emails refresh— that command is not registered in any mode.--limit/--offsetonemail thread/conversationwere accepted and ignored; they now page the thread and the payload reportstotal/has_more.Deliberate semantic note
repliesreturns the whole inbound side of the conversation, not local mode's strict depth-1in_reply_to_email_idchildren (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,allalias, refusal for provider/S3/legacy/unknown,clearrefusal, sources--search/--limit,--readpaging)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/v1stubTwo stale test titles that encoded the bug were corrected, and
reply.test.ts'sexpect(data.thread_id).toBeNull()now asserts the real conversation key.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 viagh api). PR #53 is still open and also editssrc/lib/self-hosted-mail-data-source.tsandsrc/cli/commands/email-log.remote.ts. Its hunks are the status/send_stateones, but two will collide textually:v1ToTuiMessage(it adds fields next to thethread_id/provider_thread_idlines this PR rewrites) andmail-types.tsTuiMessagevs this PR'sMailboxListOptions. Rebase this branch after #53 lands. Do not merge before then.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.