Skip to main content

max / goingson

Stop IMAP sync silently dropping unparseable messages The fetch watermark advanced for every UID before the parse block, so a message that tripped the MIME-nesting guard or failed mailparse folded into the persisted watermark and was never re-fetched. Advance the watermark only for terminally-handled UIDs (saved, oversized, or nesting-capped -- all deterministic); a mailparse error no longer advances it, so a transient failure is retried rather than lost, while an always-failing tail message cannot wedge sync progress. Also align the JMAP received_at fallback to DateTime::UNIX_EPOCH (matching the IMAP path) so a server omitting receivedAt does not stamp the sync time and corrupt mailbox ordering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 14:21 UTC
Commit: dd52661d2f137ef80d070000a1c01c565193d5a9
Parent: 6e9e6ca
2 files changed, +24 insertions, -6 deletions
@@ -419,12 +419,19 @@ impl ImapClient {
419 419
420 420 let mut safe_uids: Vec<u32> = Vec::new();
421 421 let mut skipped_large = 0usize;
422 + // Highest UID we may advance the watermark past without fetching a body:
423 + // oversized messages are permanently over the size cap, so re-fetching
424 + // them would skip them identically. Parseable messages advance the
425 + // watermark only once saved (below), so a transient parse failure is
426 + // retried next sync instead of being silently dropped forever.
427 + let mut max_skipped_uid: Option<u32> = None;
422 428 while let Some(result) = size_stream.next().await {
423 429 if let Ok(msg) = result
424 430 && let Some(uid) = msg.uid {
425 431 if let Some(size) = msg.size
426 432 && size > MAX_EMAIL_SIZE {
427 433 skipped_large += 1;
434 + max_skipped_uid = Some(max_skipped_uid.map_or(uid, |m: u32| m.max(uid)));
428 435 tracing::warn!(uid, size, folder = %folder, "Skipping oversized email ({} bytes)", size);
429 436 continue;
430 437 }
@@ -452,8 +459,10 @@ impl ImapClient {
452 459 let mut msg_count = 0;
453 460 let mut body_count = 0;
454 461 let mut parse_errors = 0;
455 - // Seed max_uid from all UIDs (including skipped large ones) so they aren't re-fetched
456 - let mut max_uid: Option<u32> = uids.iter().copied().max();
462 + // Seed from the oversized (permanently-skipped) UIDs only. Parseable
463 + // messages advance this as they are saved, so a message that fails to
464 + // parse does not silently fold into the persisted watermark.
465 + let mut max_uid: Option<u32> = max_skipped_uid;
457 466
458 467 // Fetch full bodies in bounded UID batches so neither the IMAP command
459 468 // nor the in-flight fetch pipeline grows with the size of the mailbox.
@@ -481,8 +490,6 @@ impl ImapClient {
481 490 }
482 491 };
483 492
484 - max_uid = Some(max_uid.map_or(uid, |m: u32| m.max(uid)));
485 -
486 493 let is_read = message.flags().any(|f| matches!(f, async_imap::types::Flag::Seen));
487 494
488 495 if let Some(body) = message.body() {
@@ -490,10 +497,18 @@ impl ImapClient {
490 497 if !mime_nesting_within_limit(body) {
491 498 tracing::warn!(uid, folder = %folder_name, "skipping email with excessive MIME nesting");
492 499 parse_errors += 1;
500 + // Deterministic skip (same message always trips the guard):
501 + // safe to advance past so it isn't re-fetched every sync.
502 + max_uid = Some(max_uid.map_or(uid, |m: u32| m.max(uid)));
493 503 continue;
494 504 }
495 505 match mailparse::parse_mail(body) {
496 - Ok(parsed) => emails.push(Self::build_parsed_email(&parsed, uid, &folder_name, is_read)),
506 + Ok(parsed) => {
507 + emails.push(Self::build_parsed_email(&parsed, uid, &folder_name, is_read));
508 + // Advance only once the message is saved, so a parse
509 + // failure below is retried rather than silently lost.
510 + max_uid = Some(max_uid.map_or(uid, |m: u32| m.max(uid)));
511 + }
497 512 Err(e) => {
498 513 tracing::debug!(uid, folder = %folder_name, error = %e, "Failed to parse email");
499 514 parse_errors += 1;
@@ -159,7 +159,10 @@ impl JmapClient {
159 159 subject: email.subject.unwrap_or_default(),
160 160 body,
161 161 body_truncated,
162 - date: email.received_at.unwrap_or_else(Utc::now),
162 + // Fall back to the epoch (matching the IMAP path) rather than
163 + // "now": a JMAP server omitting receivedAt must not stamp every
164 + // such message with the sync time and corrupt mailbox ordering.
165 + date: email.received_at.unwrap_or(DateTime::UNIX_EPOCH),
163 166 is_read,
164 167 });
165 168 }