From ce3f8944a34db4fa45bcf77fac510633a4b4347e Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 10 Jun 2026 11:36:38 -0700 Subject: [PATCH] fix(smtp): don't clobber the IMAP-owned INBOX uid_validity on journal ingest When SMTP journaling ingests a message, parse_email() upserts the account's INBOX MailBox row so the journaled envelope has a row to attach to. But it built the row with uid_validity/highest_uid/uid_next = None and called batch_upsert, which replaces the WHOLE row. The INBOX row id (create_hash(account_id, "INBOX")) is the same id the IMAP sync maintains, so every journaled delivery reset the IMAP-maintained uid_validity to None. On the next reconcile, local_mailbox.uid_validity != Some(remote) is then true, so the mailbox is treated as invalid and wiped + rebuilt. For a large, UID-sparse INBOX whose rebuild gets interrupted, the local copy is silently lost and never restored (the incremental fetch resumes past all existing UIDs). See #297 for the full diagnosis and DB evidence. Fix: only create the INBOX row when it does not already exist; otherwise leave the IMAP-owned row untouched. The journaling path only needs the row to exist so the envelope can attach to it. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/smtp/src/server.rs | 52 +++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/crates/smtp/src/server.rs b/crates/smtp/src/server.rs index 9659866..be0b868 100644 --- a/crates/smtp/src/server.rs +++ b/crates/smtp/src/server.rs @@ -615,26 +615,40 @@ async fn parse_email(data: &[u8], session: &Session) -> BichonResult<()> { return Ok(()); } }; - let mailbox = MailBox { - id: create_hash(rcpt.id, "INBOX"), - account_id: rcpt.id, - name: "INBOX".into(), - delimiter: Some("/".to_string()), - attributes: vec![Attribute { - attr: AttributeEnum::Extension, - extension: Some("CreatedByBichon".into()), - }], - exists: 0, - unseen: None, - uid_next: None, - uid_validity: None, - highest_uid: None, - }; - let mailbox_id = mailbox.id; + let mailbox_id = create_hash(rcpt.id, "INBOX"); - if let Err(e) = MailBox::batch_upsert(&[mailbox]) { - tracing::error!("SMTP: Failed to upsert mailbox for {}: {:?}", rcpt.email, e); - return Err(e.into()); + // The INBOX row is owned by the IMAP sync, which maintains `uid_validity`, + // `highest_uid` and `uid_next` on it. `batch_upsert` replaces the *whole* + // row, so blindly upserting here (with those fields = None) clobbers the + // IMAP-maintained state back to None. The next reconcile then sees + // `uid_validity` change from Some -> None, treats the mailbox as invalid, + // and wipes + rebuilds it — silently losing the local copy of a large + // mailbox when that rebuild is interrupted (see #297). + // + // We only need the row to *exist* so the journaled envelope can attach to + // it, so create it only when it is missing and otherwise leave the + // IMAP-owned row untouched. + if MailBox::find_mailbox(rcpt.id, mailbox_id)?.is_none() { + let mailbox = MailBox { + id: mailbox_id, + account_id: rcpt.id, + name: "INBOX".into(), + delimiter: Some("/".to_string()), + attributes: vec![Attribute { + attr: AttributeEnum::Extension, + extension: Some("CreatedByBichon".into()), + }], + exists: 0, + unseen: None, + uid_next: None, + uid_validity: None, + highest_uid: None, + }; + + if let Err(e) = MailBox::batch_upsert(&[mailbox]) { + tracing::error!("SMTP: Failed to upsert mailbox for {}: {:?}", rcpt.email, e); + return Err(e.into()); + } } extract_envelope_from_smtp(data, rcpt.id, mailbox_id)