Recover .eml from disk even when the metadata row says has_details=0

After a SQLite schema migration the `mails` table is dropped and
recreated empty; the encrypted `.eml.enc` files on disk survive that
migration (they are keyed by element id, not by row id). Phase 0 of the
syncer was gating the body recovery on `meta.has_details`, so the post-
migration boot would deliver a `mail_to_rfc2822(mail, None)`
headers-only body to IMAP even though the full body sat right there on
disk — a real mail's content quietly showed up as a placeholder in
Thunderbird until something forced a refetch.

Try `read_eml` unconditionally. When it returns the body, also flip
`has_details = 1` on the row so subsequent prefetch sweeps skip the
mail and the heal is permanent. Mismatch → still falls back to
headers-only as before. The extra read at boot is a `Path::exists()`
plus an AES decrypt per mail, negligible vs the network costs we
already pay.

While here, demote the misleading IMAP-fetch log: when `details` is
None but `rfc2822` is populated we *do* serve the real body, so log
"placeholder" only when `rfc2822` is also missing.

166 bridge lib tests pass.
This commit is contained in:
Anthony
2026-05-28 16:07:15 +02:00
parent 4bb07798dd
commit 56bd92787b
2 changed files with 29 additions and 12 deletions
+6 -2
View File
@@ -394,12 +394,16 @@ impl ImapSession {
self.mails[idx].details.as_ref(),
);
self.mails[idx].rfc2822 = Some(rfc);
} else if self.mails[idx].details.is_none() {
} else if self.mails[idx].rfc2822.is_none() {
log::warn!(
"No details for uid={}, body will be placeholder",
"No body for uid={}, will serve headers-only placeholder",
self.mails[idx].uid,
);
}
// If `rfc2822` is already populated (Phase 0 read it from
// disk), there is nothing else to do — the body will be
// served from it; `details` being None is normal and not a
// placeholder situation.
}
let cached = &self.mails[idx];
+23 -10
View File
@@ -434,17 +434,30 @@ async fn load_cached_folder(
let mail: Mail = serde_json::from_str(&meta.mail_json)
.map_err(|e| format!("Bad cached mail {}: {e}", meta.element_id))?;
let rfc2822 = if meta.has_details {
match local_store.read_eml(&meta.element_id) {
Ok(Some(eml)) => Some(eml),
Ok(None) => Some(mail_to_rfc2822(&mail, None)),
Err(e) => {
warn!("Failed to read cached eml {}: {e}", meta.element_id);
Some(mail_to_rfc2822(&mail, None))
// Always try to recover the full body from disk first — the `.eml`
// file is the source of truth and may exist even when the metadata
// row says otherwise (a schema migration drops the `mails` table but
// keeps the encrypted `.eml.enc` files, so `has_details` is reset to
// 0 on first boot after the migration). Self-heal the row when we
// find an orphaned body, so the prefetch loop knows to skip it on
// the next sweep.
let rfc2822 = match local_store.read_eml(&meta.element_id) {
Ok(Some(eml)) => {
if !meta.has_details {
if let Err(e) = local_store.mark_has_details(&meta.element_id) {
warn!(
"Failed to heal has_details for {}: {e}",
meta.element_id
);
}
}
}
} else {
Some(mail_to_rfc2822(&mail, None))
Some(eml)
},
Ok(None) => Some(mail_to_rfc2822(&mail, None)),
Err(e) => {
warn!("Failed to read cached eml {}: {e}", meta.element_id);
Some(mail_to_rfc2822(&mail, None))
},
};
stored_mails.push(StoredMail {