mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
Merge pull request #340 from rustmailer/fix/imap-uid-search-truncation
fix(imap): stop silent mail loss from truncated UID SEARCH enumeration
This commit is contained in:
@@ -124,10 +124,12 @@ impl ImapExecutor {
|
|||||||
|
|
||||||
/// Fetches new mail for a mailbox.
|
/// Fetches new mail for a mailbox.
|
||||||
///
|
///
|
||||||
/// When `before` is `Some(date)`, a two-step approach is used:
|
/// UIDs are enumerated via a ranged `UID FETCH {start}:* (UID RFC822.SIZE
|
||||||
/// `UID SEARCH` to find matching UIDs (standard IMAP), then batch `UID FETCH`
|
/// INTERNALDATE)` (RFC 3501 §6.4.4 closed-interval semantics — unlike
|
||||||
/// for the specific UIDs. When `before` is `None`, a direct ranged
|
/// `UID SEARCH`, which servers may answer with a subset, a truncated
|
||||||
/// `UID FETCH {start}:*` is issued and results are streamed.
|
/// enumeration cannot silently skip messages), then bodies are downloaded
|
||||||
|
/// in batches. When `before` is `Some(date)`, the INTERNALDATE is compared
|
||||||
|
/// against the date client-side (equivalent to SEARCH's BEFORE key).
|
||||||
///
|
///
|
||||||
/// Returns `Ok(Some(max_uid))` with the highest UID fetched, or `Ok(None)`
|
/// Returns `Ok(Some(max_uid))` with the highest UID fetched, or `Ok(None)`
|
||||||
/// if no new mail was found.
|
/// if no new mail was found.
|
||||||
@@ -141,63 +143,60 @@ impl ImapExecutor {
|
|||||||
) -> BichonResult<Option<u32>> {
|
) -> BichonResult<Option<u32>> {
|
||||||
assert!(start_uid > 0, "start_uid must be greater than 0");
|
assert!(start_uid > 0, "start_uid must be greater than 0");
|
||||||
|
|
||||||
session
|
let examined = session
|
||||||
.examine(&mailbox.encoded_name())
|
.examine(&mailbox.encoded_name())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
.map_err(|e| raise_error!(format!("{:#?}", e), classify_imap_error(&e)))?;
|
||||||
|
|
||||||
match before {
|
match before {
|
||||||
Some(date) => {
|
Some(date) => {
|
||||||
Self::fetch_new_mail_with_before(session, account, mailbox, start_uid, date, token)
|
Self::fetch_new_mail_with_before(
|
||||||
|
session,
|
||||||
|
account,
|
||||||
|
mailbox,
|
||||||
|
start_uid,
|
||||||
|
date,
|
||||||
|
examined,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
Self::fetch_new_mail_range(session, account, mailbox, start_uid, examined, token)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
None => Self::fetch_new_mail_range(session, account, mailbox, start_uid, token).await,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two-step approach for date-filtered incremental fetch: UID SEARCH first,
|
/// Date-filtered incremental fetch: enumerate UIDs in `{start}:*` via
|
||||||
/// then batch UID FETCH for matching UIDs. Uses standard IMAP syntax that
|
/// ranged `UID FETCH` (RFC 3501 §6.4.4 closed-interval semantics, so a
|
||||||
/// works across all compliant servers.
|
/// truncated result cannot silently skip messages), then filter by
|
||||||
|
/// INTERNALDATE client-side and batch-download the bodies.
|
||||||
|
///
|
||||||
|
/// The `BEFORE {date}` filter is applied locally: RFC 3501's BEFORE key
|
||||||
|
/// matches messages whose internal date (ignoring time and timezone) is
|
||||||
|
/// earlier than the given date, which is exactly the same comparison done
|
||||||
|
/// here on the INTERNALDATE returned by the enumeration fetch.
|
||||||
async fn fetch_new_mail_with_before(
|
async fn fetch_new_mail_with_before(
|
||||||
session: &mut Session<Box<dyn SessionStream>>,
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
account: &AccountModel,
|
account: &AccountModel,
|
||||||
mailbox: &MailBox,
|
mailbox: &MailBox,
|
||||||
start_uid: u64,
|
start_uid: u64,
|
||||||
date: &str,
|
date: &str,
|
||||||
|
examined: async_imap::types::Mailbox,
|
||||||
token: CancellationToken,
|
token: CancellationToken,
|
||||||
) -> BichonResult<Option<u32>> {
|
) -> BichonResult<Option<u32>> {
|
||||||
let query = format!("UID {start_uid}:* BEFORE {date}");
|
let uid_range = format!("{start_uid}:*");
|
||||||
info!(
|
let (entries, skipped_oversized) = Self::collect_range_uids(
|
||||||
"[account {}][mailbox {}] fetch_new_mail: UID SEARCH {}",
|
session,
|
||||||
account.id, mailbox.name, query
|
&uid_range,
|
||||||
);
|
account.id,
|
||||||
let results = session.uid_search(&query).await.map_err(|e| {
|
mailbox,
|
||||||
let err_msg = format!("UID SEARCH failed in [{}]: {:#?}", mailbox.name, e);
|
account.max_email_size_bytes,
|
||||||
let _ = DownloadState::append_session_error(account.id, err_msg);
|
token.clone(),
|
||||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
)
|
||||||
})?;
|
.await?;
|
||||||
|
let mut uid_vec = filter_before_date(&entries, date)?;
|
||||||
if results.is_empty() {
|
|
||||||
info!(
|
|
||||||
account_id = account.id,
|
|
||||||
mailbox = %mailbox.name,
|
|
||||||
start_uid,
|
|
||||||
date,
|
|
||||||
"fetch_new_mail_with_before: UID SEARCH returned no results"
|
|
||||||
);
|
|
||||||
DownloadState::update_folder_progress(
|
|
||||||
account.id,
|
|
||||||
mailbox.name.clone(),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
FolderStatus::Success,
|
|
||||||
Some("No new emails found.".into()),
|
|
||||||
)?;
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut uid_vec: Vec<u32> = results.into_iter().collect();
|
|
||||||
uid_vec.sort();
|
|
||||||
// Same non-compliant-server guard as fetch_new_mail_range: `{start}:*`
|
// Same non-compliant-server guard as fetch_new_mail_range: `{start}:*`
|
||||||
// may be clamped by the server and return uids below start_uid, which
|
// may be clamped by the server and return uids below start_uid, which
|
||||||
// are already stored locally (or are drift — gap-fill's job).
|
// are already stored locally (or are drift — gap-fill's job).
|
||||||
@@ -210,8 +209,54 @@ impl ImapExecutor {
|
|||||||
found = uid_vec.len(),
|
found = uid_vec.len(),
|
||||||
first = uid_vec.first().copied(),
|
first = uid_vec.first().copied(),
|
||||||
last = uid_vec.last().copied(),
|
last = uid_vec.last().copied(),
|
||||||
"fetch_new_mail_with_before: UID SEARCH result"
|
skipped_oversized,
|
||||||
|
"fetch_new_mail_with_before: UID FETCH result"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if uid_vec.is_empty() {
|
||||||
|
// Same truncated-result guard as fetch_new_mail_range: if the
|
||||||
|
// server claims new mail but nothing passed the filters, refuse to
|
||||||
|
// advance highest_uid. A legitimate empty result here is: mail
|
||||||
|
// existed but was all oversized (skipped_oversized > 0), or all
|
||||||
|
// entries fell outside the date window (entries non-empty).
|
||||||
|
if skipped_oversized == 0 && entries.is_empty() {
|
||||||
|
if let Some(msg) = empty_enumeration_anomaly(
|
||||||
|
mailbox.name.as_str(),
|
||||||
|
&uid_range,
|
||||||
|
start_uid,
|
||||||
|
examined.uid_next,
|
||||||
|
) {
|
||||||
|
tracing::warn!(
|
||||||
|
account_id = account.id,
|
||||||
|
mailbox = %mailbox.name,
|
||||||
|
start_uid,
|
||||||
|
uid_next = examined.uid_next,
|
||||||
|
"{}",
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
DownloadState::append_session_error(account.id, msg)?;
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Failed,
|
||||||
|
Some("UID enumeration came back empty despite new mail on server. Retrying on next sync.".into()),
|
||||||
|
)?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Success,
|
||||||
|
Some("No new emails found.".into()),
|
||||||
|
)?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
let max_uid = uid_vec.last().copied();
|
let max_uid = uid_vec.last().copied();
|
||||||
let planned = uid_vec.len() as u64;
|
let planned = uid_vec.len() as u64;
|
||||||
let batch_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize;
|
let batch_size = account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize;
|
||||||
@@ -297,34 +342,128 @@ impl ImapExecutor {
|
|||||||
Ok(max_uid)
|
Ok(max_uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enumerates every UID in `{start}:*` via a lightweight
|
||||||
|
/// `UID FETCH {start}:* (UID RFC822.SIZE INTERNALDATE)` — no bodies.
|
||||||
|
///
|
||||||
|
/// Unlike `UID SEARCH`, a ranged UID FETCH is required by RFC 3501 §6.4.4
|
||||||
|
/// to return the full closed interval, so a truncated response cannot
|
||||||
|
/// silently skip messages in the middle of the range.
|
||||||
|
///
|
||||||
|
/// UIDs whose RFC822.SIZE exceeds `max_email_size_bytes` are dropped here
|
||||||
|
/// (they would be skipped again by the batched body fetch's SIZE pre-check,
|
||||||
|
/// so filtering up front saves a round trip per batch). Returns the
|
||||||
|
/// accepted `(uid, size, internal_date_epoch_millis)` entries and the
|
||||||
|
/// number of oversized messages skipped.
|
||||||
|
async fn collect_range_uids(
|
||||||
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
|
uid_range: &str,
|
||||||
|
account_id: u64,
|
||||||
|
mailbox: &MailBox,
|
||||||
|
max_email_size_bytes: Option<u64>,
|
||||||
|
token: CancellationToken,
|
||||||
|
) -> BichonResult<(Vec<(u32, u64, i64)>, u64)> {
|
||||||
|
let limit = max_email_size_bytes.unwrap_or(DEFAULT_MAX_EMAIL_SIZE);
|
||||||
|
let mut uid_stream = session
|
||||||
|
.uid_fetch(uid_range, "(UID RFC822.SIZE INTERNALDATE)")
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let err_msg = format!("UID FETCH failed in [{}]: {:#?}", mailbox.name, e);
|
||||||
|
let _ = DownloadState::append_session_error(account_id, err_msg);
|
||||||
|
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
||||||
|
})?;
|
||||||
|
let mut entries: Vec<(u32, u64, i64)> = Vec::new();
|
||||||
|
let mut skipped_oversized = 0u64;
|
||||||
|
while let Some(fetch) = uid_stream
|
||||||
|
.try_next()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let err_msg = format!("UID FETCH stream failed in [{}]: {:#?}", mailbox.name, e);
|
||||||
|
let _ = DownloadState::append_session_error(account_id, err_msg);
|
||||||
|
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
||||||
|
})?
|
||||||
|
{
|
||||||
|
if token.is_cancelled() {
|
||||||
|
DownloadState::update_session_status(
|
||||||
|
account_id,
|
||||||
|
DownloadStatus::Cancelled,
|
||||||
|
Some("User stopped or system shutdown".to_string()),
|
||||||
|
)?;
|
||||||
|
return Err(raise_error!(
|
||||||
|
"Stream cancelled".into(),
|
||||||
|
ErrorCode::InternalError
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(uid) = fetch.uid else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let size = fetch.size.unwrap_or(0) as u64;
|
||||||
|
let internal_date = fetch
|
||||||
|
.internal_date()
|
||||||
|
.map(|d| d.timestamp_millis())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if size == 0 || size <= limit {
|
||||||
|
entries.push((uid, size, internal_date));
|
||||||
|
} else {
|
||||||
|
skipped_oversized += 1;
|
||||||
|
tracing::warn!(
|
||||||
|
account_id,
|
||||||
|
mailbox_id = mailbox.id,
|
||||||
|
uid,
|
||||||
|
size,
|
||||||
|
limit,
|
||||||
|
"Skipping oversized email during UID enumeration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((entries, skipped_oversized))
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches all messages with UID >= start_uid via batched UID FETCH.
|
/// Fetches all messages with UID >= start_uid via batched UID FETCH.
|
||||||
///
|
///
|
||||||
/// A single ranged `UID FETCH {start}:*` can block for minutes on slow
|
/// A single ranged `UID FETCH {start}:* (BODY[])` can block for minutes on
|
||||||
/// servers pushing hundreds of messages, and hits the socket read timeout
|
/// slow servers pushing hundreds of messages, and hits the socket read
|
||||||
/// if the server stalls, with zero progress feedback in the meantime.
|
/// timeout if the server stalls, with zero progress feedback in the
|
||||||
/// Instead, enumerate the UIDs first, then download in small batches —
|
/// meantime. Instead, enumerate the UIDs first via a lightweight
|
||||||
/// each batch is a short round-trip with a SIZE pre-check (oversized
|
/// `UID FETCH {start}:* (UID RFC822.SIZE)` (headers/size only, no bodies),
|
||||||
/// messages are skipped without fetching their body), progress is reported
|
/// then download in small batches — each batch is a short round-trip with
|
||||||
/// per batch, and the whole download stays responsive to cancellation.
|
/// a SIZE pre-check (oversized messages are skipped without fetching their
|
||||||
|
/// body), progress is reported per batch, and the whole download stays
|
||||||
|
/// responsive to cancellation.
|
||||||
|
///
|
||||||
|
/// A plain `UID SEARCH {start}:*` is NOT used to enumerate: RFC 3501
|
||||||
|
/// grants SEARCH the freedom to return a subset or non-normalized results,
|
||||||
|
/// and servers in the wild (e.g. Gmail) occasionally return only the last
|
||||||
|
/// matching UID for a huge range. Since the caller advances highest_uid to
|
||||||
|
/// the last UID found, a truncated SEARCH permanently skips everything
|
||||||
|
/// between start_uid and that last UID. UID FETCH on a range, by contrast,
|
||||||
|
/// is REQUIRED by RFC 3501 §6.4.4 to return the full closed interval
|
||||||
|
/// [start_uid, max UID].
|
||||||
async fn fetch_new_mail_range(
|
async fn fetch_new_mail_range(
|
||||||
session: &mut Session<Box<dyn SessionStream>>,
|
session: &mut Session<Box<dyn SessionStream>>,
|
||||||
account: &AccountModel,
|
account: &AccountModel,
|
||||||
mailbox: &MailBox,
|
mailbox: &MailBox,
|
||||||
start_uid: u64,
|
start_uid: u64,
|
||||||
|
examined: async_imap::types::Mailbox,
|
||||||
token: CancellationToken,
|
token: CancellationToken,
|
||||||
) -> BichonResult<Option<u32>> {
|
) -> BichonResult<Option<u32>> {
|
||||||
let uid_range = format!("{start_uid}:*");
|
let uid_range = format!("{start_uid}:*");
|
||||||
info!(
|
info!(
|
||||||
"[account {}][mailbox {}] fetch_new_mail: batched UID FETCH {}",
|
"[account {}][mailbox {}] fetch_new_mail: enumerate UIDs via UID FETCH {}",
|
||||||
account.id, mailbox.name, uid_range
|
account.id, mailbox.name, uid_range
|
||||||
);
|
);
|
||||||
|
|
||||||
let results = session.uid_search(&uid_range).await.map_err(|e| {
|
// Track how many messages were dropped by the size filter so an empty
|
||||||
let err_msg = format!("UID SEARCH failed in [{}]: {:#?}", mailbox.name, e);
|
// result is not misread as an enumeration failure (anomaly guard).
|
||||||
let _ = DownloadState::append_session_error(account.id, err_msg);
|
let (entries, skipped_oversized) = Self::collect_range_uids(
|
||||||
raise_error!(format!("{:#?}", e), classify_imap_error(&e))
|
session,
|
||||||
})?;
|
&uid_range,
|
||||||
let mut uid_vec: Vec<u32> = results.into_iter().collect();
|
account.id,
|
||||||
|
mailbox,
|
||||||
|
account.max_email_size_bytes,
|
||||||
|
token.clone(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut uid_vec: Vec<u32> = entries.iter().map(|&(uid, _, _)| uid).collect();
|
||||||
uid_vec.sort();
|
uid_vec.sort();
|
||||||
// Some non-compliant servers (e.g. Zoho) interpret `{start}:*` as a
|
// Some non-compliant servers (e.g. Zoho) interpret `{start}:*` as a
|
||||||
// sequence range and clamp it, returning the last message even when
|
// sequence range and clamp it, returning the last message even when
|
||||||
@@ -339,10 +478,47 @@ impl ImapExecutor {
|
|||||||
found = uid_vec.len(),
|
found = uid_vec.len(),
|
||||||
first = uid_vec.first().copied(),
|
first = uid_vec.first().copied(),
|
||||||
last = uid_vec.last().copied(),
|
last = uid_vec.last().copied(),
|
||||||
"fetch_new_mail_range: UID SEARCH result"
|
skipped_oversized,
|
||||||
|
"fetch_new_mail_range: UID FETCH result"
|
||||||
);
|
);
|
||||||
|
|
||||||
if uid_vec.is_empty() {
|
if uid_vec.is_empty() {
|
||||||
|
// Guard against a truncated (or silently dropped) FETCH result:
|
||||||
|
// if the server reports a UIDNEXT well above start_uid yet no UIDs
|
||||||
|
// came back, do NOT advance highest_uid past start_uid — that would
|
||||||
|
// permanently skip everything in between. Report the anomaly and
|
||||||
|
// keep the old highest_uid so the next sync retries.
|
||||||
|
//
|
||||||
|
// Oversized-only mail is legitimate (nothing to download within the
|
||||||
|
// size limit), so skip the anomaly check when the size filter (not
|
||||||
|
// the server) is what emptied the range.
|
||||||
|
if skipped_oversized == 0 {
|
||||||
|
if let Some(msg) = empty_enumeration_anomaly(
|
||||||
|
mailbox.name.as_str(),
|
||||||
|
&uid_range,
|
||||||
|
start_uid,
|
||||||
|
examined.uid_next,
|
||||||
|
) {
|
||||||
|
tracing::warn!(
|
||||||
|
account_id = account.id,
|
||||||
|
mailbox = %mailbox.name,
|
||||||
|
start_uid,
|
||||||
|
uid_next = examined.uid_next,
|
||||||
|
"{}",
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
DownloadState::append_session_error(account.id, msg)?;
|
||||||
|
DownloadState::update_folder_progress(
|
||||||
|
account.id,
|
||||||
|
mailbox.name.clone(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
FolderStatus::Failed,
|
||||||
|
Some("UID enumeration came back empty despite new mail on server. Retrying on next sync.".into()),
|
||||||
|
)?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
DownloadState::update_folder_progress(
|
DownloadState::update_folder_progress(
|
||||||
account.id,
|
account.id,
|
||||||
mailbox.name.clone(),
|
mailbox.name.clone(),
|
||||||
@@ -944,6 +1120,58 @@ pub fn generate_uid_sequence_hashset(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filters `(uid, size, internal_date_epoch_millis)` entries to those whose
|
||||||
|
/// internal date (ignoring time and timezone, matching RFC 3501's BEFORE key)
|
||||||
|
/// is strictly earlier than `date` (`%d-%b-%Y`, e.g. "26-May-2025").
|
||||||
|
/// Returns the matching UIDs sorted ascending. Errors if the date cannot be
|
||||||
|
/// parsed — a silently-broken date filter would download mail the user asked
|
||||||
|
/// to exclude.
|
||||||
|
fn filter_before_date(
|
||||||
|
entries: &[(u32, u64, i64)],
|
||||||
|
date: &str,
|
||||||
|
) -> BichonResult<Vec<u32>> {
|
||||||
|
let cutoff = chrono::NaiveDate::parse_from_str(date, "%d-%b-%Y").map_err(|e| {
|
||||||
|
raise_error!(
|
||||||
|
format!("Invalid BEFORE date '{date}': {e}"),
|
||||||
|
ErrorCode::InvalidParameter
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mut uid_vec: Vec<u32> = entries
|
||||||
|
.iter()
|
||||||
|
.filter(|&&(_, _, internal_date)| {
|
||||||
|
let d = chrono::DateTime::from_timestamp_millis(internal_date)
|
||||||
|
.map(|dt| dt.date_naive())
|
||||||
|
.unwrap_or_default();
|
||||||
|
d < cutoff
|
||||||
|
})
|
||||||
|
.map(|&(uid, _, _)| uid)
|
||||||
|
.collect();
|
||||||
|
uid_vec.sort();
|
||||||
|
Ok(uid_vec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a range enumeration comes back empty, decide whether that is an
|
||||||
|
/// anomaly (server claims messages exist in the range but none were returned)
|
||||||
|
/// or a genuine "no new mail" result. Returns a warning message for the
|
||||||
|
/// anomaly, `None` when the empty result is legitimate (and highest_uid may be
|
||||||
|
/// left unchanged safely).
|
||||||
|
fn empty_enumeration_anomaly(
|
||||||
|
mailbox_name: &str,
|
||||||
|
uid_range: &str,
|
||||||
|
start_uid: u64,
|
||||||
|
server_uid_next: Option<u32>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let uid_next = server_uid_next?;
|
||||||
|
if (uid_next as u64) > start_uid {
|
||||||
|
Some(format!(
|
||||||
|
"Mailbox '{}': UID FETCH {} returned no UIDs but server UIDNEXT={} ({} messages in range). Refusing to advance highest_uid to avoid skipping them; the next sync will retry.",
|
||||||
|
mailbox_name, uid_range, uid_next, uid_next.saturating_sub(start_uid as u32)
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_message_id_header(header_bytes: &[u8]) -> Option<String> {
|
fn parse_message_id_header(header_bytes: &[u8]) -> Option<String> {
|
||||||
let header = std::str::from_utf8(header_bytes).ok()?;
|
let header = std::str::from_utf8(header_bytes).ok()?;
|
||||||
for line in header.lines() {
|
for line in header.lines() {
|
||||||
@@ -968,6 +1196,8 @@ fn parse_message_id_header(header_bytes: &[u8]) -> Option<String> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::imap::session::SessionStream;
|
||||||
|
use tokio_io_timeout::TimeoutStream;
|
||||||
|
|
||||||
// ── compress_uid_list ──────────────────────────────────────────
|
// ── compress_uid_list ──────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1021,6 +1251,46 @@ mod test {
|
|||||||
assert_eq!(batches[2].1, 1);
|
assert_eq!(batches[2].1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── filter_before_date ─────────────────────────────────────────
|
||||||
|
|
||||||
|
fn ms(y: i32, m: u32, d: u32) -> i64 {
|
||||||
|
chrono::NaiveDate::from_ymd_opt(y, m, d)
|
||||||
|
.unwrap()
|
||||||
|
.and_hms_opt(12, 0, 0)
|
||||||
|
.unwrap()
|
||||||
|
.and_utc()
|
||||||
|
.timestamp_millis()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_before_date_keeps_only_earlier_dates() {
|
||||||
|
let entries = vec![
|
||||||
|
(1, 100, ms(2025, 5, 20)),
|
||||||
|
(2, 200, ms(2025, 5, 26)), // exactly on the cutoff day — excluded (BEFORE is strict)
|
||||||
|
(3, 300, ms(2025, 5, 27)),
|
||||||
|
];
|
||||||
|
let uids = filter_before_date(&entries, "27-May-2025").unwrap();
|
||||||
|
assert_eq!(uids, vec![1, 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_before_date_timezone_ignored() {
|
||||||
|
// Internal date late in the day in +1400 still counts as that day:
|
||||||
|
// 2025-05-26 23:59:00 +1400 == 2025-05-26 09:59 UTC.
|
||||||
|
let entries = vec![
|
||||||
|
(1, 100, ms(2025, 5, 25)),
|
||||||
|
(2, 200, ms(2025, 5, 26)),
|
||||||
|
];
|
||||||
|
let uids = filter_before_date(&entries, "26-May-2025").unwrap();
|
||||||
|
assert_eq!(uids, vec![1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filter_before_date_invalid_date_errors() {
|
||||||
|
let entries = vec![(1, 100, ms(2025, 5, 20))];
|
||||||
|
assert!(filter_before_date(&entries, "not-a-date").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
// ── parse_message_id_header ─────────────────────────────────────
|
// ── parse_message_id_header ─────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1093,4 +1363,176 @@ To: recipient@example.com\r\n\r\n";
|
|||||||
Some("plain@example.com".into())
|
Some("plain@example.com".into())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── empty_enumeration_anomaly ──────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_enumeration_no_anomaly_when_uidnext_below_start() {
|
||||||
|
// No new mail: server UIDNEXT <= start_uid → legitimate empty result.
|
||||||
|
assert_eq!(
|
||||||
|
empty_enumeration_anomaly("INBOX", "816098:*", 816098, Some(816098)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
empty_enumeration_anomaly("INBOX", "816098:*", 816098, Some(816097)),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_enumeration_no_anomaly_when_uidnext_unknown() {
|
||||||
|
// Server did not report UIDNEXT; cannot prove mail exists in range.
|
||||||
|
assert_eq!(
|
||||||
|
empty_enumeration_anomaly("INBOX", "816098:*", 816098, None),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_enumeration_anomaly_when_uidnext_above_start() {
|
||||||
|
// Server claims messages exist but none came back → anomaly message.
|
||||||
|
let msg = empty_enumeration_anomaly("portal_issues", "816098:*", 816098, Some(816118));
|
||||||
|
let msg = msg.expect("should be Some for anomalous empty enumeration");
|
||||||
|
assert!(msg.contains("portal_issues"));
|
||||||
|
assert!(msg.contains("UIDNEXT=816118"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── collect_range_uids via mock server ─────────────────────────
|
||||||
|
|
||||||
|
use crate::imap::mock_server::{
|
||||||
|
examine_response, uid_fetch_size_response, MockImapServer, MockImapServerHandle,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build an `async_imap::Session` connected to the mock server,
|
||||||
|
/// authenticated and with the given mailbox examined.
|
||||||
|
async fn mock_session(
|
||||||
|
handle: &MockImapServerHandle,
|
||||||
|
) -> async_imap::Session<Box<dyn SessionStream>> {
|
||||||
|
let tcp = tokio::net::TcpStream::connect((handle.host(), handle.port()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let timeout_stream = TimeoutStream::new(tcp);
|
||||||
|
let pinned: std::pin::Pin<Box<TimeoutStream<tokio::net::TcpStream>>> =
|
||||||
|
Box::pin(timeout_stream);
|
||||||
|
let stream: Box<dyn SessionStream> = Box::new(pinned);
|
||||||
|
let mut client = async_imap::Client::new(stream);
|
||||||
|
|
||||||
|
// Read greeting
|
||||||
|
client.read_response().await.unwrap();
|
||||||
|
|
||||||
|
// Login
|
||||||
|
let mut session = client
|
||||||
|
.login("user", "pass")
|
||||||
|
.await
|
||||||
|
.map_err(|(e, _)| panic!("Login failed: {e:?}"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Examine
|
||||||
|
session.examine("INBOX").await.unwrap();
|
||||||
|
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn collect_range_uids_via_mock_server() {
|
||||||
|
let handle = MockImapServer::new()
|
||||||
|
.respond("LOGIN", "{TAG} OK LOGIN done\r\n")
|
||||||
|
.respond("EXAMINE", examine_response("INBOX", 3, 42, 4))
|
||||||
|
.respond(
|
||||||
|
"UID FETCH",
|
||||||
|
uid_fetch_size_response(&[(1, 100), (2, 200), (3, 300)]),
|
||||||
|
)
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut session = mock_session(&handle).await;
|
||||||
|
|
||||||
|
let mut mailbox = MailBox::default();
|
||||||
|
mailbox.name = "INBOX".into();
|
||||||
|
|
||||||
|
let (entries, skipped) = ImapExecutor::collect_range_uids(
|
||||||
|
&mut session,
|
||||||
|
"1:*",
|
||||||
|
1,
|
||||||
|
&mailbox,
|
||||||
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
entries.iter().map(|&(uid, _, _)| uid).collect::<Vec<_>>(),
|
||||||
|
vec![1, 2, 3]
|
||||||
|
);
|
||||||
|
assert_eq!(skipped, 0);
|
||||||
|
session.logout().await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn collect_range_uids_empty_mailbox() {
|
||||||
|
let handle = MockImapServer::new()
|
||||||
|
.respond("LOGIN", "{TAG} OK LOGIN done\r\n")
|
||||||
|
.respond("EXAMINE", examine_response("INBOX", 0, 42, 1))
|
||||||
|
.respond("UID FETCH", b"{TAG} OK FETCH completed\r\n".to_vec())
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut session = mock_session(&handle).await;
|
||||||
|
|
||||||
|
let mut mailbox = MailBox::default();
|
||||||
|
mailbox.name = "INBOX".into();
|
||||||
|
|
||||||
|
let (entries, skipped) = ImapExecutor::collect_range_uids(
|
||||||
|
&mut session,
|
||||||
|
"1:*",
|
||||||
|
1,
|
||||||
|
&mailbox,
|
||||||
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(entries.is_empty());
|
||||||
|
assert_eq!(skipped, 0);
|
||||||
|
session.logout().await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn collect_range_uids_filters_oversized() {
|
||||||
|
let handle = MockImapServer::new()
|
||||||
|
.respond("LOGIN", "{TAG} OK LOGIN done\r\n")
|
||||||
|
.respond("EXAMINE", examine_response("INBOX", 4, 42, 5))
|
||||||
|
.respond(
|
||||||
|
"UID FETCH",
|
||||||
|
uid_fetch_size_response(&[(1, 100), (2, 500), (3, 1000), (4, 2000)]),
|
||||||
|
)
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut session = mock_session(&handle).await;
|
||||||
|
|
||||||
|
let mut mailbox = MailBox::default();
|
||||||
|
mailbox.name = "INBOX".into();
|
||||||
|
|
||||||
|
// Limit 1000: UIDs 1..3 accepted, UID 4 (2000) skipped.
|
||||||
|
let (entries, skipped) = ImapExecutor::collect_range_uids(
|
||||||
|
&mut session,
|
||||||
|
"1:*",
|
||||||
|
1,
|
||||||
|
&mailbox,
|
||||||
|
Some(1000),
|
||||||
|
CancellationToken::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
entries.iter().map(|&(uid, _, _)| uid).collect::<Vec<_>>(),
|
||||||
|
vec![1, 2, 3]
|
||||||
|
);
|
||||||
|
assert_eq!(skipped, 1);
|
||||||
|
session.logout().await.ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,20 @@ pub fn uid_search_response(uids: &[u32]) -> Vec<u8> {
|
|||||||
format!("* SEARCH {uid_str}\r\n{{TAG}} OK SEARCH completed\r\n").into_bytes()
|
format!("* SEARCH {uid_str}\r\n{{TAG}} OK SEARCH completed\r\n").into_bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a UID FETCH response returning UID + RFC822.SIZE + INTERNALDATE
|
||||||
|
/// (no body). Each entry: (uid, size)
|
||||||
|
pub fn uid_fetch_size_response(entries: &[(u32, u32)]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (uid, size) in entries {
|
||||||
|
let line = format!(
|
||||||
|
"* {uid} FETCH (UID {uid} RFC822.SIZE {size} INTERNALDATE \"01-Jan-2025 00:00:00 +0000\")\r\n"
|
||||||
|
);
|
||||||
|
out.extend_from_slice(line.as_bytes());
|
||||||
|
}
|
||||||
|
out.extend_from_slice(b"{TAG} OK FETCH completed\r\n");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a UID FETCH response returning full headers (for BODY[HEADER]).
|
/// Build a UID FETCH response returning full headers (for BODY[HEADER]).
|
||||||
/// Each entry: (uid, message_id)
|
/// Each entry: (uid, message_id)
|
||||||
pub fn uid_fetch_metadata_response(entries: &[(u32, &str)]) -> Vec<u8> {
|
pub fn uid_fetch_metadata_response(entries: &[(u32, &str)]) -> Vec<u8> {
|
||||||
|
|||||||
Reference in New Issue
Block a user