use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow};
use rustls::ClientConfig;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
use tokio::task::JoinHandle;
use crate::account::{Account, Protocol};
use crate::imap::{self, Caps, FlagKind, Session};
use crate::net::Endpoint;
use crate::secrets::{Credentials, NeedPassword};
use crate::store::{Flags, Folder, Header, Known};
use crate::{pop3, smtp, tags, text};
const OFFLINE_FOR: Duration = Duration::from_secs(20);
const IDLE_FOR: Duration = Duration::from_secs(25 * 60);
const MAX_STATUS: usize = 200;
const POP_BATCH: usize = 300;
const POP_MAX: usize = 60 << 20;
const POP_BYTES: usize = 256 << 20;
#[derive(Clone, Debug)]
pub enum Request {
Folders,
Sync {
folder: String,
known: Known,
count: u32,
},
Older {
folder: String,
below: u32,
count: u32,
},
Body {
folder: String,
uid: u32,
uidvalidity: u32,
},
Flag {
folder: String,
uids: Vec<u32>,
flag: FlagKind,
on: bool,
uidvalidity: u32,
},
Move {
folder: String,
uids: Vec<u32>,
to: String,
uidvalidity: u32,
},
Delete {
folder: String,
uids: Vec<u32>,
uidvalidity: u32,
},
Append {
folder: String,
raw: Vec<u8>,
flags: Flags,
id: u64,
retry: bool,
},
Headers {
folder: String,
uids: Vec<u32>,
uidvalidity: u32,
},
MoveBack {
folder: String,
uidvalidity: u32,
floor: u32,
moved: Vec<(String, u32)>,
to: String,
},
Tag {
folder: String,
uids: Vec<u32>,
tag: String,
on: bool,
uidvalidity: u32,
},
Search {
folder: String,
query: String,
},
Pop {
seen: HashSet<String>,
remove: Vec<String>,
count: u32,
},
}
impl Request {
pub fn changes(&self) -> bool {
matches!(
self,
Request::Flag { .. }
| Request::Tag { .. }
| Request::Move { .. }
| Request::MoveBack { .. }
| Request::Delete { .. }
| Request::Append { retry: true, .. }
)
}
fn what(&self) -> &'static str {
match self {
Request::Folders => "listing folders",
Request::Sync { .. } => "checking for mail",
Request::Older { .. } => "fetching older mail",
Request::Body { .. } => "downloading the message",
Request::Flag { .. } => "changing flags",
Request::Tag { .. } => "tagging",
Request::Move { .. } | Request::MoveBack { .. } => "moving mail",
Request::Delete { .. } => "deleting mail",
Request::Append { .. } => "saving a copy",
Request::Headers { .. } => "checking for mail",
Request::Search { .. } => "searching",
Request::Pop { .. } => "checking for mail",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum State {
Connecting,
Online,
Offline(String),
NeedPassword {
refused: bool,
},
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Synced {
pub folder: String,
pub uidvalidity: u32,
pub reset: bool,
pub new: Vec<Header>,
pub flags: Vec<(u32, Flags)>,
pub checked: Option<(u32, u32)>,
pub low: u32,
pub high: u32,
pub unread: u32,
pub total: u32,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Popped {
pub new: Vec<(String, Vec<u8>)>,
pub skipped: Vec<String>,
pub on_server: HashSet<String>,
pub deleted: Vec<String>,
pub more: bool,
}
#[derive(Clone, Debug)]
pub enum Event {
State(State),
Folders(Vec<Folder>),
Synced(Synced),
Older {
folder: String,
headers: Vec<Header>,
low: u32,
},
Body {
folder: String,
uid: u32,
result: Result<Option<Vec<u8>>, String>,
},
Searched {
folder: String,
query: String,
headers: Vec<Header>,
},
Done {
folder: String,
},
Moved {
folder: String,
uidvalidity: u32,
uids: Vec<u32>,
to: String,
to_uidvalidity: u32,
floor: u32,
},
MovedBack {
folder: String,
to: String,
moved: Vec<u32>,
asked: usize,
},
Deleted {
folder: String,
uids: Vec<u32>,
all: bool,
},
Waiting(usize),
Appended {
folder: String,
id: u64,
error: Option<String>,
},
Headers {
folder: String,
headers: Vec<Header>,
},
Failed {
what: &'static str,
error: String,
},
Changed {
folder: String,
},
Popped(Popped),
}
pub type Sink = Arc<dyn Fn(Event) + Send + Sync>;
pub struct Worker {
pub tx: UnboundedSender<Request>,
pub creds: Credentials,
pub endpoints: Endpoints,
task: JoinHandle<()>,
}
impl Drop for Worker {
fn drop(&mut self) {
self.task.abort();
}
}
#[derive(Clone)]
pub struct Endpoints {
pub incoming: Endpoint,
pub smtp: Endpoint,
}
impl Endpoints {
pub fn of(account: &Account, tls: Arc<ClientConfig>) -> Self {
Self {
incoming: Endpoint {
host: account.host.trim().to_string(),
port: account.port(),
security: account.security,
tls: tls.clone(),
},
smtp: Endpoint {
host: account.smtp_host.trim().to_string(),
port: account.smtp_port(),
security: account.smtp_security,
tls,
},
}
}
}
pub fn spawn(creds: Credentials, endpoints: Endpoints, sink: Sink) -> Worker {
let (tx, rx) = unbounded_channel();
let task = match creds.account().protocol {
Protocol::Imap => tokio::spawn(run_imap(creds.clone(), endpoints.clone(), rx, sink)),
Protocol::Pop3 => tokio::spawn(run_pop(creds.clone(), endpoints.clone(), rx, sink)),
};
Worker {
tx,
creds,
endpoints,
task,
}
}
fn login_failed(error: &anyhow::Error) -> State {
if error.downcast_ref::<NeedPassword>().is_some() {
State::NeedPassword { refused: false }
} else if error.downcast_ref::<imap::Refused>().is_some()
|| error.downcast_ref::<pop3::Refused>().is_some()
|| error.downcast_ref::<smtp::Refused>().is_some()
{
State::NeedPassword { refused: true }
} else {
State::Offline(text::one_line(&format!("{error:#}"), 300))
}
}
async fn imap_login(creds: &Credentials, endpoint: &Endpoint) -> Result<(Session, Caps)> {
let secret = creds.get().await?;
let account = creds.account();
match imap::connect(endpoint, account.username(), secret.expose(), account.auth).await {
Ok(session) => {
let _ = creds.remember(&secret).await;
Ok(session)
}
Err(e) => {
if e.downcast_ref::<imap::Refused>().is_some() {
creds.refused(&secret).await;
}
Err(e)
}
}
}
const MAX_TRIES: u32 = 3;
fn batch(first: Request, rx: &mut UnboundedReceiver<Request>) -> VecDeque<Request> {
let mut all = vec![first];
while let Ok(next) = rx.try_recv() {
all.push(next);
}
let mut out: VecDeque<Request> = VecDeque::new();
for request in all {
let downloading = out.iter().any(|r| match (r, &request) {
(
Request::Body {
folder: a,
uid: x,
uidvalidity: v,
},
Request::Body {
folder: b,
uid: y,
uidvalidity: w,
},
) => a == b && x == y && v == w,
_ => false,
});
if downloading {
continue;
}
let duplicate = out.iter().position(|r| match (r, &request) {
(Request::Folders, Request::Folders) => true,
(Request::Sync { folder: a, .. }, Request::Sync { folder: b, .. }) => a == b,
(Request::Pop { .. }, Request::Pop { .. }) => true,
_ => false,
});
if let Some(i) = duplicate {
out.remove(i);
}
out.push_back(request);
}
out
}
fn broken(error: &anyhow::Error) -> bool {
error.downcast_ref::<imap::Said>().is_none()
}
#[derive(Default)]
struct Watcher(Option<JoinHandle<()>>);
impl Watcher {
fn running(&self) -> bool {
self.0.as_ref().is_some_and(|w| !w.is_finished())
}
}
impl Drop for Watcher {
fn drop(&mut self) {
if let Some(task) = &self.0 {
task.abort();
}
}
}
const RETRY_EVERY: Duration = Duration::from_secs(30);
fn report_failure(request: &Request, error: String, sink: &Sink) {
match request {
Request::Append { folder, id, .. } => sink(Event::Appended {
folder: folder.clone(),
id: *id,
error: Some(error),
}),
_ => sink(Event::Failed {
what: request.what(),
error,
}),
}
}
async fn run_imap(
creds: Credentials,
endpoints: Endpoints,
mut rx: UnboundedReceiver<Request>,
sink: Sink,
) {
let mut session: Option<(Session, Caps)> = None;
let mut failed_at: Option<Instant> = None;
let mut watcher = Watcher::default();
let mut held: VecDeque<(Request, u32)> = VecDeque::new();
let mut waiting = 0;
let mut report = |n: usize, sink: &Sink| {
if n != waiting {
waiting = n;
sink(Event::Waiting(n));
}
};
loop {
let fresh = if held.is_empty() {
match rx.recv().await {
Some(first) => batch(first, &mut rx),
None => break,
}
} else {
tokio::select! {
next = rx.recv() => match next {
Some(first) => batch(first, &mut rx),
None => break,
},
_ = tokio::time::sleep(RETRY_EVERY) => VecDeque::new(),
}
};
let mut requests: VecDeque<(Request, u32)> = std::mem::take(&mut held);
requests.extend(fresh.into_iter().map(|r| (r, 0)));
while let Some((request, tries)) = requests.pop_front() {
let queued = requests.iter().filter(|(r, _)| r.changes()).count();
report(held.len() + queued + usize::from(request.changes()), &sink);
if session.is_none() {
let recently = failed_at.is_some_and(|at| at.elapsed() < OFFLINE_FOR);
let retrying = !held.is_empty();
if (recently || retrying) && !matches!(request, Request::Folders) {
if request.changes() {
held.push_back((request, tries));
} else {
report_failure(&request, "offline".into(), &sink);
}
continue;
}
sink(Event::State(State::Connecting));
match imap_login(&creds, &endpoints.incoming).await {
Ok(s) => {
let idle = s.1.idle;
session = Some(s);
failed_at = None;
sink(Event::State(State::Online));
while let Some(h) = held.pop_back() {
requests.push_front(h);
}
if idle && !watcher.running() {
watcher.0 = Some(tokio::spawn(watch_inbox(
creds.clone(),
endpoints.incoming.clone(),
sink.clone(),
)));
}
}
Err(e) => {
failed_at = Some(Instant::now());
sink(Event::State(login_failed(&e)));
if request.changes() {
held.push_back((request, tries));
} else {
report_failure(&request, "offline".into(), &sink);
}
continue;
}
}
}
let Some((s, caps)) = session.as_mut() else {
continue;
};
if let Err(e) = handle_imap(request.clone(), s, *caps, &creds, &sink).await {
let lost = broken(&e);
let error = text::one_line(&format!("{e:#}"), 300);
if lost && request.changes() && tries + 1 < MAX_TRIES {
held.push_back((request, tries + 1));
} else if lost && request.changes() {
report_failure(
&request,
format!("{error} (each of {MAX_TRIES} tries: given up)"),
&sink,
);
} else {
report_failure(&request, error.clone(), &sink);
}
if lost {
session = None;
failed_at = Some(Instant::now());
sink(Event::State(State::Offline(error)));
}
}
}
report(held.len(), &sink);
}
if let Some((s, _)) = session {
imap::logout(s).await;
}
}
async fn select_same(s: &mut Session, folder: &str, uidvalidity: u32) -> Result<imap::Selected> {
let selected = imap::select(s, folder).await?;
if uidvalidity != 0 && selected.uidvalidity != uidvalidity {
return Err(imap::Said(format!(
"{} was made again on the server, so its messages changed: nothing was done",
shown_folder(folder)
))
.into());
}
Ok(selected)
}
fn shown_folder(folder: &str) -> String {
text::one_line(
&crate::utf7::decode(folder).unwrap_or_else(|| folder.to_string()),
60,
)
}
async fn handle_imap(
request: Request,
s: &mut Session,
caps: Caps,
creds: &Credentials,
sink: &Sink,
) -> Result<()> {
match request {
Request::Folders => {
let mut folders = imap::folders(s, creds.account()).await?;
for f in folders.iter_mut().filter(|f| f.selectable).take(MAX_STATUS) {
if let Ok((unread, total)) = imap::status(s, &f.name).await {
f.unread = unread;
f.total = total;
}
}
sink(Event::Folders(folders));
}
Request::Sync {
folder,
known,
count,
} => {
let synced = sync(s, &folder, known, count).await?;
sink(Event::Synced(synced));
}
Request::Older {
folder,
below,
count,
} => {
imap::select(s, &folder).await?;
let mut uids = if below > 1 {
imap::search(s, &format!("UID 1:{}", below - 1)).await?
} else {
Vec::new()
};
uids.retain(|&u| u < below);
let keep = uids.len().saturating_sub(count as usize);
let uids = &uids[keep..];
let headers = imap::headers(s, uids).await?;
let low = uids.first().copied().unwrap_or(1);
sink(Event::Older {
folder,
headers,
low,
});
}
Request::Body {
folder,
uid,
uidvalidity,
} => {
let result = match select_same(s, &folder, uidvalidity).await {
Ok(_) => imap::body(s, uid).await,
Err(e) => Err(e),
};
match result {
Ok(raw) => sink(Event::Body {
folder,
uid,
result: Ok(raw),
}),
Err(e) if broken(&e) => return Err(e),
Err(e) => sink(Event::Body {
folder,
uid,
result: Err(text::one_line(&format!("{e:#}"), 300)),
}),
}
}
Request::Flag {
folder,
uids,
flag,
on,
uidvalidity,
} => {
select_same(s, &folder, uidvalidity).await?;
imap::set_flag(s, &uids, flag, on).await?;
}
Request::Tag {
folder,
uids,
tag,
on,
uidvalidity,
} => {
let selected = select_same(s, &folder, uidvalidity).await?;
if on {
let keyword = tags::keyword(&tag).unwrap_or_default();
if !selected.keeps(&keyword) {
return Err(imap::Said(format!(
"{} doesn't keep tags on the server",
shown_folder(&folder)
))
.into());
}
imap::set_keywords(s, &uids, &[keyword], true).await?;
} else {
imap::set_keywords(s, &uids, &tags::keywords(&tag), false).await?;
}
}
Request::Move {
folder,
uids,
to,
uidvalidity,
} => {
select_same(s, &folder, uidvalidity).await?;
let (to_uidvalidity, floor) = match imap::uid_next(s, &to).await {
Ok(next) => next,
Err(e) if e.is::<imap::Said>() => (0, 0),
Err(e) => return Err(e),
};
imap::move_to(s, caps, &uids, &to).await?;
sink(Event::Moved {
folder,
uidvalidity,
uids,
to: to.clone(),
to_uidvalidity,
floor,
});
sink(Event::Done { folder: to });
}
Request::MoveBack {
folder,
uidvalidity,
floor,
moved,
to,
} => {
select_same(s, &folder, uidvalidity).await?;
let uids = imap::find_moved(s, &moved, floor).await?;
if !uids.is_empty() {
imap::move_to(s, caps, &uids, &to).await?;
}
sink(Event::MovedBack {
folder,
to,
moved: uids,
asked: moved.len(),
});
}
Request::Delete {
folder,
uids,
uidvalidity,
} => {
select_same(s, &folder, uidvalidity).await?;
let all = imap::delete(s, caps, &uids).await?;
sink(Event::Deleted { folder, uids, all });
}
Request::Append {
folder,
raw,
flags,
id,
..
} => {
if let Err(e) = imap::append(s, &folder, &raw, flags.clone()).await {
if broken(&e) {
return Err(e);
}
imap::create(s, &folder).await?;
imap::append(s, &folder, &raw, flags).await?;
}
sink(Event::Appended {
folder,
id,
error: None,
});
}
Request::Headers {
folder,
uids,
uidvalidity,
} => {
select_same(s, &folder, uidvalidity).await?;
let headers = imap::headers(s, &uids).await?;
sink(Event::Headers { folder, headers });
}
Request::Search { folder, query } => {
let Some(criteria) = imap::search_criteria(&query) else {
return Ok(());
};
imap::select(s, &folder).await?;
let uids = imap::search(s, &criteria).await?;
let keep = uids.len().saturating_sub(200);
let headers = imap::headers(s, &uids[keep..]).await?;
sink(Event::Searched {
folder,
query,
headers,
});
}
Request::Pop { .. } => {}
}
Ok(())
}
async fn sync(s: &mut Session, folder: &str, known: Known, count: u32) -> Result<Synced> {
let selected = imap::select(s, folder).await?;
let reset = known.uidvalidity != 0 && known.uidvalidity != selected.uidvalidity;
let known = if reset || known.uidvalidity == 0 {
Known::default()
} else {
known
};
let count = count.max(1) as usize;
let mut new_uids: Vec<u32> = if selected.exists == 0 {
Vec::new()
} else if known.high == 0 {
let start = selected.exists.saturating_sub(count as u32 - 1).max(1);
let fetched: Vec<async_imap::types::Fetch> = {
use futures::TryStreamExt;
tokio::time::timeout(imap::TIMEOUT, async {
s.fetch(format!("{start}:{}", selected.exists), "(UID)")
.await?
.try_collect()
.await
})
.await
.map_err(|_| anyhow!("FETCH: the server stopped answering"))?
.map_err(|e| anyhow!("FETCH: {e}"))?
};
let mut uids: Vec<u32> = fetched.iter().filter_map(|f| f.uid).collect();
uids.sort_unstable();
uids
} else {
match known.high.checked_add(1) {
Some(next) => {
let mut uids = imap::search(s, &format!("UID {next}:*")).await?;
uids.retain(|&u| u > known.high);
uids
}
None => Vec::new(),
}
};
let gap = new_uids.len() > count;
if gap {
new_uids.drain(..new_uids.len() - count);
}
let new = imap::headers(s, &new_uids).await?;
let (flags, checked) = if known.high > 0 && !gap {
(
imap::flags(s, known.low, known.high).await?,
Some((known.low, known.high)),
)
} else {
(Vec::new(), None)
};
let low = if known.high == 0 || gap {
new_uids.first().copied().unwrap_or(0)
} else {
known.low
};
let high = new_uids.last().copied().unwrap_or(0).max(known.high);
let unread = imap::search(s, "UNSEEN")
.await
.map(|u| u.len())
.unwrap_or(0) as u32;
Ok(Synced {
folder: folder.to_string(),
uidvalidity: selected.uidvalidity,
reset: reset || gap,
new,
flags,
checked,
low,
high,
unread,
total: selected.exists,
})
}
async fn watch_inbox(creds: Credentials, endpoint: Endpoint, sink: Sink) {
let mut wait = Duration::from_secs(15);
loop {
let result: Result<()> = async {
let (mut session, _) = imap_login(&creds, &endpoint).await?;
tokio::time::timeout(imap::TIMEOUT, session.examine("INBOX"))
.await
.map_err(|_| anyhow!("EXAMINE: the server stopped answering"))??;
wait = Duration::from_secs(15);
loop {
let (back, changed) = imap::idle(session, IDLE_FOR).await?;
session = back;
if changed {
sink(Event::Changed {
folder: "INBOX".into(),
});
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
.await;
if let Err(e) = result
&& matches!(login_failed(&e), State::NeedPassword { .. })
{
wait = Duration::from_secs(60);
}
tokio::time::sleep(wait).await;
wait = (wait * 2).min(Duration::from_secs(10 * 60));
}
}
async fn run_pop(
creds: Credentials,
endpoints: Endpoints,
mut rx: UnboundedReceiver<Request>,
sink: Sink,
) {
while let Some(first) = rx.recv().await {
for request in batch(first, &mut rx) {
let Request::Pop {
seen,
remove,
count,
} = request
else {
continue;
};
sink(Event::State(State::Connecting));
match pop_check(&creds, &endpoints.incoming, &seen, &remove, count).await {
Ok(popped) => {
sink(Event::State(State::Online));
sink(Event::Popped(popped));
}
Err(e) => {
let state = login_failed(&e);
let said = matches!(state, State::NeedPassword { .. });
sink(Event::State(state));
if !said {
sink(Event::Failed {
what: "checking for mail",
error: text::one_line(&format!("{e:#}"), 300),
});
}
}
}
}
}
}
async fn pop_check(
creds: &Credentials,
endpoint: &Endpoint,
seen: &HashSet<String>,
remove: &[String],
count: u32,
) -> Result<Popped> {
let secret = creds.get().await?;
let account = creds.account();
let mut pop =
match pop3::connect(endpoint, account.username(), secret.expose(), account.auth).await {
Ok(pop) => pop,
Err(e) => {
if e.downcast_ref::<pop3::Refused>().is_some() {
creds.refused(&secret).await;
}
return Err(e);
}
};
let _ = creds.remember(&secret).await;
let ids = pop.uidl().await?;
let sizes: HashMap<u32, u64> = pop.list().await?.into_iter().collect();
let mut popped = Popped {
on_server: ids.iter().map(|(_, id)| id.clone()).collect(),
..Popped::default()
};
let mut new: Vec<&(u32, String)> = ids.iter().filter(|(_, id)| !seen.contains(id)).collect();
new.sort_by_key(|(n, _)| std::cmp::Reverse(*n));
if seen.is_empty() && new.len() > count as usize {
popped
.skipped
.extend(new.drain(count as usize..).map(|(_, id)| id.clone()));
}
if new.len() > POP_BATCH {
new.truncate(POP_BATCH);
popped.more = true;
}
let mut bytes = 0;
for (n, id) in new {
if sizes.get(n).copied().unwrap_or(0) > POP_MAX as u64 {
popped.skipped.push(id.clone());
continue;
}
if bytes > POP_BYTES {
popped.more = true;
break;
}
match pop.retr(*n, POP_MAX).await {
Ok(raw) => {
bytes += raw.len();
popped.new.push((id.clone(), raw));
}
Err(e) if e.downcast_ref::<pop3::TooBig>().is_some() => popped.skipped.push(id.clone()),
Err(e) if e.downcast_ref::<pop3::BadMessage>().is_some() => {
popped.skipped.push(id.clone());
popped.more = true;
return Ok(popped);
}
Err(e) => return Err(e),
}
}
let mut deleting = Vec::new();
for id in remove {
if let Some((n, _)) = ids.iter().find(|(_, i)| i == id) {
pop.dele(*n).await?;
deleting.push(id.clone());
} else {
popped.deleted.push(id.clone());
}
}
pop.quit().await?;
popped.deleted.extend(deleting);
Ok(popped)
}
pub async fn send(
creds: Credentials,
endpoint: Endpoint,
from: String,
to: Vec<String>,
raw: Vec<u8>,
) -> Result<()> {
let secret = creds.get().await?;
let account = creds.account();
let result = smtp::send(
&endpoint,
account.smtp_username(),
secret.expose(),
account.auth,
&from,
&to,
&raw,
)
.await;
if let Err(e) = &result
&& e.chain()
.any(|c| c.downcast_ref::<smtp::Refused>().is_some())
&& account.smtp_username() == account.username()
{
creds.refused(&secret).await;
}
result
}
pub fn needs_password(error: &anyhow::Error) -> bool {
error.chain().any(|c| {
c.downcast_ref::<NeedPassword>().is_some()
|| c.downcast_ref::<smtp::Refused>().is_some()
|| c.downcast_ref::<imap::Refused>().is_some()
|| c.downcast_ref::<pop3::Refused>().is_some()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repeated_checks_waiting_together_run_once() {
let (tx, mut rx) = unbounded_channel();
let sync = |folder: &str, high| Request::Sync {
folder: folder.into(),
known: Known {
uidvalidity: 1,
low: 1,
high,
},
count: 10,
};
tx.send(sync("INBOX", 5)).unwrap();
tx.send(Request::Folders).unwrap();
tx.send(sync("Sent", 1)).unwrap();
tx.send(Request::Folders).unwrap();
let first = sync("INBOX", 3);
let all = batch(first, &mut rx);
assert_eq!(all.len(), 3, "{all:?}");
assert!(matches!(&all[2], Request::Folders));
assert!(
matches!(&all[0], Request::Sync { known, .. } if known.high == 5),
"the latest"
);
}
#[test]
fn one_download_answers_every_read_waiting_for_it() {
let (tx, mut rx) = unbounded_channel();
let body = |uid| Request::Body {
folder: "INBOX".into(),
uid,
uidvalidity: 7,
};
for uid in [9, 9, 8, 9] {
tx.send(body(uid)).unwrap();
}
let all = batch(body(9), &mut rx);
let uids: Vec<u32> = (all.iter())
.map(|r| match r {
Request::Body { uid, .. } => *uid,
_ => 0,
})
.collect();
assert_eq!(uids, [9, 8]);
}
#[test]
fn login_failures_are_told_apart() {
let need = anyhow!(NeedPassword);
assert_eq!(login_failed(&need), State::NeedPassword { refused: false });
let refused = anyhow!(imap::Refused("no".into()));
assert_eq!(
login_failed(&refused),
State::NeedPassword { refused: true }
);
assert!(matches!(
login_failed(&anyhow!("timeout")),
State::Offline(_)
));
assert!(needs_password(
&anyhow!(smtp::Refused("x".into())).context("cannot log in")
));
}
}