use async_imap::imap_proto::types::BodyStructure;
use futures::TryStreamExt;
use crate::commands::keyring::email::imap_client::ImapSession;
pub(crate) const ATTACHMENT_COUNT_CAP: usize = 5_000;
const FETCH_BATCH: usize = 50;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AttachmentCount {
pub attachments: u64,
pub unparsed: usize,
}
pub(crate) fn within_cap(total_new: usize) -> bool {
total_new <= ATTACHMENT_COUNT_CAP
}
fn count_attachments(structure: &BodyStructure) -> u64 {
match structure {
BodyStructure::Multipart { bodies, .. } => bodies.iter().map(count_attachments).sum(),
BodyStructure::Basic { common, .. }
| BodyStructure::Text { common, .. }
| BodyStructure::Message { common, .. } => {
let has_attachment_disposition = common
.disposition
.as_ref()
.is_some_and(|d| d.ty.eq_ignore_ascii_case("attachment"));
let has_content_type_name = common.ty.params.as_ref().is_some_and(|params| {
params
.iter()
.any(|(key, _)| key.eq_ignore_ascii_case("name"))
});
u64::from(has_attachment_disposition || has_content_type_name)
}
}
}
async fn fetch_batch(
session: &mut ImapSession,
mailbox: &str,
uids: &[u32],
) -> Result<u64, String> {
let uid_set = uids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let mut fetches = session
.uid_fetch(&uid_set, "(UID BODYSTRUCTURE)")
.await
.map_err(|err| format!("failed to fetch BODYSTRUCTURE in '{mailbox}': {err}"))?;
let mut attachments = 0;
while let Some(fetch) = fetches
.try_next()
.await
.map_err(|err| format!("failed to fetch BODYSTRUCTURE in '{mailbox}': {err}"))?
{
attachments += fetch.bodystructure().map_or(0, count_attachments);
}
Ok(attachments)
}
#[derive(Debug, PartialEq, Eq)]
enum BisectOutcome {
Fetched(u64),
Poisoned,
Retry(Vec<u32>, Vec<u32>),
}
fn bisect_step(batch: &[u32], result: Result<u64, String>) -> BisectOutcome {
match result {
Ok(attachments) => BisectOutcome::Fetched(attachments),
Err(_) if batch.len() <= 1 => BisectOutcome::Poisoned,
Err(_) => {
let mid = batch.len() / 2;
BisectOutcome::Retry(batch[..mid].to_vec(), batch[mid..].to_vec())
}
}
}
pub(crate) async fn count_new_attachments(
session: &mut ImapSession,
mailbox: &str,
uids: &[u32],
) -> AttachmentCount {
let mut count = AttachmentCount::default();
let mut work: Vec<Vec<u32>> = uids
.chunks(FETCH_BATCH)
.map(<[u32]>::to_vec)
.rev()
.collect();
while let Some(batch) = work.pop() {
let result = fetch_batch(session, mailbox, &batch).await;
match bisect_step(&batch, result) {
BisectOutcome::Fetched(attachments) => count.attachments += attachments,
BisectOutcome::Poisoned => {
tracing::warn!(
mailbox,
uid = batch.first().copied(),
"BODYSTRUCTURE could not be parsed; counting no attachments for this message"
);
count.unparsed += batch.len();
}
BisectOutcome::Retry(left, right) => {
work.push(right);
work.push(left);
}
}
}
count
}
#[cfg(test)]
mod tests {
use async_imap::imap_proto::types::{
BodyContentCommon, BodyContentSinglePart, ContentDisposition, ContentEncoding, ContentType,
};
use super::*;
fn basic_part(
disposition: Option<&'static str>,
name: Option<&'static str>,
) -> BodyStructure<'static> {
BodyStructure::Basic {
common: BodyContentCommon {
ty: ContentType {
ty: "application".into(),
subtype: "octet-stream".into(),
params: name.map(|name| vec![("name".into(), name.into())]),
},
disposition: disposition.map(|ty| ContentDisposition {
ty: ty.into(),
params: None,
}),
language: None,
location: None,
},
other: BodyContentSinglePart {
id: None,
md5: None,
description: None,
transfer_encoding: ContentEncoding::SevenBit,
octets: 100,
},
extension: None,
}
}
fn multipart(bodies: Vec<BodyStructure<'static>>) -> BodyStructure<'static> {
BodyStructure::Multipart {
common: BodyContentCommon {
ty: ContentType {
ty: "multipart".into(),
subtype: "mixed".into(),
params: None,
},
disposition: None,
language: None,
location: None,
},
bodies,
extension: None,
}
}
#[test]
fn a_bare_part_is_not_an_attachment() {
assert_eq!(count_attachments(&basic_part(None, None)), 0);
assert_eq!(count_attachments(&basic_part(Some("inline"), None)), 0);
}
#[test]
fn a_name_param_counts_without_a_disposition() {
assert_eq!(count_attachments(&basic_part(None, Some("a.pdf"))), 1);
}
#[test]
fn nested_multiparts_sum_their_attachments() {
let nested = multipart(vec![
basic_part(None, None),
basic_part(Some("Attachment"), None),
basic_part(Some("attachment"), None),
]);
let top = multipart(vec![basic_part(Some("inline"), None), nested]);
assert_eq!(count_attachments(&top), 2);
}
#[test]
fn bisect_splits_a_failed_batch_and_poisons_a_lone_uid() {
assert_eq!(bisect_step(&[1, 2, 3], Ok(4)), BisectOutcome::Fetched(4));
assert_eq!(
bisect_step(&[1, 2, 3], Err("parse".into())),
BisectOutcome::Retry(vec![1], vec![2, 3])
);
assert_eq!(
bisect_step(&[9], Err("parse".into())),
BisectOutcome::Poisoned
);
}
#[test]
fn bisect_converges_on_the_one_poisoned_uid() {
let fake = |batch: &[u32]| -> Result<u64, String> {
if batch.contains(&3) {
Err("parse".into())
} else {
Ok(batch.len() as u64)
}
};
let mut work = vec![vec![1u32, 2, 3, 4, 5]];
let (mut fetched, mut poisoned) = (0, 0);
while let Some(batch) = work.pop() {
match bisect_step(&batch, fake(&batch)) {
BisectOutcome::Fetched(n) => fetched += n,
BisectOutcome::Poisoned => poisoned += 1,
BisectOutcome::Retry(left, right) => {
work.push(right);
work.push(left);
}
}
}
assert_eq!((fetched, poisoned), (4, 1));
}
#[test]
fn the_cap_is_pinned_and_inclusive() {
assert_eq!(ATTACHMENT_COUNT_CAP, 5_000);
assert!(within_cap(ATTACHMENT_COUNT_CAP));
assert!(!within_cap(ATTACHMENT_COUNT_CAP + 1));
}
}