IMAP4rev1 Recent flag support

This commit is contained in:
mdecimus 2023-11-27 15:21:43 +01:00
parent 8f6ac2d114
commit 0168c1dca8
14 changed files with 265 additions and 87 deletions

View file

@ -70,9 +70,15 @@ impl ImapResponse for Response {
}
buf.extend_from_slice(b"* ");
buf.extend_from_slice(self.total_messages.to_string().as_bytes());
buf.extend_from_slice(
b" EXISTS\r\n* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n",
);
if self.recent_messages > 0 {
buf.extend_from_slice(
b" EXISTS\r\n* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft \\Recent)\r\n",
);
} else {
buf.extend_from_slice(
b" EXISTS\r\n* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n",
);
}
if self.is_rev2 {
self.mailbox.serialize(&mut buf, self.is_rev2, false);
} else {

View file

@ -36,10 +36,10 @@ use super::{SelectedMailbox, Session, SessionData, State, IMAP};
impl<T: AsyncRead> Session<T> {
pub async fn ingest(&mut self, bytes: &[u8]) -> crate::Result<bool> {
for line in String::from_utf8_lossy(bytes).split("\r\n") {
/*for line in String::from_utf8_lossy(bytes).split("\r\n") {
//let c = println!("<- {:?}", &line[..std::cmp::min(line.len(), 100)]);
let c = println!("{}", line);
}
}*/
tracing::trace!(parent: &self.span,
event = "read",
@ -373,8 +373,16 @@ impl State {
matches!(self, State::Authenticated { .. } | State::Selected { .. })
}
pub fn is_mailbox_selected(&self) -> bool {
matches!(self, State::Selected { .. })
pub fn close_mailbox(&self) -> bool {
match self {
State::Selected { mailbox, data } => {
if mailbox.is_select {
data.clear_recent(&mailbox.id);
}
true
}
_ => false,
}
}
}

View file

@ -33,11 +33,14 @@ use jmap_proto::{
object::Object,
types::{collection::Collection, property::Property, value::Value},
};
use store::write::{assert::HashedValue, BatchBuilder, F_VALUE};
use store::{
roaring::RoaringBitmap,
write::{assert::HashedValue, BatchBuilder, F_VALUE},
};
use crate::core::ImapId;
use super::{MailboxId, MailboxState, NextMailboxState, SelectedMailbox, SessionData};
use super::{Mailbox, MailboxId, MailboxState, NextMailboxState, SelectedMailbox, SessionData};
pub(crate) const MAX_RETRIES: usize = 10;
@ -107,7 +110,7 @@ impl SessionData {
)
.await?
.into_iter()
.zip(message_ids.into_iter())
.zip(message_ids.iter())
{
// Make sure the message is still in this mailbox
if let Some(uid_mailbox) = uid_mailbox {
@ -137,6 +140,7 @@ impl SessionData {
let mut try_count = 0;
let mut uid_next = 1;
let mut uid_other = 0;
let mut recent_messages = RoaringBitmap::new();
// Shuffle unassigned
/*if unassigned.len() > 1 {
@ -231,6 +235,7 @@ impl SessionData {
message_id = message_id,
"Duplicate UID");
}
recent_messages.insert(message_id);
}
Err(store::Error::AssertValueFailed)
if try_count < MAX_RETRIES =>
@ -309,6 +314,21 @@ impl SessionData {
uid_to_id.insert(uid, message_id);
}
// Update recent flags
for account in self.mailboxes.lock().iter_mut() {
if account.account_id == mailbox.account_id {
let mailbox = account
.mailbox_state
.entry(mailbox.mailbox_id)
.or_insert_with(Mailbox::default);
mailbox.recent_messages &= &message_ids;
if !recent_messages.is_empty() {
mailbox.recent_messages |= &recent_messages;
}
break;
}
}
Ok(MailboxState {
uid_next,
uid_validity,
@ -424,6 +444,38 @@ impl SessionData {
Err(StatusResponse::database_failure())
}
}
pub fn get_recent(&self, mailbox: &MailboxId) -> RoaringBitmap {
for account in self.mailboxes.lock().iter() {
if account.account_id == mailbox.account_id {
if let Some(mailbox) = account.mailbox_state.get(&mailbox.mailbox_id) {
return mailbox.recent_messages.clone();
}
}
}
RoaringBitmap::new()
}
pub fn get_recent_count(&self, mailbox: &MailboxId) -> usize {
for account in self.mailboxes.lock().iter() {
if account.account_id == mailbox.account_id {
if let Some(mailbox) = account.mailbox_state.get(&mailbox.mailbox_id) {
return mailbox.recent_messages.len() as usize;
}
}
}
0
}
pub fn clear_recent(&self, mailbox: &MailboxId) {
for account in self.mailboxes.lock().iter_mut() {
if account.account_id == mailbox.account_id {
if let Some(mailbox) = account.mailbox_state.get_mut(&mailbox.mailbox_id) {
mailbox.recent_messages.clear();
}
}
}
}
}
impl SelectedMailbox {

View file

@ -42,6 +42,7 @@ use jmap::{
JMAP,
};
use parking_lot::Mutex;
use store::roaring::RoaringBitmap;
use tokio::{
io::{AsyncRead, ReadHalf},
sync::{mpsc, watch},
@ -127,6 +128,7 @@ pub struct Mailbox {
pub uid_validity: Option<u32>,
pub uid_next: Option<u32>,
pub size: Option<u32>,
pub recent_messages: RoaringBitmap,
}
#[derive(Debug)]

View file

@ -58,7 +58,7 @@ pub fn spawn_writer(mut stream: Event, span: tracing::Span) -> mpsc::Sender<Even
size = bytes.len()
);
let c = print!("{}", String::from_utf8_lossy(&bytes));
//let c = print!("{}", String::from_utf8_lossy(&bytes));
match stream_tx.write_all(bytes.as_ref()).await {
Ok(_) => {
@ -101,7 +101,7 @@ pub fn spawn_writer(mut stream: Event, span: tracing::Span) -> mpsc::Sender<Even
size = bytes.len()
);
let c = print!("{}", String::from_utf8_lossy(&bytes));
//let c = print!("{}", String::from_utf8_lossy(&bytes));
match stream_tx.write_all(bytes.as_ref()).await {
Ok(_) => {
@ -132,10 +132,10 @@ impl<T: AsyncRead> Session<T> {
pub async fn write_bytes(&self, bytes: impl Into<Cow<'static, [u8]>>) -> crate::OpResult {
let bytes = bytes.into();
let c = println!(
"{:?}",
/*let c = println!(
"-> {:?}",
String::from_utf8_lossy(&bytes[..std::cmp::min(bytes.len(), 100)])
);
);*/
if let Err(err) = self.writer.send(Event::Bytes(bytes)).await {
debug!("Failed to send bytes: {}", err);
@ -149,10 +149,10 @@ impl<T: AsyncRead> Session<T> {
impl SessionData {
pub async fn write_bytes(&self, bytes: impl Into<Cow<'static, [u8]>>) -> bool {
let bytes = bytes.into();
let c = println!(
"{:?}",
/*let c = println!(
"-> {:?}",
String::from_utf8_lossy(&bytes[..std::cmp::min(bytes.len(), 100)])
);
);*/
if let Err(err) = self.writer.send(Event::Bytes(bytes)).await {
debug!("Failed to send bytes: {}", err);

View file

@ -34,7 +34,7 @@ use jmap_proto::{
type_state::DataType, value::Value,
},
};
use store::{query::Filter, write::BatchBuilder};
use store::{query::Filter, roaring::RoaringBitmap, write::BatchBuilder};
use tokio::io::AsyncRead;
use crate::core::{Account, Mailbox, Session, SessionData};
@ -217,6 +217,7 @@ impl SessionData {
} else {
None
},
recent_messages: RoaringBitmap::new(),
},
);
}

View file

@ -66,6 +66,7 @@ impl<T: AsyncRead> Session<T> {
Ok(arguments) => {
let (data, mailbox) = self.state.select_data();
let is_qresync = self.is_qresync;
let is_rev2 = self.version.is_rev2();
let enabled_condstore = if !self.is_condstore && arguments.changed_since.is_some()
|| arguments.attributes.contains(&Attribute::ModSeq)
@ -78,9 +79,16 @@ impl<T: AsyncRead> Session<T> {
tokio::spawn(async move {
data.write_bytes(
data.fetch(arguments, mailbox, is_uid, is_qresync, enabled_condstore)
.await
.into_bytes(),
data.fetch(
arguments,
mailbox,
is_uid,
is_qresync,
is_rev2,
enabled_condstore,
)
.await
.into_bytes(),
)
.await;
});
@ -98,6 +106,7 @@ impl SessionData {
mailbox: Arc<SelectedMailbox>,
is_uid: bool,
is_qresync: bool,
is_rev2: bool,
enabled_condstore: bool,
) -> StatusResponse {
// Validate VANISHED parameter
@ -117,6 +126,11 @@ impl SessionData {
Ok(modseq) => modseq,
Err(response) => return response.with_tag(arguments.tag),
};
let recent_messages = if !is_rev2 {
self.get_recent(&mailbox.id).into()
} else {
None
};
// Convert IMAP ids to JMAP ids.
let mut ids = match mailbox
@ -353,6 +367,12 @@ impl SessionData {
if set_seen_flag {
flags.push(Flag::Seen);
}
if recent_messages
.as_ref()
.map_or(false, |recent| recent.contains(id))
{
flags.push(Flag::Recent);
}
items.push(DataItem::Flags { flags });
}
Attribute::InternalDate => {

View file

@ -268,6 +268,7 @@ impl SessionData {
mailbox.clone(),
true,
is_qresync,
is_rev2,
false,
)
.await;

View file

@ -574,17 +574,11 @@ impl SessionData {
filters.push(query::Filter::End);
}
search::Filter::Recent => {
filters.push(query::Filter::is_in_bitmap(
Property::Keywords,
Keyword::Recent,
));
filters.push(query::Filter::is_in_set(self.get_recent(&mailbox.id)));
}
search::Filter::New => {
filters.push(query::Filter::And);
filters.push(query::Filter::is_in_bitmap(
Property::Keywords,
Keyword::Recent,
));
filters.push(query::Filter::is_in_set(self.get_recent(&mailbox.id)));
filters.push(query::Filter::Not);
filters.push(query::Filter::is_in_bitmap(
Property::Keywords,
@ -595,10 +589,7 @@ impl SessionData {
}
search::Filter::Old => {
filters.push(query::Filter::Not);
filters.push(query::Filter::is_in_bitmap(
Property::Keywords,
Keyword::Seen,
));
filters.push(query::Filter::is_in_set(self.get_recent(&mailbox.id)));
filters.push(query::Filter::End);
}
search::Filter::Older(secs) => {

View file

@ -52,13 +52,14 @@ impl<T: AsyncRead> Session<T> {
}
if let Some(mailbox) = data.get_mailbox_by_name(&arguments.mailbox_name) {
// Syncronize messages
// Synchronize messages
match data.fetch_messages(&mailbox).await {
Ok(state) => {
let closed_previous = self.state.is_mailbox_selected();
let closed_previous = self.state.close_mailbox();
let is_condstore = self.is_condstore || arguments.condstore;
// Build new state
let is_rev2 = self.version.is_rev2();
let uid_validity = state.uid_validity;
let uid_next = state.uid_next;
let total_messages = state.total_messages;
@ -105,6 +106,7 @@ impl<T: AsyncRead> Session<T> {
mailbox.clone(),
true,
true,
is_rev2,
false,
)
.await;
@ -115,12 +117,12 @@ impl<T: AsyncRead> Session<T> {
let response = Response {
mailbox: ListItem::new(arguments.mailbox_name),
total_messages,
recent_messages: 0,
recent_messages: data.get_recent_count(&mailbox.id),
unseen_seq: 0,
uid_validity,
uid_next,
closed_previous,
is_rev2: self.version.is_rev2(),
is_rev2,
highest_modseq,
mailbox_id: Id::from_parts(
mailbox.id.account_id,
@ -164,6 +166,7 @@ impl<T: AsyncRead> Session<T> {
}
pub async fn handle_unselect(&mut self, request: Request<Command>) -> crate::OpResult {
self.state.close_mailbox();
self.state = State::Authenticated {
data: self.state.session_data(),
};

View file

@ -29,7 +29,10 @@ use imap_proto::{
receiver::Request,
Command, ResponseCode, StatusResponse,
};
use jmap_proto::types::{collection::Collection, id::Id, keyword::Keyword, property::Property};
use jmap_proto::{
object::Object,
types::{collection::Collection, id::Id, keyword::Keyword, property::Property, value::Value},
};
use store::roaring::RoaringBitmap;
use store::Deserialize;
use tokio::io::AsyncRead;
@ -127,7 +130,6 @@ impl SessionData {
// Make sure all requested fields are up to date
let mut items_update = Vec::with_capacity(items.len());
let mut items_response = Vec::with_capacity(items.len());
let mut do_synchronize = false;
for account in self.mailboxes.lock().iter_mut() {
if account.account_id == mailbox.account_id {
@ -135,6 +137,7 @@ impl SessionData {
.mailbox_state
.entry(mailbox.mailbox_id)
.or_insert_with(Mailbox::default);
let update_recent = mailbox_state.total_messages.is_none();
for item in items {
match item {
Status::Messages => {
@ -149,7 +152,6 @@ impl SessionData {
items_response.push((*item, StatusItemType::Number(value as u64)));
} else {
items_update.push_unique(*item);
do_synchronize = true;
}
}
Status::UidValidity => {
@ -157,7 +159,6 @@ impl SessionData {
items_response.push((*item, StatusItemType::Number(value as u64)));
} else {
items_update.push_unique(*item);
do_synchronize = true;
}
}
Status::Unseen => {
@ -197,7 +198,14 @@ impl SessionData {
));
}
Status::Recent => {
items_response.push((*item, StatusItemType::Number(0)));
if !update_recent {
items_response.push((
*item,
StatusItemType::Number(mailbox_state.recent_messages.len()),
));
} else {
items_update.push_unique(*item);
}
}
}
}
@ -208,12 +216,6 @@ impl SessionData {
if !items_update.is_empty() {
// Retrieve latest values
let mut values_update = Vec::with_capacity(items_update.len());
let mailbox_state = if do_synchronize {
self.fetch_messages(&mailbox).await?.into()
} else {
None
};
let mailbox_message_ids = self
.jmap
.get_tag(
@ -232,8 +234,38 @@ impl SessionData {
for item in items_update {
let result = match item {
Status::Messages => mailbox_message_ids.as_ref().map(|v| v.len()).unwrap_or(0),
Status::UidNext => mailbox_state.as_ref().unwrap().uid_next as u64,
Status::UidValidity => mailbox_state.as_ref().unwrap().uid_validity as u64,
Status::UidNext => {
(self
.jmap
.get_property::<u32>(
mailbox.account_id,
Collection::Mailbox,
mailbox.mailbox_id,
Property::EmailIds,
)
.await?
.unwrap_or(0)
+ 1) as u64
}
Status::UidValidity => self
.jmap
.get_property::<Object<Value>>(
mailbox.account_id,
Collection::Mailbox,
mailbox.mailbox_id,
&Property::Value,
)
.await?
.and_then(|obj| obj.get(&Property::Cid).as_uint())
.ok_or_else(|| {
tracing::debug!(event = "error",
context = "store",
account_id = mailbox.account_id,
collection = ?Collection::Mailbox,
mailbox_id = mailbox.mailbox_id,
"Failed to obtain uid validity");
StatusResponse::no("Mailbox unavailable.")
})?,
Status::Unseen => {
if let (Some(message_ids), Some(mailbox_message_ids)) =
(&message_ids, &mailbox_message_ids)
@ -284,7 +316,11 @@ impl SessionData {
0
}
}
Status::HighestModSeq | Status::MailboxId | Status::Recent => {
Status::Recent => {
self.fetch_messages(&mailbox).await?;
0
}
Status::HighestModSeq | Status::MailboxId => {
unreachable!()
}
};
@ -309,7 +345,15 @@ impl SessionData {
Status::Unseen => mailbox_state.total_unseen = value.into(),
Status::Deleted => mailbox_state.total_deleted = value.into(),
Status::Size => mailbox_state.size = value.into(),
Status::HighestModSeq | Status::MailboxId | Status::Recent => {
Status::Recent => {
items_response
.iter_mut()
.find(|(i, _)| *i == Status::Recent)
.unwrap()
.1 =
StatusItemType::Number(mailbox_state.recent_messages.len());
}
Status::HighestModSeq | Status::MailboxId => {
unreachable!()
}
}

View file

@ -25,59 +25,102 @@ use imap_proto::ResponseType;
use super::{AssertResult, ImapConnection, Type};
pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) {
pub async fn test(_imap: &mut ImapConnection, imap_check: &mut ImapConnection) {
// Check status
imap.send("LIST \"\" % RETURN (STATUS (UIDNEXT MESSAGES UNSEEN SIZE))")
imap_check
.send("LIST \"\" % RETURN (STATUS (UIDNEXT MESSAGES UNSEEN SIZE RECENT))")
.await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("\"INBOX\" (UIDNEXT 11 MESSAGES 10 UNSEEN 10 SIZE 12193)");
.assert_contains("\"INBOX\" (UIDNEXT 11 MESSAGES 10 UNSEEN 10 SIZE 12193 RECENT 0)");
// Select INBOX
imap.send("SELECT INBOX").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
imap_check.send("SELECT INBOX").await;
imap_check.assert_read(Type::Tagged, ResponseType::Ok).await;
// Copying to the same mailbox should fail
imap.send("COPY 1:* INBOX").await;
imap.assert_read(Type::Tagged, ResponseType::No)
imap_check.send("COPY 1:* INBOX").await;
imap_check
.assert_read(Type::Tagged, ResponseType::No)
.await
.assert_response_code("CANNOT");
// Copying to a non-existent mailbox should fail
imap.send("COPY 1:* \"/dev/null\"").await;
imap.assert_read(Type::Tagged, ResponseType::No)
imap_check.send("COPY 1:* \"/dev/null\"").await;
imap_check
.assert_read(Type::Tagged, ResponseType::No)
.await
.assert_response_code("TRYCREATE");
// Create test folders
imap.send("CREATE \"Scamorza Affumicata\"").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
imap.send("CREATE \"Burrata al Tartufo\"").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
imap_check.send("CREATE \"Scamorza Affumicata\"").await;
imap_check.assert_read(Type::Tagged, ResponseType::Ok).await;
imap_check.send("CREATE \"Burrata al Tartufo\"").await;
imap_check.assert_read(Type::Tagged, ResponseType::Ok).await;
// Copy messages
imap.send("COPY 1,3,5,7 \"Scamorza Affumicata\"").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
imap_check
.send("COPY 1,3,5,7 \"Scamorza Affumicata\"")
.await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("COPYUID")
.assert_contains("1:4");
// Check status
imap.send("STATUS \"Scamorza Affumicata\" (UIDNEXT MESSAGES UNSEEN SIZE)")
imap_check
.send("STATUS \"Scamorza Affumicata\" (UIDNEXT MESSAGES UNSEEN SIZE RECENT)")
.await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("MESSAGES 4")
.assert_contains("RECENT 4")
.assert_contains("UNSEEN 4")
.assert_contains("UIDNEXT 5")
.assert_contains("SIZE 5851");
// Move all messages to Burrata
imap.send("SELECT \"Scamorza Affumicata\"").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
// Check \Recent flag
imap_check.send("SELECT \"Scamorza Affumicata\"").await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("* 4 RECENT");
imap_check.send("FETCH 1:* (UID FLAGS)").await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_count("\\Recent", 4);
imap_check.send("UNSELECT").await;
imap_check.assert_read(Type::Tagged, ResponseType::Ok).await;
imap_check
.send("STATUS \"Scamorza Affumicata\" (UIDNEXT MESSAGES UNSEEN SIZE RECENT)")
.await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("MESSAGES 4")
.assert_contains("RECENT 0")
.assert_contains("UNSEEN 4")
.assert_contains("UIDNEXT 5")
.assert_contains("SIZE 5851");
imap_check.send("SELECT \"Scamorza Affumicata\"").await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("* 0 RECENT");
imap_check.send("FETCH 1:* (UID FLAGS)").await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_count("\\Recent", 0);
imap.send("MOVE 1:* \"Burrata al Tartufo\"").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
// Move all messages to Burrata
imap_check.send("MOVE 1:* \"Burrata al Tartufo\"").await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("* OK [COPYUID")
.assert_contains("1:4")
@ -87,20 +130,23 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) {
.assert_contains("* 1 EXPUNGE");
// Check status
imap.send("LIST \"\" % RETURN (STATUS (UIDNEXT MESSAGES UNSEEN SIZE))")
imap_check
.send("LIST \"\" % RETURN (STATUS (UIDNEXT MESSAGES UNSEEN SIZE))")
.await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("\"Burrata al Tartufo\" (UIDNEXT 5 MESSAGES 4 UNSEEN 4 SIZE 5851)")
.assert_contains("\"Scamorza Affumicata\" (UIDNEXT 5 MESSAGES 0 UNSEEN 0 SIZE 0)")
.assert_contains("\"INBOX\" (UIDNEXT 11 MESSAGES 10 UNSEEN 10 SIZE 12193)");
// Move the messages back to Scamorza, UIDNEXT should increase.
imap.send("SELECT \"Burrata al Tartufo\"").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
imap_check.send("SELECT \"Burrata al Tartufo\"").await;
imap_check.assert_read(Type::Tagged, ResponseType::Ok).await;
imap.send("MOVE 1:* \"Scamorza Affumicata\"").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
imap_check.send("MOVE 1:* \"Scamorza Affumicata\"").await;
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("* OK [COPYUID")
.assert_contains("5:8")
@ -110,9 +156,11 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) {
.assert_contains("* 1 EXPUNGE");
// Check status
imap.send("LIST \"\" % RETURN (STATUS (UIDNEXT MESSAGES UNSEEN SIZE))")
imap_check
.send("LIST \"\" % RETURN (STATUS (UIDNEXT MESSAGES UNSEEN SIZE))")
.await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
imap_check
.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("\"Burrata al Tartufo\" (UIDNEXT 5 MESSAGES 0 UNSEEN 0 SIZE 0)")
.assert_contains("\"Scamorza Affumicata\" (UIDNEXT 9 MESSAGES 4 UNSEEN 4 SIZE 5851)")

View file

@ -144,7 +144,8 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) {
.assert_contains("* 7 FETCH (UID 7 ")
.assert_contains("* 8 FETCH (UID 8 ")
.assert_contains("* 9 FETCH (UID 9 ")
.assert_contains("* 10 FETCH (UID 10 ");
.assert_contains("* 10 FETCH (UID 10 ")
.assert_count("\\Recent", 0);
imap.send("FETCH 7:* (UID FLAGS)").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)

View file

@ -560,10 +560,11 @@ impl AssertResult for Vec<String> {
assert_eq!(
self.iter().filter(|l| l.contains(text)).count(),
occurrences,
"Expected {} occurrences of {:?}, found {}.",
"Expected {} occurrences of {:?}, found {} in {:?}.",
occurrences,
text,
self.iter().filter(|l| l.contains(text)).count()
self.iter().filter(|l| l.contains(text)).count(),
self
);
self
}