mailmig: Stalwart -> SNM IMAP migration tool (initial)
This commit is contained in:
commit
be16176b6e
24 files changed
+7804
No files matched your search
@@ -0,0 +1,677 @@
|
||||
//! 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>>,
|
||||
}
|
||||
|
||||
impl SelectInfo {
|
||||
/// Whether the mailbox accepts this flag permanently.
|
||||
pub fn accepts_flag(&self, f: &str) -> bool {
|
||||
match &self.permanent_flags {
|
||||
None => 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::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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user