688 lines
26 KiB
Rust
688 lines
26 KiB
Rust
//! Minimal async IMAP4rev1/rev2 client: just what a migration needs.
|
|
|
|
use super::proto::{self, Arg, Kind, Part, Response, Status, Value};
|
|
use super::utf7;
|
|
use crate::config::{Endpoint, TlsMode};
|
|
use anyhow::{Context, Result, anyhow, bail};
|
|
use base64::Engine;
|
|
use std::collections::{BTreeMap, HashSet};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufStream};
|
|
use tokio::net::TcpStream;
|
|
|
|
pub trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
|
|
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
|
|
|
|
pub struct Client {
|
|
io: BufStream<Box<dyn Stream>>,
|
|
tag: u32,
|
|
pub caps: HashSet<String>,
|
|
pub label: String,
|
|
timeout: Duration,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Mailbox {
|
|
/// Name exactly as on the wire (modified UTF-7).
|
|
pub wire: String,
|
|
/// Decoded Unicode name.
|
|
pub name: String,
|
|
pub delim: Option<char>,
|
|
/// Attributes, e.g. `\Noselect`, `\Sent` (as sent by the server).
|
|
pub attrs: Vec<String>,
|
|
}
|
|
|
|
impl Mailbox {
|
|
pub fn has_attr(&self, a: &str) -> bool {
|
|
self.attrs.iter().any(|x| x.eq_ignore_ascii_case(a))
|
|
}
|
|
pub fn selectable(&self) -> bool {
|
|
!self.has_attr("\\Noselect") && !self.has_attr("\\NonExistent")
|
|
}
|
|
pub fn special_use(&self) -> Option<&'static str> {
|
|
["\\Sent", "\\Trash", "\\Junk", "\\Drafts", "\\Archive", "\\All", "\\Flagged"]
|
|
.into_iter()
|
|
.find(|s| self.has_attr(s))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Namespaces {
|
|
/// (prefix, delimiter) of the personal namespace(s).
|
|
pub personal: Vec<(String, Option<char>)>,
|
|
/// Prefixes of other-users and shared namespaces (to skip).
|
|
pub foreign: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SelectInfo {
|
|
pub exists: u32,
|
|
pub uidvalidity: Option<u32>,
|
|
pub permanent_flags: Option<Vec<String>>,
|
|
/// EXAMINE / [READ-ONLY]: PERMANENTFLAGS is then empty and says nothing
|
|
/// about what APPEND will keep.
|
|
pub read_only: bool,
|
|
}
|
|
|
|
impl SelectInfo {
|
|
/// Whether the mailbox accepts this flag permanently.
|
|
pub fn accepts_flag(&self, f: &str) -> bool {
|
|
match &self.permanent_flags {
|
|
None => true,
|
|
Some(_) if self.read_only => true,
|
|
Some(p) => {
|
|
p.iter().any(|x| x.eq_ignore_ascii_case(f)) || (!f.starts_with('\\') && p.iter().any(|x| x == "\\*"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct MsgMeta {
|
|
pub uid: u32,
|
|
pub flags: Vec<String>,
|
|
pub internaldate: Option<String>,
|
|
pub size: u64,
|
|
pub headers: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Fetched {
|
|
pub uid: u32,
|
|
pub flags: Vec<String>,
|
|
pub internaldate: Option<String>,
|
|
pub body: Vec<u8>,
|
|
}
|
|
|
|
pub struct CmdResult {
|
|
pub untagged: Vec<Response>,
|
|
pub status: Status,
|
|
}
|
|
|
|
/// A NO/BAD completion, kept typed so callers can inspect the response code.
|
|
#[derive(Debug)]
|
|
pub struct ImapNo {
|
|
pub cmd: String,
|
|
pub status: Status,
|
|
}
|
|
|
|
impl std::fmt::Display for ImapNo {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{} -> {}", self.cmd, self.status.word)?;
|
|
if let Some(c) = &self.status.code {
|
|
write!(f, " [{c}]")?;
|
|
}
|
|
write!(f, " {}", self.status.text)
|
|
}
|
|
}
|
|
impl std::error::Error for ImapNo {}
|
|
|
|
pub enum Login<'a> {
|
|
Plain { user: &'a str, password: &'a str },
|
|
Authzid { authzid: &'a str, authcid: &'a str, password: &'a str },
|
|
}
|
|
|
|
fn tls_config(ep: &Endpoint) -> Result<Arc<rustls::ClientConfig>> {
|
|
if let Some(ca) = &ep.ca_file {
|
|
use rustls_pki_types::pem::PemObject;
|
|
let mut roots = rustls::RootCertStore::empty();
|
|
for cert in rustls_pki_types::CertificateDer::pem_file_iter(ca)
|
|
.with_context(|| format!("reading CA file {}", ca.display()))?
|
|
{
|
|
roots.add(cert?)?;
|
|
}
|
|
Ok(Arc::new(rustls::ClientConfig::builder().with_root_certificates(roots).with_no_client_auth()))
|
|
} else {
|
|
use rustls_platform_verifier::ConfigVerifierExt;
|
|
Ok(Arc::new(rustls::ClientConfig::with_platform_verifier()?))
|
|
}
|
|
}
|
|
|
|
async fn tls_wrap(ep: &Endpoint, tcp: TcpStream) -> Result<Box<dyn Stream>> {
|
|
let name = ep.tls_server_name.clone().unwrap_or_else(|| ep.host.clone());
|
|
let server_name = rustls_pki_types::ServerName::try_from(name.clone())
|
|
.with_context(|| format!("invalid TLS server name {name}"))?;
|
|
let connector = tokio_rustls::TlsConnector::from(tls_config(ep)?);
|
|
let tls = connector.connect(server_name, tcp).await.with_context(|| format!("TLS handshake with {}", ep.host))?;
|
|
Ok(Box::new(tls))
|
|
}
|
|
|
|
impl Client {
|
|
pub async fn connect(ep: &Endpoint, label: &str) -> Result<Client> {
|
|
let addr = (ep.host.as_str(), ep.port());
|
|
let timeout = Duration::from_secs(ep.timeout);
|
|
let tcp = tokio::time::timeout(Duration::from_secs(30), TcpStream::connect(addr))
|
|
.await
|
|
.map_err(|_| anyhow!("{label}: connect to {}:{} timed out", ep.host, ep.port()))?
|
|
.with_context(|| format!("{label}: connect to {}:{}", ep.host, ep.port()))?;
|
|
tcp.set_nodelay(true).ok();
|
|
let stream: Box<dyn Stream> = match ep.tls {
|
|
TlsMode::Implicit => tls_wrap(ep, tcp).await?,
|
|
TlsMode::None => Box::new(tcp),
|
|
TlsMode::Starttls => {
|
|
let mut tcp = tcp;
|
|
starttls_preamble(&mut tcp, label, timeout).await?;
|
|
tls_wrap(ep, tcp).await?
|
|
}
|
|
};
|
|
let mut c =
|
|
Client { io: BufStream::new(stream), tag: 0, caps: HashSet::new(), label: label.to_string(), timeout };
|
|
if ep.tls != TlsMode::Starttls {
|
|
c.greeting().await?;
|
|
}
|
|
c.capability().await?;
|
|
Ok(c)
|
|
}
|
|
|
|
async fn greeting(&mut self) -> Result<()> {
|
|
let r = self.read_response().await?;
|
|
match r.status() {
|
|
Some(s) if s.word == "OK" || s.word == "PREAUTH" => {
|
|
if let Some(code) = &s.code {
|
|
self.set_caps_from_code(code);
|
|
}
|
|
Ok(())
|
|
}
|
|
_ => bail!("{}: unexpected greeting: {}", self.label, r.summary()),
|
|
}
|
|
}
|
|
|
|
fn set_caps_from_code(&mut self, code: &str) {
|
|
let mut it = code.split_whitespace();
|
|
if it.next().is_some_and(|w| w.eq_ignore_ascii_case("CAPABILITY")) {
|
|
self.caps = it.map(|c| c.to_ascii_uppercase()).collect();
|
|
}
|
|
}
|
|
|
|
pub fn has_cap(&self, c: &str) -> bool {
|
|
self.caps.contains(&c.to_ascii_uppercase())
|
|
}
|
|
|
|
async fn read_response(&mut self) -> Result<Response> {
|
|
let fut = async {
|
|
let mut parts = Vec::new();
|
|
loop {
|
|
let mut line = Vec::new();
|
|
let n = self.io.read_until(b'\n', &mut line).await?;
|
|
if n == 0 {
|
|
bail!("{}: connection closed by server", self.label);
|
|
}
|
|
while matches!(line.last(), Some(b'\n' | b'\r')) {
|
|
line.pop();
|
|
}
|
|
if let Some(len) = proto::literal_len(&line) {
|
|
parts.push(Part::Text(line));
|
|
let mut buf = vec![0u8; len];
|
|
self.io.read_exact(&mut buf).await?;
|
|
parts.push(Part::Lit(buf));
|
|
} else {
|
|
parts.push(Part::Text(line));
|
|
return Ok(Response { parts });
|
|
}
|
|
}
|
|
};
|
|
let r: Result<Response> = tokio::time::timeout(self.timeout, fut)
|
|
.await
|
|
.map_err(|_| anyhow!("{}: timed out waiting for server", self.label))?;
|
|
let r = r?;
|
|
tracing::trace!("{} S: {}", self.label, r.summary());
|
|
Ok(r)
|
|
}
|
|
|
|
/// Send a command made of raw text and literals; collect untagged
|
|
/// responses until the tagged completion.
|
|
pub async fn run(&mut self, args: Vec<Arg>, log_as: &str) -> Result<CmdResult> {
|
|
self.tag += 1;
|
|
let tag = format!("m{}", self.tag);
|
|
tracing::trace!("{} C: {tag} {log_as}", self.label);
|
|
let mut untagged = Vec::new();
|
|
self.io.write_all(tag.as_bytes()).await?;
|
|
self.io.write_all(b" ").await?;
|
|
for a in args {
|
|
match a {
|
|
Arg::Raw(s) => self.io.write_all(s.as_bytes()).await?,
|
|
Arg::Lit(data) => {
|
|
let nonsync = self.has_cap("LITERAL+") || (self.has_cap("LITERAL-") && data.len() <= 4096);
|
|
if nonsync {
|
|
self.io.write_all(format!("{{{}+}}\r\n", data.len()).as_bytes()).await?;
|
|
} else {
|
|
self.io.write_all(format!("{{{}}}\r\n", data.len()).as_bytes()).await?;
|
|
self.io.flush().await?;
|
|
loop {
|
|
let r = self.read_response().await?;
|
|
match r.kind() {
|
|
Kind::Continuation => break,
|
|
Kind::Tagged(t) if t == tag => {
|
|
let status = r.status().unwrap_or(Status {
|
|
word: "BAD".into(),
|
|
code: None,
|
|
text: r.summary(),
|
|
});
|
|
return Err(ImapNo { cmd: log_as.to_string(), status }.into());
|
|
}
|
|
_ => untagged.push(r),
|
|
}
|
|
}
|
|
}
|
|
self.io.write_all(&data).await?;
|
|
}
|
|
}
|
|
}
|
|
self.io.write_all(b"\r\n").await?;
|
|
self.io.flush().await?;
|
|
loop {
|
|
let r = self.read_response().await?;
|
|
match r.kind() {
|
|
Kind::Tagged(t) if t == tag => {
|
|
let status =
|
|
r.status().ok_or_else(|| anyhow!("{}: malformed completion: {}", self.label, r.summary()))?;
|
|
if status.word != "OK" {
|
|
return Err(ImapNo { cmd: log_as.to_string(), status }.into());
|
|
}
|
|
return Ok(CmdResult { untagged, status });
|
|
}
|
|
Kind::Continuation => bail!("{}: unexpected continuation for {log_as}", self.label),
|
|
_ => {
|
|
if r.status().is_some_and(|s| s.word == "BYE") && !log_as.starts_with("LOGOUT") {
|
|
tracing::debug!("{}: server said BYE: {}", self.label, r.summary());
|
|
}
|
|
untagged.push(r)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn simple(&mut self, cmd: &str) -> Result<CmdResult> {
|
|
self.run(vec![Arg::Raw(cmd.to_string())], cmd).await
|
|
}
|
|
|
|
pub async fn capability(&mut self) -> Result<()> {
|
|
let r = self.simple("CAPABILITY").await?;
|
|
for u in &r.untagged {
|
|
let v = u.values()?;
|
|
if v.get(1).is_some_and(|x| x.is_atom("CAPABILITY")) {
|
|
self.caps = v[2..].iter().filter_map(|x| x.text()).map(|c| c.to_ascii_uppercase()).collect();
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn login(&mut self, how: Login<'_>) -> Result<()> {
|
|
let res = match how {
|
|
Login::Plain { user, password } => {
|
|
if self.has_cap("LOGINDISABLED") {
|
|
bail!("{}: server has LOGINDISABLED (use TLS)", self.label);
|
|
}
|
|
let mut args = vec![Arg::Raw("LOGIN ".into())];
|
|
args.push(proto::astring(user));
|
|
args.push(Arg::Raw(" ".into()));
|
|
args.push(proto::astring(password));
|
|
self.run(args, &format!("LOGIN {user} ****")).await
|
|
}
|
|
Login::Authzid { authzid, authcid, password } => {
|
|
let raw = format!("{authzid}\0{authcid}\0{password}");
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
|
|
if self.has_cap("SASL-IR") {
|
|
self.run(
|
|
vec![Arg::Raw(format!("AUTHENTICATE PLAIN {b64}"))],
|
|
&format!("AUTHENTICATE PLAIN {authzid}/{authcid} ****"),
|
|
)
|
|
.await
|
|
} else {
|
|
self.authenticate_continuation(&b64, authzid, authcid).await
|
|
}
|
|
}
|
|
};
|
|
let r = res.map_err(|e| anyhow!("{}: login failed: {e}", self.label))?;
|
|
match r.status.code.as_deref() {
|
|
Some(code) if code.to_ascii_uppercase().starts_with("CAPABILITY") => self.set_caps_from_code(code),
|
|
_ => self.capability().await?,
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn authenticate_continuation(&mut self, b64: &str, authzid: &str, authcid: &str) -> Result<CmdResult> {
|
|
self.tag += 1;
|
|
let tag = format!("m{}", self.tag);
|
|
self.io.write_all(format!("{tag} AUTHENTICATE PLAIN\r\n").as_bytes()).await?;
|
|
self.io.flush().await?;
|
|
let log_as = format!("AUTHENTICATE PLAIN {authzid}/{authcid} ****");
|
|
let r = self.read_response().await?;
|
|
if r.kind() != Kind::Continuation {
|
|
bail!("{}: AUTHENTICATE PLAIN refused: {}", self.label, r.summary());
|
|
}
|
|
self.io.write_all(format!("{b64}\r\n").as_bytes()).await?;
|
|
self.io.flush().await?;
|
|
let mut untagged = Vec::new();
|
|
loop {
|
|
let r = self.read_response().await?;
|
|
match r.kind() {
|
|
Kind::Tagged(t) if t == tag => {
|
|
let status = r.status().ok_or_else(|| anyhow!("malformed completion"))?;
|
|
if status.word != "OK" {
|
|
return Err(ImapNo { cmd: log_as, status }.into());
|
|
}
|
|
return Ok(CmdResult { untagged, status });
|
|
}
|
|
_ => untagged.push(r),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn namespace(&mut self) -> Result<Option<Namespaces>> {
|
|
if !self.has_cap("NAMESPACE") && !self.has_cap("IMAP4REV2") {
|
|
return Ok(None);
|
|
}
|
|
let r = self.simple("NAMESPACE").await?;
|
|
for u in &r.untagged {
|
|
let v = u.values()?;
|
|
if v.get(1).is_some_and(|x| x.is_atom("NAMESPACE")) && v.len() >= 5 {
|
|
let mut ns = Namespaces::default();
|
|
let entries = |val: &Value| -> Vec<(String, Option<char>)> {
|
|
val.list()
|
|
.unwrap_or(&[])
|
|
.iter()
|
|
.filter_map(|e| {
|
|
let l = e.list()?;
|
|
let prefix = l.first()?.text()?;
|
|
let delim = l.get(1).and_then(|d| d.text()).and_then(|d| d.chars().next());
|
|
Some((prefix, delim))
|
|
})
|
|
.collect()
|
|
};
|
|
ns.personal = entries(&v[2]);
|
|
ns.foreign = entries(&v[3]).into_iter().chain(entries(&v[4])).map(|(p, _)| p).collect();
|
|
return Ok(Some(ns));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
pub async fn list(&mut self) -> Result<Vec<Mailbox>> {
|
|
let cmd = if self.has_cap("LIST-EXTENDED") && self.has_cap("SPECIAL-USE") {
|
|
r#"LIST "" "*" RETURN (SPECIAL-USE)"#
|
|
} else {
|
|
r#"LIST "" "*""#
|
|
};
|
|
let r = self.simple(cmd).await?;
|
|
let mut out: Vec<Mailbox> = Vec::new();
|
|
for u in &r.untagged {
|
|
let v = u.values()?;
|
|
if !v.get(1).is_some_and(|x| x.is_atom("LIST")) || v.len() < 5 {
|
|
continue;
|
|
}
|
|
let attrs: Vec<String> = v[2].list().unwrap_or(&[]).iter().filter_map(|a| a.text()).collect();
|
|
let delim = v[3].text().and_then(|d| d.chars().next());
|
|
let wire = v[4].text().unwrap_or_default();
|
|
let wire = if wire.eq_ignore_ascii_case("INBOX") { "INBOX".to_string() } else { wire };
|
|
if let Some(existing) = out.iter_mut().find(|m| m.wire == wire) {
|
|
existing.attrs.extend(attrs);
|
|
continue;
|
|
}
|
|
out.push(Mailbox { name: utf7::decode(&wire), wire, delim, attrs });
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
pub async fn select(&mut self, wire: &str, readonly: bool) -> Result<SelectInfo> {
|
|
let verb = if readonly { "EXAMINE" } else { "SELECT" };
|
|
let mut args = vec![Arg::Raw(format!("{verb} "))];
|
|
args.push(mailbox_arg(wire));
|
|
let r = self.run(args, &format!("{verb} {wire}")).await?;
|
|
let mut info = SelectInfo {
|
|
read_only: readonly || r.status.code.as_deref().is_some_and(|c| c.eq_ignore_ascii_case("READ-ONLY")),
|
|
..Default::default()
|
|
};
|
|
for u in &r.untagged {
|
|
if let Some(s) = u.status() {
|
|
if let Some(code) = s.code {
|
|
let up = code.to_ascii_uppercase();
|
|
if let Some(rest) = up.strip_prefix("UIDVALIDITY ") {
|
|
info.uidvalidity = rest.trim().parse().ok();
|
|
} else if up.starts_with("PERMANENTFLAGS") {
|
|
let open = code.find('(').unwrap_or(code.len());
|
|
let close = code.rfind(')').unwrap_or(code.len());
|
|
if open < close {
|
|
info.permanent_flags =
|
|
Some(code[open + 1..close].split_whitespace().map(|s| s.to_string()).collect());
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
let v = u.values()?;
|
|
if v.len() >= 3 && v[2].is_atom("EXISTS") {
|
|
info.exists = v[1].number().unwrap_or(0) as u32;
|
|
}
|
|
}
|
|
Ok(info)
|
|
}
|
|
|
|
/// Run a UID FETCH and merge the items of all FETCH responses by UID.
|
|
async fn uid_fetch(&mut self, set: &str, items: &str) -> Result<BTreeMap<u32, Vec<(String, Value)>>> {
|
|
let cmd = format!("UID FETCH {set} ({items})");
|
|
let r = self.simple(&cmd).await?;
|
|
let mut out: BTreeMap<u32, Vec<(String, Value)>> = BTreeMap::new();
|
|
for u in r.untagged {
|
|
if u.status().is_some() {
|
|
continue;
|
|
}
|
|
let v = u.values()?;
|
|
if v.len() < 4 || !v[2].is_atom("FETCH") {
|
|
continue;
|
|
}
|
|
let Some(list) = v[3].list() else { continue };
|
|
let mut pairs = Vec::new();
|
|
let mut uid = None;
|
|
for kv in list.chunks(2) {
|
|
if let [k, val] = kv {
|
|
let key = k.text().unwrap_or_default().to_ascii_uppercase();
|
|
if key == "UID" {
|
|
uid = val.number().map(|n| n as u32);
|
|
}
|
|
pairs.push((key, val.clone()));
|
|
}
|
|
}
|
|
if let Some(uid) = uid {
|
|
out.entry(uid).or_default().extend(pairs);
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// UID, flags, date, size and identifying headers of every message in the
|
|
/// selected mailbox.
|
|
pub async fn fetch_meta(&mut self) -> Result<Vec<MsgMeta>> {
|
|
let items = "UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID DATE FROM SUBJECT)]";
|
|
let map = self.uid_fetch("1:*", items).await?;
|
|
Ok(map
|
|
.into_iter()
|
|
.map(|(uid, pairs)| {
|
|
let mut m = MsgMeta { uid, ..Default::default() };
|
|
for (k, v) in pairs {
|
|
match k.as_str() {
|
|
"FLAGS" => m.flags = flags_of(&v),
|
|
"INTERNALDATE" => m.internaldate = v.text(),
|
|
"RFC822.SIZE" => m.size = v.number().unwrap_or(0),
|
|
k if k.starts_with("BODY[") => m.headers = v.bytes().unwrap_or(&[]).to_vec(),
|
|
_ => {}
|
|
}
|
|
}
|
|
m
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
pub async fn fetch_bodies(&mut self, uids: &[u32]) -> Result<Vec<Fetched>> {
|
|
if uids.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let set = uid_set(uids);
|
|
let map = self.uid_fetch(&set, "UID FLAGS INTERNALDATE BODY.PEEK[]").await?;
|
|
let mut out = Vec::new();
|
|
for (uid, pairs) in map {
|
|
let mut f = Fetched { uid, ..Default::default() };
|
|
let mut got_body = false;
|
|
for (k, v) in pairs {
|
|
match k.as_str() {
|
|
"FLAGS" => f.flags = flags_of(&v),
|
|
"INTERNALDATE" => f.internaldate = v.text(),
|
|
k if k.starts_with("BODY[") => {
|
|
f.body = v.bytes().unwrap_or(&[]).to_vec();
|
|
got_body = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if got_body {
|
|
out.push(f);
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
pub async fn append(&mut self, wire: &str, flags: &[String], date: Option<&str>, body: &[u8]) -> Result<()> {
|
|
let mut head = String::from("APPEND ");
|
|
let mut args = Vec::new();
|
|
args.push(Arg::Raw(std::mem::take(&mut head)));
|
|
args.push(mailbox_arg(wire));
|
|
if !flags.is_empty() {
|
|
args.push(Arg::Raw(format!(" ({})", flags.join(" "))));
|
|
}
|
|
if let Some(d) = date {
|
|
args.push(Arg::Raw(format!(" {}", proto::quote(d))));
|
|
}
|
|
args.push(Arg::Raw(" ".into()));
|
|
args.push(Arg::Lit(body.to_vec()));
|
|
self.run(args, &format!("APPEND {wire} <{} bytes>", body.len())).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// CREATE, treating "already exists" as success.
|
|
pub async fn create(&mut self, wire: &str) -> Result<()> {
|
|
let args = vec![Arg::Raw("CREATE ".into()), mailbox_arg(wire)];
|
|
match self.run(args, &format!("CREATE {wire}")).await {
|
|
Ok(_) => Ok(()),
|
|
Err(e) => match e.downcast_ref::<ImapNo>() {
|
|
Some(no)
|
|
if no.status.code.as_deref().is_some_and(|c| c.eq_ignore_ascii_case("ALREADYEXISTS"))
|
|
|| no.status.text.to_ascii_lowercase().contains("exist") =>
|
|
{
|
|
Ok(())
|
|
}
|
|
_ => Err(e),
|
|
},
|
|
}
|
|
}
|
|
|
|
pub async fn subscribe(&mut self, wire: &str) -> Result<()> {
|
|
let args = vec![Arg::Raw("SUBSCRIBE ".into()), mailbox_arg(wire)];
|
|
self.run(args, &format!("SUBSCRIBE {wire}")).await.map(|_| ())
|
|
}
|
|
|
|
pub async fn uid_store_flags(&mut self, uid: u32, flags: &[String]) -> Result<()> {
|
|
self.simple(&format!("UID STORE {uid} FLAGS.SILENT ({})", flags.join(" "))).await.map(|_| ())
|
|
}
|
|
|
|
pub async fn logout(mut self) {
|
|
let _ = tokio::time::timeout(Duration::from_secs(5), self.simple("LOGOUT")).await;
|
|
}
|
|
}
|
|
|
|
fn mailbox_arg(wire: &str) -> Arg {
|
|
// Always quote mailbox names; INBOX and ASCII names are safe quoted.
|
|
if wire.bytes().all(|c| (0x20..0x7f).contains(&c)) {
|
|
Arg::Raw(proto::quote(wire))
|
|
} else {
|
|
Arg::Lit(wire.as_bytes().to_vec())
|
|
}
|
|
}
|
|
|
|
fn flags_of(v: &Value) -> Vec<String> {
|
|
v.list().unwrap_or(&[]).iter().filter_map(|f| f.text()).collect()
|
|
}
|
|
|
|
/// Compress sorted UIDs into an IMAP sequence set ("1:3,7,9:10").
|
|
pub fn uid_set(uids: &[u32]) -> String {
|
|
let mut sorted = uids.to_vec();
|
|
sorted.sort_unstable();
|
|
sorted.dedup();
|
|
let mut out = String::new();
|
|
let mut i = 0;
|
|
while i < sorted.len() {
|
|
let start = sorted[i];
|
|
let mut end = start;
|
|
while i + 1 < sorted.len() && sorted[i + 1] == end + 1 {
|
|
i += 1;
|
|
end = sorted[i];
|
|
}
|
|
if !out.is_empty() {
|
|
out.push(',');
|
|
}
|
|
if start == end {
|
|
out.push_str(&start.to_string());
|
|
} else {
|
|
out.push_str(&format!("{start}:{end}"));
|
|
}
|
|
i += 1;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Plain-text greeting + STARTTLS on the raw socket, before the TLS wrap.
|
|
async fn starttls_preamble(tcp: &mut TcpStream, label: &str, timeout: Duration) -> Result<()> {
|
|
let fut = async {
|
|
let mut r = tokio::io::BufReader::new(&mut *tcp);
|
|
let mut line = String::new();
|
|
r.read_line(&mut line).await?;
|
|
if !line.to_ascii_uppercase().starts_with("* OK") {
|
|
bail!("{label}: unexpected greeting: {}", line.trim_end());
|
|
}
|
|
r.write_all(b"s0 STARTTLS\r\n").await?;
|
|
r.flush().await?;
|
|
loop {
|
|
line.clear();
|
|
if r.read_line(&mut line).await? == 0 {
|
|
bail!("{label}: connection closed during STARTTLS");
|
|
}
|
|
if let Some(rest) = line.strip_prefix("s0 ") {
|
|
if !rest.to_ascii_uppercase().starts_with("OK") {
|
|
bail!("{label}: STARTTLS refused: {}", line.trim_end());
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if !r.buffer().is_empty() {
|
|
bail!("{label}: server sent data after STARTTLS (possible injection)");
|
|
}
|
|
Ok(())
|
|
};
|
|
tokio::time::timeout(timeout, fut).await.map_err(|_| anyhow!("{label}: STARTTLS timed out"))?
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn uid_sets() {
|
|
assert_eq!(uid_set(&[1, 2, 3, 7, 9, 10]), "1:3,7,9:10");
|
|
assert_eq!(uid_set(&[5]), "5");
|
|
assert_eq!(uid_set(&[10, 1, 2]), "1:2,10");
|
|
}
|
|
|
|
#[test]
|
|
fn permanent_flags() {
|
|
let s = SelectInfo { permanent_flags: Some(vec!["\\Seen".into(), "\\*".into()]), ..Default::default() };
|
|
assert!(s.accepts_flag("\\Seen"));
|
|
assert!(s.accepts_flag("$Forwarded"));
|
|
assert!(!s.accepts_flag("\\Flagged"));
|
|
let s = SelectInfo { permanent_flags: Some(vec!["\\Seen".into()]), ..Default::default() };
|
|
assert!(!s.accepts_flag("$Forwarded"));
|
|
// EXAMINE: PERMANENTFLAGS () is meaningless for APPEND
|
|
let s = SelectInfo { permanent_flags: Some(vec![]), read_only: true, ..Default::default() };
|
|
assert!(s.accepts_flag("\\Seen"));
|
|
}
|
|
}
|