Three placement-model follow-ups: one confidentiality fix, one documented decision, one round of housekeeping. One commit per issue.
#406 — a repointed placement stranded its old message (confidentiality)
save_email_at's placement upsert ends in DO UPDATE SET message_key = excluded.message_key, .... If the same (account, folder, uidvalidity, uid) slot was re-saved under a differentmessage_key, the previous message silently lost that placement without passing through delete_placements. If it was its last one, nothing would ever delete the messages row — and since messages_fts holds the decrypted plaintext body and is only cleared by the messages_ad trigger on DELETE FROM messages, the body stayed readable to anyone with filesystem access to the database, indefinitely. This was the one write path that bypassed the reap the placement model exists to guarantee.
The fix reads the slot's current occupant key instead of just its existence (the COUNT(*) that fed placement_is_new became a SELECT message_key, so placement_is_new is unchanged), and, in the same transaction as the upsert, reaps a displaced key on exactly the terms delete_placements uses:
So the message goes only when nothing points at it any more; the trigger clears FTS as a consequence. No new reap semantics were invented.
Two tests, both mutation-verified:
repointing_a_slot_reaps_the_displaced_message_and_its_plaintext_index — repoint strands the old message; messages row and messages_fts row must both be gone. Disabling the reap fails exactly this test.
repointing_a_slot_keeps_a_displaced_message_that_still_lives_elsewhere — the old message still holds an Archive placement, so it must survive with its FTS row intact. Dropping the NOT EXISTS guard (reaping unconditionally) fails exactly this test.
#409 — filter fire claims are permanent, deliberately (no behaviour change)
filter_fired (rule_id, message_key, fired_at) is never reaped, so a claim outlives the message it was made against. This PR changes no behaviour — it documents the decision on claim_filter_fire, whose doc comment explained the claim but not its lifetime.
The decision is to leave it as-is. Reaping alongside the message would let a remote party re-trigger third-party side effects at will: a mailing-list message deleted and re-delivered would FORWARD again. Under content-addressed identity a byte-identical redelivery genuinely is the same message, so declining to re-fire is the honest reading rather than a missed event. And the failure direction this leaves open — "a rule that should have fired again did not" — is the safe one for actions that reach outside the mailbox, where the priority is that side-effecting actions never double-fire.
The cost is documented too: a table that only grows, bounded by rules × messages ever seen at roughly 150 bytes a row, cleared only when an identity schema bump resets user_version. No TTL, no reaping.
1. Rename.sync_folder_messages{,_with_session,_batched} return FolderChanges (upserts, removals, presence, next checkpoint), not messages. Renamed to sync_folder_changes*; all call sites are within crates/nuncio-mail/src/imap.rs and the return type is explicit everywhere.
2. export_mbox's dropped X-Nuncio-Folder header — chose to leave it out and sharpen the comment to a permanent omission. Not because threading Placement in would ripple (it would: export_messages_to_file hands the same &[Email] to all four formats), but because the header cannot be made correct. A message may hold several placements at once — the same message filed in INBOX and Archive is one message, not two — so a single X-Nuncio-Folder could only ever name one of them, and MBOX has no way to spell "these several". That would be a wrong answer dressed as an authoritative one. Callers needing occupancy read it from the store, where it is complete. X-Nuncio-Account stays, because an account is single-valued on the message.
3. UID-less guard in the two body-fetch loops. The enumeration pass filters uid >= 1 and counts what it skips; the body and headers-only fetch loops did not, so a FETCH item arriving without a UID would write a junk placement at remote_id: "0". Both loops now mirror the enumeration skip and feed the same skipped counter in the completion log. RFC 3501 section 6.4.8 forbids a server omitting UID in a UID FETCH response, so this needs a non-conforming server to trigger — hardened anyway.
Gates
cargo fmt --all -- --check, cargo check-all, and cargo test-all are green at each commit. The tracing-race flake fix also holds: RUST_TEST_THREADS=2 cargo test-all run three times, zero FAILED lines each.
Three placement-model follow-ups: one confidentiality fix, one documented decision, one round of housekeeping. One commit per issue.
## #406 — a repointed placement stranded its old message (confidentiality)
`save_email_at`'s placement upsert ends in `DO UPDATE SET message_key = excluded.message_key, ...`. If the same `(account, folder, uidvalidity, uid)` slot was re-saved under a *different* `message_key`, the previous message silently lost that placement without passing through `delete_placements`. If it was its last one, nothing would ever delete the `messages` row — and since `messages_fts` holds the **decrypted plaintext** body and is only cleared by the `messages_ad` trigger on `DELETE FROM messages`, the body stayed readable to anyone with filesystem access to the database, indefinitely. This was the one write path that bypassed the reap the placement model exists to guarantee.
The fix reads the slot's current occupant key instead of just its existence (the `COUNT(*)` that fed `placement_is_new` became a `SELECT message_key`, so `placement_is_new` is unchanged), and, in the same transaction as the upsert, reaps a displaced key on exactly the terms `delete_placements` uses:
```sql
DELETE FROM messages WHERE message_key = ?
AND NOT EXISTS (SELECT 1 FROM placements WHERE message_key = ?)
```
So the message goes only when nothing points at it any more; the trigger clears FTS as a consequence. No new reap semantics were invented.
Two tests, both mutation-verified:
- `repointing_a_slot_reaps_the_displaced_message_and_its_plaintext_index` — repoint strands the old message; `messages` row and `messages_fts` row must both be gone. Disabling the reap fails exactly this test.
- `repointing_a_slot_keeps_a_displaced_message_that_still_lives_elsewhere` — the old message still holds an Archive placement, so it must survive with its FTS row intact. Dropping the `NOT EXISTS` guard (reaping unconditionally) fails exactly this test.
## #409 — filter fire claims are permanent, deliberately (no behaviour change)
`filter_fired (rule_id, message_key, fired_at)` is never reaped, so a claim outlives the message it was made against. This PR **changes no behaviour** — it documents the decision on `claim_filter_fire`, whose doc comment explained the claim but not its lifetime.
The decision is to leave it as-is. Reaping alongside the message would let a remote party re-trigger third-party side effects at will: a mailing-list message deleted and re-delivered would `FORWARD` again. Under content-addressed identity a byte-identical redelivery genuinely *is* the same message, so declining to re-fire is the honest reading rather than a missed event. And the failure direction this leaves open — "a rule that should have fired again did not" — is the safe one for actions that reach outside the mailbox, where the priority is that side-effecting actions never double-fire.
The cost is documented too: a table that only grows, bounded by rules × messages ever seen at roughly 150 bytes a row, cleared only when an identity schema bump resets `user_version`. No TTL, no reaping.
## #410 — housekeeping (three parts)
**1. Rename.** `sync_folder_messages{,_with_session,_batched}` return `FolderChanges` (upserts, removals, presence, next checkpoint), not messages. Renamed to `sync_folder_changes*`; all call sites are within `crates/nuncio-mail/src/imap.rs` and the return type is explicit everywhere.
**2. `export_mbox`'s dropped `X-Nuncio-Folder` header — chose to leave it out and sharpen the comment to a permanent omission.** Not because threading `Placement` in would ripple (it would: `export_messages_to_file` hands the same `&[Email]` to all four formats), but because the header cannot be made correct. A message may hold several placements at once — the same message filed in INBOX and Archive is one message, not two — so a single `X-Nuncio-Folder` could only ever name one of them, and MBOX has no way to spell "these several". That would be a wrong answer dressed as an authoritative one. Callers needing occupancy read it from the store, where it is complete. `X-Nuncio-Account` stays, because an account *is* single-valued on the message.
**3. UID-less guard in the two body-fetch loops.** The enumeration pass filters `uid >= 1` and counts what it skips; the body and headers-only fetch loops did not, so a FETCH item arriving without a UID would write a junk placement at `remote_id: "0"`. Both loops now mirror the enumeration skip and feed the same `skipped` counter in the completion log. RFC 3501 section 6.4.8 forbids a server omitting UID in a `UID FETCH` response, so this needs a non-conforming server to trigger — hardened anyway.
## Gates
`cargo fmt --all -- --check`, `cargo check-all`, and `cargo test-all` are green at each commit. The tracing-race flake fix also holds: `RUST_TEST_THREADS=2 cargo test-all` run three times, zero `FAILED` lines each.
Closes #406
Closes #409
Closes #410
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Three placement-model follow-ups: one confidentiality fix, one documented decision, one round of housekeeping. One commit per issue.
#406 — a repointed placement stranded its old message (confidentiality)
save_email_at's placement upsert ends inDO UPDATE SET message_key = excluded.message_key, .... If the same(account, folder, uidvalidity, uid)slot was re-saved under a differentmessage_key, the previous message silently lost that placement without passing throughdelete_placements. If it was its last one, nothing would ever delete themessagesrow — and sincemessages_ftsholds the decrypted plaintext body and is only cleared by themessages_adtrigger onDELETE FROM messages, the body stayed readable to anyone with filesystem access to the database, indefinitely. This was the one write path that bypassed the reap the placement model exists to guarantee.The fix reads the slot's current occupant key instead of just its existence (the
COUNT(*)that fedplacement_is_newbecame aSELECT message_key, soplacement_is_newis unchanged), and, in the same transaction as the upsert, reaps a displaced key on exactly the termsdelete_placementsuses:So the message goes only when nothing points at it any more; the trigger clears FTS as a consequence. No new reap semantics were invented.
Two tests, both mutation-verified:
repointing_a_slot_reaps_the_displaced_message_and_its_plaintext_index— repoint strands the old message;messagesrow andmessages_ftsrow must both be gone. Disabling the reap fails exactly this test.repointing_a_slot_keeps_a_displaced_message_that_still_lives_elsewhere— the old message still holds an Archive placement, so it must survive with its FTS row intact. Dropping theNOT EXISTSguard (reaping unconditionally) fails exactly this test.#409 — filter fire claims are permanent, deliberately (no behaviour change)
filter_fired (rule_id, message_key, fired_at)is never reaped, so a claim outlives the message it was made against. This PR changes no behaviour — it documents the decision onclaim_filter_fire, whose doc comment explained the claim but not its lifetime.The decision is to leave it as-is. Reaping alongside the message would let a remote party re-trigger third-party side effects at will: a mailing-list message deleted and re-delivered would
FORWARDagain. Under content-addressed identity a byte-identical redelivery genuinely is the same message, so declining to re-fire is the honest reading rather than a missed event. And the failure direction this leaves open — "a rule that should have fired again did not" — is the safe one for actions that reach outside the mailbox, where the priority is that side-effecting actions never double-fire.The cost is documented too: a table that only grows, bounded by rules × messages ever seen at roughly 150 bytes a row, cleared only when an identity schema bump resets
user_version. No TTL, no reaping.#410 — housekeeping (three parts)
1. Rename.
sync_folder_messages{,_with_session,_batched}returnFolderChanges(upserts, removals, presence, next checkpoint), not messages. Renamed tosync_folder_changes*; all call sites are withincrates/nuncio-mail/src/imap.rsand the return type is explicit everywhere.2.
export_mbox's droppedX-Nuncio-Folderheader — chose to leave it out and sharpen the comment to a permanent omission. Not because threadingPlacementin would ripple (it would:export_messages_to_filehands the same&[Email]to all four formats), but because the header cannot be made correct. A message may hold several placements at once — the same message filed in INBOX and Archive is one message, not two — so a singleX-Nuncio-Foldercould only ever name one of them, and MBOX has no way to spell "these several". That would be a wrong answer dressed as an authoritative one. Callers needing occupancy read it from the store, where it is complete.X-Nuncio-Accountstays, because an account is single-valued on the message.3. UID-less guard in the two body-fetch loops. The enumeration pass filters
uid >= 1and counts what it skips; the body and headers-only fetch loops did not, so a FETCH item arriving without a UID would write a junk placement atremote_id: "0". Both loops now mirror the enumeration skip and feed the sameskippedcounter in the completion log. RFC 3501 section 6.4.8 forbids a server omitting UID in aUID FETCHresponse, so this needs a non-conforming server to trigger — hardened anyway.Gates
cargo fmt --all -- --check,cargo check-all, andcargo test-allare green at each commit. The tracing-race flake fix also holds:RUST_TEST_THREADS=2 cargo test-allrun three times, zeroFAILEDlines each.Closes #406
Closes #409
Closes #410