Skip to content

Commit d8e5279

Browse files
committed
fix(imap): always advance expected UIDNEXT to avoid skipping IDLE in a loop
Ensure the client does not busy loop skipping IDLE if UIDNEXT of the mailbox is higher than the last seen UID plus 1, e.g. if the message with UID=UIDNEXT-1 was deleted before we fetched it. We do not fallback to UIDNEXT=1 anymore if the STATUS command cannot determine UIDNEXT. There are no known IMAP servers with broken STATUS so far. This allows to guarantee that select_with_uidvalidity() sets UIDNEXT for the mailbox and use it in fetch_new_messages() to ensure that UIDNEXT always advances even when there are no messages to fetch.
1 parent 5549733 commit d8e5279

File tree

2 files changed

+66
-55
lines changed

2 files changed

+66
-55
lines changed

src/imap.rs

Lines changed: 65 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -568,9 +568,14 @@ impl Imap {
568568
Ok(())
569569
}
570570

571-
/// Select a folder and take care of uidvalidity changes.
572-
/// Also, when selecting a folder for the first time, sets the uid_next to the current
571+
/// Selects a folder and takes care of UIDVALIDITY changes.
572+
///
573+
/// When selecting a folder for the first time, sets the uid_next to the current
573574
/// mailbox.uid_next so that no old emails are fetched.
575+
///
576+
/// Makes sure that UIDNEXT is known for `selected_mailbox`
577+
/// and errors out if UIDNEXT cannot be determined.
578+
///
574579
/// Returns Result<new_emails> (i.e. whether new emails arrived),
575580
/// if in doubt, returns new_emails=true so emails are fetched.
576581
pub(crate) async fn select_with_uidvalidity(
@@ -591,6 +596,37 @@ impl Imap {
591596
let new_uid_validity = mailbox
592597
.uid_validity
593598
.with_context(|| format!("No UIDVALIDITY for folder {folder}"))?;
599+
let new_uid_next = if let Some(uid_next) = mailbox.uid_next {
600+
uid_next
601+
} else {
602+
warn!(
603+
context,
604+
"SELECT response for IMAP folder {folder:?} has no UIDNEXT, fall back to STATUS command."
605+
);
606+
607+
// RFC 3501 says STATUS command SHOULD NOT be used
608+
// on the currently selected mailbox because the same
609+
// information can be obtained by other means,
610+
// such as reading SELECT response.
611+
//
612+
// However, it also says that UIDNEXT is REQUIRED
613+
// in the SELECT response and if we are here,
614+
// it is actually not returned.
615+
//
616+
// In particular, Winmail Pro Mail Server 5.1.0616
617+
// never returns UIDNEXT in SELECT response,
618+
// but responds to "STATUS INBOX (UIDNEXT)" command.
619+
let status = session
620+
.inner
621+
.status(folder, "(UIDNEXT)")
622+
.await
623+
.context("STATUS (UIDNEXT) error for {folder:?}")?;
624+
625+
status
626+
.uid_next
627+
.context("STATUS {folder} (UIDNEXT) did not return UIDNEXT")?
628+
};
629+
mailbox.uid_next = Some(new_uid_next);
594630

595631
let old_uid_validity = get_uidvalidity(context, folder)
596632
.await
@@ -606,18 +642,16 @@ impl Imap {
606642
// the caller tries to fetch new messages (we could of course run a SELECT command now, but trying to fetch
607643
// new messages is only one command, just as a SELECT command)
608644
true
609-
} else if let Some(uid_next) = mailbox.uid_next {
610-
if uid_next < old_uid_next {
645+
} else {
646+
if new_uid_next < old_uid_next {
611647
warn!(
612648
context,
613-
"The server illegally decreased the uid_next of folder {folder:?} from {old_uid_next} to {uid_next} without changing validity ({new_uid_validity}), resyncing UIDs...",
649+
"The server illegally decreased the uid_next of folder {folder:?} from {old_uid_next} to {new_uid_next} without changing validity ({new_uid_validity}), resyncing UIDs...",
614650
);
615-
set_uid_next(context, folder, uid_next).await?;
651+
set_uid_next(context, folder, new_uid_next).await?;
616652
context.schedule_resync().await?;
617653
}
618-
uid_next != old_uid_next // If uid_next changed, there are new emails
619-
} else {
620-
true // We have no uid_next and if in doubt, return true
654+
new_uid_next != old_uid_next // If UIDNEXT changed, there are new emails
621655
};
622656
return Ok(new_emails);
623657
}
@@ -627,43 +661,6 @@ impl Imap {
627661

628662
// ============== uid_validity has changed or is being set the first time. ==============
629663

630-
let new_uid_next = match mailbox.uid_next {
631-
Some(uid_next) => uid_next,
632-
None => {
633-
warn!(
634-
context,
635-
"SELECT response for IMAP folder {folder:?} has no UIDNEXT, fall back to STATUS command."
636-
);
637-
638-
// RFC 3501 says STATUS command SHOULD NOT be used
639-
// on the currently selected mailbox because the same
640-
// information can be obtained by other means,
641-
// such as reading SELECT response.
642-
//
643-
// However, it also says that UIDNEXT is REQUIRED
644-
// in the SELECT response and if we are here,
645-
// it is actually not returned.
646-
//
647-
// In particular, Winmail Pro Mail Server 5.1.0616
648-
// never returns UIDNEXT in SELECT response,
649-
// but responds to "SELECT INBOX (UIDNEXT)" command.
650-
let status = session
651-
.inner
652-
.status(folder, "(UIDNEXT)")
653-
.await
654-
.context("STATUS (UIDNEXT) error for {folder:?}")?;
655-
656-
if let Some(uid_next) = status.uid_next {
657-
uid_next
658-
} else {
659-
warn!(context, "STATUS {folder} (UIDNEXT) did not return UIDNEXT");
660-
661-
// Set UIDNEXT to 1 as a last resort fallback.
662-
1
663-
}
664-
}
665-
};
666-
667664
set_uid_next(context, folder, new_uid_next).await?;
668665
set_uidvalidity(context, folder, new_uid_validity).await?;
669666

@@ -867,14 +864,28 @@ impl Imap {
867864
uids_fetch_in_batch.push(uid);
868865
}
869866

870-
// determine which uid_next to use to update to
871-
// receive_imf() returns an `Err` value only on recoverable errors, otherwise it just logs an error.
872-
// `largest_uid_processed` is the largest uid where receive_imf() did NOT return an error.
873-
874-
// So: Update the uid_next to the largest uid that did NOT recoverably fail. Not perfect because if there was
875-
// another message afterwards that succeeded, we will not retry. The upside is that we will not retry an infinite amount of times.
876-
let largest_uid_without_errors = max(largest_uid_fetched, largest_uid_skipped.unwrap_or(0));
877-
let new_uid_next = largest_uid_without_errors + 1;
867+
// Advance uid_next to the maximum of the largest known UID plus 1
868+
// and mailbox UIDNEXT.
869+
// Largest known UID is normally less than UIDNEXT,
870+
// but a message may have arrived between determining UIDNEXT
871+
// and executing the FETCH command.
872+
let mailbox_uid_next = self
873+
.session
874+
.as_ref()
875+
.context("No IMAP session")?
876+
.selected_mailbox
877+
.as_ref()
878+
.with_context(|| format!("Expected {folder:?} to be selected"))?
879+
.uid_next
880+
.with_context(|| {
881+
format!(
882+
"Expected UIDNEXT to be determined for {folder:?} by select_with_uidvalidity"
883+
)
884+
})?;
885+
let new_uid_next = max(
886+
max(largest_uid_fetched, largest_uid_skipped.unwrap_or(0)) + 1,
887+
mailbox_uid_next,
888+
);
878889

879890
if new_uid_next > old_uid_next {
880891
set_uid_next(context, folder, new_uid_next).await?;

src/imap/idle.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ impl Session {
5353
if uid_next > expected_uid_next {
5454
info!(
5555
context,
56-
"Skipping IDLE because UIDNEXT indicates there are new messages."
56+
"Skipping IDLE on {folder:?} because UIDNEXT {uid_next}>{expected_uid_next} indicates there are new messages."
5757
);
5858
return Ok((self, info));
5959
}

0 commit comments

Comments
 (0)