first commit

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-05 12:52:32 +01:00
commit 19dfc51360
85 files changed
+25504

No files matched your search

+1254
View File
File diff suppressed because it is too large. Load diff
+149
View File
@@ -0,0 +1,149 @@
//! Shared application state.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use arc_swap::ArcSwap;
use rand::RngCore;
use crate::backend::dav::DavClient;
use crate::backend::{ImapPool, Session};
use crate::config::Settings;
use crate::db::{self, Db};
use crate::logging::LogRing;
use crate::metrics::{AuthGuard, Metrics};
#[derive(Debug, Clone)]
pub struct Boot {
pub data_dir: PathBuf,
pub eas_listen: SocketAddr,
pub admin_listen: SocketAddr,
pub allow_remote_admin: bool,
pub admin_tls: Option<(String, String)>,
pub eas_tls: Option<(String, String)>,
}
pub struct App {
pub db: Db,
pub boot: Boot,
settings: ArcSwap<Settings>,
pub settings_rev: AtomicI64,
pub metrics: Metrics,
pub auth: AuthGuard,
pub pool: Arc<ImapPool>,
pub logs: Arc<LogRing>,
pub started: Instant,
pub started_ts: i64,
http: Mutex<(i64, Option<reqwest::Client>, Option<reqwest::Client>)>,
/// Random per-process key for HMACs (credential cache keys).
pub secret: [u8; 32],
loops: Mutex<std::collections::HashMap<String, (u32, i64)>>,
pub shutdown: tokio_util::sync::CancellationToken,
/// Collection ids a device still uses but that do not exist here (e.g. left over from
/// Z-Push). They are sent as FolderHierarchy Delete on the next FolderSync.
stale: Mutex<std::collections::HashMap<i64, std::collections::BTreeSet<String>>>,
}
impl App {
pub async fn new(boot: Boot, logs: Arc<LogRing>) -> anyhow::Result<Arc<App>> {
let db = db::open(&boot.data_dir).await?;
let (settings, rev) = db::load_settings(&db).await?;
if let Err(e) = settings.validate() {
tracing::warn!("stored settings do not validate ({e}); running with them anyway");
}
logs.set_cap(settings.logging.ring_size);
let mut secret = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut secret);
Ok(Arc::new(App {
db,
boot,
settings: ArcSwap::from_pointee(settings),
settings_rev: AtomicI64::new(rev),
metrics: Metrics::new(),
auth: AuthGuard::default(),
pool: Arc::new(ImapPool::new(secret)),
logs,
started: Instant::now(),
started_ts: crate::util::now(),
http: Mutex::new((-1, None, None)),
secret,
loops: Mutex::new(std::collections::HashMap::new()),
shutdown: tokio_util::sync::CancellationToken::new(),
stale: Mutex::new(std::collections::HashMap::new()),
}))
}
pub fn settings(&self) -> Arc<Settings> {
self.settings.load_full()
}
pub async fn update_settings(&self, s: Settings) -> anyhow::Result<i64> {
let rev = db::save_settings(&self.db, &s).await?;
self.logs.set_cap(s.logging.ring_size);
self.settings.store(Arc::new(s));
self.settings_rev.store(rev, Ordering::SeqCst);
// backend endpoints may have changed
self.pool.clear();
self.auth.clear_cache();
Ok(rev)
}
fn http_clients(&self, s: &Settings) -> (Option<reqwest::Client>, Option<reqwest::Client>) {
let rev = self.settings_rev.load(Ordering::SeqCst);
let mut g = self.http.lock().unwrap();
if g.0 != rev {
let cal = s
.caldav
.enabled
.then(|| DavClient::build_http(s.caldav.verify_tls, s.caldav.timeout_secs).ok())
.flatten();
let card = s
.carddav
.enabled
.then(|| DavClient::build_http(s.carddav.verify_tls, s.carddav.timeout_secs).ok())
.flatten();
*g = (rev, cal, card);
}
(g.1.clone(), g.2.clone())
}
pub fn stale_add(&self, device: i64, collection: &str) {
if collection.is_empty() || collection.len() > 64 {
return;
}
let mut g = self.stale.lock().unwrap();
let set = g.entry(device).or_default();
if set.len() < 500 {
set.insert(collection.to_string());
}
}
pub fn stale_take(&self, device: i64) -> Vec<String> {
self.stale
.lock()
.unwrap()
.remove(&device)
.map(|s| s.into_iter().collect())
.unwrap_or_default()
}
/// Count how often a (device, collection, old sync key) was retried within the last hour.
pub fn loop_hit(&self, key: &str) -> u32 {
let now = crate::util::now();
let mut g = self.loops.lock().unwrap();
g.retain(|_, (_, t)| *t > now - 3600);
let e = g.entry(key.to_string()).or_insert((0, now));
e.0 += 1;
e.1 = now;
e.0
}
pub fn session(&self, login: &str, password: &str) -> Session {
let s = self.settings();
let (cal, card) = self.http_clients(&s);
Session::new(s, self.pool.clone(), cal, card, login, password)
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Autodiscover for mobile devices (MobileSync schema) - replaces z-push/autodiscover.
use std::net::SocketAddr;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{ConnectInfo, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode};
use axum::response::{IntoResponse, Response};
use http_body_util::BodyExt;
use crate::app::App;
use crate::backend::AuthResult;
use crate::eas::{basic_auth, client_ip, normalize_login, user_allowed};
use crate::metrics::Event;
use crate::util::{now, xml_escape};
use crate::xmltree;
const MOBILESYNC: &str =
"http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006";
fn xml_response(status: StatusCode, body: String) -> Response {
let mut r = (status, body).into_response();
r.headers_mut().insert(
"Content-Type",
HeaderValue::from_static("text/xml; charset=utf-8"),
);
r
}
fn error_xml(code: u32, msg: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
<Response>
<Error Time="{}" Id="1">
<ErrorCode>{code}</ErrorCode>
<Message>{}</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>"#,
chrono::Utc::now().format("%H:%M:%S%.6f"),
xml_escape(msg)
)
}
fn unauthorized() -> Response {
let mut r = (StatusCode::UNAUTHORIZED, "Access denied").into_response();
r.headers_mut().insert(
"WWW-Authenticate",
HeaderValue::from_static("Basic realm=\"ActiveSync\""),
);
r
}
fn safe_host(h: &str) -> Option<&str> {
(!h.is_empty()
&& h.len() < 256
&& h.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')))
.then_some(h)
}
pub async fn handler(
State(app): State<Arc<App>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
method: Method,
headers: HeaderMap,
body: Body,
) -> Response {
let s = app.settings();
let ip = client_ip(peer, &headers, &s).to_string();
let started = std::time::Instant::now();
app.metrics
.c
.autodiscover
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if !s.autodiscover.enabled {
return StatusCode::NOT_FOUND.into_response();
}
if method != Method::POST && method != Method::GET {
return StatusCode::METHOD_NOT_ALLOWED.into_response();
}
let body = match http_body_util::Limited::new(body, 64 * 1024)
.collect()
.await
{
Ok(b) => b.to_bytes(),
Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
};
// requested address + schema
let (mut email, schema) = if body.is_empty() {
(String::new(), MOBILESYNC.to_string())
} else {
match xmltree::parse(&body) {
Ok(x) => (
x.text_of("EMailAddress").unwrap_or_default(),
x.text_of("AcceptableResponseSchema").unwrap_or_default(),
),
Err(_) => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")),
}
};
if schema != MOBILESYNC {
tracing::info!(ip = %ip, schema = %schema, "autodiscover: unsupported response schema");
return xml_response(StatusCode::OK, error_xml(600, "Invalid Request"));
}
// authentication
let creds = basic_auth(&headers, &s);
let login = match (&creds, s.autodiscover.require_auth) {
(None, true) => return unauthorized(),
(Some((l, _)), _) => l.clone(),
(None, false) => match normalize_login(&email, &s) {
Some(l) => l,
None => return xml_response(StatusCode::OK, error_xml(600, "Invalid Request")),
},
};
let ip_key = format!("ip:{ip}");
let user_key = format!("user:{login}");
if app.auth.locked_until(&ip_key).is_some() || app.auth.locked_until(&user_key).is_some() {
return (StatusCode::TOO_MANY_REQUESTS, "Too many failed logins").into_response();
}
if !user_allowed(&login, &s) {
return unauthorized();
}
if let Some((l, p)) = &creds {
let key = app.pool.key(l, p);
if !app.auth.is_cached(&key) {
let mut sess = app.session(l, p);
match sess.authenticate().await {
Ok(AuthResult::Ok) => {
app.auth.success(&user_key);
app.auth.cache_ok(key, s.security.auth_cache_secs);
sess.close().await;
}
Ok(AuthResult::Denied) => {
app.metrics.auth_failure();
if s.security.log_auth_failures {
tracing::warn!(target: "active_push::auth", "authentication failed user={l} ip={ip} service=autodiscover");
}
let (m, w, lo) = (
s.security.auth_fail_max,
s.security.auth_fail_window_secs,
s.security.lockout_secs,
);
app.auth.fail(&ip_key, m * 3, w, lo);
app.auth.fail(&user_key, m, w, lo);
return unauthorized();
}
Err(e) => {
tracing::error!("autodiscover: backend unavailable: {e:#}");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
}
}
}
if email.is_empty() || !email.contains('@') {
email = crate::backend::primary_email(&s, &login);
}
let url = if !s.autodiscover.server_url.is_empty() {
s.autodiscover.server_url.clone()
} else {
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get("host"))
.and_then(|h| h.to_str().ok())
.and_then(safe_host)
.unwrap_or("localhost");
format!("https://{host}/Microsoft-Server-ActiveSync")
};
let display = email.clone();
let xml = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<Autodiscover xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
<Response xmlns="{MOBILESYNC}">
<Culture>en:en</Culture>
<User>
<DisplayName>{}</DisplayName>
<EMailAddress>{}</EMailAddress>
</User>
<Action>
<Settings>
<Server>
<Type>MobileSync</Type>
<Url>{}</Url>
<Name>{}</Name>
</Server>
</Settings>
</Action>
</Response>
</Autodiscover>"#,
xml_escape(&display),
xml_escape(&email),
xml_escape(&url),
xml_escape(&url)
);
tracing::info!(user = %login, ip = %ip, "autodiscover answered");
app.metrics.record(Event {
ts: now(),
user: login,
device_id: String::new(),
device_type: "Autodiscover".into(),
command: "Autodiscover".into(),
ip,
http: 200,
status: "ok".into(),
ms: started.elapsed().as_millis() as u64,
bytes_in: body.len() as u64,
bytes_out: xml.len() as u64,
items_out: 0,
items_in: 0,
});
xml_response(StatusCode::OK, xml)
}
+580
View File
@@ -0,0 +1,580 @@
//! Minimal CalDAV / CardDAV client (RFC 4791 / RFC 6352).
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
use reqwest::{Client, Method, StatusCode};
use crate::util::xml_escape;
use crate::xmltree;
const PATH_SEG: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'`')
.add(b'{')
.add(b'}')
.add(b'/')
.add(b'%')
.add(b'\\');
pub fn encode_segment(s: &str) -> String {
utf8_percent_encode(s, PATH_SEG).to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DavKind {
Calendar,
AddressBook,
}
#[derive(Debug, Clone)]
pub struct Collection {
pub href: String,
pub display_name: String,
pub ctag: String,
pub sync_token: String,
pub supports_events: bool,
pub supports_todos: bool,
}
#[derive(Debug, Clone)]
pub struct Resource {
pub href: String,
pub etag: String,
pub data: Option<String>,
}
#[derive(Clone)]
pub struct DavClient {
http: Client,
base: url::Url,
user: String,
pass: String,
}
impl DavClient {
pub fn build_http(verify: bool, timeout_secs: u64) -> Result<Client> {
let mut b = Client::builder()
.timeout(Duration::from_secs(timeout_secs.max(5)))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::limited(3))
.user_agent(concat!("ActivePush/", env!("CARGO_PKG_VERSION")))
.pool_idle_timeout(Duration::from_secs(60));
if !verify {
b = b.danger_accept_invalid_certs(true);
}
Ok(b.build()?)
}
pub fn new(http: Client, base_url: &str, user: &str, pass: &str) -> Result<Self> {
let base = url::Url::parse(base_url).context("invalid DAV base URL")?;
Ok(DavClient {
http,
base,
user: user.to_string(),
pass: pass.to_string(),
})
}
fn url(&self, href: &str) -> Result<url::Url> {
let u = self.base.join(href)?;
// never follow hrefs to other hosts (SSRF protection)
if u.host_str() != self.base.host_str()
|| u.port_or_known_default() != self.base.port_or_known_default()
{
bail!("DAV href points to a different host");
}
Ok(u)
}
async fn request(
&self,
method: &str,
href: &str,
depth: Option<&str>,
body: Option<String>,
content_type: &str,
extra: &[(&str, String)],
) -> Result<(StatusCode, reqwest::header::HeaderMap, String)> {
let m = Method::from_bytes(method.as_bytes())?;
let mut rb = self
.http
.request(m, self.url(href)?)
.basic_auth(&self.user, Some(&self.pass));
if let Some(d) = depth {
rb = rb.header("Depth", d);
}
for (k, v) in extra {
rb = rb.header(*k, v);
}
if let Some(b) = body {
rb = rb.header("Content-Type", content_type).body(b);
}
let resp = rb
.send()
.await
.with_context(|| format!("DAV {method} {href}"))?;
let status = resp.status();
let headers = resp.headers().clone();
let text = resp.text().await.unwrap_or_default();
if status == StatusCode::UNAUTHORIZED {
bail!("DAV authentication failed");
}
Ok((status, headers, text))
}
pub async fn check(&self, home: &str) -> Result<()> {
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>"#;
let (st, _, _) = self
.request(
"PROPFIND",
home,
Some("0"),
Some(body.into()),
"application/xml; charset=utf-8",
&[],
)
.await?;
if !st.is_success() && st.as_u16() != 207 {
bail!("DAV home {home} returned {st}");
}
Ok(())
}
/// Discover collections below a home path.
pub async fn collections(&self, home: &str, kind: DavKind) -> Result<Vec<Collection>> {
let body = r#"<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:resourcetype/><d:displayname/><cs:getctag/><d:sync-token/><c:supported-calendar-component-set/></d:prop>
</d:propfind>"#;
let (st, _, text) = self
.request(
"PROPFIND",
home,
Some("1"),
Some(body.into()),
"application/xml; charset=utf-8",
&[],
)
.await?;
if st.as_u16() != 207 {
bail!("PROPFIND {home} returned {st}");
}
let doc = xmltree::parse(text.as_bytes())?;
let mut out = Vec::new();
for resp in doc.children_named("response") {
let href = resp.text_of("href").unwrap_or_default();
let Some(rt) = resp.find("resourcetype") else {
continue;
};
let is_cal = rt.child("calendar").is_some();
let is_book = rt.child("addressbook").is_some();
if (kind == DavKind::Calendar && !is_cal) || (kind == DavKind::AddressBook && !is_book)
{
continue;
}
let (mut ev, mut todo) = (true, true);
if let Some(set) = resp.find("supported-calendar-component-set") {
let comps: Vec<String> = set
.children_named("comp")
.filter_map(|c| c.attr("name").map(|n| n.to_ascii_uppercase()))
.collect();
if !comps.is_empty() {
ev = comps.iter().any(|c| c == "VEVENT");
todo = comps.iter().any(|c| c == "VTODO");
}
}
let name = resp
.text_of("displayname")
.filter(|s| !s.is_empty())
.unwrap_or_else(|| last_segment(&href));
out.push(Collection {
href: normalize_dir(&href),
display_name: name,
ctag: resp.text_of("getctag").unwrap_or_default(),
sync_token: resp.text_of("sync-token").unwrap_or_default(),
supports_events: ev,
supports_todos: todo,
});
}
Ok(out)
}
/// Cheap change marker for a collection (ctag / sync-token / etag list hash fallback).
pub async fn ctag(&self, href: &str) -> Result<String> {
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"><d:prop><cs:getctag/><d:sync-token/></d:prop></d:propfind>"#;
let (st, _, text) = self
.request(
"PROPFIND",
href,
Some("0"),
Some(body.into()),
"application/xml; charset=utf-8",
&[],
)
.await?;
if st.as_u16() != 207 {
bail!("PROPFIND {href} returned {st}");
}
let doc = xmltree::parse(text.as_bytes())?;
let tag = doc.text_of("getctag").filter(|s| !s.is_empty());
let tok = doc.text_of("sync-token").filter(|s| !s.is_empty());
Ok(tag.or(tok).unwrap_or_default())
}
/// List resources (href + etag). For calendars a component filter and optional
/// time-range start (UTC, compact format) are applied server side.
pub async fn list(
&self,
href: &str,
comp: Option<&str>,
since: Option<&str>,
) -> Result<Vec<Resource>> {
let text = if let Some(comp) = comp {
let range = since
.map(|s| format!("<c:time-range start=\"{}\"/>", xml_escape(s)))
.unwrap_or_default();
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop><d:getetag/></d:prop>
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="{comp}">{range}</c:comp-filter></c:comp-filter></c:filter>
</c:calendar-query>"#
);
let (st, _, text) = self
.request(
"REPORT",
href,
Some("1"),
Some(body),
"application/xml; charset=utf-8",
&[],
)
.await?;
if st.as_u16() != 207 {
bail!("calendar-query {href} returned {st}");
}
text
} else {
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
let (st, _, text) = self
.request(
"PROPFIND",
href,
Some("1"),
Some(body.into()),
"application/xml; charset=utf-8",
&[],
)
.await?;
if st.as_u16() != 207 {
bail!("PROPFIND {href} returned {st}");
}
text
};
let doc = xmltree::parse(text.as_bytes())?;
let base = normalize_dir(href);
let mut out = Vec::new();
for resp in doc.children_named("response") {
let h = resp.text_of("href").unwrap_or_default();
if normalize_dir(&h) == base || h.ends_with('/') {
continue;
}
if resp
.find("resourcetype")
.map(|r| !r.children.is_empty())
.unwrap_or(false)
{
continue;
}
let ok = resp.children_named("propstat").any(|p| {
p.text_of("status")
.map(|s| s.contains(" 200"))
.unwrap_or(true)
&& p.find("getetag").is_some()
});
if !ok {
continue;
}
out.push(Resource {
href: h,
etag: resp.text_of("getetag").unwrap_or_default(),
data: None,
});
}
Ok(out)
}
/// Fetch several resources at once (calendar-multiget / addressbook-multiget).
pub async fn multiget(
&self,
collection: &str,
hrefs: &[String],
kind: DavKind,
) -> Result<Vec<Resource>> {
if hrefs.is_empty() {
return Ok(vec![]);
}
let mut hs = String::new();
for h in hrefs {
hs.push_str(&format!("<d:href>{}</d:href>", xml_escape(h)));
}
let body = match kind {
DavKind::Calendar => format!(
r#"<?xml version="1.0" encoding="utf-8"?><c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop>{hs}</c:calendar-multiget>"#
),
DavKind::AddressBook => format!(
r#"<?xml version="1.0" encoding="utf-8"?><c:addressbook-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><c:address-data/></d:prop>{hs}</c:addressbook-multiget>"#
),
};
let (st, _, text) = self
.request(
"REPORT",
collection,
Some("1"),
Some(body),
"application/xml; charset=utf-8",
&[],
)
.await?;
if st.as_u16() != 207 {
// fallback: individual GETs
let mut out = Vec::new();
for h in hrefs {
if let Ok(r) = self.get(h).await {
out.push(r);
}
}
return Ok(out);
}
let doc = xmltree::parse(text.as_bytes())?;
let mut out = Vec::new();
for resp in doc.children_named("response") {
let data = resp
.text_of("calendar-data")
.or_else(|| resp.text_of("address-data"))
.filter(|s| !s.is_empty());
if data.is_none() {
continue;
}
out.push(Resource {
href: resp.text_of("href").unwrap_or_default(),
etag: resp.text_of("getetag").unwrap_or_default(),
data,
});
}
Ok(out)
}
pub async fn get(&self, href: &str) -> Result<Resource> {
let (st, headers, text) = self.request("GET", href, None, None, "", &[]).await?;
if st == StatusCode::NOT_FOUND {
bail!("not found");
}
if !st.is_success() {
bail!("GET {href} returned {st}");
}
Ok(Resource {
href: href.to_string(),
etag: headers
.get("etag")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string(),
data: Some(text),
})
}
/// PUT a resource. `if_match` = Some(etag) for updates, None for create (If-None-Match: *).
pub async fn put(
&self,
href: &str,
body: String,
content_type: &str,
if_match: Option<&str>,
) -> Result<String> {
let extra = match if_match {
Some(e) if !e.is_empty() => vec![("If-Match", e.to_string())],
Some(_) => vec![],
None => vec![("If-None-Match", "*".to_string())],
};
let (st, headers, text) = self
.request("PUT", href, None, Some(body), content_type, &extra)
.await?;
if st == StatusCode::PRECONDITION_FAILED {
bail!("conflict: resource changed on server");
}
if !st.is_success() {
bail!(
"PUT {href} returned {st}: {}",
crate::util::truncate_bytes(&text, 200)
);
}
let etag = headers
.get("etag")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
match etag {
Some(e) => Ok(e),
None => Ok(self.get(href).await.map(|r| r.etag).unwrap_or_default()),
}
}
pub async fn delete(&self, href: &str, if_match: Option<&str>) -> Result<()> {
let extra = if_match
.filter(|e| !e.is_empty())
.map(|e| vec![("If-Match", e.to_string())])
.unwrap_or_default();
let (st, _, _) = self.request("DELETE", href, None, None, "", &extra).await?;
if st.is_success() || st == StatusCode::NOT_FOUND {
Ok(())
} else {
Err(anyhow!("DELETE {href} returned {st}"))
}
}
pub async fn make_collection(
&self,
href: &str,
name: &str,
kind: DavKind,
todo: bool,
) -> Result<()> {
let n = xml_escape(name);
let (method, body) = match kind {
DavKind::Calendar => (
"MKCALENDAR",
format!(
r#"<?xml version="1.0" encoding="utf-8"?><c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:set><d:prop><d:displayname>{n}</d:displayname><c:supported-calendar-component-set><c:comp name="{}"/></c:supported-calendar-component-set></d:prop></d:set></c:mkcalendar>"#,
if todo { "VTODO" } else { "VEVENT" }
),
),
DavKind::AddressBook => (
"MKCOL",
format!(
r#"<?xml version="1.0" encoding="utf-8"?><d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:set><d:prop><d:resourcetype><d:collection/><c:addressbook/></d:resourcetype><d:displayname>{n}</d:displayname></d:prop></d:set></d:mkcol>"#
),
),
};
let (st, _, text) = self
.request(
method,
href,
None,
Some(body),
"application/xml; charset=utf-8",
&[],
)
.await?;
if !st.is_success() {
bail!(
"{method} {href} returned {st}: {}",
crate::util::truncate_bytes(&text, 200)
);
}
Ok(())
}
pub async fn rename(&self, href: &str, name: &str) -> Result<()> {
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?><d:propertyupdate xmlns:d="DAV:"><d:set><d:prop><d:displayname>{}</d:displayname></d:prop></d:set></d:propertyupdate>"#,
xml_escape(name)
);
let (st, _, _) = self
.request(
"PROPPATCH",
href,
None,
Some(body),
"application/xml; charset=utf-8",
&[],
)
.await?;
if !(st.is_success() || st.as_u16() == 207) {
bail!("PROPPATCH {href} returned {st}");
}
Ok(())
}
/// addressbook-query text match on FN / EMAIL (GAL search).
pub async fn search_cards(&self, href: &str, text: &str) -> Result<Vec<Resource>> {
let t = xml_escape(text);
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav">
<d:prop><d:getetag/><c:address-data/></d:prop>
<c:filter test="anyof">
<c:prop-filter name="FN"><c:text-match collation="i;unicode-casemap" match-type="contains">{t}</c:text-match></c:prop-filter>
<c:prop-filter name="EMAIL"><c:text-match collation="i;unicode-casemap" match-type="contains">{t}</c:text-match></c:prop-filter>
<c:prop-filter name="N"><c:text-match collation="i;unicode-casemap" match-type="contains">{t}</c:text-match></c:prop-filter>
</c:filter>
</c:addressbook-query>"#
);
let (st, _, resp) = self
.request(
"REPORT",
href,
Some("1"),
Some(body),
"application/xml; charset=utf-8",
&[],
)
.await?;
if st.as_u16() != 207 {
bail!("addressbook-query returned {st}");
}
let doc = xmltree::parse(resp.as_bytes())?;
let mut out = Vec::new();
for r in doc.children_named("response") {
if let Some(d) = r.text_of("address-data").filter(|s| !s.is_empty()) {
out.push(Resource {
href: r.text_of("href").unwrap_or_default(),
etag: r.text_of("getetag").unwrap_or_default(),
data: Some(d),
});
}
}
Ok(out)
}
}
pub fn normalize_dir(h: &str) -> String {
let d = percent_decode_str(h).decode_utf8_lossy().into_owned();
if d.ends_with('/') {
d
} else {
format!("{d}/")
}
}
pub fn last_segment(h: &str) -> String {
let d = percent_decode_str(h.trim_end_matches('/'))
.decode_utf8_lossy()
.into_owned();
d.rsplit('/').next().unwrap_or("").to_string()
}
/// Re-encode a decoded collection path for use in URLs.
pub fn encode_path(path: &str) -> String {
path.split('/')
.map(encode_segment)
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths() {
assert_eq!(normalize_dir("/a/b%20c"), "/a/b c/");
assert_eq!(last_segment("/a/b/x%40y.ics"), "x@y.ics");
assert_eq!(encode_path("/a b/c@d/"), "/a%20b/c@d/");
}
}
+412
View File
@@ -0,0 +1,412 @@
//! Generic iCalendar (RFC 5545) / vCard (RFC 6350, 2.1/3.0 tolerant) content-line model.
#[derive(Debug, Clone, PartialEq)]
pub struct Property {
pub name: String,
pub params: Vec<(String, String)>,
pub value: String,
}
impl Property {
pub fn new(name: &str, value: impl Into<String>) -> Self {
Property {
name: name.to_ascii_uppercase(),
params: vec![],
value: value.into(),
}
}
pub fn text(name: &str, value: &str) -> Self {
Property::new(name, escape_text(value))
}
pub fn with_param(mut self, k: &str, v: &str) -> Self {
self.params.push((k.to_ascii_uppercase(), v.to_string()));
self
}
pub fn param(&self, k: &str) -> Option<&str> {
self.params
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(k))
.map(|(_, v)| v.as_str())
}
/// All TYPE values (handles `TYPE=a,b`, repeated TYPE and vCard 2.1 bare params).
pub fn types(&self) -> Vec<String> {
let mut out = Vec::new();
for (k, v) in &self.params {
if k.eq_ignore_ascii_case("TYPE") {
out.extend(v.split(',').map(|s| s.trim().to_ascii_lowercase()));
} else if v.is_empty() {
out.push(k.to_ascii_lowercase());
}
}
out
}
pub fn text_value(&self) -> String {
unescape_text(&self.value)
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Component {
pub name: String,
pub props: Vec<Property>,
pub children: Vec<Component>,
}
impl Component {
pub fn new(name: &str) -> Self {
Component {
name: name.to_ascii_uppercase(),
..Default::default()
}
}
pub fn prop(&self, name: &str) -> Option<&Property> {
self.props
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
}
pub fn props_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Property> + 'a {
self.props
.iter()
.filter(move |p| p.name.eq_ignore_ascii_case(name))
}
pub fn text(&self, name: &str) -> Option<String> {
self.prop(name)
.map(|p| p.text_value())
.filter(|s| !s.is_empty())
}
pub fn remove(&mut self, name: &str) {
self.props.retain(|p| !p.name.eq_ignore_ascii_case(name));
}
pub fn set(&mut self, p: Property) {
self.remove(&p.name.clone());
self.props.push(p);
}
pub fn add(&mut self, p: Property) {
self.props.push(p);
}
pub fn child(&self, name: &str) -> Option<&Component> {
self.children
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
}
pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a Component> + 'a {
self.children
.iter()
.filter(move |c| c.name.eq_ignore_ascii_case(name))
}
pub fn serialize(&self) -> String {
let mut out = String::new();
self.write(&mut out);
out
}
fn write(&self, out: &mut String) {
fold_line(out, &format!("BEGIN:{}", self.name));
for p in &self.props {
let mut line = p.name.clone();
for (k, v) in &p.params {
line.push(';');
line.push_str(k);
line.push('=');
if v.contains([':', ';', ',']) && !v.starts_with('"') {
line.push('"');
line.push_str(&v.replace('"', "'"));
line.push('"');
} else {
line.push_str(v);
}
}
line.push(':');
line.push_str(&p.value);
fold_line(out, &line);
}
for c in &self.children {
c.write(out);
}
fold_line(out, &format!("END:{}", self.name));
}
}
fn fold_line(out: &mut String, line: &str) {
// fold at 75 octets on char boundaries
let line = line.replace(['\r', '\n'], "");
let mut count = 0;
for c in line.chars() {
let l = c.len_utf8();
if count + l > 75 {
out.push_str("\r\n ");
count = 1;
}
out.push(c);
count += l;
}
out.push_str("\r\n");
}
/// Parse an iCalendar/vCard stream. Returns the top-level components.
pub fn parse(input: &str) -> Vec<Component> {
// unfold
let mut lines: Vec<String> = Vec::new();
for raw in input.split('\n') {
let raw = raw.strip_suffix('\r').unwrap_or(raw);
if (raw.starts_with(' ') || raw.starts_with('\t')) && !lines.is_empty() {
lines.last_mut().unwrap().push_str(&raw[1..]);
} else if !raw.is_empty() {
lines.push(raw.to_string());
}
}
// vCard 2.1 quoted-printable soft line breaks
let mut merged: Vec<String> = Vec::new();
for l in lines {
if let Some(last) = merged.last_mut() {
if last.to_ascii_uppercase().contains("QUOTED-PRINTABLE") && last.ends_with('=') {
last.pop();
last.push_str(&l);
continue;
}
}
merged.push(l);
}
let mut stack: Vec<Component> = vec![Component::new("ROOT")];
for line in merged {
let Some(p) = parse_line(&line) else { continue };
if p.name == "BEGIN" {
if stack.len() > 32 {
break;
}
stack.push(Component::new(&p.value));
} else if p.name == "END" {
if stack.len() > 1 {
let c = stack.pop().unwrap();
stack.last_mut().unwrap().children.push(c);
}
} else if let Some(top) = stack.last_mut() {
if top.props.len() < 10_000 {
top.props.push(p);
}
}
}
while stack.len() > 1 {
let c = stack.pop().unwrap();
stack.last_mut().unwrap().children.push(c);
}
stack.pop().map(|r| r.children).unwrap_or_default()
}
fn parse_line(line: &str) -> Option<Property> {
// find the first ':' not inside quotes
let mut in_q = false;
let mut colon = None;
for (i, c) in line.char_indices() {
match c {
'"' => in_q = !in_q,
':' if !in_q => {
colon = Some(i);
break;
}
_ => {}
}
}
let colon = colon?;
let head = &line[..colon];
let mut value = line[colon + 1..].to_string();
let mut parts = split_unquoted(head, ';').into_iter();
let mut name = parts.next()?.trim().to_ascii_uppercase();
// strip vCard group prefix (item1.EMAIL)
if let Some((_, n)) = name.split_once('.') {
name = n.to_string();
}
let mut params = Vec::new();
for p in parts {
match p.split_once('=') {
Some((k, v)) => params.push((
k.trim().to_ascii_uppercase(),
v.trim().trim_matches('"').to_string(),
)),
None => params.push((p.trim().to_ascii_uppercase(), String::new())),
}
}
let qp = params.iter().any(|(k, v)| {
(k == "ENCODING" && v.eq_ignore_ascii_case("QUOTED-PRINTABLE")) || k == "QUOTED-PRINTABLE"
});
if qp {
value = decode_qp(&value);
params.retain(|(k, v)| {
!(k == "ENCODING" && v.eq_ignore_ascii_case("QUOTED-PRINTABLE"))
&& k != "QUOTED-PRINTABLE"
});
}
Some(Property {
name,
params,
value,
})
}
fn split_unquoted(s: &str, sep: char) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_q = false;
for c in s.chars() {
if c == '"' {
in_q = !in_q;
}
if c == sep && !in_q {
out.push(std::mem::take(&mut cur));
} else {
cur.push(c);
}
}
out.push(cur);
out
}
fn decode_qp(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'=' && i + 2 < b.len() + 1 {
if let (Some(h), Some(l)) = (b.get(i + 1), b.get(i + 2)) {
if let Ok(v) = u8::from_str_radix(&format!("{}{}", *h as char, *l as char), 16) {
out.push(v);
i += 3;
continue;
}
}
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn escape_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
';' => out.push_str("\\;"),
',' => out.push_str("\\,"),
'\n' => out.push_str("\\n"),
'\r' => {}
_ => out.push(c),
}
}
out
}
pub fn unescape_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut it = s.chars();
while let Some(c) = it.next() {
if c == '\\' {
match it.next() {
Some('n') | Some('N') => out.push('\n'),
Some(o) => out.push(o),
None => {}
}
} else {
out.push(c);
}
}
out
}
/// Split a structured value (N, ADR) on unescaped `;`.
pub fn split_structured(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut esc = false;
for c in s.chars() {
if esc {
cur.push('\\');
cur.push(c);
esc = false;
} else if c == '\\' {
esc = true;
} else if c == ';' {
out.push(unescape_text(&std::mem::take(&mut cur)));
} else {
cur.push(c);
}
}
out.push(unescape_text(&cur));
out
}
/// Split a list value (CATEGORIES) on unescaped `,`.
pub fn split_list(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut esc = false;
for c in s.chars() {
if esc {
cur.push('\\');
cur.push(c);
esc = false;
} else if c == '\\' {
esc = true;
} else if c == ',' {
out.push(unescape_text(&std::mem::take(&mut cur)));
} else {
cur.push(c);
}
}
out.push(unescape_text(&cur));
out.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_and_roundtrip() {
let src = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:abc\r\nSUMMARY:Spotkanie\\, ważne\r\nDESCRIPTION:Linia 1\\nLinia 2 bardzo długa linia, która musi zostać zawinięta bo przekracza 75 bajtów w UTF-8 ąęś\r\nDTSTART;TZID=Europe/Warsaw:20261105T100000\r\nATTENDEE;CN=\"Kowalski, Jan\";PARTSTAT=ACCEPTED:mailto:jan@x.pl\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
let c = parse(src);
assert_eq!(c.len(), 1);
let ev = c[0].child("VEVENT").unwrap();
assert_eq!(ev.text("SUMMARY").unwrap(), "Spotkanie, ważne");
assert_eq!(
ev.prop("DTSTART").unwrap().param("TZID"),
Some("Europe/Warsaw")
);
assert_eq!(
ev.prop("ATTENDEE").unwrap().param("CN"),
Some("Kowalski, Jan")
);
let again = parse(&c[0].serialize());
assert_eq!(again[0], c[0]);
}
#[test]
fn vcard21() {
let src = "BEGIN:VCARD\nVERSION:2.1\nitem1.TEL;CELL;PREF:+48 600\nN;ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:Kowalsk=C4=85;Anna;;;\nEND:VCARD\n";
let c = parse(src);
let tel = c[0].prop("TEL").unwrap();
assert!(tel.types().contains(&"cell".to_string()));
assert_eq!(
split_structured(&c[0].prop("N").unwrap().value)[0],
"Kowalską"
);
}
}
+1072
View File
File diff suppressed because it is too large. Load diff
+247
View File
@@ -0,0 +1,247 @@
//! Small MIME builder used for SmartReply / SmartForward composition.
use base64::Engine;
use rand::Rng;
pub struct Attachment {
pub name: String,
pub content_type: String,
pub data: Vec<u8>,
pub content_id: Option<String>,
pub inline: bool,
}
fn boundary() -> String {
let n: u64 = rand::thread_rng().gen();
format!("=_ap_{n:016x}")
}
fn b64_lines(data: &[u8]) -> String {
let enc = base64::engine::general_purpose::STANDARD.encode(data);
let mut out = String::with_capacity(enc.len() + enc.len() / 76 * 2 + 2);
for chunk in enc.as_bytes().chunks(76) {
out.push_str(std::str::from_utf8(chunk).unwrap_or(""));
out.push_str("\r\n");
}
out
}
/// RFC 2047 encoded-word for header values / filenames when needed.
pub fn encode_word(s: &str) -> String {
if s.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) && !s.contains("=?") {
return s.to_string();
}
format!(
"=?UTF-8?B?{}?=",
base64::engine::general_purpose::STANDARD.encode(s.as_bytes())
)
}
fn quote_param(s: &str) -> String {
let e = encode_word(s);
format!("\"{}\"", e.replace(['"', '\\', '\r', '\n'], "_"))
}
/// Build a complete message from header lines (already folded/encoded), bodies and attachments.
pub fn build(
headers: &[(String, String)],
text: Option<&str>,
html: Option<&str>,
atts: &[Attachment],
) -> Vec<u8> {
let mut out = String::new();
for (k, v) in headers {
let v = v.replace(['\r', '\n'], " ");
out.push_str(&format!("{k}: {v}\r\n"));
}
out.push_str("MIME-Version: 1.0\r\n");
let text_part = |t: &str| {
format!(
"Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{}",
b64_lines(t.replace("\r\n", "\n").replace('\n', "\r\n").as_bytes())
)
};
let html_part = |h: &str| {
format!(
"Content-Type: text/html; charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{}",
b64_lines(h.as_bytes())
)
};
let body = match (text, html) {
(Some(t), Some(h)) => {
let b = boundary();
format!(
"Content-Type: multipart/alternative; boundary=\"{b}\"\r\n\r\n--{b}\r\n{}--{b}\r\n{}--{b}--\r\n",
text_part(t),
html_part(h)
)
}
(Some(t), None) => text_part(t),
(None, Some(h)) => html_part(h),
(None, None) => text_part(""),
};
if atts.is_empty() {
out.push_str(&body);
return out.into_bytes();
}
let b = boundary();
out.push_str(&format!(
"Content-Type: multipart/mixed; boundary=\"{b}\"\r\n\r\nThis is a multi-part message in MIME format.\r\n--{b}\r\n{body}"
));
for a in atts {
let ct = if a.content_type.contains('/') {
a.content_type.clone()
} else {
"application/octet-stream".into()
};
out.push_str(&format!("\r\n--{b}\r\n"));
if ct.eq_ignore_ascii_case("message/rfc822") {
out.push_str(&format!(
"Content-Type: message/rfc822; name={}\r\nContent-Disposition: attachment; filename={}\r\n\r\n",
quote_param(&a.name),
quote_param(&a.name)
));
out.push_str(&String::from_utf8_lossy(&a.data));
continue;
}
out.push_str(&format!(
"Content-Type: {ct}; name={}\r\n",
quote_param(&a.name)
));
out.push_str("Content-Transfer-Encoding: base64\r\n");
if let Some(cid) = &a.content_id {
out.push_str(&format!(
"Content-ID: <{}>\r\n",
cid.trim_matches(['<', '>'])
));
}
out.push_str(&format!(
"Content-Disposition: {}; filename={}\r\n\r\n",
if a.inline { "inline" } else { "attachment" },
quote_param(&a.name)
));
out.push_str(&b64_lines(&a.data));
}
out.push_str(&format!("--{b}--\r\n"));
out.into_bytes()
}
/// Split a raw message into (header block, body) at the first empty line.
pub fn split_headers(raw: &[u8]) -> (&[u8], &[u8]) {
for (i, w) in raw.windows(4).enumerate() {
if w == b"\r\n\r\n" {
return (&raw[..i + 2], &raw[i + 4..]);
}
}
for (i, w) in raw.windows(2).enumerate() {
if w == b"\n\n" {
return (&raw[..i + 1], &raw[i + 2..]);
}
}
(raw, &[])
}
/// Unfolded raw header lines (name, value).
pub fn header_lines(block: &[u8]) -> Vec<(String, String)> {
let text = String::from_utf8_lossy(block);
let mut out: Vec<(String, String)> = Vec::new();
for line in text.split('\n') {
let line = line.trim_end_matches('\r');
if line.is_empty() {
continue;
}
if line.starts_with([' ', '\t']) {
if let Some(last) = out.last_mut() {
last.1.push(' ');
last.1.push_str(line.trim());
}
continue;
}
if let Some((k, v)) = line.split_once(':') {
out.push((k.trim().to_string(), v.trim().to_string()));
}
}
out
}
/// Remove headers (case-insensitive) from a raw message, keeping the body untouched.
pub fn strip_headers(raw: &[u8], names: &[&str]) -> Vec<u8> {
let (h, body) = split_headers(raw);
let text = String::from_utf8_lossy(h);
let mut out = String::with_capacity(raw.len());
let mut skipping = false;
for line in text.split_inclusive('\n') {
if line.starts_with([' ', '\t']) {
if !skipping {
out.push_str(line);
}
continue;
}
let name = line.split(':').next().unwrap_or("").trim();
skipping = names.iter().any(|n| n.eq_ignore_ascii_case(name));
if !skipping {
out.push_str(line);
}
}
let mut v = out.into_bytes();
if !v.ends_with(b"\n") {
v.extend_from_slice(b"\r\n");
}
v.extend_from_slice(b"\r\n");
v.extend_from_slice(body);
v
}
/// Prepend header lines to a raw message.
pub fn prepend_headers(raw: &[u8], headers: &[(String, String)]) -> Vec<u8> {
let mut v = Vec::with_capacity(raw.len() + 256);
for (k, val) in headers {
v.extend_from_slice(format!("{k}: {}\r\n", val.replace(['\r', '\n'], " ")).as_bytes());
}
v.extend_from_slice(raw);
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_and_parse() {
let raw = build(
&[
("Subject".into(), encode_word("Zażółć")),
("To".into(), "a@b.pl".into()),
],
Some("tekst"),
Some("<p>tekst</p>"),
&[Attachment {
name: "plik ą.txt".into(),
content_type: "text/plain".into(),
data: b"abc".to_vec(),
content_id: None,
inline: false,
}],
);
let m = mail_parser::MessageParser::default().parse(&raw).unwrap();
assert_eq!(m.subject(), Some("Zażółć"));
assert_eq!(m.body_text(0).unwrap(), "tekst");
assert_eq!(m.attachment_count(), 1);
use mail_parser::MimeHeaders;
assert_eq!(
m.attachment(0).unwrap().attachment_name(),
Some("plik ą.txt")
);
}
#[test]
fn strip() {
let raw = b"From: a\r\nBcc: x@y,\r\n z@w\r\nSubject: s\r\n\r\nbody";
let s = strip_headers(raw, &["bcc"]);
assert_eq!(
String::from_utf8(s).unwrap(),
"From: a\r\nSubject: s\r\n\r\nbody"
);
}
}
+1174
View File
File diff suppressed because it is too large. Load diff
+145
View File
@@ -0,0 +1,145 @@
//! Outgoing mail: SMTP submission (lettre) or local sendmail binary.
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use lettre::address::Envelope;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use tokio::io::AsyncWriteExt;
use crate::config::{SendMethod, SmtpAuth, SmtpSettings, TlsMode};
pub async fn send(
cfg: &SmtpSettings,
login: &str,
password: &str,
from: &str,
recipients: &[String],
raw: &[u8],
) -> Result<()> {
if recipients.is_empty() {
bail!("message has no recipients");
}
if raw.len() as u64 > cfg.max_message_mb * 1024 * 1024 {
bail!("message too large");
}
let from_addr: Address = from
.parse()
.map_err(|_| anyhow!("invalid sender address {from}"))?;
let mut to = Vec::with_capacity(recipients.len());
for r in recipients {
to.push(
r.parse::<Address>()
.map_err(|_| anyhow!("invalid recipient address {r}"))?,
);
}
match cfg.method {
SendMethod::Smtp => {
let tls = |host: &str| -> Result<TlsParameters> {
Ok(TlsParameters::builder(host.to_string())
.dangerous_accept_invalid_certs(!cfg.verify_tls)
.dangerous_accept_invalid_hostnames(!cfg.verify_tls)
.build_rustls()?)
};
let mut b = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
.port(cfg.port)
.timeout(Some(Duration::from_secs(cfg.timeout_secs.max(5))));
b = match cfg.security {
TlsMode::None => b.tls(Tls::None),
TlsMode::StartTls => b.tls(Tls::Required(tls(&cfg.host)?)),
TlsMode::Tls => b.tls(Tls::Wrapper(tls(&cfg.host)?)),
};
b = match cfg.auth {
SmtpAuth::None => b,
SmtpAuth::UserCredentials => {
b.credentials(Credentials::new(login.to_string(), password.to_string()))
}
SmtpAuth::Static => {
b.credentials(Credentials::new(cfg.username.clone(), cfg.password.clone()))
}
};
let transport = b.build();
let env = Envelope::new(Some(from_addr), to).context("invalid envelope")?;
transport
.send_raw(&env, raw)
.await
.map_err(|e| anyhow!("SMTP: {e}"))?;
Ok(())
}
SendMethod::Sendmail => {
let mut cmd = tokio::process::Command::new(&cfg.sendmail_path);
cmd.arg("-i").arg("-f").arg(&from_addr).arg("--");
for t in &to {
cmd.arg(t);
}
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().context("spawn sendmail")?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(raw).await?;
stdin.shutdown().await.ok();
}
let out = tokio::time::timeout(
Duration::from_secs(cfg.timeout_secs.max(5)),
child.wait_with_output(),
)
.await
.map_err(|_| anyhow!("sendmail timeout"))??;
if !out.status.success() {
bail!(
"sendmail failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
}
}
/// Check connectivity (and optionally authentication) for the panel "test" button.
pub async fn test(cfg: &SmtpSettings, login: &str, password: &str) -> Result<String> {
match cfg.method {
SendMethod::Sendmail => {
if std::path::Path::new(&cfg.sendmail_path).exists() {
Ok(format!("{} exists", cfg.sendmail_path))
} else {
bail!("{} not found", cfg.sendmail_path)
}
}
SendMethod::Smtp => {
let tls = TlsParameters::builder(cfg.host.clone())
.dangerous_accept_invalid_certs(!cfg.verify_tls)
.dangerous_accept_invalid_hostnames(!cfg.verify_tls)
.build_rustls()?;
let mut b = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
.port(cfg.port)
.timeout(Some(Duration::from_secs(10)));
b = match cfg.security {
TlsMode::None => b.tls(Tls::None),
TlsMode::StartTls => b.tls(Tls::Required(tls)),
TlsMode::Tls => b.tls(Tls::Wrapper(tls)),
};
match cfg.auth {
SmtpAuth::UserCredentials if !login.is_empty() => {
b = b.credentials(Credentials::new(login.into(), password.into()))
}
SmtpAuth::Static => {
b = b.credentials(Credentials::new(cfg.username.clone(), cfg.password.clone()))
}
_ => {}
}
let t: AsyncSmtpTransport<Tokio1Executor> = b.build();
let ok = t.test_connection().await.map_err(|e| anyhow!("{e}"))?;
if ok {
Ok(format!("SMTP {}:{} OK", cfg.host, cfg.port))
} else {
bail!("SMTP connection test failed")
}
}
}
}
+501
View File
@@ -0,0 +1,501 @@
//! Time zone helpers: EAS TIME_ZONE_INFORMATION blobs <-> IANA zones, date formats, iCal dates.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use base64::Engine;
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, Offset, TimeZone, Timelike, Utc};
use chrono_tz::Tz;
use super::ical::{Component, Property};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct SysTime {
pub year: u16,
pub month: u16,
pub dow: u16,
pub day: u16,
pub hour: u16,
pub minute: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TzRule {
pub bias: i32,
pub std_date: SysTime,
pub std_bias: i32,
pub dst_date: SysTime,
pub dst_bias: i32,
}
fn offset_min(tz: &Tz, t: DateTime<Utc>) -> i32 {
t.with_timezone(tz).offset().fix().local_minus_utc() / 60
}
fn sys_from_local(local: NaiveDateTime) -> SysTime {
let day = local.day();
let dim = days_in_month(local.year(), local.month());
let week = if day + 7 > dim { 5 } else { (day - 1) / 7 + 1 };
SysTime {
year: 0,
month: local.month() as u16,
dow: local.weekday().num_days_from_sunday() as u16,
day: week as u16,
hour: local.hour() as u16,
minute: local.minute() as u16,
}
}
pub fn days_in_month(y: i32, m: u32) -> u32 {
let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
NaiveDate::from_ymd_opt(ny, nm, 1)
.and_then(|d| d.pred_opt())
.map(|d| d.day())
.unwrap_or(30)
}
/// Compute the Windows-style rule for a zone in a given year.
pub fn rule_for(tz: &Tz, year: i32) -> TzRule {
static CACHE: OnceLock<Mutex<HashMap<(String, i32), TzRule>>> = OnceLock::new();
let key = (tz.name().to_string(), year);
if let Some(r) = CACHE
.get_or_init(Default::default)
.lock()
.unwrap()
.get(&key)
{
return *r;
}
let start = Utc.with_ymd_and_hms(year, 1, 1, 0, 0, 0).unwrap();
let mut transitions = Vec::new();
let mut prev = offset_min(tz, start);
let mut min_off = prev;
let mut max_off = prev;
let mut t = start;
// hourly scan, then refine to the minute
for _ in 0..(366 * 24) {
let n = t + chrono::Duration::hours(1);
let o = offset_min(tz, n);
if o != prev {
let mut lo = t;
let mut hi = n;
while (hi - lo).num_minutes() > 1 {
let mid = lo + (hi - lo) / 2;
if offset_min(tz, mid) == prev {
lo = mid;
} else {
hi = mid;
}
}
transitions.push((hi, prev, o));
prev = o;
}
min_off = min_off.min(o);
max_off = max_off.max(o);
t = n;
if t.year() > year {
break;
}
}
let mut rule = TzRule {
bias: -min_off,
..Default::default()
};
if max_off != min_off && transitions.len() >= 2 {
rule.dst_bias = -(max_off - min_off);
for (instant, from, to) in transitions {
if to > from {
// into daylight: expressed in local standard time
let local = (instant + chrono::Duration::minutes(from as i64)).naive_utc();
rule.dst_date = sys_from_local(local);
} else {
// into standard: expressed in local daylight time
let local = (instant + chrono::Duration::minutes(from as i64)).naive_utc();
rule.std_date = sys_from_local(local);
}
}
}
CACHE
.get_or_init(Default::default)
.lock()
.unwrap()
.insert(key, rule);
rule
}
fn write_sys(out: &mut Vec<u8>, s: &SysTime) {
for v in [s.year, s.month, s.dow, s.day, s.hour, s.minute, 0, 0] {
out.extend_from_slice(&v.to_le_bytes());
}
}
fn write_name(out: &mut Vec<u8>, name: &str) {
let mut units: Vec<u16> = name.encode_utf16().take(31).collect();
units.resize(32, 0);
for u in units {
out.extend_from_slice(&u.to_le_bytes());
}
}
pub fn blob_for(tz: &Tz, year: i32) -> String {
let r = rule_for(tz, year);
let mut out = Vec::with_capacity(172);
out.extend_from_slice(&r.bias.to_le_bytes());
write_name(&mut out, tz.name());
write_sys(&mut out, &r.std_date);
out.extend_from_slice(&r.std_bias.to_le_bytes());
write_name(&mut out, tz.name());
write_sys(&mut out, &r.dst_date);
out.extend_from_slice(&r.dst_bias.to_le_bytes());
base64::engine::general_purpose::STANDARD.encode(out)
}
pub fn parse_blob(b64: &str) -> Option<TzRule> {
let b = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.ok()?;
if b.len() < 172 {
return None;
}
let i32_at = |o: usize| i32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
let u16_at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
let sys_at = |o: usize| SysTime {
year: u16_at(o),
month: u16_at(o + 2),
dow: u16_at(o + 4),
day: u16_at(o + 6),
hour: u16_at(o + 8),
minute: u16_at(o + 10),
};
Some(TzRule {
bias: i32_at(0),
std_date: sys_at(68),
std_bias: i32_at(84),
dst_date: sys_at(152),
dst_bias: i32_at(168),
})
}
fn rules_match(a: &TzRule, b: &TzRule) -> bool {
let norm = |s: &SysTime| (s.month, s.dow, s.day, s.hour);
a.bias == b.bias
&& (a.dst_date.month == 0) == (b.dst_date.month == 0)
&& (a.dst_date.month == 0
|| (norm(&a.std_date) == norm(&b.std_date)
&& norm(&a.dst_date) == norm(&b.dst_date)
&& a.dst_bias == b.dst_bias))
}
/// Map a device-supplied blob to an IANA zone (preferring `preferred`, then common zones).
pub fn tz_from_blob(b64: &str, preferred: Tz) -> Tz {
static CACHE: OnceLock<Mutex<HashMap<(String, Tz), Tz>>> = OnceLock::new();
let key = (b64.to_string(), preferred);
if let Some(t) = CACHE
.get_or_init(Default::default)
.lock()
.unwrap()
.get(&key)
{
return *t;
}
let Some(rule) = parse_blob(b64) else {
return preferred;
};
let year = Utc::now().year();
const COMMON: &[&str] = &[
"Europe/Warsaw",
"Europe/Berlin",
"Europe/London",
"Europe/Kiev",
"Europe/Moscow",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Asia/Tokyo",
"Asia/Shanghai",
"Australia/Sydney",
];
let found = if rules_match(&rule_for(&preferred, year), &rule) {
preferred
} else if rule.bias == 0 && rule.dst_date.month == 0 {
chrono_tz::UTC
} else {
COMMON
.iter()
.filter_map(|n| n.parse::<Tz>().ok())
.chain(
chrono_tz::TZ_VARIANTS
.iter()
.copied()
.filter(|z| !z.name().starts_with("Etc/") || rule.dst_date.month == 0),
)
.find(|z| rules_match(&rule_for(z, year), &rule))
.unwrap_or(preferred)
};
{
let mut c = CACHE.get_or_init(Default::default).lock().unwrap();
if c.len() > 2000 {
c.clear();
}
c.insert(key, found);
}
found
}
// ------------------------------------------------------------------ EAS date formats
pub fn fmt_compact(d: &DateTime<Utc>) -> String {
d.format("%Y%m%dT%H%M%SZ").to_string()
}
pub fn fmt_iso(d: &DateTime<Utc>) -> String {
d.format("%Y-%m-%dT%H:%M:%S.000Z").to_string()
}
pub fn parse_eas_date(s: &str) -> Option<DateTime<Utc>> {
let s = s.trim();
for f in [
"%Y%m%dT%H%M%SZ",
"%Y-%m-%dT%H:%M:%S%.fZ",
"%Y-%m-%dT%H:%M:%SZ",
"%Y%m%dT%H%M%S",
] {
if let Ok(n) = NaiveDateTime::parse_from_str(s, f) {
return Some(Utc.from_utc_datetime(&n));
}
}
DateTime::parse_from_rfc3339(s)
.ok()
.map(|d| d.with_timezone(&Utc))
}
// ------------------------------------------------------------------ iCalendar dates
/// Resolve a TZID parameter to an IANA zone.
pub fn resolve_tzid(tzid: &str, default: Tz) -> Tz {
let t = tzid.trim().trim_matches('"');
if let Ok(z) = t.parse::<Tz>() {
return z;
}
// /mozilla.org/20050126_1/Europe/Warsaw, /freeassociation.sourceforge.net/Tzfile/Europe/Warsaw
let segs: Vec<&str> = t.split('/').filter(|s| !s.is_empty()).collect();
for n in [3usize, 2, 1] {
if segs.len() >= n {
if let Ok(z) = segs[segs.len() - n..].join("/").parse::<Tz>() {
return z;
}
}
}
let windows = [
("Central European Standard Time", "Europe/Warsaw"),
("Central Europe Standard Time", "Europe/Budapest"),
("W. Europe Standard Time", "Europe/Berlin"),
("Romance Standard Time", "Europe/Paris"),
("GMT Standard Time", "Europe/London"),
("Greenwich Standard Time", "Atlantic/Reykjavik"),
("E. Europe Standard Time", "Europe/Chisinau"),
("FLE Standard Time", "Europe/Kiev"),
("GTB Standard Time", "Europe/Bucharest"),
("Russian Standard Time", "Europe/Moscow"),
("Eastern Standard Time", "America/New_York"),
("Central Standard Time", "America/Chicago"),
("Mountain Standard Time", "America/Denver"),
("Pacific Standard Time", "America/Los_Angeles"),
("UTC", "UTC"),
("Coordinated Universal Time", "UTC"),
("China Standard Time", "Asia/Shanghai"),
("Tokyo Standard Time", "Asia/Tokyo"),
("India Standard Time", "Asia/Kolkata"),
("AUS Eastern Standard Time", "Australia/Sydney"),
];
for (w, i) in windows {
if t.eq_ignore_ascii_case(w) {
return i.parse().unwrap_or(default);
}
}
default
}
#[derive(Debug, Clone, Copy)]
pub struct IcalTime {
pub utc: DateTime<Utc>,
pub all_day: bool,
pub tz: Option<Tz>,
pub date: Option<NaiveDate>,
}
pub fn parse_ical_time(p: &Property, default: Tz) -> Option<IcalTime> {
let v = p.value.trim();
let is_date = p
.param("VALUE")
.map(|x| x.eq_ignore_ascii_case("DATE"))
.unwrap_or(false)
|| (v.len() == 8 && v.chars().all(|c| c.is_ascii_digit()));
if is_date {
let d = NaiveDate::parse_from_str(&v[..8.min(v.len())], "%Y%m%d").ok()?;
let local = d.and_hms_opt(0, 0, 0)?;
let utc = default
.from_local_datetime(&local)
.earliest()
.map(|x| x.with_timezone(&Utc))?;
return Some(IcalTime {
utc,
all_day: true,
tz: None,
date: Some(d),
});
}
if let Some(stripped) = v.strip_suffix('Z') {
let n = NaiveDateTime::parse_from_str(stripped, "%Y%m%dT%H%M%S").ok()?;
return Some(IcalTime {
utc: Utc.from_utc_datetime(&n),
all_day: false,
tz: None,
date: None,
});
}
let n = NaiveDateTime::parse_from_str(v, "%Y%m%dT%H%M%S").ok()?;
let tz = p
.param("TZID")
.map(|t| resolve_tzid(t, default))
.unwrap_or(default);
let utc = tz
.from_local_datetime(&n)
.earliest()
.or_else(|| {
tz.from_local_datetime(&(n + chrono::Duration::hours(1)))
.earliest()
})
.map(|x| x.with_timezone(&Utc))?;
Some(IcalTime {
utc,
all_day: false,
tz: Some(tz),
date: None,
})
}
pub fn ical_utc(name: &str, d: &DateTime<Utc>) -> Property {
Property::new(name, d.format("%Y%m%dT%H%M%SZ").to_string())
}
pub fn ical_local(name: &str, d: &DateTime<Utc>, tz: &Tz) -> Property {
Property::new(
name,
d.with_timezone(tz).format("%Y%m%dT%H%M%S").to_string(),
)
.with_param("TZID", tz.name())
}
pub fn ical_date(name: &str, d: NaiveDate) -> Property {
Property::new(name, d.format("%Y%m%d").to_string()).with_param("VALUE", "DATE")
}
/// Build a VTIMEZONE for `tz` using the rules of the current year.
pub fn vtimezone(tz: &Tz) -> Component {
let mut c = Component::new("VTIMEZONE");
c.add(Property::new("TZID", tz.name()));
let year = Utc::now().year();
let r = rule_for(tz, year);
let fmt_off = |m: i32| {
let s = if m < 0 { '-' } else { '+' };
format!("{s}{:02}{:02}", m.abs() / 60, m.abs() % 60)
};
let std_off = -r.bias;
if r.dst_date.month == 0 {
let mut s = Component::new("STANDARD");
s.add(Property::new("DTSTART", "19700101T000000"));
s.add(Property::new("TZOFFSETFROM", fmt_off(std_off)));
s.add(Property::new("TZOFFSETTO", fmt_off(std_off)));
c.children.push(s);
return c;
}
let dst_off = std_off - r.dst_bias;
let byday = |st: &SysTime| {
let wd = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"][st.dow as usize % 7];
let n = if st.day >= 5 {
"-1".to_string()
} else {
st.day.to_string()
};
format!("FREQ=YEARLY;BYMONTH={};BYDAY={n}{wd}", st.month)
};
let mut d = Component::new("DAYLIGHT");
d.add(Property::new(
"DTSTART",
format!("19700101T{:02}{:02}00", r.dst_date.hour, r.dst_date.minute),
));
d.add(Property::new("RRULE", byday(&r.dst_date)));
d.add(Property::new("TZOFFSETFROM", fmt_off(std_off)));
d.add(Property::new("TZOFFSETTO", fmt_off(dst_off)));
let mut s = Component::new("STANDARD");
s.add(Property::new(
"DTSTART",
format!("19700101T{:02}{:02}00", r.std_date.hour, r.std_date.minute),
));
s.add(Property::new("RRULE", byday(&r.std_date)));
s.add(Property::new("TZOFFSETFROM", fmt_off(dst_off)));
s.add(Property::new("TZOFFSETTO", fmt_off(std_off)));
c.children.push(d);
c.children.push(s);
c
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warsaw_rule() {
let r = rule_for(&chrono_tz::Europe::Warsaw, 2026);
assert_eq!(r.bias, -60);
assert_eq!(r.dst_bias, -60);
assert_eq!(
(
r.dst_date.month,
r.dst_date.day,
r.dst_date.dow,
r.dst_date.hour
),
(3, 5, 0, 2)
);
assert_eq!(
(
r.std_date.month,
r.std_date.day,
r.std_date.dow,
r.std_date.hour
),
(10, 5, 0, 3)
);
}
#[test]
fn blob_roundtrip() {
let b = blob_for(&chrono_tz::America::New_York, 2026);
let tz = tz_from_blob(&b, chrono_tz::Europe::Warsaw);
let r1 = rule_for(&tz, 2026);
assert_eq!(r1, rule_for(&chrono_tz::America::New_York, 2026));
let w = blob_for(&chrono_tz::Europe::Warsaw, 2026);
assert_eq!(
tz_from_blob(&w, chrono_tz::Europe::Warsaw),
chrono_tz::Europe::Warsaw
);
}
#[test]
fn ical_times() {
let p = Property::new("DTSTART", "20260704T100000")
.with_param("TZID", "/mozilla.org/x/Europe/Warsaw");
let t = parse_ical_time(&p, chrono_tz::UTC).unwrap();
assert_eq!(fmt_compact(&t.utc), "20260704T080000Z");
let d = parse_ical_time(
&Property::new("DTSTART", "20260704").with_param("VALUE", "DATE"),
chrono_tz::Europe::Warsaw,
)
.unwrap();
assert!(d.all_day);
assert_eq!(fmt_compact(&d.utc), "20260703T220000Z");
}
}
+764
View File
@@ -0,0 +1,764 @@
//! Runtime settings. Persisted as JSON in SQLite, editable from the admin panel and hot-applied.
//! Defaults mirror Z-Push `config.php`, `backend/*/config.php` and `policies.ini`.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum TlsMode {
None,
#[default]
StartTls,
Tls,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum LoginFormat {
/// Use the login exactly as the device sent it (after stripping a Windows `DOMAIN\`).
#[default]
AsGiven,
/// Use only the local part (`user` from `user@example.com`) - Z-Push USE_FULLEMAIL_FOR_LOGIN=false.
LocalPart,
/// Always use a full address; append `default_domain` when missing.
FullEmail,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct Settings {
pub general: General,
pub imap: ImapSettings,
pub smtp: SmtpSettings,
pub caldav: DavSettings,
pub carddav: CardDavSettings,
pub sync: SyncSettings,
pub ping: PingSettings,
pub provisioning: Provisioning,
pub autodiscover: Autodiscover,
pub security: Security,
pub logging: Logging,
pub panel: Panel,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct General {
/// Shown in the panel and used for autodiscover display name.
pub server_name: String,
/// IANA zone used for all-day events and recurring items when the device gives no zone.
pub default_timezone: String,
pub login_format: LoginFormat,
/// Domain appended for FullEmail login and used for `{domain}` in templates.
pub default_domain: String,
/// If non-empty, only these domains may log in (lowercase).
pub allowed_domains: Vec<String>,
/// Template for the user's primary e-mail address. Placeholders: {login} {local} {domain}.
pub email_template: String,
/// Highest ActiveSync protocol version announced to devices.
pub max_protocol: String,
}
impl Default for General {
fn default() -> Self {
General {
server_name: "ActivePush".into(),
default_timezone: "Europe/Warsaw".into(),
login_format: LoginFormat::AsGiven,
default_domain: String::new(),
allowed_domains: vec![],
email_template: "{login}".into(),
max_protocol: "14.1".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ImapSettings {
pub host: String,
pub port: u16,
pub security: TlsMode,
pub verify_tls: bool,
/// Optional namespace prefix (e.g. "INBOX.") - Z-Push IMAP_FOLDER_PREFIX.
pub folder_prefix: String,
pub folder_inbox: String,
pub folder_sent: String,
pub folder_drafts: String,
pub folder_trash: String,
pub folder_spam: String,
pub folder_archive: String,
/// Prefer RFC 6154 SPECIAL-USE flags over the names above.
pub use_special_use: bool,
/// Folders hidden from devices (exact names or `prefix*`).
pub excluded_folders: Vec<String>,
/// Mark messages \Seen before moving them to Trash - IMAP_AUTOSEEN_ON_DELETE.
pub autoseen_on_delete: bool,
/// Inline (true) or attached (false) forward body - IMAP_INLINE_FORWARD.
pub inline_forward: bool,
pub timeout_secs: u64,
/// Keep authenticated connections for reuse between requests of the same user.
pub pool_idle_secs: u64,
pub pool_max_per_user: usize,
}
impl Default for ImapSettings {
fn default() -> Self {
ImapSettings {
host: "127.0.0.1".into(),
port: 143,
security: TlsMode::None,
verify_tls: true,
folder_prefix: String::new(),
folder_inbox: "INBOX".into(),
folder_sent: "Sent".into(),
folder_drafts: "Drafts".into(),
folder_trash: "Trash".into(),
folder_spam: "Junk".into(),
folder_archive: "Archive".into(),
use_special_use: true,
excluded_folders: vec![],
autoseen_on_delete: false,
inline_forward: true,
timeout_secs: 60,
pool_idle_secs: 90,
pool_max_per_user: 4,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SendMethod {
#[default]
Smtp,
Sendmail,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SmtpAuth {
None,
/// Authenticate with the device user's own credentials (recommended with submission port).
#[default]
UserCredentials,
/// Authenticate with a fixed relay account.
Static,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SmtpSettings {
pub method: SendMethod,
pub host: String,
pub port: u16,
pub security: TlsMode,
pub verify_tls: bool,
pub auth: SmtpAuth,
pub username: String,
/// Secret - never returned by the API.
pub password: String,
pub sendmail_path: String,
pub timeout_secs: u64,
/// Store a copy of sent mail in the Sent folder when the device asks for it.
pub save_in_sent: bool,
/// Reject messages whose From is not one of the user's addresses (anti-spoofing).
pub enforce_from: bool,
pub max_message_mb: u64,
}
impl Default for SmtpSettings {
fn default() -> Self {
SmtpSettings {
method: SendMethod::Smtp,
host: "127.0.0.1".into(),
port: 587,
security: TlsMode::StartTls,
verify_tls: false,
auth: SmtpAuth::UserCredentials,
username: String::new(),
password: String::new(),
sendmail_path: "/usr/sbin/sendmail".into(),
timeout_secs: 60,
save_in_sent: true,
enforce_from: true,
max_message_mb: 50,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DavSettings {
pub enabled: bool,
/// Base URL of the server, e.g. `https://dav.example.com`.
pub base_url: String,
/// Path to the user's calendar home. Placeholders: %u login, %l local part, %d domain.
pub path: String,
pub verify_tls: bool,
/// Only collections whose path matches this regex (empty = all).
pub include_regex: String,
pub exclude_regex: String,
pub timeout_secs: u64,
/// Name of the default calendar (path segment); empty = first found.
pub default_collection: String,
}
impl Default for DavSettings {
fn default() -> Self {
DavSettings {
enabled: false,
base_url: "https://127.0.0.1".into(),
path: "/dav/calendars/%u/".into(),
verify_tls: true,
include_regex: String::new(),
exclude_regex: String::new(),
timeout_secs: 60,
default_collection: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CardDavSettings {
pub enabled: bool,
pub base_url: String,
pub path: String,
pub verify_tls: bool,
pub include_regex: String,
pub exclude_regex: String,
pub timeout_secs: u64,
pub default_collection: String,
/// Global address list collection (GAL search); empty = search the user's books.
pub gal_path: String,
pub gal_min_length: usize,
pub max_picture_kb: usize,
}
impl Default for CardDavSettings {
fn default() -> Self {
CardDavSettings {
enabled: false,
base_url: "https://127.0.0.1".into(),
path: "/dav/addressbooks/%u/".into(),
verify_tls: true,
include_regex: String::new(),
exclude_regex: String::new(),
timeout_secs: 60,
default_collection: String::new(),
gal_path: String::new(),
gal_min_length: 3,
max_picture_kb: 5120,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SyncSettings {
/// SYNC_MAX_ITEMS - upper bound for WindowSize.
pub max_items: u32,
/// SYNC_FILTERTIME_MAX - 0 = no limit, otherwise EAS filter type 1..7.
pub filter_time_max: u8,
/// SYNC_CONFLICT_DEFAULT: true = server wins (overwrite PIM), false = client wins.
pub server_wins: bool,
pub max_attachment_mb: u64,
pub max_request_mb: u64,
/// Hanging Sync (Wait / HeartbeatInterval) bounds in seconds.
pub heartbeat_min: u64,
pub heartbeat_max: u64,
/// Detect a device retrying the same SyncKey and shrink its window (loop detection).
pub loop_detection: bool,
/// Max seconds a Search waits - SEARCH_WAIT.
pub search_wait_secs: u64,
/// SEARCH_MAXRESULTS
pub search_max_results: u32,
/// Retry-After for 503 responses - RETRY_AFTER_DELAY.
pub retry_after_secs: u64,
}
impl Default for SyncSettings {
fn default() -> Self {
SyncSettings {
max_items: 512,
filter_time_max: 0,
server_wins: true,
max_attachment_mb: 50,
max_request_mb: 60,
heartbeat_min: 60,
heartbeat_max: 3540,
loop_detection: true,
search_wait_secs: 10,
search_max_results: 50,
retry_after_secs: 300,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PingSettings {
/// PING_INTERVAL - how often backend changes are checked while a Ping hangs.
pub check_interval_secs: u64,
/// PING_LOWER_BOUND_LIFETIME / PING_HIGHER_BOUND_LIFETIME.
pub lifetime_min: u64,
pub lifetime_max: u64,
pub max_folders: usize,
}
impl Default for PingSettings {
fn default() -> Self {
PingSettings {
check_interval_secs: 30,
lifetime_min: 60,
lifetime_max: 3540,
max_folders: 300,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Provisioning {
pub enabled: bool,
/// LOOSE_PROVISIONING - allow devices that never provision.
pub loose: bool,
pub default_policy: String,
pub policies: Vec<Policy>,
/// Per-user policy assignment: (username, policy name).
pub user_policies: Vec<UserPolicy>,
}
impl Default for Provisioning {
fn default() -> Self {
Provisioning {
enabled: true,
loose: false,
default_policy: "default".into(),
policies: vec![Policy::default()],
user_policies: vec![],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserPolicy {
pub username: String,
pub policy: String,
}
/// All fields from Z-Push `policies.ini`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Policy {
pub name: String,
pub devpwenabled: bool,
pub alphanumpwreq: bool,
pub devencenabled: bool,
pub attenabled: bool,
pub mindevpwlenngth: u32,
pub maxinacttimedevlock: u32,
pub maxdevpwfailedattempts: u32,
/// -1 = unlimited
pub maxattsize: i64,
pub allowsimpledevpw: bool,
pub devpwexpiration: u32,
pub devpwhistory: u32,
pub allowstoragecard: bool,
pub allowcam: bool,
pub reqdevenc: bool,
pub allowunsignedapps: bool,
pub allowunsignedinstallationpackages: bool,
pub mindevcomplexchars: u32,
pub allowwifi: bool,
pub allowtextmessaging: bool,
pub allowpopimapemail: bool,
/// 0 disabled, 1 handsfree only, 2 allowed
pub allowbluetooth: u32,
pub allowirda: bool,
pub reqmansyncroam: bool,
pub allowdesktopsync: bool,
pub maxcalagefilter: u32,
pub allowhtmlemail: bool,
pub maxemailagefilter: u32,
pub maxemailbodytruncsize: i64,
pub maxemailhtmlbodytruncsize: i64,
pub reqsignedsmimemessages: bool,
pub reqencsmimemessages: bool,
pub reqsignedsmimealgorithm: u32,
pub reqencsmimealgorithm: u32,
pub allowsmimeencalgneg: u32,
pub allowsmimesoftcerts: bool,
pub allowbrowser: bool,
pub allowconsumeremail: bool,
pub allowremotedesktop: bool,
pub allowinternetsharing: bool,
pub pwrecoveryenabled: bool,
}
impl Default for Policy {
fn default() -> Self {
Policy {
name: "default".into(),
devpwenabled: false,
alphanumpwreq: false,
devencenabled: false,
attenabled: true,
mindevpwlenngth: 4,
maxinacttimedevlock: 900,
maxdevpwfailedattempts: 8,
maxattsize: -1,
allowsimpledevpw: true,
devpwexpiration: 0,
devpwhistory: 0,
allowstoragecard: true,
allowcam: true,
reqdevenc: false,
allowunsignedapps: true,
allowunsignedinstallationpackages: true,
mindevcomplexchars: 3,
allowwifi: true,
allowtextmessaging: true,
allowpopimapemail: true,
allowbluetooth: 2,
allowirda: true,
reqmansyncroam: false,
allowdesktopsync: true,
maxcalagefilter: 0,
allowhtmlemail: true,
maxemailagefilter: 0,
maxemailbodytruncsize: -1,
maxemailhtmlbodytruncsize: -1,
reqsignedsmimemessages: false,
reqencsmimemessages: false,
reqsignedsmimealgorithm: 0,
reqencsmimealgorithm: 0,
allowsmimeencalgneg: 2,
allowsmimesoftcerts: true,
allowbrowser: true,
allowconsumeremail: true,
allowremotedesktop: true,
allowinternetsharing: true,
pwrecoveryenabled: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Autodiscover {
pub enabled: bool,
/// Public ActiveSync URL handed to devices; empty = derived from the request Host.
pub server_url: String,
/// Validate credentials against IMAP before answering (recommended).
pub require_auth: bool,
}
impl Default for Autodiscover {
fn default() -> Self {
Autodiscover {
enabled: true,
server_url: String::new(),
require_auth: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Security {
/// Failed logins per IP / per user before lockout.
pub auth_fail_max: u32,
pub auth_fail_window_secs: u64,
pub lockout_secs: u64,
/// Proxies whose forwarded-for header is trusted (CIDR). Default: loopback.
pub trusted_proxies: Vec<String>,
pub real_ip_header: String,
/// If non-empty, only these client networks may use ActiveSync (CIDR).
pub eas_allowed_networks: Vec<String>,
/// Users that may never sync (lowercase logins).
pub blocked_users: Vec<String>,
/// If non-empty, only these users may sync.
pub allowed_users: Vec<String>,
/// New devices need administrator approval before they can sync.
pub require_device_approval: bool,
/// Device types (case-insensitive substring of DeviceType or User-Agent) that are refused.
pub blocked_device_types: Vec<String>,
/// LOGAUTHFAIL - log failed logins (fail2ban friendly line).
pub log_auth_failures: bool,
/// Successful credentials are cached (HMAC only, never plaintext) for this many seconds.
pub auth_cache_secs: u64,
}
impl Default for Security {
fn default() -> Self {
Security {
auth_fail_max: 8,
auth_fail_window_secs: 600,
lockout_secs: 900,
trusted_proxies: vec!["127.0.0.1/32".into(), "::1/128".into()],
real_ip_header: "X-Forwarded-For".into(),
eas_allowed_networks: vec![],
blocked_users: vec![],
allowed_users: vec![],
require_device_approval: false,
blocked_device_types: vec![],
log_auth_failures: true,
auth_cache_secs: 300,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Logging {
/// Log decoded WBXML of requests and responses (debug only; contains mail data!).
pub log_wbxml: bool,
/// Users with verbose (WBXML) logging regardless of global setting - LOGUSERLEVEL.
pub debug_users: Vec<String>,
/// Lines kept in memory for the panel log viewer.
pub ring_size: usize,
/// Days of hourly statistics kept in the database.
pub stats_retention_days: u32,
/// Days of audit log kept.
pub audit_retention_days: u32,
}
impl Default for Logging {
fn default() -> Self {
Logging {
log_wbxml: false,
debug_users: vec![],
ring_size: 2000,
stats_retention_days: 90,
audit_retention_days: 365,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Panel {
pub session_ttl_minutes: u64,
/// Bearer token for /metrics (Prometheus). Empty = metrics endpoint disabled. Secret.
pub prometheus_token: String,
/// Remove devices not seen for this many days (0 = never) - like `z-push-admin -a fixstates`.
pub device_inactive_days: u32,
/// If non-empty, the panel only answers these client networks (CIDR).
pub allowed_networks: Vec<String>,
}
impl Default for Panel {
fn default() -> Self {
Panel {
session_ttl_minutes: 480,
prometheus_token: String::new(),
device_inactive_days: 180,
allowed_networks: vec![],
}
}
}
/// JSON pointers of secret values. They are blanked on read and kept when an update sends "".
pub const SECRET_FIELDS: &[&str] = &["/smtp/password", "/panel/prometheus_token"];
impl Settings {
pub fn validate(&self) -> Result<(), String> {
if self.imap.host.trim().is_empty() {
return Err("IMAP host is required".into());
}
if self.imap.port == 0 {
return Err("IMAP port is invalid".into());
}
if self
.general
.default_timezone
.parse::<chrono_tz::Tz>()
.is_err()
{
return Err(format!(
"Unknown time zone '{}'",
self.general.default_timezone
));
}
if !["2.5", "12.0", "12.1", "14.0", "14.1"].contains(&self.general.max_protocol.as_str()) {
return Err("max_protocol must be one of 2.5, 12.0, 12.1, 14.0, 14.1".into());
}
for (name, d) in [
("CalDAV", &self.caldav.base_url),
("CardDAV", &self.carddav.base_url),
] {
if !d.is_empty() && url::Url::parse(d).is_err() {
return Err(format!("{name} base URL is invalid"));
}
}
for r in [
&self.caldav.include_regex,
&self.caldav.exclude_regex,
&self.carddav.include_regex,
&self.carddav.exclude_regex,
] {
if !r.is_empty() && regex::Regex::new(r).is_err() {
return Err(format!("Invalid regular expression: {r}"));
}
}
for n in self
.security
.trusted_proxies
.iter()
.chain(self.security.eas_allowed_networks.iter())
.chain(self.panel.allowed_networks.iter())
{
if crate::util::Cidr::parse(n).is_none() {
return Err(format!(
"Invalid network '{n}' (expected CIDR, e.g. 10.0.0.0/8)"
));
}
}
if self.provisioning.policies.is_empty() {
return Err("At least one policy is required".into());
}
if !self
.provisioning
.policies
.iter()
.any(|p| p.name == self.provisioning.default_policy)
{
return Err("Default policy does not exist".into());
}
if self.sync.max_items == 0 || self.sync.max_items > 2000 {
return Err("sync.max_items must be 1..2000".into());
}
if self.ping.check_interval_secs < 5 {
return Err("ping.check_interval_secs must be >= 5".into());
}
if self.ping.lifetime_min > self.ping.lifetime_max {
return Err("ping lifetime min > max".into());
}
if self.smtp.method == SendMethod::Sendmail
&& !std::path::Path::new(&self.smtp.sendmail_path).is_absolute()
{
return Err("sendmail_path must be absolute".into());
}
Ok(())
}
pub fn policy_for(&self, user: &str) -> &Policy {
let name = self
.provisioning
.user_policies
.iter()
.find(|u| u.username.eq_ignore_ascii_case(user))
.map(|u| u.policy.as_str())
.unwrap_or(&self.provisioning.default_policy);
self.provisioning
.policies
.iter()
.find(|p| p.name == name)
.or_else(|| self.provisioning.policies.first())
.expect("validated: at least one policy")
}
pub fn timezone(&self) -> chrono_tz::Tz {
self.general
.default_timezone
.parse()
.unwrap_or(chrono_tz::Europe::Warsaw)
}
/// Remove secrets for display.
pub fn redacted(&self) -> serde_json::Value {
let mut v = serde_json::to_value(self).unwrap_or_default();
for p in SECRET_FIELDS {
if let Some(x) = v.pointer_mut(p) {
let set = x.as_str().map(|s| !s.is_empty()).unwrap_or(false);
*x = serde_json::Value::String(if set { "__SET__".into() } else { String::new() });
}
}
v
}
/// Merge an update from the panel: secret fields that are "__SET__" or missing keep the old value.
pub fn merge_update(&self, incoming: serde_json::Value) -> Result<Settings, String> {
let old = serde_json::to_value(self).map_err(|e| e.to_string())?;
// deep-merge: a partial document only changes the keys it contains
let mut incoming = {
let mut base = old.clone();
json_merge(&mut base, incoming);
base
};
for p in SECRET_FIELDS {
let keep = match incoming.pointer(p) {
None => true,
Some(v) => v.as_str() == Some("__SET__"),
};
if keep {
if let (Some(dst), Some(src)) = (incoming.pointer_mut(p), old.pointer(p)) {
*dst = src.clone();
}
}
}
let s: Settings =
serde_json::from_value(incoming).map_err(|e| format!("Invalid settings: {e}"))?;
s.validate()?;
Ok(s)
}
}
fn json_merge(base: &mut serde_json::Value, patch: serde_json::Value) {
match (base, patch) {
(serde_json::Value::Object(b), serde_json::Value::Object(p)) => {
for (k, v) in p {
json_merge(b.entry(k).or_insert(serde_json::Value::Null), v);
}
}
(b, p) => *b = p,
}
}
pub fn expand_user_template(tpl: &str, login: &str, default_domain: &str) -> String {
let (local, domain) = match login.split_once('@') {
Some((l, d)) => (l.to_string(), d.to_string()),
None => (login.to_string(), default_domain.to_string()),
};
tpl.replace("%u", login)
.replace("%l", &local)
.replace("%d", &domain)
.replace("{login}", login)
.replace("{local}", &local)
.replace("{domain}", &domain)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_validate() {
Settings::default().validate().unwrap();
}
#[test]
fn secrets_kept() {
let mut s = Settings::default();
s.smtp.password = "secret".into();
let red = s.redacted();
assert_eq!(red.pointer("/smtp/password").unwrap(), "__SET__");
let merged = s.merge_update(red).unwrap();
assert_eq!(merged.smtp.password, "secret");
}
#[test]
fn template() {
assert_eq!(
expand_user_template("/dav/%u/", "a@b.pl", "x"),
"/dav/a@b.pl/"
);
assert_eq!(
expand_user_template("{local}@{domain}", "jan", "firma.pl"),
"jan@firma.pl"
);
}
}
+735
View File
@@ -0,0 +1,735 @@
//! SQLite persistence (WAL). All queries are parameterised.
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Serialize;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::{FromRow, Row, SqlitePool};
use crate::config::Settings;
use crate::util::now;
pub type Db = SqlitePool;
pub async fn open(data_dir: &Path) -> Result<Db> {
std::fs::create_dir_all(data_dir).with_context(|| format!("create {}", data_dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700));
}
let path = data_dir.join("active-push.db");
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.foreign_keys(true)
.busy_timeout(Duration::from_secs(15))
// new databases release freed pages incrementally (see housekeeping)
.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental);
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(opts)
.await
.with_context(|| format!("open database {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for f in ["active-push.db", "active-push.db-wal", "active-push.db-shm"] {
let _ =
std::fs::set_permissions(data_dir.join(f), std::fs::Permissions::from_mode(0o600));
}
}
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
// ------------------------------------------------------------------ settings
pub async fn load_settings(db: &Db) -> Result<(Settings, i64)> {
let row = sqlx::query("SELECT json, revision FROM settings WHERE id = 1")
.fetch_optional(db)
.await?;
match row {
Some(r) => {
let json: String = r.get(0);
let rev: i64 = r.get(1);
let s: Settings = serde_json::from_str(&json).context("stored settings are invalid")?;
Ok((s, rev))
}
None => {
let s = Settings::default();
save_settings(db, &s).await?;
Ok((s, 1))
}
}
}
pub async fn save_settings(db: &Db, s: &Settings) -> Result<i64> {
let json = serde_json::to_string(s)?;
let r = sqlx::query(
"INSERT INTO settings (id, json, revision, updated_at) VALUES (1, ?, 1, ?)
ON CONFLICT(id) DO UPDATE SET json = excluded.json, revision = settings.revision + 1, updated_at = excluded.updated_at
RETURNING revision",
)
.bind(json)
.bind(now())
.fetch_one(db)
.await?;
Ok(r.get(0))
}
// ------------------------------------------------------------------ devices
#[derive(Debug, Clone, FromRow, Serialize, Default)]
pub struct Device {
pub id: i64,
pub device_id: String,
pub username: String,
pub device_type: String,
pub user_agent: String,
pub model: String,
pub imei: String,
pub friendly_name: String,
pub os: String,
pub os_language: String,
pub phone_number: String,
pub mobile_operator: String,
pub as_version: String,
pub policy_key: i64,
pub policy_temp_key: i64,
pub policy_name: String,
pub policy_hash: String,
pub wipe_status: i64,
pub wipe_requested_at: Option<i64>,
pub wipe_requested_by: Option<String>,
pub wipe_completed_at: Option<i64>,
pub blocked: i64,
pub approved: i64,
pub first_seen: i64,
pub last_seen: i64,
pub last_ip: String,
pub last_command: String,
pub requests: i64,
#[serde(skip)]
pub recovery_password: String,
#[serde(skip)]
pub ping_folders: String,
pub ping_lifetime: i64,
#[serde(skip)]
pub last_sync_request: String,
pub notes: String,
}
/// Wipe status values (Z-Push SYNC_PROVISION_RWSTATUS_*).
pub const WIPE_NA: i64 = 0;
pub const WIPE_OK: i64 = 1;
pub const WIPE_PENDING: i64 = 2;
pub const WIPE_REQUESTED: i64 = 4;
pub const WIPE_WIPED: i64 = 8;
pub const WIPE_ACCOUNT_PENDING: i64 = 16;
pub const WIPE_ACCOUNT_REQUESTED: i64 = 32;
pub const WIPE_ACCOUNT_WIPED: i64 = 64;
pub async fn device_get_or_create(
db: &Db,
device_id: &str,
username: &str,
device_type: &str,
user_agent: &str,
approved_default: bool,
) -> Result<(Device, bool)> {
if let Some(d) = device_get(db, device_id, username).await? {
return Ok((d, false));
}
let ts = now();
sqlx::query(
"INSERT OR IGNORE INTO devices (device_id, username, device_type, user_agent, first_seen, last_seen, approved)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(device_id)
.bind(username)
.bind(device_type)
.bind(user_agent)
.bind(ts)
.bind(ts)
.bind(approved_default as i64)
.execute(db)
.await?;
let d = device_get(db, device_id, username)
.await?
.context("device insert failed")?;
Ok((d, true))
}
pub async fn device_get(db: &Db, device_id: &str, username: &str) -> Result<Option<Device>> {
Ok(
sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE device_id = ? AND username = ?")
.bind(device_id)
.bind(username)
.fetch_optional(db)
.await?,
)
}
pub async fn device_by_id(db: &Db, id: i64) -> Result<Option<Device>> {
Ok(
sqlx::query_as::<_, Device>("SELECT * FROM devices WHERE id = ?")
.bind(id)
.fetch_optional(db)
.await?,
)
}
pub async fn device_list(db: &Db) -> Result<Vec<Device>> {
Ok(
sqlx::query_as::<_, Device>("SELECT * FROM devices ORDER BY last_seen DESC")
.fetch_all(db)
.await?,
)
}
pub async fn device_touch(
db: &Db,
id: i64,
ip: &str,
command: &str,
as_version: &str,
user_agent: &str,
device_type: &str,
) -> Result<()> {
sqlx::query(
"UPDATE devices SET last_seen = ?, last_ip = ?, last_command = ?, requests = requests + 1,
as_version = CASE WHEN ? = '' THEN as_version ELSE ? END,
user_agent = CASE WHEN ? = '' THEN user_agent ELSE ? END,
device_type = CASE WHEN ? = '' THEN device_type ELSE ? END
WHERE id = ?",
)
.bind(now())
.bind(ip)
.bind(command)
.bind(as_version)
.bind(as_version)
.bind(user_agent)
.bind(user_agent)
.bind(device_type)
.bind(device_type)
.bind(id)
.execute(db)
.await?;
Ok(())
}
#[derive(Debug, Default, Clone)]
pub struct DeviceInfo {
pub model: Option<String>,
pub imei: Option<String>,
pub friendly_name: Option<String>,
pub os: Option<String>,
pub os_language: Option<String>,
pub phone_number: Option<String>,
pub mobile_operator: Option<String>,
pub user_agent: Option<String>,
}
pub async fn device_set_info(db: &Db, id: i64, i: &DeviceInfo) -> Result<()> {
sqlx::query(
"UPDATE devices SET model = COALESCE(?, model), imei = COALESCE(?, imei), friendly_name = COALESCE(?, friendly_name),
os = COALESCE(?, os), os_language = COALESCE(?, os_language), phone_number = COALESCE(?, phone_number),
mobile_operator = COALESCE(?, mobile_operator), user_agent = COALESCE(?, user_agent) WHERE id = ?",
)
.bind(&i.model)
.bind(&i.imei)
.bind(&i.friendly_name)
.bind(&i.os)
.bind(&i.os_language)
.bind(&i.phone_number)
.bind(&i.mobile_operator)
.bind(&i.user_agent)
.bind(id)
.execute(db)
.await?;
Ok(())
}
pub async fn device_set_field(
db: &Db,
id: i64,
field: DeviceField,
value: Option<i64>,
text: Option<&str>,
) -> Result<()> {
let col = match field {
DeviceField::PolicyKey => "policy_key",
DeviceField::PolicyTempKey => "policy_temp_key",
DeviceField::WipeStatus => "wipe_status",
DeviceField::Blocked => "blocked",
DeviceField::Approved => "approved",
DeviceField::PingLifetime => "ping_lifetime",
DeviceField::PolicyName => "policy_name",
DeviceField::PolicyHash => "policy_hash",
DeviceField::RecoveryPassword => "recovery_password",
DeviceField::PingFolders => "ping_folders",
DeviceField::LastSyncRequest => "last_sync_request",
DeviceField::Notes => "notes",
};
let sql = format!("UPDATE devices SET {col} = ? WHERE id = ?");
let q = sqlx::query(&sql);
let q = match (value, text) {
(Some(v), _) => q.bind(v),
(None, Some(t)) => q.bind(t.to_string()),
(None, None) => q.bind(Option::<String>::None),
};
q.bind(id).execute(db).await?;
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub enum DeviceField {
PolicyKey,
PolicyTempKey,
WipeStatus,
Blocked,
Approved,
PingLifetime,
PolicyName,
PolicyHash,
RecoveryPassword,
PingFolders,
LastSyncRequest,
Notes,
}
pub async fn device_request_wipe(db: &Db, id: i64, by: &str, account_only: bool) -> Result<()> {
let st = if account_only {
WIPE_ACCOUNT_PENDING
} else {
WIPE_PENDING
};
sqlx::query("UPDATE devices SET wipe_status = ?, wipe_requested_at = ?, wipe_requested_by = ?, wipe_completed_at = NULL WHERE id = ?")
.bind(st)
.bind(now())
.bind(by)
.bind(id)
.execute(db)
.await?;
Ok(())
}
pub async fn device_wipe_done(db: &Db, id: i64, status: i64) -> Result<()> {
sqlx::query("UPDATE devices SET wipe_status = ?, wipe_completed_at = ? WHERE id = ?")
.bind(status)
.bind(now())
.bind(id)
.execute(db)
.await?;
Ok(())
}
pub async fn device_delete(db: &Db, id: i64) -> Result<()> {
sqlx::query("DELETE FROM devices WHERE id = ?")
.bind(id)
.execute(db)
.await?;
Ok(())
}
pub async fn device_reset_states(db: &Db, id: i64) -> Result<()> {
sqlx::query("DELETE FROM sync_states WHERE device_rowid = ?")
.bind(id)
.execute(db)
.await?;
sqlx::query("DELETE FROM folder_stats WHERE device_rowid = ?")
.bind(id)
.execute(db)
.await?;
sqlx::query("UPDATE devices SET ping_folders = '', last_sync_request = '' WHERE id = ?")
.bind(id)
.execute(db)
.await?;
Ok(())
}
pub async fn devices_cleanup_inactive(db: &Db, days: u32) -> Result<u64> {
if days == 0 {
return Ok(0);
}
let cutoff = now() - days as i64 * 86400;
let r = sqlx::query(
"DELETE FROM devices WHERE last_seen < ? AND wipe_status NOT IN (2, 4, 16, 32)",
)
.bind(cutoff)
.execute(db)
.await?;
Ok(r.rows_affected())
}
// ------------------------------------------------------------------ states
pub async fn state_get(
db: &Db,
dev: i64,
collection: &str,
uuid: &str,
counter: i64,
) -> Result<Option<Vec<u8>>> {
let r = sqlx::query(
"SELECT data FROM sync_states WHERE device_rowid = ? AND collection = ? AND uuid = ? AND counter = ?",
)
.bind(dev)
.bind(collection)
.bind(uuid)
.bind(counter)
.fetch_optional(db)
.await?;
Ok(r.map(|r| r.get(0)))
}
/// Store state `counter` and drop everything older than `counter - 1`
/// (the previous state is kept so a device may retry a lost response).
pub async fn state_put(
db: &Db,
dev: i64,
collection: &str,
uuid: &str,
counter: i64,
data: &[u8],
) -> Result<()> {
let mut tx = db.begin().await?;
sqlx::query(
"INSERT OR REPLACE INTO sync_states (device_rowid, collection, uuid, counter, data, created_at) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(dev)
.bind(collection)
.bind(uuid)
.bind(counter)
.bind(data)
.bind(now())
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM sync_states WHERE device_rowid = ? AND collection = ? AND (uuid != ? OR counter < ?)",
)
.bind(dev)
.bind(collection)
.bind(uuid)
.bind(counter - 1)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn state_delete_collection(db: &Db, dev: i64, collection: &str) -> Result<()> {
sqlx::query("DELETE FROM sync_states WHERE device_rowid = ? AND collection = ?")
.bind(dev)
.bind(collection)
.execute(db)
.await?;
sqlx::query("DELETE FROM folder_stats WHERE device_rowid = ? AND collection = ?")
.bind(dev)
.bind(collection)
.execute(db)
.await?;
Ok(())
}
/// Latest state per collection for a device (used by Ping/GetItemEstimate when no key is given).
pub async fn state_latest(
db: &Db,
dev: i64,
collection: &str,
) -> Result<Option<(String, i64, Vec<u8>)>> {
let r = sqlx::query(
"SELECT uuid, counter, data FROM sync_states WHERE device_rowid = ? AND collection = ? ORDER BY created_at DESC, counter DESC LIMIT 1",
)
.bind(dev)
.bind(collection)
.fetch_optional(db)
.await?;
Ok(r.map(|r| (r.get(0), r.get(1), r.get(2))))
}
pub async fn state_collections(db: &Db, dev: i64) -> Result<Vec<String>> {
let rows = sqlx::query("SELECT DISTINCT collection FROM sync_states WHERE device_rowid = ?")
.bind(dev)
.fetch_all(db)
.await?;
Ok(rows.into_iter().map(|r| r.get(0)).collect())
}
// ------------------------------------------------------------------ folder / item maps
pub async fn folder_short_id(db: &Db, user: &str, backend_id: &str) -> Result<i64> {
if let Some(r) =
sqlx::query("SELECT short_id FROM folder_map WHERE username = ? AND backend_id = ?")
.bind(user)
.bind(backend_id)
.fetch_optional(db)
.await?
{
return Ok(r.get(0));
}
let r = sqlx::query(
"INSERT INTO folder_map (username, backend_id, short_id)
VALUES (?, ?, (SELECT COALESCE(MAX(short_id), 0) + 1 FROM folder_map WHERE username = ?))
ON CONFLICT(username, backend_id) DO UPDATE SET short_id = folder_map.short_id
RETURNING short_id",
)
.bind(user)
.bind(backend_id)
.bind(user)
.fetch_one(db)
.await?;
Ok(r.get(0))
}
pub async fn folder_backend_id(db: &Db, user: &str, short_id: &str) -> Result<Option<String>> {
let Ok(n) = short_id.parse::<i64>() else {
return Ok(None);
};
let r = sqlx::query("SELECT backend_id FROM folder_map WHERE username = ? AND short_id = ?")
.bind(user)
.bind(n)
.fetch_optional(db)
.await?;
Ok(r.map(|r| r.get(0)))
}
pub async fn folder_rename_backend(db: &Db, user: &str, old: &str, new: &str) -> Result<()> {
// a stale mapping of a previously deleted folder with the new name must go
sqlx::query("DELETE FROM folder_map WHERE username = ? AND backend_id = ?")
.bind(user)
.bind(new)
.execute(db)
.await?;
sqlx::query("UPDATE folder_map SET backend_id = ? WHERE username = ? AND backend_id = ?")
.bind(new)
.bind(user)
.bind(old)
.execute(db)
.await?;
Ok(())
}
pub async fn item_short(db: &Db, user: &str, folder: &str, long_id: &str) -> Result<i64> {
if let Some(r) = sqlx::query(
"SELECT short_id FROM item_map WHERE username = ? AND folder = ? AND long_id = ?",
)
.bind(user)
.bind(folder)
.bind(long_id)
.fetch_optional(db)
.await?
{
return Ok(r.get(0));
}
let r = sqlx::query(
"INSERT INTO item_map (username, folder, long_id, short_id)
VALUES (?, ?, ?, (SELECT COALESCE(MAX(short_id), 0) + 1 FROM item_map WHERE username = ? AND folder = ?))
ON CONFLICT(username, folder, long_id) DO UPDATE SET short_id = item_map.short_id
RETURNING short_id",
)
.bind(user)
.bind(folder)
.bind(long_id)
.bind(user)
.bind(folder)
.fetch_one(db)
.await?;
Ok(r.get(0))
}
pub async fn item_long(db: &Db, user: &str, folder: &str, short: i64) -> Result<Option<String>> {
let r = sqlx::query(
"SELECT long_id FROM item_map WHERE username = ? AND folder = ? AND short_id = ?",
)
.bind(user)
.bind(folder)
.bind(short)
.fetch_optional(db)
.await?;
Ok(r.map(|r| r.get(0)))
}
// ------------------------------------------------------------------ folder stats
#[derive(Debug, Clone, FromRow, Serialize)]
pub struct FolderStat {
pub collection: String,
pub folder_name: String,
pub folder_class: String,
pub last_sync: i64,
pub items_out: i64,
pub items_in: i64,
pub item_count: i64,
pub last_status: i64,
pub window_size: i64,
pub loop_hits: i64,
}
#[allow(clippy::too_many_arguments)]
pub async fn folder_stat_update(
db: &Db,
dev: i64,
collection: &str,
name: &str,
class: &str,
out: i64,
inn: i64,
count: i64,
status: i64,
window: i64,
loop_hit: bool,
) -> Result<()> {
sqlx::query(
"INSERT INTO folder_stats (device_rowid, collection, folder_name, folder_class, last_sync, items_out, items_in, item_count, last_status, window_size, loop_hits)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(device_rowid, collection) DO UPDATE SET
folder_name = CASE WHEN excluded.folder_name = '' THEN folder_name ELSE excluded.folder_name END,
folder_class = CASE WHEN excluded.folder_class = '' THEN folder_class ELSE excluded.folder_class END,
last_sync = excluded.last_sync, items_out = items_out + excluded.items_out, items_in = items_in + excluded.items_in,
item_count = excluded.item_count, last_status = excluded.last_status, window_size = excluded.window_size,
loop_hits = loop_hits + excluded.loop_hits",
)
.bind(dev)
.bind(collection)
.bind(name)
.bind(class)
.bind(now())
.bind(out)
.bind(inn)
.bind(count)
.bind(status)
.bind(window)
.bind(loop_hit as i64)
.execute(db)
.await?;
Ok(())
}
pub async fn folder_stats(db: &Db, dev: i64) -> Result<Vec<FolderStat>> {
Ok(sqlx::query_as::<_, FolderStat>(
"SELECT collection, folder_name, folder_class, last_sync, items_out, items_in, item_count, last_status, window_size, loop_hits
FROM folder_stats WHERE device_rowid = ? ORDER BY folder_class, folder_name",
)
.bind(dev)
.fetch_all(db)
.await?)
}
// ------------------------------------------------------------------ audit
pub async fn audit(db: &Db, actor: &str, action: &str, target: &str, ip: &str, details: &str) {
let _ = sqlx::query(
"INSERT INTO audit_log (ts, actor, action, target, ip, details) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(now())
.bind(actor)
.bind(action)
.bind(target)
.bind(ip)
.bind(details)
.execute(db)
.await;
}
#[derive(Debug, Clone, FromRow, Serialize)]
pub struct AuditEntry {
pub id: i64,
pub ts: i64,
pub actor: String,
pub action: String,
pub target: String,
pub ip: String,
pub details: String,
}
pub async fn audit_list(db: &Db, limit: i64) -> Result<Vec<AuditEntry>> {
Ok(
sqlx::query_as::<_, AuditEntry>("SELECT * FROM audit_log ORDER BY id DESC LIMIT ?")
.bind(limit)
.fetch_all(db)
.await?,
)
}
pub async fn housekeeping(db: &Db, s: &Settings) -> Result<()> {
let ts = now();
sqlx::query("DELETE FROM admin_sessions WHERE expires_at < ?")
.bind(ts)
.execute(db)
.await?;
sqlx::query("DELETE FROM setup_token WHERE expires_at < ?")
.bind(ts)
.execute(db)
.await?;
sqlx::query("DELETE FROM stats_hourly WHERE hour < ?")
.bind(ts - s.logging.stats_retention_days as i64 * 86400)
.execute(db)
.await?;
sqlx::query("DELETE FROM audit_log WHERE ts < ?")
.bind(ts - s.logging.audit_retention_days as i64 * 86400)
.execute(db)
.await?;
let removed = devices_cleanup_inactive(db, s.panel.device_inactive_days).await?;
if removed > 0 {
tracing::info!(removed, "removed inactive devices");
}
// id maps of accounts that no longer have any device
sqlx::query(
"DELETE FROM item_map WHERE username NOT IN (SELECT DISTINCT username FROM devices)",
)
.execute(db)
.await?;
sqlx::query(
"DELETE FROM folder_map WHERE username NOT IN (SELECT DISTINCT username FROM devices)",
)
.execute(db)
.await?;
// states of collections a device stopped syncing more than a year ago
sqlx::query("DELETE FROM sync_states WHERE created_at < ?")
.bind(ts - 400 * 86400)
.execute(db)
.await?;
compact(db).await;
Ok(())
}
/// Keep the database files small: checkpoint/truncate the WAL and give free pages back.
pub async fn compact(db: &Db) {
let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
.execute(db)
.await;
let mode: i64 = sqlx::query_scalar("PRAGMA auto_vacuum")
.fetch_one(db)
.await
.unwrap_or(0);
let free: i64 = sqlx::query_scalar("PRAGMA freelist_count")
.fetch_one(db)
.await
.unwrap_or(0);
let pages: i64 = sqlx::query_scalar("PRAGMA page_count")
.fetch_one(db)
.await
.unwrap_or(1);
if mode == 0 {
// databases created by 0.1.0: switch to incremental vacuum once (one full VACUUM)
tracing::info!("converting database to incremental auto-vacuum");
if let Ok(mut c) = db.acquire().await {
let _ = sqlx::query("PRAGMA auto_vacuum = INCREMENTAL")
.execute(&mut *c)
.await;
let _ = sqlx::query("VACUUM").execute(&mut *c).await;
}
} else if free > 256 && free * 5 > pages {
let _ = sqlx::query("PRAGMA incremental_vacuum").execute(db).await;
}
let _ = sqlx::query("PRAGMA optimize").execute(db).await;
let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)")
.execute(db)
.await;
}
File diff suppressed because it is too large. Load diff
+650
View File
@@ -0,0 +1,650 @@
//! vCard <-> EAS Contacts (with lossless merge of unmapped vCard data).
use base64::Engine;
use chrono::{NaiveDate, TimeZone, Utc};
use chrono_tz::Tz;
use super::{categories, categories_element, incoming_body, pim_body, Options};
use crate::backend::ical::{self, Component, Property};
use crate::backend::tz;
use crate::wbxml::Element;
/// Phone slots: (EAS tag, required types, excluded types)
const PHONE_SLOTS: &[(&str, &[&str], &[&str])] = &[
("Contacts:MobilePhoneNumber", &["cell"], &[]),
("Contacts:BusinessFaxNumber", &["work", "fax"], &[]),
("Contacts:HomeFaxNumber", &["fax"], &["work"]),
("Contacts:PagerNumber", &["pager"], &[]),
("Contacts:CarPhoneNumber", &["car"], &[]),
(
"Contacts:BusinessPhoneNumber",
&["work"],
&["fax", "cell", "pager"],
),
(
"Contacts:Business2PhoneNumber",
&["work"],
&["fax", "cell", "pager"],
),
("Contacts:HomePhoneNumber", &["home"], &["fax", "cell"]),
("Contacts:Home2PhoneNumber", &["home"], &["fax", "cell"]),
("Contacts:RadioPhoneNumber", &["x-radio"], &[]),
("Contacts:AssistantPhoneNumber", &["x-assistant"], &[]),
("Contacts2:CompanyMainPhone", &["x-company"], &[]),
("Contacts2:MMS", &["x-mms"], &[]),
];
fn tel_types(slot: &str) -> &'static str {
match slot {
"Contacts:MobilePhoneNumber" => "CELL",
"Contacts:BusinessFaxNumber" => "WORK,FAX",
"Contacts:HomeFaxNumber" => "HOME,FAX",
"Contacts:PagerNumber" => "PAGER",
"Contacts:CarPhoneNumber" => "CAR",
"Contacts:BusinessPhoneNumber" | "Contacts:Business2PhoneNumber" => "WORK,VOICE",
"Contacts:HomePhoneNumber" | "Contacts:Home2PhoneNumber" => "HOME,VOICE",
"Contacts:RadioPhoneNumber" => "X-RADIO",
"Contacts:AssistantPhoneNumber" => "X-ASSISTANT",
"Contacts2:CompanyMainPhone" => "X-COMPANY",
"Contacts2:MMS" => "X-MMS",
_ => "VOICE",
}
}
/// Assign TEL properties to EAS slots. Returns (slot -> prop index) and leftover indices.
fn assign_tels(tels: &[&Property]) -> (Vec<(&'static str, usize)>, Vec<usize>) {
let mut used = vec![false; tels.len()];
let mut out = Vec::new();
for (slot, req, exc) in PHONE_SLOTS {
let pos = tels.iter().enumerate().position(|(i, p)| {
if used[i] {
return false;
}
let t = p.types();
req.iter().all(|r| t.iter().any(|x| x == r))
&& !exc.iter().any(|e| t.iter().any(|x| x == e))
});
if let Some(i) = pos {
used[i] = true;
out.push((*slot, i));
}
}
// untyped / voice-only numbers go to free home/business/mobile slots
for fallback in [
"Contacts:HomePhoneNumber",
"Contacts:BusinessPhoneNumber",
"Contacts:MobilePhoneNumber",
"Contacts:Home2PhoneNumber",
"Contacts:Business2PhoneNumber",
] {
if out.iter().any(|(s, _)| *s == fallback) {
continue;
}
if let Some(i) = (0..tels.len()).find(|i| {
!used[*i] && {
let t = tels[*i].types();
t.is_empty()
|| t.iter().all(|x| {
x == "voice" || x == "pref" || x == "other" || x.starts_with("pref")
})
}
}) {
used[i] = true;
out.push((fallback, i));
}
}
let left = (0..tels.len()).filter(|i| !used[*i]).collect();
(out, left)
}
const ADR_SLOTS: &[(&str, &str)] = &[("Business", "work"), ("Home", "home"), ("Other", "other")];
fn assign_adrs(adrs: &[&Property]) -> (Vec<(&'static str, usize)>, Vec<usize>) {
let mut used = vec![false; adrs.len()];
let mut out = Vec::new();
for (slot, typ) in ADR_SLOTS {
if let Some(i) =
(0..adrs.len()).find(|i| !used[*i] && adrs[*i].types().iter().any(|t| t == typ))
{
used[i] = true;
out.push((*slot, i));
}
}
// untyped address -> Other / Home
for (slot, _) in [("Other", ""), ("Home", "")] {
if out.iter().any(|(s, _)| *s == slot) {
continue;
}
if let Some(i) = (0..adrs.len()).find(|i| {
!used[*i]
&& adrs[*i]
.types()
.iter()
.all(|t| t == "pref" || t == "postal" || t == "parcel")
}) {
used[i] = true;
out.push((slot, i));
}
}
let left = (0..adrs.len()).filter(|i| !used[*i]).collect();
(out, left)
}
fn bday_to_eas(v: &str, dtz: &Tz) -> Option<String> {
let v = v.trim();
let d = if v.len() >= 10 && v.as_bytes()[4] == b'-' {
NaiveDate::parse_from_str(&v[..10], "%Y-%m-%d").ok()
} else if v.starts_with("--") {
// --MMDD (no year) -> use 1604 like Apple/Outlook
NaiveDate::parse_from_str(&format!("1604{}", &v[2..6.min(v.len())]), "%Y%m%d").ok()
} else {
NaiveDate::parse_from_str(&v[..8.min(v.len())], "%Y%m%d").ok()
}?;
let local = dtz
.from_local_datetime(&d.and_hms_opt(0, 0, 0)?)
.earliest()?;
Some(tz::fmt_iso(&local.with_timezone(&Utc)))
}
fn eas_to_date(s: &str, dtz: &Tz) -> Option<NaiveDate> {
let t = tz::parse_eas_date(s)?;
Some(
(t + chrono::Duration::hours(12))
.with_timezone(dtz)
.date_naive(),
)
}
pub fn to_eas(vcf: &str, opts: &Options, ver: u32, dtz: Tz, max_picture: usize) -> Option<Element> {
let cards = ical::parse(vcf);
let c = cards.iter().find(|c| c.name == "VCARD")?;
let mut app = Element::new("AirSync:ApplicationData");
let get = |n: &str| c.text(n);
if let Some(b) = get("ANNIVERSARY")
.or_else(|| get("X-ANNIVERSARY"))
.and_then(|v| bday_to_eas(&v, &dtz))
{
app.push_text("Contacts:Anniversary", b);
}
app.push_opt(
"Contacts:AssistantName",
get("X-ASSISTANT").or_else(|| get("X-MS-ASSISTANT")),
);
if let Some(b) = get("BDAY").and_then(|v| bday_to_eas(&v, &dtz)) {
app.push_text("Contacts:Birthday", b);
}
let note = get("NOTE").unwrap_or_default();
if let Some(b) = pim_body(&note, opts, ver) {
if !note.is_empty() {
app.push(b);
}
} else if !note.is_empty() {
app.push_text("Contacts:Body", note.clone());
}
// phones
let tels: Vec<&Property> = c.props_named("TEL").collect();
let (slots, _) = assign_tels(&tels);
// addresses
let adrs: Vec<&Property> = c.props_named("ADR").collect();
let (aslots, _) = assign_adrs(&adrs);
for (slot, i) in &aslots {
let parts = ical::split_structured(&adrs[*i].value);
let p = |n: usize| parts.get(n).cloned().unwrap_or_default();
let street = [p(1), p(2)]
.iter()
.filter(|s| !s.is_empty())
.cloned()
.collect::<Vec<_>>()
.join("\n");
for (suffix, val) in [
("City", p(3)),
("Country", p(6)),
("PostalCode", p(5)),
("State", p(4)),
("Street", street),
] {
if !val.is_empty() {
app.push_text(&format!("Contacts:{slot}{suffix}"), val);
}
}
}
for (slot, i) in &slots {
if !slot.starts_with("Contacts2") {
app.push_text(slot, tels[*i].text_value());
}
}
let cats: Vec<String> = c
.props_named("CATEGORIES")
.flat_map(|p| ical::split_list(&p.value))
.collect();
if let Some(e) = categories_element("Contacts", &cats) {
app.push(e);
}
if let Some(org) = c.prop("ORG") {
let parts = ical::split_structured(&org.value);
if let Some(n) = parts.first().filter(|s| !s.is_empty()) {
app.push_text("Contacts:CompanyName", n.clone());
}
if let Some(d) = parts.get(1).filter(|s| !s.is_empty()) {
app.push_text("Contacts:Department", d.clone());
}
}
for (i, e) in c.props_named("EMAIL").take(3).enumerate() {
app.push_text(&format!("Contacts:Email{}Address", i + 1), e.text_value());
}
let n = c
.prop("N")
.map(|n| ical::split_structured(&n.value))
.unwrap_or_default();
let np = |i: usize| n.get(i).cloned().filter(|s| !s.is_empty());
let fileas = get("X-EAS-FILEAS")
.or_else(|| get("FN"))
.or_else(|| match (np(0), np(1)) {
(Some(l), Some(f)) => Some(format!("{l}, {f}")),
(Some(l), None) => Some(l),
(None, Some(f)) => Some(f),
_ => None,
});
app.push_opt("Contacts:FileAs", fileas);
app.push_opt("Contacts:FirstName", np(1));
app.push_opt("Contacts:JobTitle", get("TITLE"));
app.push_opt("Contacts:LastName", np(0));
app.push_opt("Contacts:MiddleName", np(2));
app.push_opt("Contacts:OfficeLocation", get("X-OFFICE-LOCATION"));
app.push_opt("Contacts:Spouse", get("X-SPOUSE"));
app.push_opt("Contacts:Suffix", np(4));
app.push_opt("Contacts:Title", np(3));
app.push_opt("Contacts:WebPage", get("URL"));
app.push_opt("Contacts:YomiCompanyName", get("X-PHONETIC-ORG"));
app.push_opt("Contacts:YomiFirstName", get("X-PHONETIC-FIRST-NAME"));
app.push_opt("Contacts:YomiLastName", get("X-PHONETIC-LAST-NAME"));
if let Some(ph) = c.prop("PHOTO") {
let v = ph.value.trim();
let data = if let Some(idx) = v.find("base64,") {
v[idx + 7..].to_string()
} else if ph
.param("ENCODING")
.map(|e| e.eq_ignore_ascii_case("b") || e.eq_ignore_ascii_case("BASE64"))
.unwrap_or(false)
|| ph.params.iter().any(|(k, _)| k == "BASE64")
{
v.to_string()
} else {
String::new()
};
let data: String = data.chars().filter(|c| !c.is_whitespace()).collect();
if !data.is_empty() && data.len() * 3 / 4 <= max_picture {
app.push_text("Contacts:Picture", data);
}
}
// Contacts2
app.push_opt("Contacts2:CustomerId", get("X-CUSTOMER-ID"));
app.push_opt("Contacts2:GovernmentId", get("X-GOVERNMENT-ID"));
for (i, im) in c.props_named("IMPP").take(3).enumerate() {
let tag = if i == 0 {
"Contacts2:IMAddress".to_string()
} else {
format!("Contacts2:IMAddress{}", i + 1)
};
app.push_text(&tag, im.text_value());
}
app.push_opt("Contacts2:ManagerName", get("X-MANAGER"));
for (slot, i) in &slots {
if slot.starts_with("Contacts2") {
app.push_text(slot, tels[*i].text_value());
}
}
app.push_opt("Contacts2:AccountName", get("X-ACCOUNT-NAME"));
app.push_opt("Contacts2:NickName", get("NICKNAME"));
if ver >= 120 {
app.push_text("AirSyncBase:NativeBodyType", "1");
}
Some(app)
}
const MANAGED: &[&str] = &[
"FN",
"N",
"ORG",
"TITLE",
"BDAY",
"ANNIVERSARY",
"X-ANNIVERSARY",
"URL",
"NOTE",
"CATEGORIES",
"NICKNAME",
"X-SPOUSE",
"X-ASSISTANT",
"X-MS-ASSISTANT",
"X-MANAGER",
"X-OFFICE-LOCATION",
"X-PHONETIC-ORG",
"X-PHONETIC-FIRST-NAME",
"X-PHONETIC-LAST-NAME",
"X-CUSTOMER-ID",
"X-GOVERNMENT-ID",
"X-ACCOUNT-NAME",
"X-EAS-FILEAS",
"REV",
];
pub fn from_eas(app: &Element, existing: Option<&str>, dtz: Tz) -> Option<String> {
let mut card = existing
.and_then(|e| ical::parse(e).into_iter().find(|c| c.name == "VCARD"))
.unwrap_or_else(|| {
let mut c = Component::new("VCARD");
c.add(Property::new("VERSION", "3.0"));
c.add(Property::new("PRODID", "-//ActivePush//ActiveSync//EN"));
c.add(Property::text("UID", &uuid::Uuid::new_v4().to_string()));
c
});
let v4 = card
.text("VERSION")
.map(|v| v.starts_with('4'))
.unwrap_or(false);
let g = |n: &str| app.child_text(n).filter(|s| !s.trim().is_empty());
// keep overflow EMAIL/TEL/ADR/IMPP that EAS cannot represent
let old_emails: Vec<Property> = card.props_named("EMAIL").skip(3).cloned().collect();
let old_tels: Vec<Property> = card.props_named("TEL").cloned().collect();
let tel_refs: Vec<&Property> = old_tels.iter().collect();
let (_, tel_left) = assign_tels(&tel_refs);
let keep_tels: Vec<Property> = tel_left.iter().map(|i| old_tels[*i].clone()).collect();
let old_adrs: Vec<Property> = card.props_named("ADR").cloned().collect();
let adr_refs: Vec<&Property> = old_adrs.iter().collect();
let (_, adr_left) = assign_adrs(&adr_refs);
let keep_adrs: Vec<Property> = adr_left.iter().map(|i| old_adrs[*i].clone()).collect();
let old_impp: Vec<Property> = card.props_named("IMPP").skip(3).cloned().collect();
let old_photo = card.prop("PHOTO").cloned();
card.props.retain(|p| {
!MANAGED.iter().any(|m| p.name.eq_ignore_ascii_case(m))
&& !["EMAIL", "TEL", "ADR", "IMPP", "PHOTO"].contains(&p.name.as_str())
});
let (first, last, middle, title, suffix) = (
g("Contacts:FirstName").unwrap_or_default(),
g("Contacts:LastName").unwrap_or_default(),
g("Contacts:MiddleName").unwrap_or_default(),
g("Contacts:Title").unwrap_or_default(),
g("Contacts:Suffix").unwrap_or_default(),
);
let company = g("Contacts:CompanyName").unwrap_or_default();
let fn_ = [
title.as_str(),
first.as_str(),
middle.as_str(),
last.as_str(),
suffix.as_str(),
]
.iter()
.filter(|s| !s.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(" ");
let fn_ = if fn_.is_empty() {
g("Contacts:FileAs")
.or_else(|| (!company.is_empty()).then(|| company.clone()))
.or_else(|| g("Contacts:Email1Address"))
.unwrap_or_else(|| "?".into())
} else {
fn_
};
card.add(Property::text("FN", &fn_));
card.add(Property::new(
"N",
[&last, &first, &middle, &title, &suffix]
.iter()
.map(|s| ical::escape_text(s))
.collect::<Vec<_>>()
.join(";"),
));
if let Some(fa) = g("Contacts:FileAs") {
if fa != fn_ {
card.add(Property::text("X-EAS-FILEAS", &fa));
}
}
let dept = g("Contacts:Department").unwrap_or_default();
if !company.is_empty() || !dept.is_empty() {
card.add(Property::new(
"ORG",
format!(
"{};{}",
ical::escape_text(&company),
ical::escape_text(&dept)
),
));
}
if let Some(t) = g("Contacts:JobTitle") {
card.add(Property::text("TITLE", &t));
}
for i in 1..=3 {
if let Some(e) = g(&format!("Contacts:Email{i}Address")) {
// devices send `"Name" <addr>`
let addr = e
.rsplit_once('<')
.map(|(_, a)| a.trim_end_matches('>').to_string())
.unwrap_or(e)
.trim()
.to_string();
let typ = match i {
1 => "INTERNET,WORK",
2 => "INTERNET,HOME",
_ => "INTERNET,OTHER",
};
card.add(Property::text("EMAIL", &addr).with_param("TYPE", typ));
}
}
card.props.extend(old_emails);
for (slot, _, _) in PHONE_SLOTS {
if let Some(n) = g(slot) {
card.add(Property::text("TEL", &n).with_param("TYPE", tel_types(slot)));
}
}
card.props.extend(keep_tels);
for (slot, typ) in ADR_SLOTS {
let f = |s: &str| g(&format!("Contacts:{slot}{s}")).unwrap_or_default();
let (street, city, state, zip, country) = (
f("Street"),
f("City"),
f("State"),
f("PostalCode"),
f("Country"),
);
if [&street, &city, &state, &zip, &country]
.iter()
.any(|s| !s.is_empty())
{
let v = ["", "", &street, &city, &state, &zip, &country]
.iter()
.map(|s| ical::escape_text(s))
.collect::<Vec<_>>()
.join(";");
card.add(Property::new("ADR", v).with_param("TYPE", &typ.to_ascii_uppercase()));
}
}
card.props.extend(keep_adrs);
if let Some(b) = g("Contacts:Birthday").and_then(|s| eas_to_date(&s, &dtz)) {
card.add(Property::new(
"BDAY",
b.format(if v4 { "%Y%m%d" } else { "%Y-%m-%d" }).to_string(),
));
}
if let Some(b) = g("Contacts:Anniversary").and_then(|s| eas_to_date(&s, &dtz)) {
let name = if v4 { "ANNIVERSARY" } else { "X-ANNIVERSARY" };
card.add(Property::new(
name,
b.format(if v4 { "%Y%m%d" } else { "%Y-%m-%d" }).to_string(),
));
}
if let Some(u) = g("Contacts:WebPage") {
card.add(Property::new("URL", u));
}
if let Some(n) = incoming_body(app, "Contacts").filter(|s| !s.trim().is_empty()) {
card.add(Property::text("NOTE", &n));
}
if let Some(c) = categories(app, "Contacts").filter(|c| !c.is_empty()) {
card.add(Property::new(
"CATEGORIES",
c.iter()
.map(|x| ical::escape_text(x))
.collect::<Vec<_>>()
.join(","),
));
}
for (tag, prop) in [
("Contacts:AssistantName", "X-ASSISTANT"),
("Contacts:Spouse", "X-SPOUSE"),
("Contacts:OfficeLocation", "X-OFFICE-LOCATION"),
("Contacts:YomiCompanyName", "X-PHONETIC-ORG"),
("Contacts:YomiFirstName", "X-PHONETIC-FIRST-NAME"),
("Contacts:YomiLastName", "X-PHONETIC-LAST-NAME"),
("Contacts2:CustomerId", "X-CUSTOMER-ID"),
("Contacts2:GovernmentId", "X-GOVERNMENT-ID"),
("Contacts2:ManagerName", "X-MANAGER"),
("Contacts2:AccountName", "X-ACCOUNT-NAME"),
("Contacts2:NickName", "NICKNAME"),
] {
if let Some(v) = g(tag) {
card.add(Property::text(prop, &v));
}
}
for t in [
"Contacts2:IMAddress",
"Contacts2:IMAddress2",
"Contacts2:IMAddress3",
] {
if let Some(v) = g(t) {
card.add(Property::text("IMPP", &v));
}
}
card.props.extend(old_impp);
match app.child("Contacts:Picture") {
Some(p) => {
let data = p.text_value();
if !data.is_empty()
&& base64::engine::general_purpose::STANDARD
.decode(data.trim())
.is_ok()
{
if v4 {
card.add(Property::new(
"PHOTO",
format!("data:image/jpeg;base64,{}", data.trim()),
));
} else {
card.add(
Property::new("PHOTO", data.trim().to_string())
.with_param("ENCODING", "b")
.with_param("TYPE", "JPEG"),
);
}
}
}
None => {
if let Some(p) = old_photo {
card.add(p);
}
}
}
card.add(Property::new(
"REV",
Utc::now().format("%Y%m%dT%H%M%SZ").to_string(),
));
// VERSION must come first
if let Some(pos) = card.props.iter().position(|p| p.name == "VERSION") {
let v = card.props.remove(pos);
card.props.insert(0, v);
}
Some(card.serialize())
}
/// Short summary for GAL / ResolveRecipients.
pub struct CardSummary {
pub display_name: String,
pub first: String,
pub last: String,
pub emails: Vec<String>,
pub phone: String,
pub mobile: String,
pub company: String,
pub title: String,
pub office: String,
}
pub fn summary(vcf: &str) -> Option<CardSummary> {
let cards = ical::parse(vcf);
let c = cards.iter().find(|c| c.name == "VCARD")?;
let n = c
.prop("N")
.map(|n| ical::split_structured(&n.value))
.unwrap_or_default();
let tels: Vec<&Property> = c.props_named("TEL").collect();
let (slots, _) = assign_tels(&tels);
let slot = |s: &str| {
slots
.iter()
.find(|(x, _)| *x == s)
.map(|(_, i)| tels[*i].text_value())
.unwrap_or_default()
};
Some(CardSummary {
display_name: c.text("FN").unwrap_or_default(),
first: n.get(1).cloned().unwrap_or_default(),
last: n.first().cloned().unwrap_or_default(),
emails: c.props_named("EMAIL").map(|e| e.text_value()).collect(),
phone: slot("Contacts:BusinessPhoneNumber"),
mobile: slot("Contacts:MobilePhoneNumber"),
company: c
.prop("ORG")
.map(|o| {
ical::split_structured(&o.value)
.first()
.cloned()
.unwrap_or_default()
})
.unwrap_or_default(),
title: c.text("TITLE").unwrap_or_default(),
office: c.text("X-OFFICE-LOCATION").unwrap_or_default(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_lossless() {
let v = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c1\r\nFN:Jan Kowalski\r\nN:Kowalski;Jan;;;\r\nEMAIL;TYPE=WORK:a@x.pl\r\nEMAIL:b@x.pl\r\nEMAIL:c@x.pl\r\nEMAIL:d@x.pl\r\nTEL;TYPE=CELL:+48600\r\nTEL;TYPE=WORK:+4822\r\nTEL;TYPE=X-WEIRD:999\r\nADR;TYPE=HOME:;;Polna 1;Warszawa;;00-001;PL\r\nBDAY:1985-05-12\r\nX-SOCIAL:keep\r\nEND:VCARD\r\n";
let app = to_eas(
v,
&Options::default(),
141,
chrono_tz::Europe::Warsaw,
100000,
)
.unwrap();
assert_eq!(
app.child_text("Contacts:MobilePhoneNumber").unwrap(),
"+48600"
);
assert_eq!(
app.child_text("Contacts:BusinessPhoneNumber").unwrap(),
"+4822"
);
assert_eq!(app.child_text("Contacts:HomeCity").unwrap(), "Warszawa");
assert_eq!(
app.child_text("Contacts:Birthday").unwrap(),
"1985-05-11T22:00:00.000Z"
);
assert_eq!(app.child_text("Contacts:Email3Address").unwrap(), "c@x.pl");
let back = from_eas(&app, Some(v), chrono_tz::Europe::Warsaw).unwrap();
assert!(back.contains("d@x.pl"), "4th email kept");
assert!(back.contains("X-WEIRD"), "unmapped phone kept");
assert!(back.contains("X-SOCIAL:keep"));
assert!(back.contains("BDAY:1985-05-12"));
assert!(back.contains("UID:c1"));
assert!(back.starts_with("BEGIN:VCARD\r\nVERSION:3.0"));
}
}
+580
View File
@@ -0,0 +1,580 @@
//! RFC 5322 message -> EAS Email ApplicationData.
use base64::Engine;
use mail_parser::{MessageParser, MimeHeaders, PartType};
use super::{body_element, legacy_trunc_size, BodyPref, Options, BODY_HTML, BODY_MIME, BODY_TEXT};
use crate::backend::ical;
use crate::backend::imap::FetchedMessage;
use crate::backend::tz;
use crate::util::{html_to_text, text_to_html, truncate_bytes};
use crate::wbxml::Element;
pub struct MailContext<'a> {
pub collection: &'a str,
pub ver: u32,
pub opts: &'a Options,
pub default_tz: chrono_tz::Tz,
}
fn fmt_addr(name: Option<&str>, addr: Option<&str>) -> String {
match (name.map(str::trim).filter(|n| !n.is_empty()), addr) {
(Some(n), Some(a)) => format!("\"{}\" <{}>", n.replace('"', "'"), a),
(None, Some(a)) => a.to_string(),
(Some(n), None) => n.to_string(),
_ => String::new(),
}
}
fn addr_list(a: Option<&mail_parser::Address>) -> Option<String> {
let a = a?;
let v: Vec<String> = a
.iter()
.map(|x| fmt_addr(x.name(), x.address()))
.filter(|s| !s.is_empty())
.collect();
(!v.is_empty()).then(|| v.join(", "))
}
fn display_names(a: Option<&mail_parser::Address>) -> Option<String> {
let a = a?;
let v: Vec<String> = a
.iter()
.map(|x| x.name().or(x.address()).unwrap_or("").to_string())
.filter(|s| !s.is_empty())
.collect();
(!v.is_empty()).then(|| v.join("; "))
}
pub fn file_reference(collection: &str, uid: u32, idx: usize) -> String {
format!("{collection}:{uid}:{idx}")
}
pub fn parse_file_reference(r: &str) -> Option<(String, u32, usize)> {
let mut it = r.rsplitn(3, ':');
let idx = it.next()?.parse().ok()?;
let uid = it.next()?.parse().ok()?;
let col = it.next()?.to_string();
Some((col, uid, idx))
}
/// Outlook compatible GlobalObjId wrapping an iCalendar UID.
pub fn global_obj_id(uid: &str) -> String {
let mut v =
hex::decode("040000008200E00074C5B7101A82E0080000000000000000000000000000000000000000")
.unwrap();
if uid.len() <= 64 {
v.extend_from_slice(&((13 + uid.len()) as u32).to_le_bytes());
v.extend_from_slice(b"vCal-Uid");
v.extend_from_slice(&1u32.to_le_bytes());
v.extend_from_slice(uid.as_bytes());
v.push(0);
} else if let Ok(b) = hex::decode(uid) {
v = b;
}
base64::engine::general_purpose::STANDARD.encode(v)
}
pub fn uid_from_global_obj_id(b64: &str) -> Option<String> {
let b = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.ok()?;
let pos = b
.windows(8)
.position(|w| w.eq_ignore_ascii_case(b"vCal-Uid"))?;
if pos < 4 {
return None;
}
let len = u32::from_le_bytes([b[pos - 4], b[pos - 3], b[pos - 2], b[pos - 1]]) as usize;
let start = pos + 12;
let end = (start + len.saturating_sub(13)).min(b.len());
Some(String::from_utf8_lossy(&b[start..end]).into_owned())
}
fn conversation_id(subject: &str) -> Vec<u8> {
let s = subject.trim();
let mut t = s;
loop {
let l = t.to_ascii_lowercase();
let stripped = [
"re:", "fw:", "fwd:", "aw:", "odp:", "pd:", "wg:", "sv:", "tr:",
]
.iter()
.find(|p| l.starts_with(*p))
.map(|p| t[p.len()..].trim_start());
match stripped {
Some(n) => t = n,
None => break,
}
}
use md5::{Digest, Md5};
Md5::digest(t.to_lowercase().as_bytes()).to_vec()
}
pub struct Converted {
pub app: Element,
pub size: usize,
}
/// Convert a fetched message to ApplicationData.
pub fn to_eas(m: &FetchedMessage, ctx: &MailContext) -> Converted {
let parser = MessageParser::default();
let msg = parser.parse(&m.body);
let mut app = Element::new("AirSync:ApplicationData");
let ver = ctx.ver;
let Some(msg) = msg else {
app.push_text("Email:Subject", "(unparseable message)");
app.push_text("Email:Read", "0");
app.push_text("Email:MessageClass", "IPM.Note");
return Converted {
app,
size: m.body.len(),
};
};
let subject = msg.subject().unwrap_or("").to_string();
app.push_opt("Email:To", addr_list(msg.to()));
app.push_opt("Email:Cc", addr_list(msg.cc()));
app.push_opt("Email:From", addr_list(msg.from()));
app.push_text("Email:Subject", subject.clone());
app.push_opt("Email:ReplyTo", addr_list(msg.reply_to()));
let date = m
.internal_date
.or_else(|| {
msg.date()
.and_then(|d| chrono::DateTime::from_timestamp(d.to_timestamp(), 0))
})
.unwrap_or_else(chrono::Utc::now);
app.push_text("Email:DateReceived", tz::fmt_iso(&date));
app.push_opt("Email:DisplayTo", display_names(msg.to()));
app.push_text("Email:ThreadTopic", subject.clone());
let importance = {
let xp = msg
.header_raw("X-Priority")
.unwrap_or("")
.trim()
.to_string();
let imp = msg
.header_raw("Importance")
.unwrap_or("")
.trim()
.to_ascii_lowercase();
if xp.starts_with('1') || xp.starts_with('2') || imp == "high" {
2
} else if xp.starts_with('4') || xp.starts_with('5') || imp == "low" {
0
} else {
1
}
};
app.push_text("Email:Importance", importance.to_string());
let read = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Seen"));
app.push_text("Email:Read", if read { "1" } else { "0" });
// ---- attachments
let mut atts = Vec::new();
for (idx, part) in msg.attachments().enumerate() {
let name = part
.attachment_name()
.map(|s| s.to_string())
.unwrap_or_else(|| {
let ct = part.content_type();
let ext = match ct.and_then(|c| c.subtype()).unwrap_or("bin") {
"calendar" => "ics",
"rfc822" => "eml",
s => s,
};
format!("attachment{}.{}", idx + 1, ext)
});
let size = part.contents().len();
let cid = part
.content_id()
.map(|c| c.trim_matches(['<', '>']).to_string());
let inline = part
.content_disposition()
.map(|d| d.ctype().eq_ignore_ascii_case("inline"))
.unwrap_or(false)
&& cid.is_some();
atts.push((idx, name, size, cid, inline, part.is_message()));
}
if !atts.is_empty() {
if ver >= 120 {
let mut a = Element::new("AirSyncBase:Attachments");
for (idx, name, size, cid, inline, is_msg) in &atts {
let mut e = Element::new("AirSyncBase:Attachment")
.with_text("AirSyncBase:DisplayName", name.clone())
.with_text(
"AirSyncBase:FileReference",
file_reference(ctx.collection, m.uid, *idx),
)
.with_text("AirSyncBase:Method", if *is_msg { "5" } else { "1" })
.with_text("AirSyncBase:EstimatedDataSize", size.to_string());
if let Some(c) = cid {
e.push_text("AirSyncBase:ContentId", c.clone());
}
if *inline {
e.push_text("AirSyncBase:IsInline", "1");
}
a.push(e);
}
app.push(a);
} else {
let mut a = Element::new("Email:Attachments");
for (idx, name, size, _, _, _) in &atts {
a.push(
Element::new("Email:Attachment")
.with_text("Email:AttMethod", "1")
.with_text("Email:AttSize", size.to_string())
.with_text("Email:DisplayName", name.clone())
.with_text("Email:AttName", file_reference(ctx.collection, m.uid, *idx)),
);
}
app.push(a);
}
}
// ---- body
let html = msg
.body_html(0)
.map(|s| s.into_owned())
.filter(|_| msg.html_body_count() > 0 && has_real_html(&msg));
let text = msg.body_text(0).map(|s| s.into_owned());
let plain = text
.clone()
.unwrap_or_else(|| html.as_deref().map(html_to_text).unwrap_or_default());
let ctype = msg
.content_type()
.map(|c| format!("{}/{}", c.ctype(), c.subtype().unwrap_or("")).to_ascii_lowercase())
.unwrap_or_default();
let is_smime = ctype.contains("signed") || ctype.contains("pkcs7");
let native = if html.is_some() { BODY_HTML } else { BODY_TEXT };
let opts = ctx.opts;
if ver >= 120 {
let want_mime = opts.wants(BODY_MIME).is_some()
&& (opts.mime_support == 2 || (opts.mime_support == 1 && is_smime));
let (typ, data): (u8, String) = if want_mime {
(BODY_MIME, String::from_utf8_lossy(&m.body).into_owned())
} else if opts.wants(BODY_HTML).is_some()
&& (html.is_some() || opts.wants(BODY_TEXT).is_none())
{
(
BODY_HTML,
html.clone().unwrap_or_else(|| text_to_html(&plain)),
)
} else {
(BODY_TEXT, plain.replace("\r\n", "\n").replace('\n', "\r\n"))
};
let pref = opts.wants(typ).cloned().unwrap_or(BodyPref {
typ,
..Default::default()
});
app.push(body_element(typ, &data, &pref, Some(&plain)));
} else {
// AS 2.5
if opts.mime_support == 2 || (opts.mime_support == 1 && is_smime) {
let raw = String::from_utf8_lossy(&m.body).into_owned();
let limit = opts.mime_trunc.and_then(legacy_trunc_size);
let t = limit.map(|l| l < raw.len()).unwrap_or(false);
app.push_text("Email:MIMETruncated", if t { "1" } else { "0" });
app.push_text("Email:MIMESize", raw.len().to_string());
app.push_text(
"Email:MIMEData",
truncate_bytes(&raw, limit.unwrap_or(usize::MAX)),
);
} else {
let limit = opts.legacy_trunc.and_then(legacy_trunc_size);
let t = limit.map(|l| l < plain.len()).unwrap_or(false);
app.push_text("Email:BodyTruncated", if t { "1" } else { "0" });
app.push_text(
"Email:Body",
truncate_bytes(&plain, limit.unwrap_or(usize::MAX)),
);
}
}
// ---- class / meeting
let mut class = if is_smime {
if ctype.contains("signed") {
"IPM.Note.SMIME.MultipartSigned"
} else {
"IPM.Note.SMIME"
}
.to_string()
} else {
"IPM.Note".to_string()
};
let mut meeting: Option<Element> = None;
if let Some(ics) = find_calendar_part(&msg) {
if let Some((cls, mr)) = meeting_request(&ics, ctx.default_tz) {
class = cls;
meeting = mr;
}
}
app.push_text("Email:MessageClass", class);
if let Some(mr) = meeting {
app.push(mr);
}
app.push_text("Email:InternetCPID", "65001");
if ver >= 120 {
let flagged = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Flagged"));
if flagged {
app.push(
Element::new("Email:Flag")
.with_text("Email:FlagStatus", "2")
.with_text("Email:FlagType", "Flag for follow up"),
);
} else {
app.push(Element::new("Email:Flag"));
}
app.push_text("Email:ContentClass", "urn:content-classes:message");
app.push_text("AirSyncBase:NativeBodyType", native.to_string());
}
if ver >= 140 {
app.push(Element::opaque(
"Email2:ConversationId",
conversation_id(&subject),
));
let mut idx = Vec::with_capacity(5);
idx.push(0x01);
idx.extend_from_slice(&(date.timestamp() as u32).to_be_bytes());
app.push(Element::opaque("Email2:ConversationIndex", idx));
let answered = m.flags.iter().any(|f| f.eq_ignore_ascii_case("\\Answered"));
let forwarded = m
.flags
.iter()
.any(|f| f.eq_ignore_ascii_case("$Forwarded") || f.eq_ignore_ascii_case("Forwarded"));
if answered || forwarded {
app.push_text("Email2:LastVerbExecuted", if forwarded { "3" } else { "1" });
app.push_text("Email2:LastVerbExecutionTime", tz::fmt_iso(&date));
}
}
Converted {
app,
size: m.body.len(),
}
}
/// Changes-only payload (flags) for Sync Change commands.
pub fn flags_change(flags: &[String], ver: u32) -> Element {
let mut app = Element::new("AirSync:ApplicationData");
let read = flags.iter().any(|f| f.eq_ignore_ascii_case("\\Seen"));
app.push_text("Email:Read", if read { "1" } else { "0" });
if ver >= 120 {
if flags.iter().any(|f| f.eq_ignore_ascii_case("\\Flagged")) {
app.push(
Element::new("Email:Flag")
.with_text("Email:FlagStatus", "2")
.with_text("Email:FlagType", "Flag for follow up"),
);
} else {
app.push(Element::new("Email:Flag"));
}
}
app
}
pub fn flags_from_fingerprint(fp: &str) -> Vec<String> {
let mut v = Vec::new();
for c in fp.chars() {
match c {
'S' => v.push("\\Seen".to_string()),
'F' => v.push("\\Flagged".to_string()),
'A' => v.push("\\Answered".to_string()),
'W' => v.push("$Forwarded".to_string()),
_ => {}
}
}
v
}
fn has_real_html(msg: &mail_parser::Message) -> bool {
msg.html_bodies()
.any(|p| matches!(p.body, PartType::Html(_)))
}
fn find_calendar_part(msg: &mail_parser::Message) -> Option<String> {
for part in msg.parts.iter() {
let ct = part.content_type();
let is_cal = ct
.map(|c| {
c.ctype().eq_ignore_ascii_case("text")
&& c.subtype()
.map(|s| s.eq_ignore_ascii_case("calendar"))
.unwrap_or(false)
})
.unwrap_or(false);
if is_cal {
if let Some(t) = part.text_contents() {
return Some(t.to_string());
}
return Some(String::from_utf8_lossy(part.contents()).into_owned());
}
}
None
}
/// Build Email:MeetingRequest from an iMIP calendar part.
fn meeting_request(ics: &str, default_tz: chrono_tz::Tz) -> Option<(String, Option<Element>)> {
let cals = ical::parse(ics);
let cal = cals.first()?;
let method = cal.text("METHOD").unwrap_or_default().to_ascii_uppercase();
let ev = cal.child("VEVENT")?;
let class = match method.as_str() {
"REQUEST" => "IPM.Schedule.Meeting.Request",
"CANCEL" => "IPM.Schedule.Meeting.Canceled",
"REPLY" => {
let st = ev
.prop("ATTENDEE")
.and_then(|a| a.param("PARTSTAT"))
.unwrap_or("")
.to_ascii_uppercase();
match st.as_str() {
"ACCEPTED" => "IPM.Schedule.Meeting.Resp.Pos",
"TENTATIVE" => "IPM.Schedule.Meeting.Resp.Tent",
"DECLINED" => "IPM.Schedule.Meeting.Resp.Neg",
_ => "IPM.Note",
}
}
_ => return None,
};
if !(method == "REQUEST" || method == "CANCEL") {
return Some((class.to_string(), None));
}
let start = ev
.prop("DTSTART")
.and_then(|p| tz::parse_ical_time(p, default_tz))?;
let end = ev
.prop("DTEND")
.and_then(|p| tz::parse_ical_time(p, default_tz))
.map(|t| t.utc)
.unwrap_or(start.utc + chrono::Duration::hours(1));
let mut mr = Element::new("Email:MeetingRequest");
mr.push_text("Email:AllDayEvent", if start.all_day { "1" } else { "0" });
mr.push_text("Email:StartTime", tz::fmt_iso(&start.utc));
let stamp = ev
.prop("DTSTAMP")
.and_then(|p| tz::parse_ical_time(p, default_tz))
.map(|t| t.utc)
.unwrap_or_else(chrono::Utc::now);
mr.push_text("Email:DtStamp", tz::fmt_iso(&stamp));
mr.push_text("Email:EndTime", tz::fmt_iso(&end));
mr.push_text(
"Email:InstanceType",
if ev.prop("RRULE").is_some() { "1" } else { "0" },
);
mr.push_opt("Email:Location", ev.text("LOCATION"));
if let Some(o) = ev.prop("ORGANIZER") {
let addr = o
.value
.trim_start_matches("mailto:")
.trim_start_matches("MAILTO:")
.to_string();
mr.push_text("Email:Organizer", fmt_addr(o.param("CN"), Some(&addr)));
}
if ev.child("VALARM").is_some() {
mr.push_text("Email:Reminder", "15");
}
mr.push_text("Email:ResponseRequested", "1");
let sens = match ev
.text("CLASS")
.unwrap_or_default()
.to_ascii_uppercase()
.as_str()
{
"PRIVATE" => "2",
"CONFIDENTIAL" => "3",
_ => "0",
};
mr.push_text("Email:Sensitivity", sens);
mr.push_text("Email:BusyStatus", "2");
let tzone = start.tz.unwrap_or(default_tz);
mr.push_text(
"Email:TimeZone",
tz::blob_for(&tzone, chrono::Datelike::year(&start.utc)),
);
if let Some(uid) = ev.text("UID") {
mr.push_text("Email:GlobalObjId", global_obj_id(&uid));
}
mr.push_text("Email2:MeetingMessageType", "1");
Some((class.to_string(), Some(mr)))
}
/// Raw bytes of one attachment by index (as numbered in `to_eas`).
pub fn attachment(raw: &[u8], idx: usize) -> Option<(String, String, Vec<u8>)> {
let msg = MessageParser::default().parse(raw)?;
let part = msg.attachments().nth(idx)?;
let ct = part
.content_type()
.map(|c| format!("{}/{}", c.ctype(), c.subtype().unwrap_or("octet-stream")))
.unwrap_or_else(|| "application/octet-stream".into());
let name = part.attachment_name().unwrap_or("attachment").to_string();
let data = if part.is_message() {
// keep the embedded message raw
raw.get(part.raw_body_offset() as usize..part.raw_end_offset() as usize)
.map(|s| s.to_vec())
.unwrap_or_else(|| part.contents().to_vec())
} else {
part.contents().to_vec()
};
Some((name, ct, data))
}
/// Calendar part of a message (for MeetingResponse).
pub fn calendar_of(raw: &[u8]) -> Option<String> {
let msg = MessageParser::default().parse(raw)?;
find_calendar_part(&msg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn goid_roundtrip() {
let g = global_obj_id("abc-123@example.com");
assert_eq!(uid_from_global_obj_id(&g).unwrap(), "abc-123@example.com");
}
#[test]
fn convert_simple() {
let raw = "From: Jan <jan@x.pl>\r\nTo: Anna <anna@x.pl>\r\nSubject: =?utf-8?q?Za=C5=BC=C3=B3=C5=82=C4=87?=\r\nDate: Thu, 05 Nov 2026 10:00:00 +0100\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nTreść wiadomości\r\n".as_bytes().to_vec();
let m = FetchedMessage {
uid: 7,
flags: vec!["\\Seen".into()],
internal_date: None,
body: raw,
size: 0,
};
let opts = Options {
prefs: vec![BodyPref {
typ: 1,
trunc: Some(5),
..Default::default()
}],
..Default::default()
};
let c = to_eas(
&m,
&MailContext {
collection: "3",
ver: 141,
opts: &opts,
default_tz: chrono_tz::UTC,
},
);
assert_eq!(c.app.child_text("Email:Subject").unwrap(), "Zażółć");
assert_eq!(c.app.child_text("Email:Read").unwrap(), "1");
let body = c.app.child("AirSyncBase:Body").unwrap();
assert_eq!(body.child_text("AirSyncBase:Truncated").unwrap(), "1");
assert_eq!(body.child_text("AirSyncBase:Data").unwrap(), "Treś");
assert!(c.app.child_text("Email:From").unwrap().contains("jan@x.pl"));
}
#[test]
fn fileref() {
assert_eq!(
parse_file_reference("12:345:2"),
Some(("12".into(), 345, 2))
);
}
}
+169
View File
@@ -0,0 +1,169 @@
//! Conversions between backend formats (RFC 5322, iCalendar, vCard) and EAS ApplicationData.
pub mod calendar;
pub mod contact;
pub mod email;
pub mod task;
use crate::util::{html_to_text, text_to_html, truncate_bytes};
use crate::wbxml::Element;
pub const BODY_TEXT: u8 = 1;
pub const BODY_HTML: u8 = 2;
pub const BODY_RTF: u8 = 3;
pub const BODY_MIME: u8 = 4;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct BodyPref {
pub typ: u8,
pub trunc: Option<u32>,
pub all_or_none: bool,
pub preview: Option<u32>,
}
/// Options of a collection (or ItemOperations/Search fetch).
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct Options {
pub prefs: Vec<BodyPref>,
pub mime_support: u8,
/// AS 2.5 Truncation code (0..9)
pub legacy_trunc: Option<u8>,
pub mime_trunc: Option<u8>,
pub filter_type: u8,
pub conflict: Option<u8>,
pub max_items: Option<u32>,
}
impl Options {
pub fn parse(opts: &Element) -> Options {
let mut o = Options::default();
for bp in opts.children_named("AirSyncBase:BodyPreference") {
o.prefs.push(BodyPref {
typ: bp.child_u32("AirSyncBase:Type").unwrap_or(1) as u8,
trunc: bp.child_u32("AirSyncBase:TruncationSize"),
all_or_none: bp.child_u32("AirSyncBase:AllOrNone").unwrap_or(0) == 1,
preview: bp.child_u32("AirSyncBase:Preview"),
});
}
o.mime_support = opts.child_u32("AirSync:MIMESupport").unwrap_or(0) as u8;
o.legacy_trunc = opts.child_u32("AirSync:Truncation").map(|v| v as u8);
o.mime_trunc = opts.child_u32("AirSync:MIMETruncation").map(|v| v as u8);
o.filter_type = opts.child_u32("AirSync:FilterType").unwrap_or(0) as u8;
o.conflict = opts.child_u32("AirSync:Conflict").map(|v| v as u8);
o.max_items = opts.child_u32("AirSync:MaxItems");
o
}
pub fn wants(&self, t: u8) -> Option<&BodyPref> {
self.prefs.iter().find(|p| p.typ == t)
}
}
/// AS 2.5 truncation codes to byte sizes.
pub fn legacy_trunc_size(code: u8) -> Option<usize> {
match code {
0 => Some(0),
1 => Some(4096),
2 => Some(5120),
3 => Some(7168),
4 => Some(10240),
5 => Some(20480),
6 => Some(51200),
7 => Some(102400),
_ => None,
}
}
/// Build an AirSyncBase:Body element for PIM items (calendar/contacts/tasks), which store
/// plain text natively.
pub fn pim_body(text: &str, opts: &Options, ver: u32) -> Option<Element> {
if ver < 120 {
return None;
}
let want_html = opts.wants(BODY_HTML).is_some() && opts.wants(BODY_TEXT).is_none();
let pref = opts
.wants(if want_html { BODY_HTML } else { BODY_TEXT })
.cloned()
.unwrap_or(BodyPref {
typ: BODY_TEXT,
..Default::default()
});
let data = if pref.typ == BODY_HTML {
text_to_html(text)
} else {
text.replace("\r\n", "\n").replace('\n', "\r\n")
};
Some(body_element(pref.typ, &data, &pref, Some(text)))
}
/// Create AirSyncBase:Body with truncation / preview handling.
pub fn body_element(
typ: u8,
data: &str,
pref: &BodyPref,
plain_for_preview: Option<&str>,
) -> Element {
let full = data.len();
let mut body = Element::new("AirSyncBase:Body").with_text("AirSyncBase:Type", typ.to_string());
let truncated = pref.trunc.map(|t| (t as usize) < full).unwrap_or(false);
body.push_text("AirSyncBase:EstimatedDataSize", full.to_string());
if truncated {
body.push_text("AirSyncBase:Truncated", "1");
if !pref.all_or_none {
let t = pref.trunc.unwrap_or(0) as usize;
body.push_text("AirSyncBase:Data", truncate_bytes(data, t));
}
} else if full > 0 {
body.push_text("AirSyncBase:Data", data);
}
if let Some(p) = pref.preview.filter(|p| *p > 0) {
let src = plain_for_preview.map(|s| s.to_string()).unwrap_or_else(|| {
if typ == BODY_HTML {
html_to_text(data)
} else {
data.to_string()
}
});
let flat: String = src.split_whitespace().collect::<Vec<_>>().join(" ");
body.push_text(
"AirSyncBase:Preview",
truncate_bytes(&flat, p.min(255) as usize),
);
}
body
}
/// Extract plain text from a client supplied body (AirSyncBase:Body or legacy fields).
pub fn incoming_body(app: &Element, legacy_ns: &str) -> Option<String> {
if let Some(b) = app.child("AirSyncBase:Body") {
let typ = b.child_u32("AirSyncBase:Type").unwrap_or(1);
let data = b.child_text("AirSyncBase:Data")?;
return Some(if typ == 2_u32 {
html_to_text(&data)
} else {
data.replace("\r\n", "\n")
});
}
app.child_text(&format!("{legacy_ns}:Body"))
.map(|s| s.replace("\r\n", "\n"))
}
pub fn categories(app: &Element, ns: &str) -> Option<Vec<String>> {
app.child(&format!("{ns}:Categories")).map(|c| {
c.children_named(&format!("{ns}:Category"))
.map(|x| x.text_value())
.filter(|s| !s.is_empty())
.collect()
})
}
pub fn categories_element(ns: &str, cats: &[String]) -> Option<Element> {
if cats.is_empty() {
return None;
}
let mut c = Element::new(&format!("{ns}:Categories"));
for x in cats {
c.push_text(&format!("{ns}:Category"), x.clone());
}
Some(c)
}
+299
View File
@@ -0,0 +1,299 @@
//! iCalendar VTODO <-> EAS Tasks.
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use chrono_tz::Tz;
use super::calendar::{eas_to_rrule, masters_and_overrides, rrule_to_eas};
use super::{categories, categories_element, incoming_body, pim_body, Options};
use crate::backend::ical::{self, Component, Property};
use crate::backend::tz;
use crate::wbxml::Element;
/// "Local" EAS task dates are local wall-clock times written with a Z suffix.
fn local_as_utc(d: &DateTime<Utc>, tzone: &Tz) -> String {
tz::fmt_iso(&Utc.from_utc_datetime(&d.with_timezone(tzone).naive_local()))
}
fn utc_from_local_field(s: &str, tzone: &Tz) -> Option<DateTime<Utc>> {
let fake = tz::parse_eas_date(s)?;
let n: NaiveDateTime = fake.naive_utc();
tzone
.from_local_datetime(&n)
.earliest()
.map(|x| x.with_timezone(&Utc))
}
pub fn to_eas(ics: &str, opts: &Options, ver: u32, dtz: Tz) -> Option<Element> {
let cals = ical::parse(ics);
let cal = cals.iter().find(|c| c.name == "VCALENDAR")?;
let (master, _) = masters_and_overrides(cal, "VTODO");
let t = master?;
let mut app = Element::new("AirSync:ApplicationData");
let desc = t.text("DESCRIPTION").unwrap_or_default();
if let Some(b) = pim_body(&desc, opts, ver) {
app.push(b);
} else if ver < 120 && !desc.is_empty() {
app.push_text("Tasks:Body", desc.clone());
}
let cats: Vec<String> = t
.props_named("CATEGORIES")
.flat_map(|p| ical::split_list(&p.value))
.collect();
if let Some(c) = categories_element("Tasks", &cats) {
app.push(c);
}
let completed = t
.text("STATUS")
.map(|s| s.eq_ignore_ascii_case("COMPLETED"))
.unwrap_or(false)
|| t.prop("COMPLETED").is_some();
app.push_text("Tasks:Complete", if completed { "1" } else { "0" });
if completed {
let c = t
.prop("COMPLETED")
.and_then(|p| tz::parse_ical_time(p, dtz))
.map(|x| x.utc)
.unwrap_or_else(Utc::now);
app.push_text("Tasks:DateCompleted", tz::fmt_iso(&c));
}
let due = t.prop("DUE").and_then(|p| tz::parse_ical_time(p, dtz));
if let Some(d) = &due {
app.push_text("Tasks:DueDate", local_as_utc(&d.utc, &d.tz.unwrap_or(dtz)));
app.push_text("Tasks:UtcDueDate", tz::fmt_iso(&d.utc));
}
let prio: u32 = t.text("PRIORITY").and_then(|p| p.parse().ok()).unwrap_or(0);
let imp = match prio {
1..=4 => 2,
6..=9 => 0,
_ => 1,
};
app.push_text("Tasks:Importance", imp.to_string());
let start = t.prop("DTSTART").and_then(|p| tz::parse_ical_time(p, dtz));
if let (Some(rr), Some(s)) = (t.prop("RRULE"), &start) {
if let Some(mut rec) =
rrule_to_eas(&rr.value, &s.utc, &s.tz.unwrap_or(dtz), "Tasks", s.all_day)
{
rec.push_text("Tasks:Start", local_as_utc(&s.utc, &s.tz.unwrap_or(dtz)));
app.push(rec);
}
}
// reminder
let mut reminder: Option<DateTime<Utc>> = None;
if let Some(a) = t.child("VALARM") {
if let Some(tr) = a.prop("TRIGGER") {
if tr
.param("VALUE")
.map(|v| v.eq_ignore_ascii_case("DATE-TIME"))
.unwrap_or(false)
|| tr.value.ends_with('Z')
{
reminder = tz::parse_eas_date(&tr.value);
} else if let Some(m) = super::calendar::reminder_minutes(&t) {
let base = if tr
.param("RELATED")
.map(|r| r.eq_ignore_ascii_case("END"))
.unwrap_or(false)
{
due.as_ref().map(|d| d.utc)
} else {
start
.as_ref()
.map(|s| s.utc)
.or(due.as_ref().map(|d| d.utc))
};
reminder = base.map(|b| b - chrono::Duration::minutes(m));
}
}
}
app.push_text(
"Tasks:ReminderSet",
if reminder.is_some() { "1" } else { "0" },
);
if let Some(r) = reminder {
app.push_text("Tasks:ReminderTime", tz::fmt_iso(&r));
}
let sens = match t
.text("CLASS")
.unwrap_or_default()
.to_ascii_uppercase()
.as_str()
{
"PRIVATE" => "2",
"CONFIDENTIAL" => "3",
_ => "0",
};
app.push_text("Tasks:Sensitivity", sens);
if let Some(s) = &start {
app.push_text(
"Tasks:StartDate",
local_as_utc(&s.utc, &s.tz.unwrap_or(dtz)),
);
app.push_text("Tasks:UtcStartDate", tz::fmt_iso(&s.utc));
}
app.push_text("Tasks:Subject", t.text("SUMMARY").unwrap_or_default());
Some(app)
}
const MANAGED: &[&str] = &[
"SUMMARY",
"DESCRIPTION",
"STATUS",
"COMPLETED",
"PERCENT-COMPLETE",
"DUE",
"DTSTART",
"PRIORITY",
"CLASS",
"CATEGORIES",
"RRULE",
"DTSTAMP",
"LAST-MODIFIED",
"UID",
];
pub fn from_eas(app: &Element, existing: Option<&str>, dtz: Tz) -> Option<String> {
let mut cal = existing
.and_then(|e| ical::parse(e).into_iter().find(|c| c.name == "VCALENDAR"))
.unwrap_or_else(|| {
let mut c = Component::new("VCALENDAR");
c.add(Property::new("VERSION", "2.0"));
c.add(Property::new("PRODID", "-//ActivePush//ActiveSync//EN"));
c
});
let (old, _) = masters_and_overrides(&cal, "VTODO");
let mut t = old.clone().unwrap_or_else(|| Component::new("VTODO"));
let uid = old
.as_ref()
.and_then(|o| o.text("UID"))
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
t.props
.retain(|p| !MANAGED.iter().any(|m| p.name.eq_ignore_ascii_case(m)));
t.props.insert(0, Property::text("UID", &uid));
t.add(tz::ical_utc("DTSTAMP", &Utc::now()));
t.add(tz::ical_utc("LAST-MODIFIED", &Utc::now()));
if let Some(s) = app.child_text("Tasks:Subject") {
t.add(Property::text("SUMMARY", &s));
}
if let Some(b) = incoming_body(app, "Tasks").filter(|b| !b.trim().is_empty()) {
t.add(Property::text("DESCRIPTION", &b));
} else if !app.has("AirSyncBase:Body") {
if let Some(d) = old.as_ref().and_then(|o| o.prop("DESCRIPTION")) {
t.add(d.clone());
}
}
let complete = app.child_u32("Tasks:Complete").unwrap_or(0) == 1;
if complete {
t.add(Property::new("STATUS", "COMPLETED"));
t.add(Property::new("PERCENT-COMPLETE", "100"));
let c = app
.child_text("Tasks:DateCompleted")
.and_then(|s| tz::parse_eas_date(&s))
.unwrap_or_else(Utc::now);
t.add(tz::ical_utc("COMPLETED", &c));
} else {
t.add(Property::new("STATUS", "NEEDS-ACTION"));
}
let due = app
.child_text("Tasks:UtcDueDate")
.and_then(|s| tz::parse_eas_date(&s))
.or_else(|| {
app.child_text("Tasks:DueDate")
.and_then(|s| utc_from_local_field(&s, &dtz))
});
if let Some(d) = due {
t.add(tz::ical_utc("DUE", &d));
}
let start = app
.child_text("Tasks:UtcStartDate")
.and_then(|s| tz::parse_eas_date(&s))
.or_else(|| {
app.child_text("Tasks:StartDate")
.and_then(|s| utc_from_local_field(&s, &dtz))
});
if let Some(s) = start {
t.add(tz::ical_utc("DTSTART", &s));
}
let prio = match app.child_u32("Tasks:Importance").unwrap_or(1) {
2 => "1",
0 => "9",
_ => "5",
};
t.add(Property::new("PRIORITY", prio));
if let Some(s) = app.child_text("Tasks:Sensitivity") {
t.add(Property::new(
"CLASS",
match s.as_str() {
"2" => "PRIVATE",
"3" => "CONFIDENTIAL",
_ => "PUBLIC",
},
));
}
if let Some(cats) = categories(app, "Tasks").filter(|c| !c.is_empty()) {
t.add(Property::new(
"CATEGORIES",
cats.iter()
.map(|c| ical::escape_text(c))
.collect::<Vec<_>>()
.join(","),
));
}
if let Some(rec) = app.child("Tasks:Recurrence") {
if let Some(rr) = eas_to_rrule(rec, "Tasks", false, &dtz) {
t.add(Property::new("RRULE", rr));
if start.is_none() {
if let Some(s) = rec
.child_text("Tasks:Start")
.and_then(|s| utc_from_local_field(&s, &dtz))
{
t.add(tz::ical_utc("DTSTART", &s));
}
}
}
}
t.children.retain(|c| c.name != "VALARM");
if app.child_u32("Tasks:ReminderSet").unwrap_or(0) == 1 {
if let Some(r) = app
.child_text("Tasks:ReminderTime")
.and_then(|s| tz::parse_eas_date(&s))
{
let mut a = Component::new("VALARM");
a.add(Property::new("ACTION", "DISPLAY"));
a.add(Property::text(
"DESCRIPTION",
&t.text("SUMMARY").unwrap_or_else(|| "Reminder".into()),
));
a.add(tz::ical_utc("TRIGGER", &r).with_param("VALUE", "DATE-TIME"));
t.children.push(a);
}
}
cal.children.retain(|c| c.name != "VTODO");
cal.children.push(t);
Some(cal.serialize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t1\r\nSUMMARY:Kup mleko\r\nDUE:20261106T120000Z\r\nPRIORITY:1\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
let app = to_eas(ics, &Options::default(), 141, chrono_tz::Europe::Warsaw).unwrap();
assert_eq!(
app.child_text("Tasks:UtcDueDate").unwrap(),
"2026-11-06T12:00:00.000Z"
);
assert_eq!(
app.child_text("Tasks:DueDate").unwrap(),
"2026-11-06T13:00:00.000Z"
);
assert_eq!(app.child_text("Tasks:Importance").unwrap(), "2");
let mut app2 = app.clone();
app2.children.retain(|c| !c.is("Tasks:Complete"));
app2.push_text("Tasks:Complete", "1");
let back = from_eas(&app2, Some(ics), chrono_tz::Europe::Warsaw).unwrap();
assert!(back.contains("STATUS:COMPLETED"));
assert!(back.contains("UID:t1"));
}
}
+340
View File
@@ -0,0 +1,340 @@
//! FolderSync, FolderCreate, FolderUpdate, FolderDelete, GetHierarchy.
use std::collections::BTreeMap;
use anyhow::Result;
use sqlx::Row;
use super::state::{self, HierarchyState, SyncKey, HIERARCHY};
use super::{Ctx, Reply};
use crate::backend::BFolder;
use crate::db;
use crate::wbxml::Element;
pub async fn short_id(ctx: &Ctx, backend_id: &str) -> Result<String> {
Ok(db::folder_short_id(&ctx.app.db, ctx.user(), backend_id)
.await?
.to_string())
}
/// Resolve a device-visible folder id to the backend folder.
pub async fn resolve(ctx: &mut Ctx, short: &str) -> Result<Option<BFolder>> {
let Some(bid) = db::folder_backend_id(&ctx.app.db, ctx.user(), short).await? else {
return Ok(None);
};
ctx.sess.folder(&bid).await
}
pub async fn current_hierarchy(ctx: &mut Ctx) -> Result<(HierarchyState, Vec<(String, BFolder)>)> {
let folders = ctx.sess.folders().await?;
let mut st = HierarchyState::default();
let mut list = Vec::new();
let mut ids: BTreeMap<String, String> = BTreeMap::new();
for f in &folders {
let sid = short_id(ctx, &f.id).await?;
ids.insert(f.id.clone(), sid);
}
for f in folders {
let sid = ids[&f.id].clone();
let parent = f
.parent
.as_ref()
.and_then(|p| ids.get(p).cloned())
.unwrap_or_else(|| "0".to_string());
st.folders
.insert(sid.clone(), (parent, f.name.clone(), f.ftype));
list.push((sid, f));
}
Ok((st, list))
}
fn folder_el(tag: &str, sid: &str, parent: &str, name: &str, ftype: u8) -> Element {
Element::new(tag)
.with_text("FolderHierarchy:ServerId", sid)
.with_text("FolderHierarchy:ParentId", parent)
.with_text("FolderHierarchy:DisplayName", name)
.with_text("FolderHierarchy:Type", ftype.to_string())
}
fn fs_status(root: &str, status: u32) -> Reply {
Reply::Wbxml(Element::new(root).with_text("FolderHierarchy:Status", status.to_string()))
}
pub async fn folder_sync(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
let Some(req) = req else {
return Ok(fs_status("FolderHierarchy:FolderSync", 10));
};
let key_s = req
.child_text("FolderHierarchy:SyncKey")
.unwrap_or_default();
let Some(key) = SyncKey::parse(&key_s) else {
ctx.status = "invalid synckey".into();
return Ok(fs_status("FolderHierarchy:FolderSync", 9));
};
let old: HierarchyState = match &key {
SyncKey::Initial => {
// full resync: forget every collection state of this device
db::device_reset_states(&ctx.app.db, ctx.dev.id).await?;
HierarchyState::default()
}
k => match state::load(ctx, HIERARCHY, k).await? {
Some(s) => s,
None => {
ctx.status = "unknown synckey".into();
return Ok(fs_status("FolderHierarchy:FolderSync", 9));
}
},
};
let (cur, _) = current_hierarchy(ctx).await?;
let mut adds = Vec::new();
let mut updates = Vec::new();
let mut deletes = Vec::new();
for (sid, v) in &cur.folders {
match old.folders.get(sid) {
None => adds.push((sid.clone(), v.clone())),
Some(o) if o != v => updates.push((sid.clone(), v.clone())),
_ => {}
}
}
for sid in old.folders.keys() {
if !cur.folders.contains_key(sid) {
deletes.push(sid.clone());
}
}
// collections the device still references but which never existed here (Z-Push leftovers)
let stale: Vec<String> = ctx
.app
.stale_take(ctx.dev.id)
.into_iter()
.filter(|s| !cur.folders.contains_key(s) && !deletes.contains(s))
.collect();
if !stale.is_empty() {
tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, folders = %stale.join(","), "removing stale collections from device");
deletes.extend(stale);
}
// parents before children
adds.sort_by_key(|(sid, (p, _, _))| (depth(&cur, sid), p.clone()));
let changed = !(adds.is_empty() && updates.is_empty() && deletes.is_empty());
let new_key = match (&key, changed) {
(SyncKey::Key { .. }, false) => key_s.clone(),
(SyncKey::Key { uuid, counter }, true) => {
state::save(ctx, HIERARCHY, uuid, counter + 1, &cur).await?;
state::make_key(uuid, counter + 1)
}
(SyncKey::Initial, _) => {
let u = state::new_uuid();
state::save(ctx, HIERARCHY, &u, 1, &cur).await?;
state::make_key(&u, 1)
}
};
for sid in &deletes {
db::state_delete_collection(&ctx.app.db, ctx.dev.id, sid).await?;
}
let mut changes = Element::new("FolderHierarchy:Changes").with_text(
"FolderHierarchy:Count",
(adds.len() + updates.len() + deletes.len()).to_string(),
);
for (sid, (p, n, t)) in &updates {
changes.push(folder_el("FolderHierarchy:Update", sid, p, n, *t));
}
for sid in &deletes {
changes.push(
Element::new("FolderHierarchy:Delete")
.with_text("FolderHierarchy:ServerId", sid.clone()),
);
}
for (sid, (p, n, t)) in &adds {
changes.push(folder_el("FolderHierarchy:Add", sid, p, n, *t));
}
ctx.items_out = (adds.len() + updates.len() + deletes.len()) as u64;
Ok(Reply::Wbxml(
Element::new("FolderHierarchy:FolderSync")
.with_text("FolderHierarchy:Status", "1")
.with_text("FolderHierarchy:SyncKey", new_key)
.with(changes),
))
}
fn depth(st: &HierarchyState, sid: &str) -> usize {
let mut d = 0;
let mut cur = sid.to_string();
while let Some((p, _, _)) = st.folders.get(&cur) {
if p == "0" || d > 32 {
break;
}
d += 1;
cur = p.clone();
}
d
}
pub async fn folder_change(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
let cmd = ctx.req.cmd.clone();
let root = format!("FolderHierarchy:{cmd}");
let Some(req) = req else {
return Ok(fs_status(&root, 10));
};
let key_s = req
.child_text("FolderHierarchy:SyncKey")
.unwrap_or_default();
let Some(key @ SyncKey::Key { .. }) = SyncKey::parse(&key_s) else {
return Ok(fs_status(&root, 9));
};
let SyncKey::Key { uuid, counter } = &key else {
unreachable!()
};
let Some(mut st): Option<HierarchyState> = state::load(ctx, HIERARCHY, &key).await? else {
return Ok(fs_status(&root, 9));
};
let name = req
.child_text("FolderHierarchy:DisplayName")
.unwrap_or_default();
let parent_sid = req
.child_text("FolderHierarchy:ParentId")
.unwrap_or_else(|| "0".into());
let parent = if parent_sid == "0" {
None
} else {
resolve(ctx, &parent_sid).await?
};
if parent_sid != "0" && parent.is_none() {
return Ok(fs_status(&root, 5));
}
let res: Result<Element> = async {
match cmd.as_str() {
"FolderCreate" => {
if name.trim().is_empty() || name.len() > 256 {
return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "10"));
}
if st
.folders
.values()
.any(|(p, n, _)| p == &parent_sid && n.eq_ignore_ascii_case(&name))
{
return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "2"));
}
let ftype: u8 = req.child_u32("FolderHierarchy:Type").unwrap_or(12) as u8;
let f = ctx
.sess
.create_folder(parent.as_ref(), name.trim(), ftype)
.await?;
let sid = short_id(ctx, &f.id).await?;
st.folders
.insert(sid.clone(), (parent_sid.clone(), f.name.clone(), f.ftype));
state::save(ctx, HIERARCHY, uuid, counter + 1, &st).await?;
db::audit(&ctx.app.db, ctx.user(), "folder_create", &f.id, &ctx.ip, "").await;
Ok(Element::new(&root)
.with_text("FolderHierarchy:Status", "1")
.with_text(
"FolderHierarchy:SyncKey",
state::make_key(uuid, counter + 1),
)
.with_text("FolderHierarchy:ServerId", sid))
}
"FolderUpdate" | "FolderDelete" => {
let sid = req
.child_text("FolderHierarchy:ServerId")
.unwrap_or_default();
let Some(folder) = resolve(ctx, &sid).await? else {
return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "4"));
};
if cmd == "FolderDelete" {
if folder.ftype != crate::backend::FT_USER_MAIL
&& !matches!(folder.ftype, 13..=15)
{
return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "3"));
}
ctx.sess.delete_folder(&folder).await?;
st.folders.remove(&sid);
db::state_delete_collection(&ctx.app.db, ctx.dev.id, &sid).await?;
db::audit(
&ctx.app.db,
ctx.user(),
"folder_delete",
&folder.id,
&ctx.ip,
"",
)
.await;
} else {
if folder.ftype != crate::backend::FT_USER_MAIL
&& !matches!(folder.ftype, 13..=15)
{
return Ok(Element::new(&root).with_text("FolderHierarchy:Status", "3"));
}
let nf = ctx
.sess
.rename_folder(&folder, parent.as_ref(), name.trim())
.await?;
if nf.id != folder.id {
rename_mapping(ctx, &folder.id, &nf.id).await?;
}
st.folders
.insert(sid.clone(), (parent_sid.clone(), nf.name.clone(), nf.ftype));
db::audit(
&ctx.app.db,
ctx.user(),
"folder_update",
&nf.id,
&ctx.ip,
"",
)
.await;
}
state::save(ctx, HIERARCHY, uuid, counter + 1, &st).await?;
Ok(Element::new(&root)
.with_text("FolderHierarchy:Status", "1")
.with_text(
"FolderHierarchy:SyncKey",
state::make_key(uuid, counter + 1),
))
}
_ => Ok(Element::new(&root).with_text("FolderHierarchy:Status", "10")),
}
}
.await;
match res {
Ok(e) => Ok(Reply::Wbxml(e)),
Err(e) => {
tracing::warn!(user = %ctx.user(), "{cmd} failed: {e:#}");
ctx.status = format!("error: {e}");
let m = e.to_string().to_ascii_lowercase();
let code = if m.contains("alreadyexists") || m.contains("already exists") {
2
} else {
6
};
Ok(fs_status(&root, code))
}
}
}
/// After an IMAP rename, move the short-id mapping of the folder and all its children.
async fn rename_mapping(ctx: &Ctx, old: &str, new: &str) -> Result<()> {
let rows = sqlx::query("SELECT backend_id FROM folder_map WHERE username = ?")
.bind(ctx.user())
.fetch_all(&ctx.app.db)
.await?;
for r in rows {
let bid: String = r.get(0);
if bid == old {
db::folder_rename_backend(&ctx.app.db, ctx.user(), &bid, new).await?;
} else if let Some(rest) = bid.strip_prefix(old) {
if rest.starts_with(['/', '.']) {
db::folder_rename_backend(&ctx.app.db, ctx.user(), &bid, &format!("{new}{rest}"))
.await?;
}
}
}
Ok(())
}
/// Legacy AS 1.0/2.0 GetHierarchy.
pub async fn get_hierarchy(ctx: &mut Ctx) -> Result<Reply> {
let (st, _) = current_hierarchy(ctx).await?;
let mut root = Element::new("FolderHierarchy:Folders");
for (sid, (p, n, t)) in st.folders {
root.push(folder_el("FolderHierarchy:Folder", &sid, &p, &n, t));
}
Ok(Reply::Wbxml(root))
}
+279
View File
@@ -0,0 +1,279 @@
//! ItemOperations and GetAttachment.
use anyhow::{anyhow, Result};
use base64::Engine;
use super::convert::{email, Options};
use super::folders::resolve;
use super::sync::{convert_raw, parse_server_id};
use super::{Ctx, Reply};
use crate::backend::Kind;
use crate::wbxml::{self, Element};
/// Load one attachment by FileReference. Returns (name, content type, bytes).
async fn load_attachment(ctx: &mut Ctx, fref: &str) -> Result<(String, String, Vec<u8>)> {
let (col, uid, idx) =
email::parse_file_reference(fref).ok_or_else(|| anyhow!("invalid file reference"))?;
let folder = resolve(ctx, &col)
.await?
.ok_or_else(|| anyhow!("not found"))?;
if folder.kind() != Kind::Mail {
return Err(anyhow!("not found"));
}
let m = ctx
.sess
.fetch_mail(folder.path(), uid)
.await
.map_err(|_| anyhow!("not found"))?;
email::attachment(&m.body, idx).ok_or_else(|| anyhow!("not found"))
}
fn attachments_allowed(ctx: &Ctx) -> bool {
!ctx.s.provisioning.enabled || ctx.s.policy_for(ctx.user()).attenabled
}
fn max_att(ctx: &Ctx) -> usize {
let mut m = (ctx.s.sync.max_attachment_mb * 1024 * 1024) as usize;
if ctx.s.provisioning.enabled {
let p = ctx.s.policy_for(ctx.user()).maxattsize;
if p > 0 {
m = m.min(p as usize);
}
}
m
}
pub async fn get_attachment(ctx: &mut Ctx) -> Result<Reply> {
let name = ctx.req.attachment_name.clone().unwrap_or_default();
if !attachments_allowed(ctx) {
return Ok(Reply::Status(axum::http::StatusCode::FORBIDDEN));
}
match load_attachment(ctx, &name).await {
Ok((_, _, data)) if data.len() > max_att(ctx) => {
Ok(Reply::Status(axum::http::StatusCode::PAYLOAD_TOO_LARGE))
}
Ok((_, _ct, data)) => {
ctx.items_out += 1;
Ok(Reply::Raw("application/octet-stream", data))
}
Err(e) => {
ctx.status = format!("attachment: {e}");
Ok(Reply::Status(axum::http::StatusCode::NOT_FOUND))
}
}
}
fn parse_range(r: &str, total: usize) -> Option<(usize, usize)> {
let (a, b) = r.split_once('-')?;
let a: usize = a.trim().parse().ok()?;
let b: usize = b.trim().parse().ok()?;
if a > b || a >= total.max(1) {
return None;
}
Some((a, b.min(total.saturating_sub(1))))
}
pub async fn item_operations(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
let Some(req) = req else {
return Ok(Reply::Wbxml(
Element::new("ItemOperations:ItemOperations").with_text("ItemOperations:Status", "2"),
));
};
let multipart = ctx.req.accept_multipart;
let mut parts: Vec<Vec<u8>> = Vec::new();
let mut response = Element::new("ItemOperations:Response");
for op in &req.children {
match op.tag() {
"Fetch" => {
let mut f = Element::new("ItemOperations:Fetch");
let opts = op
.child("ItemOperations:Options")
.map(Options::parse)
.unwrap_or_default();
let range = op
.child("ItemOperations:Options")
.and_then(|o| o.child_text("ItemOperations:Range"));
if let Some(fref) = op.child_text("AirSyncBase:FileReference") {
if !attachments_allowed(ctx) {
f.push_text("ItemOperations:Status", "16");
f.push_text("AirSyncBase:FileReference", fref);
response.push(f);
continue;
}
match load_attachment(ctx, &fref).await {
Ok((_, ct, data)) if data.len() > max_att(ctx) => {
let _ = ct;
f.push_text("ItemOperations:Status", "11");
f.push_text("AirSyncBase:FileReference", fref);
}
Ok((_, ct, data)) => {
let total = data.len();
let (slice, rng) =
match range.as_deref().and_then(|r| parse_range(r, total)) {
Some((a, b)) => (
&data[a..=b.min(total.saturating_sub(1))],
Some(format!("{a}-{b}")),
),
None if range.is_some() && total > 0 => {
f.push_text("ItemOperations:Status", "8");
f.push_text("AirSyncBase:FileReference", fref);
response.push(f);
continue;
}
None => (&data[..], None),
};
f.push_text("ItemOperations:Status", "1");
f.push_text("AirSyncBase:FileReference", fref);
let mut props = Element::new("ItemOperations:Properties");
props.push_text("AirSyncBase:ContentType", ct);
if let Some(r) = rng {
props.push_text("ItemOperations:Range", r);
props.push_text("ItemOperations:Total", total.to_string());
}
if multipart {
parts.push(slice.to_vec());
props.push_text("ItemOperations:Part", parts.len().to_string());
} else {
props.push_text(
"ItemOperations:Data",
base64::engine::general_purpose::STANDARD.encode(slice),
);
}
f.push(props);
ctx.items_out += 1;
}
Err(e) => {
tracing::info!(user = %ctx.user(), "attachment fetch failed: {e:#}");
f.push_text(
"ItemOperations:Status",
if e.to_string().contains("invalid") {
"15"
} else {
"6"
},
);
f.push_text("AirSyncBase:FileReference", fref);
}
}
response.push(f);
continue;
}
// item fetch (by CollectionId + ServerId, or LongId from Search)
let (col, sid) = match (
op.child_text("AirSync:CollectionId"),
op.child_text("AirSync:ServerId"),
op.child_text("Search:LongId"),
) {
(Some(c), Some(s), _) => (c, s),
(_, _, Some(l)) => (super::sync::collection_of(&l).to_string(), l),
_ => {
f.push_text("ItemOperations:Status", "2");
response.push(f);
continue;
}
};
let res: Result<Element> = async {
let folder = resolve(ctx, &col)
.await?
.ok_or_else(|| anyhow!("not found"))?;
let id = parse_server_id(ctx, &sid, &folder.id)
.await?
.ok_or_else(|| anyhow!("not found"))?;
let raw = ctx
.sess
.fetch(&folder, std::slice::from_ref(&id))
.await?
.into_iter()
.next()
.ok_or_else(|| anyhow!("not found"))?;
let app = convert_raw(ctx, &col, folder.kind(), &raw, &opts)
.ok_or_else(|| anyhow!("conversion failed"))?;
Ok(app)
}
.await;
match res {
Ok(app) => {
f.push_text("ItemOperations:Status", "1");
if op.has("Search:LongId") {
f.push_text("Search:LongId", sid.clone());
} else {
f.push_text("AirSync:CollectionId", col.clone());
f.push_text("AirSync:ServerId", sid.clone());
}
let mut props = Element::new("ItemOperations:Properties");
props.children = app.children;
f.push(props);
ctx.items_out += 1;
}
Err(e) => {
f.push_text(
"ItemOperations:Status",
if e.to_string().contains("not found") {
"6"
} else {
"3"
},
);
f.push_text("AirSync:CollectionId", col);
f.push_text("AirSync:ServerId", sid);
}
}
response.push(f);
}
"EmptyFolderContents" => {
let col = op.child_text("AirSync:CollectionId").unwrap_or_default();
let mut e = Element::new("ItemOperations:EmptyFolderContents");
let res: Result<()> = async {
let folder = resolve(ctx, &col)
.await?
.ok_or_else(|| anyhow!("not found"))?;
match folder.kind() {
Kind::Mail => ctx.sess.mail_empty(&folder).await,
_ => {
let l = ctx.sess.list(&folder, None).await?;
for (id, _) in l.items {
ctx.sess.dav_delete(&folder, &id).await?;
}
Ok(())
}
}
}
.await;
e.push_text("ItemOperations:Status", if res.is_ok() { "1" } else { "3" });
e.push_text("AirSync:CollectionId", col);
response.push(e);
}
"Move" => {
let mut m = Element::new("ItemOperations:Move");
m.push_text("ItemOperations:Status", "2");
if let Some(c) = op.child("ItemOperations:ConversationId") {
m.push(c.clone());
}
response.push(m);
}
_ => {}
}
}
let root = Element::new("ItemOperations:ItemOperations")
.with_text("ItemOperations:Status", "1")
.with(response);
if multipart {
let wb = wbxml::encode(&root);
let mut all = vec![wb];
all.extend(parts);
let n = all.len();
let mut body = Vec::new();
body.extend_from_slice(&(n as i32).to_le_bytes());
let mut offset = 4 + n * 8;
for p in &all {
body.extend_from_slice(&(offset as i32).to_le_bytes());
body.extend_from_slice(&(p.len() as i32).to_le_bytes());
offset += p.len();
}
for p in all {
body.extend_from_slice(&p);
}
return Ok(Reply::Raw("application/vnd.ms-sync.multipart", body));
}
Ok(Reply::Wbxml(root))
}
+481
View File
@@ -0,0 +1,481 @@
//! SendMail, SmartReply, SmartForward, MeetingResponse.
use anyhow::{anyhow, Result};
use axum::http::StatusCode;
use mail_parser::{MessageParser, MimeHeaders};
use super::convert::{calendar, email};
use super::folders::resolve;
use super::sync::{parse_server_id, server_id};
use super::{Ctx, Reply};
use crate::backend::{mime, smtp, Kind, FT_CALENDAR, FT_SENT};
use crate::db;
use crate::util::{html_escape, html_to_text};
use crate::wbxml::Element;
fn fail(ctx: &mut Ctx, status: u32, why: &str) -> Reply {
ctx.status = format!("error: {why}");
tracing::warn!(user = %ctx.user(), cmd = %ctx.req.cmd, "send failed: {why}");
if ctx.req.ver() >= 140 {
Reply::Wbxml(
Element::new(&format!("ComposeMail:{}", ctx.req.cmd))
.with_text("ComposeMail:Status", status.to_string()),
)
} else {
Reply::Status(StatusCode::INTERNAL_SERVER_ERROR)
}
}
fn addrs(m: &mail_parser::Message, out: &mut Vec<String>) {
for a in [m.to(), m.cc(), m.bcc()].into_iter().flatten() {
for x in a.iter() {
if let Some(ad) = x.address() {
let ad = ad.trim().to_string();
if ad.contains('@') && !out.iter().any(|o| o.eq_ignore_ascii_case(&ad)) {
out.push(ad);
}
}
}
}
}
pub async fn send_mail(ctx: &mut Ctx, req: Option<Element>, body: &[u8]) -> Result<Reply> {
let cmd = ctx.req.cmd.clone();
// ---- collect parameters (WBXML for AS 14+, query + raw MIME below)
let (mime_raw, save_sent, src_folder, src_item, src_long, replace_mime) = match &req {
Some(r) => {
let mime = r
.child("ComposeMail:MIME")
.map(|m| m.bytes_value())
.unwrap_or_default();
let src = r.child("ComposeMail:Source");
(
mime,
r.has("ComposeMail:SaveInSentItems"),
src.and_then(|s| s.child_text("ComposeMail:FolderId")),
src.and_then(|s| s.child_text("ComposeMail:ItemId")),
src.and_then(|s| s.child_text("ComposeMail:LongId")),
r.has("ComposeMail:ReplaceMime"),
)
}
None => (
body.to_vec(),
ctx.req.save_in_sent,
ctx.req.collection_id.clone(),
ctx.req.item_id.clone(),
ctx.req.long_id.clone(),
false,
),
};
if mime_raw.is_empty() {
return Ok(fail(ctx, 107, "empty MIME"));
}
let Some(msg) = MessageParser::default().parse(&mime_raw) else {
return Ok(fail(ctx, 107, "invalid MIME"));
};
let mut rcpts = Vec::new();
addrs(&msg, &mut rcpts);
if rcpts.is_empty() {
return Ok(fail(ctx, 107, "no recipients"));
}
// ---- original message for smart reply / forward
let mut original: Option<(crate::backend::BFolder, u32, Vec<u8>)> = None;
if cmd != "SendMail" {
let (fid, iid) = if let Some(l) = src_long.clone() {
let mut p = l.splitn(2, ':');
(p.next().map(|s| s.to_string()), Some(l))
} else {
(src_folder.clone(), src_item.clone())
};
if let (Some(fid), Some(iid)) = (fid, iid) {
let iid = if iid.contains(':') {
iid
} else {
format!("{fid}:{iid}")
};
if let Some(folder) = resolve(ctx, &fid).await? {
if let Some(uid) = parse_server_id(ctx, &iid, &folder.id)
.await?
.and_then(|u| u.parse::<u32>().ok())
{
if let Ok(m) = ctx.sess.fetch_mail(folder.path(), uid).await {
original = Some((folder, uid, m.body));
}
}
}
}
if original.is_none() {
tracing::info!(user = %ctx.user(), "smart reply/forward source not found, sending as is");
}
}
// ---- compose
let from_addr = ctx.sess.email.clone();
let mut out: Vec<u8> = match (&original, replace_mime) {
(Some((_, _, orig)), false) => {
compose_smart(ctx, &msg, &mime_raw, orig, cmd == "SmartForward")?
}
_ => mime_raw.clone(),
};
// sender handling
let from_hdr = msg
.from()
.and_then(|f| f.first())
.and_then(|a| a.address())
.map(|s| s.to_string());
match from_hdr {
None => {
out = mime::prepend_headers(&out, &[("From".into(), from_addr.clone())]);
}
Some(f) if ctx.s.smtp.enforce_from && !f.eq_ignore_ascii_case(&from_addr) => {
let name = msg
.from()
.and_then(|f| f.first())
.and_then(|a| a.name())
.map(|n| n.to_string());
out = mime::strip_headers(&out, &["From"]);
let v = match name {
Some(n) => format!("{} <{}>", mime::encode_word(&n), from_addr),
None => from_addr.clone(),
};
out = mime::prepend_headers(&out, &[("From".into(), v)]);
tracing::info!(user = %ctx.user(), "From header {f} replaced with {from_addr}");
}
_ => {}
}
if msg.date().is_none() {
out = mime::prepend_headers(&out, &[("Date".into(), chrono::Utc::now().to_rfc2822())]);
}
if msg.message_id().is_none() {
let dom = from_addr.split('@').nth(1).unwrap_or("activepush.local");
out = mime::prepend_headers(
&out,
&[(
"Message-ID".into(),
format!("<{}@{}>", uuid::Uuid::new_v4(), dom),
)],
);
}
let wire = mime::strip_headers(&out, &["Bcc"]);
let password = ctx.sess.password().to_string();
if let Err(e) = smtp::send(
&ctx.s.smtp,
&ctx.sess.login,
&password,
&from_addr,
&rcpts,
&wire,
)
.await
{
return Ok(fail(ctx, 110, &format!("{e:#}")));
}
ctx.app.metrics.mail_sent();
ctx.items_in += 1;
tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, recipients = rcpts.len(), bytes = wire.len(), "{cmd} delivered to SMTP");
if save_sent && ctx.s.smtp.save_in_sent {
match ctx.sess.special_folder(FT_SENT).await {
Ok(Some(sent)) => {
if let Err(e) = ctx.sess.mail_append(sent.path(), "\\Seen", &out).await {
tracing::warn!(user = %ctx.user(), "saving to Sent failed: {e:#}");
}
}
_ => tracing::warn!(user = %ctx.user(), "no Sent folder found; copy not saved"),
}
}
if let Some((folder, uid, _)) = &original {
let flag = if cmd == "SmartForward" {
"$Forwarded"
} else {
"\\Answered"
};
let _ = ctx.sess.mail_add_flag(folder.path(), *uid, flag).await;
}
Ok(Reply::Empty)
}
fn compose_smart(
ctx: &Ctx,
msg: &mail_parser::Message,
raw: &[u8],
orig_raw: &[u8],
forward: bool,
) -> Result<Vec<u8>> {
let orig = MessageParser::default()
.parse(orig_raw)
.ok_or_else(|| anyhow!("original message unparseable"))?;
// headers from the client message (minus MIME structure headers)
let (hblock, _) = mime::split_headers(raw);
let mut headers: Vec<(String, String)> = mime::header_lines(hblock)
.into_iter()
.filter(|(k, _)| {
![
"content-type",
"content-transfer-encoding",
"mime-version",
"content-disposition",
]
.contains(&k.to_ascii_lowercase().as_str())
})
.collect();
if !forward {
if let Some(mid) = orig.message_id() {
if !headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("In-Reply-To"))
{
headers.push(("In-Reply-To".into(), format!("<{mid}>")));
}
if !headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("References"))
{
let mut refs: Vec<String> = orig
.references()
.as_text_list()
.map(|l| l.iter().map(|r| format!("<{r}>")).collect())
.or_else(|| orig.references().as_text().map(|r| vec![format!("<{r}>")]))
.unwrap_or_default();
refs.push(format!("<{mid}>"));
headers.push(("References".into(), refs.join(" ")));
}
}
}
let new_text = msg.body_text(0).map(|s| s.into_owned()).unwrap_or_default();
let new_html = if msg.html_body_count() > 0
&& msg
.html_bodies()
.any(|p| matches!(p.body, mail_parser::PartType::Html(_)))
{
msg.body_html(0).map(|s| s.into_owned())
} else {
None
};
let o_text = orig
.body_text(0)
.map(|s| s.into_owned())
.unwrap_or_else(|| {
orig.body_html(0)
.map(|h| html_to_text(&h))
.unwrap_or_default()
});
let o_html = if orig.html_body_count() > 0
&& orig
.html_bodies()
.any(|p| matches!(p.body, mail_parser::PartType::Html(_)))
{
orig.body_html(0).map(|s| s.into_owned())
} else {
None
};
let addr = |a: Option<&mail_parser::Address>| {
a.map(|a| {
a.iter()
.map(|x| match (x.name(), x.address()) {
(Some(n), Some(ad)) => format!("{n} <{ad}>"),
(None, Some(ad)) => ad.to_string(),
_ => String::new(),
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default()
};
let date = orig.date().map(|d| d.to_rfc822()).unwrap_or_default();
let subj = orig.subject().unwrap_or("").to_string();
let from = addr(orig.from());
let to = addr(orig.to());
let (text, html, mut atts) = if forward && !ctx.s.imap.inline_forward {
(
new_text.clone(),
new_html.clone(),
vec![mime::Attachment {
name: format!("{}.eml", if subj.is_empty() { "message" } else { &subj }),
content_type: "message/rfc822".into(),
data: orig_raw.to_vec(),
content_id: None,
inline: false,
}],
)
} else if forward {
let head = format!("-------- Original Message --------\nSubject: {subj}\nDate: {date}\nFrom: {from}\nTo: {to}\n\n");
let text = format!("{new_text}\n\n{head}{o_text}");
let html = new_html.clone().map(|nh| {
let oh = o_html.clone().unwrap_or_else(|| format!("<pre>{}</pre>", html_escape(&o_text)));
format!(
"{nh}<br><div>-------- Original Message --------<br>Subject: {}<br>Date: {}<br>From: {}<br>To: {}<br><br>{oh}</div>",
html_escape(&subj),
html_escape(&date),
html_escape(&from),
html_escape(&to)
)
});
let mut a = Vec::new();
for (i, part) in orig.attachments().enumerate() {
if let Some((name, ct, data)) = email::attachment(orig_raw, i) {
a.push(mime::Attachment {
name,
content_type: ct,
data,
content_id: part.content_id().map(|s| s.to_string()),
inline: false,
});
}
}
(text, html, a)
} else {
let quoted: String = o_text.lines().map(|l| format!("> {l}\n")).collect();
let text = format!("{new_text}\n\nOn {date}, {from} wrote:\n{quoted}");
let html = new_html.clone().map(|nh| {
let oh = o_html.clone().unwrap_or_else(|| format!("<pre>{}</pre>", html_escape(&o_text)));
format!(
"{nh}<br><div>On {}, {} wrote:</div><blockquote style=\"margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex\">{oh}</blockquote>",
html_escape(&date),
html_escape(&from)
)
});
(text, html, Vec::new())
};
// attachments the client added itself
for (i, part) in msg.attachments().enumerate() {
if let Some((name, ct, data)) = email::attachment(raw, i) {
atts.push(mime::Attachment {
name,
content_type: ct,
data,
content_id: part.content_id().map(|s| s.to_string()),
inline: false,
});
}
}
Ok(mime::build(&headers, Some(&text), html.as_deref(), &atts))
}
// ------------------------------------------------------------------ MeetingResponse
pub async fn meeting_response(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
let mut root = Element::new("MeetingResponse:MeetingResponse");
let Some(req) = req else {
return Ok(Reply::Wbxml(root));
};
for r in req.children_named("MeetingResponse:Request") {
let resp_code = r.child_u32("MeetingResponse:UserResponse").unwrap_or(0);
let col = r
.child_text("MeetingResponse:CollectionId")
.unwrap_or_default();
let rid = r
.child_text("MeetingResponse:RequestId")
.or_else(|| r.child_text("Search:LongId"))
.unwrap_or_default();
let mut res = Element::new("MeetingResponse:Result")
.with_text("MeetingResponse:RequestId", rid.clone());
let outcome: Result<Option<String>> = async {
let col = if col.is_empty() {
super::sync::collection_of(&rid).to_string()
} else {
col.clone()
};
let folder = resolve(ctx, &col)
.await?
.ok_or_else(|| anyhow!("collection not found"))?;
let sid = if rid.contains(':') {
rid.clone()
} else {
format!("{col}:{rid}")
};
match folder.kind() {
Kind::Mail => {
let uid: u32 = parse_server_id(ctx, &sid, &folder.id)
.await?
.and_then(|u| u.parse().ok())
.ok_or_else(|| anyhow!("item not found"))?;
let m = ctx.sess.fetch_mail(folder.path(), uid).await?;
let ics =
email::calendar_of(&m.body).ok_or_else(|| anyhow!("no calendar part"))?;
let partstat = match resp_code {
1 => "ACCEPTED",
2 => "TENTATIVE",
_ => "DECLINED",
};
let mut cal_id = None;
if resp_code != 3 && ctx.s.caldav.enabled {
if let Some(cal) = ctx.sess.special_folder(FT_CALENDAR).await? {
let updated = calendar::set_partstat(&ics, &ctx.sess.email, partstat)
.ok_or_else(|| anyhow!("bad calendar data"))?;
let (id, _) = ctx.sess.dav_put(&cal, None, updated, None).await?;
let cal_sid = folder_short(ctx, &cal.id).await?;
cal_id = Some(server_id(ctx, &cal_sid, &cal.id, &id).await?);
}
}
// the device sends the reply e-mail itself; remove the invitation like Z-Push
let _ = ctx.sess.mail_delete(&folder, uid, true).await;
Ok(cal_id)
}
Kind::Calendar => {
let id = parse_server_id(ctx, &sid, &folder.id)
.await?
.ok_or_else(|| anyhow!("item not found"))?;
let cur = ctx.sess.fetch_dav(&folder, &id).await?;
let partstat = match resp_code {
1 => "ACCEPTED",
2 => "TENTATIVE",
_ => "DECLINED",
};
let updated = calendar::set_partstat(
cur.data.as_deref().unwrap_or(""),
&ctx.sess.email,
partstat,
)
.ok_or_else(|| anyhow!("bad calendar data"))?;
ctx.sess
.dav_put(&folder, Some(&id), updated, Some(&cur.etag))
.await?;
Ok(Some(sid))
}
_ => Err(anyhow!("invalid collection")),
}
}
.await;
match outcome {
Ok(cal) => {
res.push_text("MeetingResponse:Status", "1");
if let Some(c) = cal {
if resp_code != 3 {
res.push_text("MeetingResponse:CalendarId", c);
}
}
db::audit(
&ctx.app.db,
ctx.user(),
"meeting_response",
&rid,
&ctx.ip,
&resp_code.to_string(),
)
.await;
}
Err(e) => {
tracing::warn!(user = %ctx.user(), "MeetingResponse failed: {e:#}");
res.push_text(
"MeetingResponse:Status",
if e.to_string().contains("not found") {
"2"
} else {
"4"
},
);
}
}
root.push(res);
}
Ok(Reply::Wbxml(root))
}
async fn folder_short(ctx: &Ctx, bid: &str) -> Result<String> {
Ok(db::folder_short_id(&ctx.app.db, ctx.user(), bid)
.await?
.to_string())
}
+445
View File
@@ -0,0 +1,445 @@
//! Settings, Search (GAL + Mailbox), ResolveRecipients.
use std::time::{Duration, Instant};
use anyhow::Result;
use super::convert::{contact, Options};
use super::folders::resolve;
use super::provision::device_info_from;
use super::sync::server_id;
use super::{Ctx, Reply};
use crate::backend::{dav, imap, Kind};
use crate::config::expand_user_template;
use crate::db::{self, DeviceField};
use crate::wbxml::Element;
// ------------------------------------------------------------------ Settings
pub async fn settings(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
let mut root = Element::new("Settings:Settings").with_text("Settings:Status", "1");
let Some(req) = req else {
return Ok(Reply::Wbxml(root));
};
for item in &req.children {
match item.tag() {
"Oof" => {
let mut o = Element::new("Settings:Oof");
if item.has("Settings:Get") {
o.push_text("Settings:Status", "1");
o.push(Element::new("Settings:Get").with_text("Settings:OofState", "0"));
} else {
// out-of-office is managed by the mail server (e.g. Sieve vacation)
o.push_text("Settings:Status", "2");
}
root.push(o);
}
"DeviceInformation" => {
if let Some(set) = item.child("Settings:Set") {
db::device_set_info(&ctx.app.db, ctx.dev.id, &device_info_from(set)).await?;
}
root.push(
Element::new("Settings:DeviceInformation").with_text("Settings:Status", "1"),
);
}
"DevicePassword" => {
if let Some(pw) = item.path(&["Settings:Set", "Settings:Password"]) {
let v = pw.text_value();
if v.len() <= 256 {
db::device_set_field(
&ctx.app.db,
ctx.dev.id,
DeviceField::RecoveryPassword,
None,
Some(&v),
)
.await?;
db::audit(
&ctx.app.db,
ctx.user(),
"recovery_password_set",
&ctx.req.device_id,
&ctx.ip,
"",
)
.await;
}
}
root.push(
Element::new("Settings:DevicePassword").with_text("Settings:Status", "1"),
);
}
"UserInformation" => {
let email = ctx.sess.email.clone();
let mut get = Element::new("Settings:Get");
if ctx.req.ver() >= 141 {
get.push(
Element::new("Settings:Accounts").with(
Element::new("Settings:Account")
.with_text("Settings:UserDisplayName", email.clone())
.with(
Element::new("Settings:EmailAddresses")
.with_text("Settings:PrimarySmtpAddress", email.clone()),
),
),
);
} else {
get.push(
Element::new("Settings:EmailAddresses")
.with_text("Settings:SmtpAddress", email),
);
}
root.push(
Element::new("Settings:UserInformation")
.with_text("Settings:Status", "1")
.with(get),
);
}
"RightsManagementInformation" => {
root.push(
Element::new("Settings:RightsManagementInformation")
.with_text("Settings:Status", "1")
.with(
Element::new("Settings:Get")
.with(Element::new("RightsManagement:RightsManagementTemplates")),
),
);
}
_ => {}
}
}
Ok(Reply::Wbxml(root))
}
// ------------------------------------------------------------------ GAL helpers
pub struct Person {
pub summary: contact::CardSummary,
pub from_gal: bool,
}
pub async fn find_people(ctx: &mut Ctx, query: &str, limit: usize) -> Result<Vec<Person>> {
let mut out: Vec<Person> = Vec::new();
let q = query.trim();
if q.chars().count() < ctx.s.carddav.gal_min_length.max(1) {
return Ok(out);
}
let Some((client, home)) = ctx.sess.carddav()? else {
return Ok(out);
};
let s = ctx.s.clone();
let mut books: Vec<(String, bool)> = Vec::new();
if !s.carddav.gal_path.is_empty() {
books.push((
dav::encode_path(&expand_user_template(
&s.carddav.gal_path,
ctx.user(),
&s.general.default_domain,
)),
true,
));
}
if let Ok(cols) = client.collections(&home, dav::DavKind::AddressBook).await {
for c in cols {
let p = dav::encode_path(&c.href);
if !books.iter().any(|(b, _)| *b == p) {
books.push((p, false));
}
}
}
for (b, gal) in books {
let res = match client.search_cards(&b, q).await {
Ok(r) => r,
Err(e) => {
tracing::debug!("GAL search in {b} failed: {e:#}");
continue;
}
};
for r in res {
if let Some(sm) = r.data.as_deref().and_then(contact::summary) {
if sm.emails.is_empty() && sm.display_name.is_empty() {
continue;
}
let dup = out.iter().any(|p| {
!sm.emails.is_empty() && p.summary.emails.first() == sm.emails.first()
});
if !dup {
out.push(Person {
summary: sm,
from_gal: gal,
});
}
if out.len() >= limit {
return Ok(out);
}
}
}
}
Ok(out)
}
fn parse_range(r: Option<String>, def: usize) -> (usize, usize) {
r.and_then(|r| {
let (a, b) = r.split_once('-')?;
Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
})
.filter(|(a, b)| a <= b)
.unwrap_or((0, def.saturating_sub(1)))
}
// ------------------------------------------------------------------ Search
pub async fn search(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
let status_only =
|s: &str| Reply::Wbxml(Element::new("Search:Search").with_text("Search:Status", s));
let Some(req) = req else {
return Ok(status_only("2"));
};
let Some(store) = req.child("Search:Store") else {
return Ok(status_only("2"));
};
let name = store.child_text("Search:Name").unwrap_or_default();
let opts_el = store.child("Search:Options");
let max = ctx.s.sync.search_max_results as usize;
let (from, to) = parse_range(
opts_el.and_then(|o| o.child_text("Search:Range")),
max.min(100),
);
let to = to.min(from + max.max(1) - 1);
let mut st = Element::new("Search:Store");
let started = Instant::now();
let deadline = Duration::from_secs(ctx.s.sync.search_wait_secs.max(3));
match name.to_ascii_lowercase().as_str() {
"gal" => {
let q = store.child_text("Search:Query").unwrap_or_default();
let people = find_people(ctx, &q, to + 1).await.unwrap_or_default();
st.push_text("Search:Status", "1");
let total = people.len();
for p in people.into_iter().skip(from).take(to + 1 - from) {
let s = p.summary;
let mut props = Element::new("Search:Properties");
props.push_text(
"GAL:DisplayName",
if s.display_name.is_empty() {
s.emails.first().cloned().unwrap_or_default()
} else {
s.display_name.clone()
},
);
props.push_opt("GAL:Phone", (!s.phone.is_empty()).then(|| s.phone.clone()));
props.push_opt(
"GAL:Office",
(!s.office.is_empty()).then(|| s.office.clone()),
);
props.push_opt("GAL:Title", (!s.title.is_empty()).then(|| s.title.clone()));
props.push_opt(
"GAL:Company",
(!s.company.is_empty()).then(|| s.company.clone()),
);
props.push_opt(
"GAL:FirstName",
(!s.first.is_empty()).then(|| s.first.clone()),
);
props.push_opt("GAL:LastName", (!s.last.is_empty()).then(|| s.last.clone()));
props.push_opt(
"GAL:MobilePhone",
(!s.mobile.is_empty()).then(|| s.mobile.clone()),
);
if let Some(e) = s.emails.first() {
props.push_text("GAL:EmailAddress", e.clone());
}
st.push(Element::new("Search:Result").with(props));
}
if total > 0 {
st.push_text(
"Search:Range",
format!(
"{from}-{}",
(from + total.min(to + 1 - from)).saturating_sub(1)
),
);
}
st.push_text("Search:Total", total.to_string());
ctx.items_out += total as u64;
}
"mailbox" => {
let Some(query) = store.child("Search:Query") else {
return Ok(status_only("2"));
};
let and = query.child("Search:And").unwrap_or(query);
let text = and.child_text("Search:FreeText").unwrap_or_default();
let col = and.child_text("AirSync:CollectionId");
let since = and
.child("Search:GreaterThan")
.and_then(|g| g.child_text("Search:Value"))
.and_then(|v| crate::backend::tz::parse_eas_date(&v));
let deep = opts_el
.map(|o| o.has("Search:DeepTraversal"))
.unwrap_or(false);
let mut opts = opts_el.map(Options::parse).unwrap_or_default();
if opts.prefs.is_empty() {
opts.prefs.push(super::convert::BodyPref {
typ: 1,
trunc: Some(5120),
preview: Some(255),
..Default::default()
});
}
// folders to search
let all = ctx.sess.folders().await?;
let mut folders = Vec::new();
match &col {
Some(c) => {
if let Some(f) = resolve(ctx, c).await? {
if deep {
let pref = f.id.clone();
for x in all.iter().filter(|x| {
x.kind() == Kind::Mail
&& (x.id == pref
|| x.id.starts_with(&format!("{pref}/"))
|| x.id.starts_with(&format!("{pref}.")))
}) {
folders.push(x.clone());
}
} else {
folders.push(f);
}
}
}
None => folders.extend(all.iter().filter(|x| x.kind() == Kind::Mail).cloned()),
}
let mut hits: Vec<(crate::backend::BFolder, u32)> = Vec::new();
for f in folders {
if started.elapsed() > deadline {
break;
}
match ctx.sess.mail_search(&f, &text, since).await {
Ok(uids) => hits.extend(uids.into_iter().rev().map(|u| (f.clone(), u))),
Err(e) => tracing::debug!("search in {} failed: {e:#}", f.name),
}
}
let total = hits.len();
st.push_text("Search:Status", "1");
for (f, uid) in hits.into_iter().skip(from).take(to + 1 - from) {
let Ok(m) = ctx.sess.fetch_mail(f.path(), uid).await else {
continue;
};
let cid = super::folders::short_id(ctx, &f.id).await?;
let sid = server_id(ctx, &cid, &f.id, &uid.to_string()).await?;
let conv = super::convert::email::to_eas(
&m,
&super::convert::email::MailContext {
collection: &cid,
ver: ctx.req.ver(),
opts: &opts,
default_tz: ctx.s.timezone(),
},
);
let mut props = Element::new("Search:Properties");
props.children = conv.app.children;
st.push(
Element::new("Search:Result")
.with_text("AirSync:Class", "Email")
.with_text("Search:LongId", sid)
.with_text("AirSync:CollectionId", cid)
.with(props),
);
ctx.items_out += 1;
}
if total > 0 {
st.push_text(
"Search:Range",
format!(
"{from}-{}",
(from + total.min(to + 1 - from)).saturating_sub(1)
),
);
}
st.push_text("Search:Total", total.to_string());
}
_ => {
st.push_text("Search:Status", "3");
}
}
let _ = imap::quote; // keep module linked for docs
Ok(Reply::Wbxml(
Element::new("Search:Search")
.with_text("Search:Status", "1")
.with(Element::new("Search:Response").with(st)),
))
}
// ------------------------------------------------------------------ ResolveRecipients
pub async fn resolve_recipients(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
let mut root = Element::new("ResolveRecipients:ResolveRecipients");
let Some(req) = req else {
return Ok(Reply::Wbxml(
root.with_text("ResolveRecipients:Status", "5"),
));
};
let opts = req.child("ResolveRecipients:Options");
let max_amb = opts
.and_then(|o| o.child_u32("ResolveRecipients:MaxAmbiguousRecipients"))
.unwrap_or(19)
.clamp(1, 100) as usize;
let want_certs = opts
.and_then(|o| o.child_u32("ResolveRecipients:CertificateRetrieval"))
.unwrap_or(1)
> 1;
let want_avail = opts
.map(|o| o.has("ResolveRecipients:Availability"))
.unwrap_or(false);
root.push_text("ResolveRecipients:Status", "1");
for to in req.children_named("ResolveRecipients:To") {
let q = to.text_value();
let people = find_people(ctx, &q, max_amb).await.unwrap_or_default();
let mut resp =
Element::new("ResolveRecipients:Response").with_text("ResolveRecipients:To", q.clone());
let mut recips: Vec<(u8, String, String)> = people
.into_iter()
.filter_map(|p| {
let e = p.summary.emails.first()?.clone();
let n = if p.summary.display_name.is_empty() {
e.clone()
} else {
p.summary.display_name.clone()
};
Some((if p.from_gal { 1 } else { 2 }, n, e))
})
.collect();
if recips.is_empty() && q.contains('@') && !q.contains(' ') {
recips.push((1, q.clone(), q.clone()));
}
let status = match recips.len() {
0 => "4",
1 => "1",
_ => "2",
};
resp.push_text("ResolveRecipients:Status", status);
resp.push_text("ResolveRecipients:RecipientCount", recips.len().to_string());
for (t, n, e) in recips {
let mut r = Element::new("ResolveRecipients:Recipient")
.with_text("ResolveRecipients:Type", t.to_string())
.with_text("ResolveRecipients:DisplayName", n)
.with_text("ResolveRecipients:EmailAddress", e);
if want_avail {
r.push(
Element::new("ResolveRecipients:Availability")
.with_text("ResolveRecipients:Status", "163"),
);
}
if want_certs {
r.push(
Element::new("ResolveRecipients:Certificates")
.with_text("ResolveRecipients:Status", "7")
.with_text("ResolveRecipients:CertificateCount", "0"),
);
}
resp.push(r);
}
root.push(resp);
}
Ok(Reply::Wbxml(root))
}
+836
View File
@@ -0,0 +1,836 @@
//! Exchange ActiveSync HTTP endpoint (`/Microsoft-Server-ActiveSync`).
pub mod convert;
pub mod folders;
pub mod itemops;
pub mod mailcmd;
pub mod misc;
pub mod ping;
pub mod provision;
pub mod state;
pub mod sync;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Instant;
use axum::body::Body;
use axum::extract::{ConnectInfo, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use base64::Engine;
use http_body_util::BodyExt;
use crate::app::App;
use crate::backend::{AuthResult, Session};
use crate::config::{LoginFormat, Settings};
use crate::db::{self, Device};
use crate::metrics::{ActiveRequest, Event};
use crate::util::{self, now};
use crate::wbxml::{self, Element};
pub const SUPPORTED_VERSIONS: &[&str] = &["2.5", "12.0", "12.1", "14.0", "14.1"];
pub const COMMANDS: &str = "Sync,SendMail,SmartForward,SmartReply,GetAttachment,GetHierarchy,CreateCollection,DeleteCollection,MoveCollection,FolderSync,FolderCreate,FolderDelete,FolderUpdate,MoveItems,GetItemEstimate,MeetingResponse,Search,Settings,Ping,ItemOperations,Provision,ResolveRecipients,ValidateCert";
#[derive(Debug, Clone, Default)]
pub struct EasRequest {
pub cmd: String,
pub user: String,
pub device_id: String,
pub device_type: String,
pub version: String,
pub policy_key: Option<u32>,
pub attachment_name: Option<String>,
pub collection_id: Option<String>,
pub item_id: Option<String>,
pub long_id: Option<String>,
pub parent_id: Option<String>,
pub occurrence: Option<String>,
pub save_in_sent: bool,
pub accept_multipart: bool,
pub user_agent: String,
pub content_type: String,
}
impl EasRequest {
/// Protocol version as integer: 25, 120, 121, 140, 141.
pub fn ver(&self) -> u32 {
match self.version.as_str() {
"2.5" => 25,
"12.0" => 120,
"12.1" => 121,
"14.0" => 140,
"14.1" => 141,
"16.0" => 160,
"16.1" => 161,
_ => 25,
}
}
}
const BASE64_COMMANDS: &[(u8, &str)] = &[
(0, "Sync"),
(1, "SendMail"),
(2, "SmartForward"),
(3, "SmartReply"),
(4, "GetAttachment"),
(9, "FolderSync"),
(10, "FolderCreate"),
(11, "FolderDelete"),
(12, "FolderUpdate"),
(13, "MoveItems"),
(14, "GetItemEstimate"),
(15, "MeetingResponse"),
(16, "Search"),
(17, "Settings"),
(18, "Ping"),
(19, "ItemOperations"),
(20, "Provision"),
(21, "ResolveRecipients"),
(22, "ValidateCert"),
];
pub fn parse_request(uri: &Uri, headers: &HeaderMap) -> Result<EasRequest, &'static str> {
let q = uri.query().unwrap_or("");
let h = |n: &str| {
headers
.get(n)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.trim()
.to_string()
};
let mut r = EasRequest {
user_agent: h("user-agent"),
content_type: h("content-type"),
..Default::default()
};
if q.contains("Cmd=") || q.contains("DeviceId=") || q.is_empty() {
for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
let v = v.to_string();
match k.as_ref() {
"Cmd" => r.cmd = v,
"User" => r.user = v,
"DeviceId" => r.device_id = v,
"DeviceType" => r.device_type = v,
"AttachmentName" => r.attachment_name = Some(v),
"CollectionId" => r.collection_id = Some(v),
"ItemId" => r.item_id = Some(v),
"LongId" => r.long_id = Some(v),
"ParentId" => r.parent_id = Some(v),
"Occurrence" => r.occurrence = Some(v),
"SaveInSent" => r.save_in_sent = v.eq_ignore_ascii_case("T"),
_ => {}
}
}
r.version = h("ms-asprotocolversion");
let pk = h("x-ms-policykey");
if !pk.is_empty() {
r.policy_key = pk.parse().ok();
}
r.accept_multipart = h("ms-asacceptmultipart").eq_ignore_ascii_case("T");
} else {
// base64 encoded query (MS-ASHTTP 2.2.1.1.1.1)
let raw = percent_encoding::percent_decode_str(q)
.decode_utf8_lossy()
.into_owned();
let b = base64::engine::general_purpose::STANDARD
.decode(raw.trim())
.map_err(|_| "invalid base64 query")?;
struct Cur<'a> {
b: &'a [u8],
p: usize,
}
impl<'a> Cur<'a> {
fn byte(&mut self) -> Result<u8, &'static str> {
let v = *self.b.get(self.p).ok_or("truncated query")?;
self.p += 1;
Ok(v)
}
fn take(&mut self, n: usize) -> Result<&'a [u8], &'static str> {
let s = self.b.get(self.p..self.p + n).ok_or("truncated query")?;
self.p += n;
Ok(s)
}
}
let mut c = Cur { b: &b, p: 0 };
let ver = c.byte()?;
r.version = format!("{}.{}", ver / 10, ver % 10);
let code = c.byte()?;
r.cmd = BASE64_COMMANDS
.iter()
.find(|(cc, _)| *cc == code)
.map(|(_, n)| n.to_string())
.ok_or("unknown command code")?;
let _locale = c.take(2)?;
let dlen = c.byte()? as usize;
let dev = c.take(dlen)?;
r.device_id = if dev.iter().all(|x| x.is_ascii_graphic()) {
String::from_utf8_lossy(dev).into_owned()
} else {
hex::encode(dev)
};
let pklen = c.byte()? as usize;
let pk = c.take(pklen)?;
if pklen == 4 {
r.policy_key = Some(u32::from_le_bytes([pk[0], pk[1], pk[2], pk[3]]));
}
let tlen = c.byte()? as usize;
r.device_type = String::from_utf8_lossy(c.take(tlen)?).into_owned();
while c.p + 1 < c.b.len() {
let tag = c.byte()?;
let len = c.byte()? as usize;
let val = c.take(len)?;
let sv = String::from_utf8_lossy(val).into_owned();
match tag {
0 => r.attachment_name = Some(sv),
1 => r.collection_id = Some(sv),
3 => r.item_id = Some(sv),
4 => r.long_id = Some(sv),
5 => r.parent_id = Some(sv),
6 => r.occurrence = Some(sv),
7 => {
let o = val.first().copied().unwrap_or(0);
r.save_in_sent = o & 0x01 != 0;
r.accept_multipart = o & 0x02 != 0;
}
8 => r.user = sv,
_ => {}
}
}
}
if r.version.is_empty() {
r.version = "2.5".into();
}
Ok(r)
}
fn valid_device_id(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 128
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
fn valid_device_type(s: &str) -> bool {
s.len() <= 64 && s.chars().all(|c| c.is_ascii_graphic())
}
/// Determine the real client IP honouring trusted proxies.
pub fn client_ip(peer: SocketAddr, headers: &HeaderMap, s: &Settings) -> IpAddr {
let peer_ip = util::normalize(peer.ip());
if !util::ip_in_any(&peer_ip, &s.security.trusted_proxies) {
return peer_ip;
}
let hv = headers
.get(s.security.real_ip_header.as_str())
.and_then(|v| v.to_str().ok())
.unwrap_or("");
// walk right-to-left skipping trusted proxies
for part in hv.split(',').rev() {
if let Ok(ip) = part.trim().parse::<IpAddr>() {
let ip = util::normalize(ip);
if !util::ip_in_any(&ip, &s.security.trusted_proxies) {
return ip;
}
}
}
peer_ip
}
/// Parse `Authorization: Basic` and normalise the login according to settings.
pub fn basic_auth(headers: &HeaderMap, s: &Settings) -> Option<(String, String)> {
let v = headers.get("authorization")?.to_str().ok()?;
let (scheme, data) = v.split_once(' ')?;
if !scheme.eq_ignore_ascii_case("basic") {
return None;
}
let dec = base64::engine::general_purpose::STANDARD
.decode(data.trim())
.ok()?;
let dec = String::from_utf8(dec).ok()?;
let (user, pass) = dec.split_once(':')?;
let login = normalize_login(user, s)?;
if pass.is_empty() || pass.len() > 1024 {
return None;
}
Some((login, pass.to_string()))
}
pub fn normalize_login(user: &str, s: &Settings) -> Option<String> {
let mut u = user.trim().to_string();
if let Some((_, rest)) = u.split_once('\\') {
u = rest.to_string();
}
let u = match s.general.login_format {
LoginFormat::AsGiven => u,
LoginFormat::LocalPart => u.split('@').next().unwrap_or("").to_string(),
LoginFormat::FullEmail => {
if u.contains('@') || s.general.default_domain.is_empty() {
u
} else {
format!("{u}@{}", s.general.default_domain)
}
}
};
let u = u.to_lowercase();
util::valid_login(&u).then_some(u)
}
/// Access policy checks shared by EAS and Autodiscover.
pub fn user_allowed(login: &str, s: &Settings) -> bool {
let sec = &s.security;
if sec
.blocked_users
.iter()
.any(|b| b.eq_ignore_ascii_case(login))
{
return false;
}
if !sec.allowed_users.is_empty()
&& !sec
.allowed_users
.iter()
.any(|b| b.eq_ignore_ascii_case(login))
{
return false;
}
if !s.general.allowed_domains.is_empty() {
let dom = login.split('@').nth(1).unwrap_or(&s.general.default_domain);
if !s
.general
.allowed_domains
.iter()
.any(|d| d.eq_ignore_ascii_case(dom))
{
return false;
}
}
true
}
pub enum Reply {
Wbxml(Element),
Raw(&'static str, Vec<u8>),
Empty,
Status(StatusCode),
}
pub struct Ctx {
pub app: Arc<App>,
pub s: Arc<Settings>,
pub req: EasRequest,
pub dev: Device,
pub sess: Session,
pub ip: String,
pub items_out: u64,
pub items_in: u64,
pub status: String,
pub debug: bool,
}
impl Ctx {
pub fn ping_key(&self) -> String {
format!("{}\u{0}{}", self.req.device_id, self.sess.login)
}
pub fn user(&self) -> &str {
&self.sess.login
}
}
fn unauthorized() -> Response {
let mut r = (StatusCode::UNAUTHORIZED, "Access denied").into_response();
r.headers_mut().insert(
"WWW-Authenticate",
HeaderValue::from_static("Basic realm=\"ActiveSync\""),
);
r
}
fn server_headers(r: &mut Response, s: &Settings) {
let h = r.headers_mut();
h.insert("MS-Server-ActiveSync", HeaderValue::from_static("14.1"));
h.insert("Cache-Control", HeaderValue::from_static("private"));
let _ = s;
}
fn versions(s: &Settings) -> String {
let max = s.general.max_protocol.as_str();
let idx = SUPPORTED_VERSIONS
.iter()
.position(|v| *v == max)
.unwrap_or(SUPPORTED_VERSIONS.len() - 1);
SUPPORTED_VERSIONS[..=idx].join(",")
}
/// Axum handler for the ActiveSync endpoint.
pub async fn handler(
State(app): State<Arc<App>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Body,
) -> Response {
let s = app.settings();
let ip = client_ip(peer, &headers, &s);
let ip_s = ip.to_string();
if !s.security.eas_allowed_networks.is_empty()
&& !util::ip_in_any(&ip, &s.security.eas_allowed_networks)
{
tracing::warn!(ip = %ip_s, "ActiveSync request from network that is not allowed");
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
}
if method == Method::GET {
let mut r = (
StatusCode::OK,
[("Content-Type", "text/html; charset=utf-8")],
"<!doctype html><title>ActiveSync</title><p>This is an Exchange ActiveSync endpoint and can only be accessed by ActiveSync-capable devices.</p>",
)
.into_response();
server_headers(&mut r, &s);
return r;
}
if method != Method::POST && method != Method::OPTIONS {
return StatusCode::METHOD_NOT_ALLOWED.into_response();
}
// ---- authentication
let Some((login, password)) = basic_auth(&headers, &s) else {
return unauthorized();
};
let ip_key = format!("ip:{ip_s}");
let user_key = format!("user:{login}");
for k in [&ip_key, &user_key] {
if let Some(until) = app.auth.locked_until(k) {
app.metrics.auth_failure();
let mut r = (StatusCode::TOO_MANY_REQUESTS, "Too many failed logins").into_response();
if let Ok(v) = HeaderValue::from_str(&(until - now()).max(1).to_string()) {
r.headers_mut().insert("Retry-After", v);
}
return r;
}
}
if !user_allowed(&login, &s) {
tracing::warn!(user = %login, ip = %ip_s, "ActiveSync access denied by policy");
return (StatusCode::FORBIDDEN, "Forbidden").into_response();
}
let mut sess = app.session(&login, &password);
let cache_key = app.pool.key(&login, &password);
if !app.auth.is_cached(&cache_key) {
match sess.authenticate().await {
Ok(AuthResult::Ok) => {
app.auth.success(&user_key);
app.auth.cache_ok(cache_key, s.security.auth_cache_secs);
}
Ok(AuthResult::Denied) => {
app.metrics.auth_failure();
if s.security.log_auth_failures {
tracing::warn!(target: "active_push::auth", "authentication failed user={login} ip={ip_s} service=activesync");
}
let max = s.security.auth_fail_max;
let w = s.security.auth_fail_window_secs;
let l = s.security.lockout_secs;
if app.auth.fail(&ip_key, max * 3, w, l) || app.auth.fail(&user_key, max, w, l) {
tracing::warn!(user = %login, ip = %ip_s, "login locked out after repeated failures");
db::audit(
&app.db,
"system",
"lockout",
&login,
&ip_s,
"repeated ActiveSync login failures",
)
.await;
}
return unauthorized();
}
Err(e) => {
tracing::error!(user = %login, "backend unavailable during authentication: {e:#}");
let mut r =
(StatusCode::SERVICE_UNAVAILABLE, "Backend unavailable").into_response();
if let Ok(v) = HeaderValue::from_str(&s.sync.retry_after_secs.to_string()) {
r.headers_mut().insert("Retry-After", v);
}
return r;
}
}
}
if method == Method::OPTIONS {
let mut r = StatusCode::OK.into_response();
server_headers(&mut r, &s);
let h = r.headers_mut();
h.insert(
"MS-ASProtocolVersions",
HeaderValue::from_str(&versions(&s)).unwrap(),
);
h.insert("MS-ASProtocolCommands", HeaderValue::from_static(COMMANDS));
h.insert("Allow", HeaderValue::from_static("OPTIONS,POST"));
h.insert("Public", HeaderValue::from_static("OPTIONS,POST"));
sess.close().await;
return r;
}
// ---- request
let req = match parse_request(&uri, &headers) {
Ok(r) => r,
Err(e) => {
tracing::info!(ip = %ip_s, "bad ActiveSync request: {e}");
return (StatusCode::BAD_REQUEST, e).into_response();
}
};
let mut req = req;
if !COMMANDS.split(',').any(|c| c == req.cmd) {
req.cmd = "Unknown".into();
}
if !valid_device_id(&req.device_id)
|| !valid_device_type(&req.device_type)
|| req.cmd.is_empty()
{
return (StatusCode::BAD_REQUEST, "Missing or invalid parameters").into_response();
}
let blocked_type = s.security.blocked_device_types.iter().any(|b| {
let b = b.to_lowercase();
!b.is_empty()
&& (req.device_type.to_lowercase().contains(&b)
|| req.user_agent.to_lowercase().contains(&b))
});
if blocked_type {
tracing::warn!(user = %login, device = %req.device_id, "device type blocked by policy");
return (StatusCode::FORBIDDEN, "Device type not allowed").into_response();
}
let max_body = (s.sync.max_request_mb * 1024 * 1024) as usize;
let body = match http_body_util::Limited::new(body, max_body).collect().await {
Ok(b) => b.to_bytes(),
Err(_) => return (StatusCode::PAYLOAD_TOO_LARGE, "Request too large").into_response(),
};
let (dev, created) = match db::device_get_or_create(
&app.db,
&req.device_id,
&login,
&req.device_type,
&req.user_agent,
!s.security.require_device_approval,
)
.await
{
Ok(d) => d,
Err(e) => {
tracing::error!("database error: {e:#}");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
if created {
tracing::info!(user = %login, device = %req.device_id, kind = %req.device_type, "new device registered");
db::audit(
&app.db,
&login,
"device_new",
&req.device_id,
&ip_s,
&req.user_agent,
)
.await;
}
if dev.blocked != 0 {
return (StatusCode::FORBIDDEN, "Device blocked").into_response();
}
if dev.approved == 0 {
tracing::info!(user = %login, device = %req.device_id, "device awaiting approval");
return (
StatusCode::FORBIDDEN,
"Device awaiting administrator approval",
)
.into_response();
}
let _ = db::device_touch(
&app.db,
dev.id,
&ip_s,
&req.cmd,
&req.version,
&req.user_agent,
&req.device_type,
)
.await;
let debug = s.logging.log_wbxml
|| s.logging
.debug_users
.iter()
.any(|u| u.eq_ignore_ascii_case(&login));
let started = Instant::now();
let active_id = app.metrics.begin(ActiveRequest {
id: 0,
user: login.clone(),
device_id: req.device_id.clone(),
device_type: req.device_type.clone(),
command: req.cmd.clone(),
ip: ip_s.clone(),
started: now(),
version: req.version.clone(),
});
let mut ctx = Ctx {
app: app.clone(),
s: s.clone(),
req: req.clone(),
dev,
sess,
ip: ip_s.clone(),
items_out: 0,
items_in: 0,
status: String::new(),
debug,
};
// A new request from the device ends its hanging Ping/Sync.
if ctx.req.cmd != "Ping" && ctx.req.cmd != "Sync" {
app.metrics.ping_interrupt(&ctx.ping_key());
}
let reply = dispatch(&mut ctx, &body).await;
let (mut resp, http, out_len) = match reply {
Ok(Reply::Wbxml(el)) => {
if ctx.debug {
tracing::info!(user = %login, cmd = %req.cmd, "response:\n{}", el.to_xml());
}
let bytes = wbxml::encode(&el);
let n = bytes.len();
let mut r = Response::new(Body::from(bytes));
r.headers_mut().insert(
"Content-Type",
HeaderValue::from_static("application/vnd.ms-sync.wbxml"),
);
(r, 200u16, n)
}
Ok(Reply::Raw(ct, bytes)) => {
let n = bytes.len();
let mut r = Response::new(Body::from(bytes));
r.headers_mut()
.insert("Content-Type", HeaderValue::from_static(ct));
(r, 200, n)
}
Ok(Reply::Empty) => (StatusCode::OK.into_response(), 200, 0),
Ok(Reply::Status(code)) => (code.into_response(), code.as_u16(), 0),
Err(e) => {
tracing::error!(user = %login, device = %req.device_id, cmd = %req.cmd, "request failed: {e:#}");
ctx.status = format!("error: {e}");
let mut r = StatusCode::INTERNAL_SERVER_ERROR.into_response();
if e.to_string().contains("IMAP") || e.to_string().contains("connect") {
r = StatusCode::SERVICE_UNAVAILABLE.into_response();
if let Ok(v) = HeaderValue::from_str(&s.sync.retry_after_secs.to_string()) {
r.headers_mut().insert("Retry-After", v);
}
}
let code = r.status().as_u16();
(r, code, 0)
}
};
server_headers(&mut resp, &s);
let ms = started.elapsed().as_millis() as u64;
if ms > 5000 && req.cmd != "Ping" && req.cmd != "Sync" {
tracing::info!(user = %login, cmd = %req.cmd, ms, "slow request");
}
app.metrics.end(
active_id,
Event {
ts: now(),
user: login.clone(),
device_id: req.device_id.clone(),
device_type: req.device_type.clone(),
command: req.cmd.clone(),
ip: ip_s,
http,
status: if ctx.status.is_empty() {
"ok".into()
} else {
ctx.status.clone()
},
ms,
bytes_in: body.len() as u64,
bytes_out: out_len as u64,
items_out: ctx.items_out,
items_in: ctx.items_in,
},
);
ctx.sess.close().await;
resp
}
fn needs_provisioning(cmd: &str) -> bool {
!matches!(cmd, "Provision" | "Ping")
}
/// WBXML root/status for "device must provision" answers (AS 14+).
fn provision_status_reply(cmd: &str, status: u32) -> Option<Element> {
let (root, st) = match cmd {
"Sync" => ("AirSync:Sync", "AirSync:Status"),
"FolderSync" => ("FolderHierarchy:FolderSync", "FolderHierarchy:Status"),
"FolderCreate" => ("FolderHierarchy:FolderCreate", "FolderHierarchy:Status"),
"FolderUpdate" => ("FolderHierarchy:FolderUpdate", "FolderHierarchy:Status"),
"FolderDelete" => ("FolderHierarchy:FolderDelete", "FolderHierarchy:Status"),
"GetItemEstimate" => ("GetItemEstimate:GetItemEstimate", "GetItemEstimate:Status"),
"Ping" => ("Ping:Ping", "Ping:Status"),
"ItemOperations" => ("ItemOperations:ItemOperations", "ItemOperations:Status"),
"Settings" => ("Settings:Settings", "Settings:Status"),
"Search" => ("Search:Search", "Search:Status"),
"SendMail" => ("ComposeMail:SendMail", "ComposeMail:Status"),
"SmartReply" => ("ComposeMail:SmartReply", "ComposeMail:Status"),
"SmartForward" => ("ComposeMail:SmartForward", "ComposeMail:Status"),
"MeetingResponse" => ("MeetingResponse:MeetingResponse", "MeetingResponse:Status"),
"ResolveRecipients" => (
"ResolveRecipients:ResolveRecipients",
"ResolveRecipients:Status",
),
"MoveItems" => ("Move:MoveItems", "Move:Status"),
_ => return None,
};
Some(Element::new(root).with_text(st, status.to_string()))
}
async fn dispatch(ctx: &mut Ctx, body: &[u8]) -> anyhow::Result<Reply> {
// ---- provisioning enforcement
let wipe_pending = matches!(
ctx.dev.wipe_status,
db::WIPE_PENDING
| db::WIPE_REQUESTED
| db::WIPE_ACCOUNT_PENDING
| db::WIPE_ACCOUNT_REQUESTED
);
if ctx.s.provisioning.enabled
&& (needs_provisioning(&ctx.req.cmd) || (wipe_pending && ctx.req.cmd != "Provision"))
{
let sent = ctx.req.policy_key;
let current = ctx.dev.policy_key as u32;
let policy_changed = !provision::policy_hash_matches(ctx);
let ok = match sent {
Some(k) => k != 0 && k == current && !policy_changed && !wipe_pending,
None => ctx.s.provisioning.loose && !wipe_pending,
};
if !ok {
ctx.status = "provisioning required".into();
tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, "provisioning required");
if ctx.req.ver() >= 140 {
let code = if wipe_pending {
140
} else if sent.map(|k| k != 0).unwrap_or(false) {
144
} else {
142
};
if let Some(e) = provision_status_reply(&ctx.req.cmd, code) {
return Ok(Reply::Wbxml(e));
}
}
return Ok(Reply::Status(StatusCode::from_u16(449).unwrap()));
}
}
// ---- plain-input commands (AS < 14 SendMail family uses raw MIME)
let is_mime = ctx
.req
.content_type
.to_ascii_lowercase()
.starts_with("message/rfc822");
let req_el = if body.is_empty() || is_mime || ctx.req.cmd == "GetAttachment" {
None
} else {
match wbxml::decode(body) {
Ok(e) => {
if ctx.debug {
tracing::info!(user = %ctx.user(), cmd = %ctx.req.cmd, "request:\n{}", e.to_xml());
}
Some(e)
}
Err(err) => {
tracing::info!(user = %ctx.user(), cmd = %ctx.req.cmd, "invalid WBXML: {err}");
ctx.status = "invalid wbxml".into();
return Ok(Reply::Status(StatusCode::BAD_REQUEST));
}
}
};
match ctx.req.cmd.as_str() {
"Provision" => provision::provision(ctx, req_el).await,
"FolderSync" => folders::folder_sync(ctx, req_el).await,
"FolderCreate" | "FolderUpdate" | "FolderDelete" => {
folders::folder_change(ctx, req_el).await
}
"GetHierarchy" => folders::get_hierarchy(ctx).await,
"Sync" => sync::sync(ctx, req_el).await,
"GetItemEstimate" => sync::get_item_estimate(ctx, req_el).await,
"Ping" => ping::ping(ctx, req_el).await,
"MoveItems" => sync::move_items(ctx, req_el).await,
"SendMail" | "SmartReply" | "SmartForward" => mailcmd::send_mail(ctx, req_el, body).await,
"GetAttachment" => itemops::get_attachment(ctx).await,
"ItemOperations" => itemops::item_operations(ctx, req_el).await,
"Settings" => misc::settings(ctx, req_el).await,
"Search" => misc::search(ctx, req_el).await,
"ResolveRecipients" => misc::resolve_recipients(ctx, req_el).await,
"MeetingResponse" => mailcmd::meeting_response(ctx, req_el).await,
"ValidateCert" => Ok(Reply::Wbxml(
Element::new("ValidateCert:ValidateCert").with_text("ValidateCert:Status", "1"),
)),
other => {
tracing::info!(cmd = other, "unsupported ActiveSync command");
ctx.status = "not implemented".into();
Ok(Reply::Status(StatusCode::NOT_IMPLEMENTED))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_query() {
// version 14.1, Sync (0), locale 0x0409, devid "abc", pk 4 bytes, type "iPhone", User "jan"
let mut b = vec![141u8, 0, 0x09, 0x04, 3];
b.extend_from_slice(b"abc");
b.push(4);
b.extend_from_slice(&7u32.to_le_bytes());
b.push(6);
b.extend_from_slice(b"iPhone");
b.extend_from_slice(&[8, 3]);
b.extend_from_slice(b"jan");
let q = base64::engine::general_purpose::STANDARD.encode(&b);
let uri: Uri = format!("/Microsoft-Server-ActiveSync?{q}").parse().unwrap();
let r = parse_request(&uri, &HeaderMap::new()).unwrap();
assert_eq!(r.cmd, "Sync");
assert_eq!(r.version, "14.1");
assert_eq!(r.device_id, "abc");
assert_eq!(r.policy_key, Some(7));
assert_eq!(r.device_type, "iPhone");
assert_eq!(r.user, "jan");
}
#[test]
fn plain_query() {
let uri: Uri = "/Microsoft-Server-ActiveSync?Cmd=FolderSync&User=jan&DeviceId=ABC123&DeviceType=Android"
.parse()
.unwrap();
let mut h = HeaderMap::new();
h.insert("MS-ASProtocolVersion", HeaderValue::from_static("14.0"));
h.insert("X-MS-PolicyKey", HeaderValue::from_static("123"));
let r = parse_request(&uri, &h).unwrap();
assert_eq!(r.cmd, "FolderSync");
assert_eq!(r.ver(), 140);
assert_eq!(r.policy_key, Some(123));
}
#[test]
fn login_formats() {
let mut s = Settings::default();
s.general.default_domain = "firma.pl".into();
s.general.login_format = LoginFormat::FullEmail;
assert_eq!(normalize_login("DOM\\Jan", &s).unwrap(), "jan@firma.pl");
s.general.login_format = LoginFormat::LocalPart;
assert_eq!(normalize_login("jan@firma.pl", &s).unwrap(), "jan");
assert!(normalize_login("a b", &s).is_none());
}
}
+238
View File
@@ -0,0 +1,238 @@
//! Ping (push) and the shared "wait for changes" loop used by hanging Sync.
use std::time::{Duration, Instant};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use super::folders::resolve;
use super::state::{self, CollectionState};
use super::sync::{diff, effective_filter, filter_since};
use super::{Ctx, Reply};
use crate::backend::{BFolder, Kind};
use crate::db;
use crate::metrics::PingInfo;
use crate::util::now;
use crate::wbxml::Element;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PingFolder {
id: String,
class: String,
}
fn ping_status(status: u32) -> Element {
Element::new("Ping:Ping").with_text("Ping:Status", status.to_string())
}
pub async fn ping(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
let s = ctx.s.clone();
let mut lifetime = ctx.dev.ping_lifetime.max(0) as u64;
let mut folders: Vec<PingFolder> =
serde_json::from_str(&ctx.dev.ping_folders).unwrap_or_default();
if let Some(r) = &req {
if let Some(h) = r.child_u32("Ping:HeartbeatInterval") {
lifetime = h as u64;
}
if let Some(f) = r.child("Ping:Folders") {
folders = f
.children_named("Ping:Folder")
.map(|x| PingFolder {
id: x.child_text("Ping:Id").unwrap_or_default(),
class: x.child_text("Ping:Class").unwrap_or_default(),
})
.filter(|f| !f.id.is_empty())
.collect();
}
}
if lifetime == 0 || folders.is_empty() {
ctx.status = "ping: missing parameters".into();
return Ok(Reply::Wbxml(ping_status(3)));
}
if lifetime < s.ping.lifetime_min || lifetime > s.ping.lifetime_max {
let lim = if lifetime < s.ping.lifetime_min {
s.ping.lifetime_min
} else {
s.ping.lifetime_max
};
return Ok(Reply::Wbxml(
ping_status(5).with_text("Ping:HeartbeatInterval", lim.to_string()),
));
}
if folders.len() > s.ping.max_folders {
return Ok(Reply::Wbxml(
ping_status(6).with_text("Ping:MaxFolders", s.ping.max_folders.to_string()),
));
}
// persist for subsequent empty Pings
let fj = serde_json::to_string(&folders)?;
if fj != ctx.dev.ping_folders {
db::device_set_field(
&ctx.app.db,
ctx.dev.id,
db::DeviceField::PingFolders,
None,
Some(&fj),
)
.await?;
}
if lifetime as i64 != ctx.dev.ping_lifetime {
db::device_set_field(
&ctx.app.db,
ctx.dev.id,
db::DeviceField::PingLifetime,
Some(lifetime as i64),
None,
)
.await?;
}
let ids: Vec<String> = folders.iter().map(|f| f.id.clone()).collect();
match wait_for_changes(ctx, &ids, lifetime, "ping").await {
Err(e) if e.to_string() == "hierarchy" => {
ctx.status = "ping: folder sync required".into();
Ok(Reply::Wbxml(ping_status(7)))
}
Err(e) => Err(e),
Ok(None) => {
ctx.status = "ping: no changes".into();
Ok(Reply::Wbxml(ping_status(1)))
}
Ok(Some(changed)) => {
ctx.status = format!("ping: {} changed", changed.len());
let mut f = Element::new("Ping:Folders");
for c in changed {
f.push_text("Ping:Folder", c);
}
Ok(Reply::Wbxml(ping_status(2).with(f)))
}
}
}
struct Watched {
id: String,
folder: BFolder,
sig: String,
}
/// Does the device's latest state for `cid` differ from the backend?
async fn has_changes(ctx: &mut Ctx, cid: &str, folder: &BFolder) -> Result<bool> {
let Some((_, _, st)) = state::load_latest::<CollectionState>(ctx, cid).await? else {
// never synced: nothing to report until the device primes the folder
return Ok(false);
};
let filter = effective_filter(ctx, folder.kind(), st.filter);
let listing = match folder.kind() {
Kind::Tasks if filter == 8 => ctx.sess.list(folder, None).await?,
_ => ctx.sess.list(folder, filter_since(filter)).await?,
};
if !st.meta.is_empty() && !listing.meta.is_empty() && st.meta != listing.meta {
return Ok(true);
}
let d = diff(&st.items, &listing, folder.kind());
Ok(!(d.adds.is_empty() && d.changes.is_empty() && d.deletes.is_empty()))
}
/// Wait until one of the collections changes. Returns Ok(None) on timeout/interruption,
/// Err("hierarchy") if a collection does not exist anymore.
pub async fn wait_for_changes(
ctx: &mut Ctx,
ids: &[String],
lifetime: u64,
kind: &'static str,
) -> Result<Option<Vec<String>>> {
let mut watched = Vec::new();
for id in ids {
match resolve(ctx, id).await? {
Some(f) => watched.push(Watched {
id: id.clone(),
folder: f,
sig: String::new(),
}),
None => {
ctx.app.stale_add(ctx.dev.id, id);
if kind == "ping" {
return Err(anyhow::anyhow!("hierarchy"));
}
}
}
}
let key = ctx.ping_key();
let (cancel, gen) = ctx.app.metrics.ping_start(
&key,
PingInfo {
user: ctx.user().to_string(),
device_id: ctx.req.device_id.clone(),
device_type: ctx.req.device_type.clone(),
started: now(),
lifetime,
folders: watched.len(),
kind,
},
);
let result = async {
// initial check: changes that happened while the device was not pinging
let mut changed = Vec::new();
for w in watched.iter_mut() {
w.sig = ctx.sess.signature(&w.folder).await.unwrap_or_default();
let st = state::load_latest::<CollectionState>(ctx, &w.id).await?;
let stored = st.map(|(_, _, s)| s.signature).unwrap_or_default();
if (stored.is_empty() || stored != w.sig)
&& has_changes(ctx, &w.id.clone(), &w.folder.clone()).await?
{
changed.push(w.id.clone());
}
}
if !changed.is_empty() {
return Ok(Some(changed));
}
let deadline = Instant::now() + Duration::from_secs(lifetime.saturating_sub(5).max(5));
let interval = Duration::from_secs(ctx.s.ping.check_interval_secs.max(5));
let mut checks = 0u32;
loop {
let now_i = Instant::now();
if now_i >= deadline {
return Ok(None);
}
let sleep = interval.min(deadline - now_i);
tokio::select! {
_ = tokio::time::sleep(sleep) => {}
_ = cancel.notified() => return Ok(None),
_ = ctx.app.shutdown.cancelled() => return Ok(None),
}
checks += 1;
let mut changed = Vec::new();
for w in watched.iter_mut() {
let sig = match ctx.sess.signature(&w.folder).await {
Ok(s) => s,
Err(e) => {
tracing::debug!("ping signature failed: {e:#}");
continue;
}
};
if sig != w.sig {
w.sig = sig;
if has_changes(ctx, &w.id.clone(), &w.folder.clone()).await? {
changed.push(w.id.clone());
}
}
}
if !changed.is_empty() {
return Ok(Some(changed));
}
// every ~10 checks verify the folders still exist
if checks % 10 == 0 && kind == "ping" {
ctx.sess.invalidate_folders();
let all = ctx.sess.folders().await?;
if watched
.iter()
.any(|w| !all.iter().any(|f| f.id == w.folder.id))
{
return Err(anyhow::anyhow!("hierarchy"));
}
}
}
}
.await;
ctx.app.metrics.ping_end(&key, gen);
result
}
+346
View File
@@ -0,0 +1,346 @@
//! Provision command (policies + remote wipe).
use anyhow::Result;
use rand::Rng;
use super::{Ctx, Reply};
use crate::config::Policy;
use crate::db::{self, DeviceField, DeviceInfo};
use crate::util::sha256_hex;
use crate::wbxml::Element;
pub fn policy_hash(p: &Policy) -> String {
sha256_hex(serde_json::to_string(p).unwrap_or_default().as_bytes())[..16].to_string()
}
pub fn policy_hash_matches(ctx: &Ctx) -> bool {
let p = ctx.s.policy_for(ctx.user());
ctx.dev.policy_hash.is_empty() || ctx.dev.policy_hash == policy_hash(p)
}
fn b(v: bool) -> &'static str {
if v {
"1"
} else {
"0"
}
}
/// MS-ASPROV EASProvisionDoc.
pub fn provision_doc(p: &Policy, ver: u32) -> Element {
let mut d = Element::new("Provision:EASProvisionDoc");
d.push_text("Provision:DevicePasswordEnabled", b(p.devpwenabled));
d.push_text(
"Provision:AlphanumericDevicePasswordRequired",
b(p.alphanumpwreq),
);
d.push_text("Provision:PasswordRecoveryEnabled", b(p.pwrecoveryenabled));
d.push_text("Provision:DeviceEncryptionEnabled", b(p.devencenabled));
d.push_text("Provision:AttachmentsEnabled", b(p.attenabled));
d.push_text(
"Provision:MinDevicePasswordLength",
p.mindevpwlenngth.to_string(),
);
d.push_text(
"Provision:MaxInactivityTimeDeviceLock",
p.maxinacttimedevlock.to_string(),
);
d.push_text(
"Provision:MaxDevicePasswordFailedAttempts",
p.maxdevpwfailedattempts.to_string(),
);
if p.maxattsize >= 0 {
d.push_text("Provision:MaxAttachmentSize", p.maxattsize.to_string());
} else {
d.push(Element::new("Provision:MaxAttachmentSize"));
}
d.push_text("Provision:AllowSimpleDevicePassword", b(p.allowsimpledevpw));
if p.devpwexpiration > 0 {
d.push_text(
"Provision:DevicePasswordExpiration",
p.devpwexpiration.to_string(),
);
} else {
d.push(Element::new("Provision:DevicePasswordExpiration"));
}
d.push_text(
"Provision:DevicePasswordHistory",
p.devpwhistory.to_string(),
);
if ver >= 121 {
d.push_text("Provision:AllowStorageCard", b(p.allowstoragecard));
d.push_text("Provision:AllowCamera", b(p.allowcam));
d.push_text("Provision:RequireDeviceEncryption", b(p.reqdevenc));
d.push_text(
"Provision:AllowUnsignedApplications",
b(p.allowunsignedapps),
);
d.push_text(
"Provision:AllowUnsignedInstallationPackages",
b(p.allowunsignedinstallationpackages),
);
d.push_text(
"Provision:MinDevicePasswordComplexCharacters",
p.mindevcomplexchars.to_string(),
);
d.push_text("Provision:AllowWiFi", b(p.allowwifi));
d.push_text("Provision:AllowTextMessaging", b(p.allowtextmessaging));
d.push_text("Provision:AllowPOPIMAPEmail", b(p.allowpopimapemail));
d.push_text("Provision:AllowBluetooth", p.allowbluetooth.to_string());
d.push_text("Provision:AllowIrDA", b(p.allowirda));
d.push_text(
"Provision:RequireManualSyncWhenRoaming",
b(p.reqmansyncroam),
);
d.push_text("Provision:AllowDesktopSync", b(p.allowdesktopsync));
d.push_text(
"Provision:MaxCalendarAgeFilter",
p.maxcalagefilter.to_string(),
);
d.push_text("Provision:AllowHTMLEmail", b(p.allowhtmlemail));
d.push_text(
"Provision:MaxEmailAgeFilter",
p.maxemailagefilter.to_string(),
);
if p.maxemailbodytruncsize >= 0 {
d.push_text(
"Provision:MaxEmailBodyTruncationSize",
p.maxemailbodytruncsize.to_string(),
);
} else {
d.push(Element::new("Provision:MaxEmailBodyTruncationSize"));
}
if p.maxemailhtmlbodytruncsize >= 0 {
d.push_text(
"Provision:MaxEmailHTMLBodyTruncationSize",
p.maxemailhtmlbodytruncsize.to_string(),
);
} else {
d.push(Element::new("Provision:MaxEmailHTMLBodyTruncationSize"));
}
d.push_text(
"Provision:RequireSignedSMIMEMessages",
b(p.reqsignedsmimemessages),
);
d.push_text(
"Provision:RequireEncryptedSMIMEMessages",
b(p.reqencsmimemessages),
);
d.push_text(
"Provision:RequireSignedSMIMEAlgorithm",
p.reqsignedsmimealgorithm.to_string(),
);
d.push_text(
"Provision:RequireEncryptionSMIMEAlgorithm",
p.reqencsmimealgorithm.to_string(),
);
d.push_text(
"Provision:AllowSMIMEEncryptionAlgorithmNegotiation",
p.allowsmimeencalgneg.to_string(),
);
d.push_text("Provision:AllowSMIMESoftCerts", b(p.allowsmimesoftcerts));
d.push_text("Provision:AllowBrowser", b(p.allowbrowser));
d.push_text("Provision:AllowConsumerEmail", b(p.allowconsumeremail));
d.push_text("Provision:AllowRemoteDesktop", b(p.allowremotedesktop));
d.push_text("Provision:AllowInternetSharing", b(p.allowinternetsharing));
d.push(Element::new("Provision:UnapprovedInROMApplicationList"));
d.push(Element::new("Provision:ApprovedApplicationList"));
}
d
}
pub fn device_info_from(set: &Element) -> DeviceInfo {
let g = |n: &str| {
set.child_text(n)
.map(|s| crate::util::truncate_bytes(&s, 200).to_string())
};
DeviceInfo {
model: g("Settings:Model"),
imei: g("Settings:IMEI"),
friendly_name: g("Settings:FriendlyName"),
os: g("Settings:OS"),
os_language: g("Settings:OSLanguage"),
phone_number: g("Settings:PhoneNumber"),
mobile_operator: g("Settings:MobileOperator"),
user_agent: g("Settings:UserAgent"),
}
}
fn new_key() -> u32 {
rand::thread_rng().gen_range(1_000_000..u32::MAX)
}
pub async fn provision(ctx: &mut Ctx, req: Option<Element>) -> Result<Reply> {
let Some(req) = req else {
return Ok(Reply::Wbxml(
Element::new("Provision:Provision").with_text("Provision:Status", "2"),
));
};
let ver = ctx.req.ver();
let mut resp = Element::new("Provision:Provision").with_text("Provision:Status", "1");
if let Some(di) = req.child("Settings:DeviceInformation") {
if let Some(set) = di.child("Settings:Set") {
db::device_set_info(&ctx.app.db, ctx.dev.id, &device_info_from(set)).await?;
}
resp.push(Element::new("Settings:DeviceInformation").with_text("Settings:Status", "1"));
}
// ---- remote wipe acknowledgement
if let Some(rw) = req
.child("Provision:RemoteWipe")
.or_else(|| req.child("Provision:AccountOnlyRemoteWipe"))
{
let st = rw.child_u32("Provision:Status").unwrap_or(0);
tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, status = st, "device acknowledged remote wipe");
db::device_wipe_done(
&ctx.app.db,
ctx.dev.id,
if st == 1 {
db::WIPE_WIPED
} else {
db::WIPE_REQUESTED
},
)
.await?;
db::audit(
&ctx.app.db,
ctx.user(),
"wipe_ack",
&ctx.req.device_id,
&ctx.ip,
&format!("status {st}"),
)
.await;
return Ok(Reply::Wbxml(resp));
}
let wipe = matches!(ctx.dev.wipe_status, db::WIPE_PENDING | db::WIPE_REQUESTED);
if let Some(pol) = req.path(&["Provision:Policies", "Provision:Policy"]) {
let ptype = pol.child_text("Provision:PolicyType").unwrap_or_default();
let mut pe =
Element::new("Provision:Policy").with_text("Provision:PolicyType", ptype.clone());
if ptype != "MS-EAS-Provisioning-WBXML" && ptype != "MS-WAP-Provisioning-XML" {
pe.push_text("Provision:Status", "3");
resp.push(Element::new("Provision:Policies").with(pe));
return Ok(Reply::Wbxml(resp));
}
if !ctx.s.provisioning.enabled {
pe.push_text("Provision:Status", "2");
resp.push(Element::new("Provision:Policies").with(pe));
return Ok(Reply::Wbxml(resp));
}
let policy = ctx.s.policy_for(ctx.user()).clone();
match pol
.child_text("Provision:PolicyKey")
.and_then(|k| k.parse::<u32>().ok())
{
None => {
// phase 1: deliver the policy with a temporary key
let temp = new_key();
db::device_set_field(
&ctx.app.db,
ctx.dev.id,
DeviceField::PolicyTempKey,
Some(temp as i64),
None,
)
.await?;
pe.push_text("Provision:Status", "1");
pe.push_text("Provision:PolicyKey", temp.to_string());
let data = if ptype == "MS-WAP-Provisioning-XML" {
Element::text(
"Provision:Data",
format!(
"<wap-provisioningdoc><characteristic type=\"SecurityPolicy\"><parm name=\"4131\" value=\"{}\"/><parm name=\"4133\" value=\"1\"/></characteristic></wap-provisioningdoc>",
if policy.devpwenabled { 0 } else { 1 }
),
)
} else {
Element::new("Provision:Data").with(provision_doc(&policy, ver))
};
pe.push(data);
ctx.status = "policy sent".into();
}
Some(key) => {
// phase 2: acknowledgement
let client_status = pol.child_u32("Provision:Status").unwrap_or(1);
let ok_key = key == ctx.dev.policy_temp_key as u32
|| key == ctx.dev.policy_key as u32
|| ctx.dev.policy_temp_key == 0;
if !ok_key {
pe.push_text("Provision:Status", "5");
} else if client_status == 1 || (client_status == 2 && ctx.s.provisioning.loose) {
let fin = new_key();
db::device_set_field(
&ctx.app.db,
ctx.dev.id,
DeviceField::PolicyKey,
Some(fin as i64),
None,
)
.await?;
db::device_set_field(
&ctx.app.db,
ctx.dev.id,
DeviceField::PolicyTempKey,
Some(0),
None,
)
.await?;
db::device_set_field(
&ctx.app.db,
ctx.dev.id,
DeviceField::PolicyName,
None,
Some(&policy.name),
)
.await?;
db::device_set_field(
&ctx.app.db,
ctx.dev.id,
DeviceField::PolicyHash,
None,
Some(&policy_hash(&policy)),
)
.await?;
pe.push_text("Provision:Status", "1");
pe.push_text("Provision:PolicyKey", fin.to_string());
ctx.status = "provisioned".into();
tracing::info!(user = %ctx.user(), device = %ctx.req.device_id, policy = %policy.name, "device provisioned");
} else {
tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, client_status, "device could not apply policy");
pe.push_text("Provision:Status", "1");
pe.push_text("Provision:PolicyKey", "0");
ctx.status = format!("device rejected policy ({client_status})");
}
}
}
resp.push(Element::new("Provision:Policies").with(pe));
}
if wipe {
resp.push(Element::new("Provision:RemoteWipe"));
db::device_set_field(
&ctx.app.db,
ctx.dev.id,
DeviceField::WipeStatus,
Some(db::WIPE_REQUESTED),
None,
)
.await?;
db::audit(
&ctx.app.db,
"system",
"wipe_sent",
&ctx.req.device_id,
&ctx.ip,
ctx.user(),
)
.await;
tracing::warn!(user = %ctx.user(), device = %ctx.req.device_id, "remote wipe command sent to device");
ctx.status = "remote wipe sent".into();
}
Ok(Reply::Wbxml(resp))
}
+128
View File
@@ -0,0 +1,128 @@
//! Sync keys and persisted per-device state (folder hierarchy + item fingerprints).
use std::collections::BTreeMap;
use anyhow::Result;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use crate::db;
use super::convert::Options;
use super::Ctx;
pub const HIERARCHY: &str = "h";
#[derive(Debug, Clone, PartialEq)]
pub enum SyncKey {
Initial,
Key { uuid: String, counter: i64 },
}
impl SyncKey {
pub fn parse(s: &str) -> Option<SyncKey> {
let s = s.trim();
if s == "0" {
return Some(SyncKey::Initial);
}
let rest = s.strip_prefix('{')?;
let (uuid, counter) = rest.split_once('}')?;
if uuid.len() != 36 || !uuid.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return None;
}
Some(SyncKey::Key {
uuid: uuid.to_string(),
counter: counter.parse().ok()?,
})
}
}
pub fn make_key(uuid: &str, counter: i64) -> String {
format!("{{{uuid}}}{counter}")
}
pub fn new_uuid() -> String {
uuid::Uuid::new_v4().to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HierarchyState {
/// short id -> (parent short id, name, type)
pub folders: BTreeMap<String, (String, String, u8)>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CollectionState {
pub backend_id: String,
pub class: String,
pub meta: String,
pub filter: u8,
/// item id -> fingerprint, as known by the device
pub items: BTreeMap<String, String>,
/// client id -> server id of the last processed Adds (idempotent retries)
pub client_ids: Vec<(String, String)>,
/// Options remembered for empty/partial Sync requests
pub options: Option<Options>,
/// last backend signature (Ping)
pub signature: String,
/// number of consecutive retries of the same key (loop detection)
pub retries: u32,
pub window: u32,
}
pub async fn load<T: DeserializeOwned>(
ctx: &Ctx,
collection: &str,
key: &SyncKey,
) -> Result<Option<T>> {
let SyncKey::Key { uuid, counter } = key else {
return Ok(None);
};
let Some(data) = db::state_get(&ctx.app.db, ctx.dev.id, collection, uuid, *counter).await?
else {
return Ok(None);
};
Ok(serde_json::from_slice(&data).ok())
}
pub async fn load_latest<T: DeserializeOwned>(
ctx: &Ctx,
collection: &str,
) -> Result<Option<(String, i64, T)>> {
let Some((uuid, counter, data)) = db::state_latest(&ctx.app.db, ctx.dev.id, collection).await?
else {
return Ok(None);
};
Ok(serde_json::from_slice(&data)
.ok()
.map(|t| (uuid, counter, t)))
}
pub async fn save<T: Serialize>(
ctx: &Ctx,
collection: &str,
uuid: &str,
counter: i64,
v: &T,
) -> Result<()> {
let data = serde_json::to_vec(v)?;
db::state_put(&ctx.app.db, ctx.dev.id, collection, uuid, counter, &data).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys() {
let u = new_uuid();
let k = make_key(&u, 5);
assert_eq!(
SyncKey::parse(&k),
Some(SyncKey::Key {
uuid: u,
counter: 5
})
);
assert_eq!(SyncKey::parse("0"), Some(SyncKey::Initial));
assert_eq!(SyncKey::parse("garbage"), None);
}
}
+1081
View File
File diff suppressed because it is too large. Load diff
+68
View File
@@ -0,0 +1,68 @@
//! ActivePush - Exchange ActiveSync gateway for IMAP/SMTP + CalDAV/CardDAV (Z-Push rewrite).
pub mod admin;
pub mod app;
pub mod autodiscover;
pub mod backend;
pub mod config;
pub mod db;
pub mod eas;
pub mod logging;
pub mod metrics;
pub mod probe;
pub mod server;
pub mod tls;
pub mod util;
pub mod wbxml;
pub mod xmltree;
use std::sync::Arc;
use axum::routing::any;
use axum::Router;
/// Public (device facing) router: ActiveSync + Autodiscover. Paths match the nginx
/// locations previously routed to Z-Push.
pub fn public_router(app: Arc<app::App>) -> Router {
Router::new()
.route("/Microsoft-Server-ActiveSync", any(eas::handler))
.route("/microsoft-server-activesync", any(eas::handler))
.route("/AutoDiscover/AutoDiscover.xml", any(autodiscover::handler))
.route("/Autodiscover/Autodiscover.xml", any(autodiscover::handler))
.route("/autodiscover/autodiscover.xml", any(autodiscover::handler))
.route("/healthz", axum::routing::get(healthz))
.route("/", axum::routing::get(index))
.fallback(not_found)
.with_state(app)
}
async fn index() -> impl axum::response::IntoResponse {
(
[("Content-Type", "text/plain; charset=utf-8")],
"ActivePush - Exchange ActiveSync endpoint.\nDevices connect to /Microsoft-Server-ActiveSync.\n",
)
}
async fn not_found() -> impl axum::response::IntoResponse {
(
axum::http::StatusCode::NOT_FOUND,
[("Content-Type", "text/plain; charset=utf-8")],
"Not found.\n",
)
}
/// Liveness + database check for load balancers and monitoring.
async fn healthz(
axum::extract::State(app): axum::extract::State<Arc<app::App>>,
) -> impl axum::response::IntoResponse {
match sqlx::query_scalar::<_, i64>("SELECT 1")
.fetch_one(&app.db)
.await
{
Ok(_) => (axum::http::StatusCode::OK, "ok\n"),
Err(_) => (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"database unavailable\n",
),
}
}
+134
View File
@@ -0,0 +1,134 @@
//! tracing setup + in-memory ring buffer exposed in the admin panel.
use std::collections::VecDeque;
use std::fmt::Write as _;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use serde::Serialize;
use tracing::field::{Field, Visit};
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::layer::{Context, SubscriberExt};
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer};
#[derive(Debug, Clone, Serialize)]
pub struct LogLine {
pub ts: i64,
pub level: String,
pub target: String,
pub message: String,
}
pub struct LogRing {
lines: Mutex<VecDeque<LogLine>>,
cap: AtomicUsize,
}
impl LogRing {
pub fn new(cap: usize) -> Arc<Self> {
Arc::new(LogRing {
lines: Mutex::new(VecDeque::with_capacity(cap)),
cap: AtomicUsize::new(cap.max(100)),
})
}
pub fn set_cap(&self, cap: usize) {
self.cap.store(cap.clamp(100, 100_000), Ordering::Relaxed);
}
pub fn push(&self, l: LogLine) {
let cap = self.cap.load(Ordering::Relaxed);
let mut g = self.lines.lock().unwrap();
g.push_back(l);
while g.len() > cap {
g.pop_front();
}
}
pub fn tail(&self, n: usize, min_level: Option<&str>, q: Option<&str>) -> Vec<LogLine> {
let rank = |l: &str| match l {
"ERROR" => 5,
"WARN" => 4,
"INFO" => 3,
"DEBUG" => 2,
_ => 1,
};
let min = min_level.map(rank).unwrap_or(0);
let q = q.map(|s| s.to_lowercase());
let g = self.lines.lock().unwrap();
let mut out: Vec<LogLine> = g
.iter()
.rev()
.filter(|l| rank(&l.level) >= min)
.filter(|l| {
q.as_ref()
.map(|q| l.message.to_lowercase().contains(q) || l.target.contains(q))
.unwrap_or(true)
})
.take(n)
.cloned()
.collect();
out.reverse();
out
}
}
struct RingLayer {
ring: Arc<LogRing>,
}
struct MsgVisitor(String);
impl Visit for MsgVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
let _ = write!(self.0, "{value:?}");
} else {
let _ = write!(self.0, " {}={:?}", field.name(), value);
}
}
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "message" {
self.0.push_str(value);
} else {
let _ = write!(self.0, " {}={}", field.name(), value);
}
}
}
impl<S: Subscriber> Layer<S> for RingLayer {
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
let meta = event.metadata();
if *meta.level() > Level::INFO && !meta.target().starts_with("active_push") {
return;
}
let mut v = MsgVisitor(String::new());
event.record(&mut v);
self.ring.push(LogLine {
ts: chrono::Utc::now().timestamp(),
level: meta.level().to_string(),
target: meta.target().to_string(),
message: v.0,
});
}
}
pub fn init(ring: Arc<LogRing>, json: bool) {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("active_push=info,warn"));
let ring_layer = RingLayer { ring }.with_filter(EnvFilter::new(
std::env::var("ACTIVE_PUSH_PANEL_LOG").unwrap_or_else(|_| "active_push=info,warn".into()),
));
let registry = tracing_subscriber::registry().with(ring_layer);
if json {
registry
.with(tracing_subscriber::fmt::layer().json().with_filter(filter))
.init();
} else {
registry
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_filter(filter),
)
.init();
}
}
+486
View File
@@ -0,0 +1,486 @@
use std::io::Read;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use active_push::app::{App, Boot};
use active_push::{admin, db, logging, probe, public_router, server, tls};
#[derive(Parser)]
#[command(
name = "active-push",
version,
about = "ActivePush - Exchange ActiveSync server for IMAP/SMTP/CalDAV/CardDAV"
)]
struct Cli {
/// Data directory (SQLite database).
#[arg(
long,
env = "ACTIVE_PUSH_DATA_DIR",
default_value = "/var/lib/active-push",
global = true
)]
data_dir: PathBuf,
/// Device facing listener (put nginx/RustProxy in front of it).
#[arg(
long,
env = "ACTIVE_PUSH_EAS_LISTEN",
default_value = "127.0.0.1:8081",
global = true
)]
eas_listen: SocketAddr,
/// Administration panel listener.
#[arg(
long,
env = "ACTIVE_PUSH_ADMIN_LISTEN",
default_value = "127.0.0.1:9091",
global = true
)]
admin_listen: SocketAddr,
/// Allow the panel to listen on a non-loopback address.
#[arg(long, env = "ACTIVE_PUSH_ALLOW_REMOTE_ADMIN", global = true)]
allow_remote_admin: bool,
/// Optional TLS for the panel (PEM files).
#[arg(long, env = "ACTIVE_PUSH_ADMIN_TLS_CERT", global = true)]
admin_tls_cert: Option<String>,
#[arg(long, env = "ACTIVE_PUSH_ADMIN_TLS_KEY", global = true)]
admin_tls_key: Option<String>,
/// Optional TLS for the ActiveSync listener (normally TLS ends at the reverse proxy).
#[arg(long, env = "ACTIVE_PUSH_EAS_TLS_CERT", global = true)]
eas_tls_cert: Option<String>,
#[arg(long, env = "ACTIVE_PUSH_EAS_TLS_KEY", global = true)]
eas_tls_key: Option<String>,
/// JSON log output (for journald/ELK).
#[arg(long, env = "ACTIVE_PUSH_LOG_JSON", global = true)]
log_json: bool,
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
/// Run the server (default).
Serve,
/// Administrator management.
Admin {
#[command(subcommand)]
cmd: AdminCmd,
},
/// Settings import/export.
Config {
#[command(subcommand)]
cmd: ConfigCmd,
},
/// Device management (z-push-admin equivalents).
Device {
#[command(subcommand)]
cmd: DeviceCmd,
},
/// ActiveSync smoke test against a running server.
Probe {
#[arg(long)]
url: String,
#[arg(long)]
user: String,
/// Read the password from this environment variable (otherwise prompt).
#[arg(long)]
password_env: Option<String>,
#[arg(long)]
insecure: bool,
/// Device id used for the test partnership.
#[arg(long, default_value = "probe0000000001")]
device_id: String,
#[arg(long, default_value = "ActivePushProbe")]
device_type: String,
/// Model reported via DeviceInformation.
#[arg(long, default_value = "ActivePush probe")]
model: String,
},
}
#[derive(Subcommand)]
enum AdminCmd {
/// Print a one-time setup token (valid 1 hour) for creating the first admin in the panel.
SetupToken,
/// Create the first administrator (password is prompted).
Init {
#[arg(long)]
username: String,
},
/// Reset an administrator password and revoke all sessions.
ResetPassword {
#[arg(long)]
username: Option<String>,
},
/// Revoke all panel sessions.
LogoutAll,
Status,
}
#[derive(Subcommand)]
enum ConfigCmd {
/// Print current settings as JSON (secrets redacted unless --with-secrets).
Get {
#[arg(long)]
with_secrets: bool,
},
/// Validate and store settings from a JSON file ("-" = stdin). A running server picks
/// them up on restart; use the panel for live changes.
Apply { file: String },
/// Validate a JSON settings file without storing it.
Validate { file: String },
}
#[derive(Subcommand)]
enum DeviceCmd {
List,
/// Request a remote wipe.
Wipe {
id: i64,
},
/// Forget all sync states (device resynchronises everything).
Resync {
id: i64,
},
Remove {
id: i64,
},
Block {
id: i64,
},
Unblock {
id: i64,
},
}
fn read_password(prompt: &str) -> Result<String> {
let p1 = rpassword::prompt_password(prompt)?;
let p2 = rpassword::prompt_password("Repeat password: ")?;
if p1 != p2 {
bail!("passwords do not match");
}
Ok(p1)
}
fn read_file(f: &str) -> Result<String> {
if f == "-" {
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
Ok(s)
} else {
std::fs::read_to_string(f).with_context(|| format!("read {f}"))
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(run(cli))
}
async fn run(cli: Cli) -> Result<()> {
let ring = logging::LogRing::new(2000);
let cmd = cli.cmd.unwrap_or(Cmd::Serve);
if matches!(cmd, Cmd::Serve) {
logging::init(ring.clone(), cli.log_json);
} else {
// CLI commands: quiet logging to stderr
let _ = tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "warn".into()),
))
.with_writer(std::io::stderr)
.try_init();
}
if let Cmd::Probe {
url,
user,
password_env,
insecure,
device_id,
device_type,
model,
} = &cmd
{
let pass = match password_env {
Some(v) => {
std::env::var(v).with_context(|| format!("environment variable {v} not set"))?
}
None => rpassword::prompt_password("Password: ")?,
};
return probe::run(url, user, &pass, *insecure, device_id, device_type, model).await;
}
let boot = Boot {
data_dir: cli.data_dir.clone(),
eas_listen: cli.eas_listen,
admin_listen: cli.admin_listen,
allow_remote_admin: cli.allow_remote_admin,
admin_tls: cli.admin_tls_cert.clone().zip(cli.admin_tls_key.clone()),
eas_tls: cli.eas_tls_cert.clone().zip(cli.eas_tls_key.clone()),
};
check_data_dir_owner(&boot.data_dir)?;
let app = App::new(boot, ring).await?;
match cmd {
Cmd::Serve => serve(app).await,
Cmd::Admin { cmd } => match cmd {
AdminCmd::SetupToken => {
let t = admin::cli_setup_token(&app).await?;
println!("{t}");
eprintln!("One-time setup token (valid 60 minutes). Open the panel and paste it to create the administrator.");
Ok(())
}
AdminCmd::Init { username } => {
let pw = read_password("New administrator password (min. 12 characters): ")?;
admin::cli_init(&app, &username, &pw).await?;
println!("Administrator {username} created.");
Ok(())
}
AdminCmd::ResetPassword { username } => {
let pw = read_password("New password (min. 12 characters): ")?;
let u = admin::cli_reset_password(&app, username.as_deref(), &pw).await?;
println!("Password for {u} changed; all panel sessions were revoked.");
Ok(())
}
AdminCmd::LogoutAll => {
let n = admin::cli_logout_all(&app).await?;
println!("{n} session(s) revoked.");
Ok(())
}
AdminCmd::Status => {
println!("{}", admin::cli_status(&app).await?);
Ok(())
}
},
Cmd::Config { cmd } => match cmd {
ConfigCmd::Get { with_secrets } => {
let s = app.settings();
let v = if with_secrets {
serde_json::to_value(&*s)?
} else {
s.redacted()
};
println!("{}", serde_json::to_string_pretty(&v)?);
Ok(())
}
ConfigCmd::Apply { file } => {
let v: serde_json::Value = serde_json::from_str(&read_file(&file)?)?;
let s = app
.settings()
.merge_update(v)
.map_err(|e| anyhow::anyhow!(e))?;
let rev = app.update_settings(s).await?;
db::audit(
&app.db,
"cli",
"settings_saved",
"",
"cli",
&format!("revision {rev}"),
)
.await;
println!("Settings stored (revision {rev}). Restart the service or save in the panel to apply live.");
Ok(())
}
ConfigCmd::Validate { file } => {
let v: serde_json::Value = serde_json::from_str(&read_file(&file)?)?;
app.settings()
.merge_update(v)
.map_err(|e| anyhow::anyhow!(e))?;
println!("Settings are valid.");
Ok(())
}
},
Cmd::Device { cmd } => {
match cmd {
DeviceCmd::List => {
println!(
"{:>5} {:<28} {:<16} {:<30} {:<19} STATE",
"ID", "USER", "TYPE", "DEVICE ID", "LAST SEEN"
);
for d in db::device_list(&app.db).await? {
let state = if d.blocked == 1 {
"blocked"
} else if d.wipe_status == db::WIPE_WIPED {
"wiped"
} else if d.wipe_status >= db::WIPE_PENDING {
"wipe pending"
} else if d.approved == 0 {
"awaiting approval"
} else {
"ok"
};
println!(
"{:>5} {:<28} {:<16} {:<30} {:<19} {state}",
d.id,
d.username,
d.device_type,
d.device_id,
active_push::util::format_ts(d.last_seen)
);
}
}
DeviceCmd::Wipe { id } => {
db::device_request_wipe(&app.db, id, "cli", false).await?;
db::audit(&app.db, "cli", "device_wipe", &id.to_string(), "cli", "").await;
println!("Remote wipe requested for device {id}; it is executed on the next device connection.");
}
DeviceCmd::Resync { id } => {
db::device_reset_states(&app.db, id).await?;
println!("States of device {id} removed.");
}
DeviceCmd::Remove { id } => {
db::device_delete(&app.db, id).await?;
println!("Device {id} removed.");
}
DeviceCmd::Block { id } => {
db::device_set_field(&app.db, id, db::DeviceField::Blocked, Some(1), None)
.await?;
println!("Device {id} blocked.");
}
DeviceCmd::Unblock { id } => {
db::device_set_field(&app.db, id, db::DeviceField::Blocked, Some(0), None)
.await?;
println!("Device {id} unblocked.");
}
}
Ok(())
}
Cmd::Probe { .. } => unreachable!(),
}
}
/// Refuse to touch a data directory owned by another user (e.g. `active-push admin …` run as
/// root against the service's /var/lib/active-push would leave root-owned database files).
fn check_data_dir_owner(dir: &std::path::Path) -> Result<()> {
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::MetadataExt;
let Ok(meta) = std::fs::metadata(dir) else {
return Ok(());
};
let euid = std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|s| {
s.lines().find(|l| l.starts_with("Uid:")).and_then(|l| {
l.split_whitespace()
.nth(2)
.and_then(|v| v.parse::<u32>().ok())
})
});
if let Some(euid) = euid {
if euid != meta.uid() && std::env::var("ACTIVE_PUSH_ALLOW_FOREIGN_DATA_DIR").is_err() {
bail!(
"{} belongs to uid {}, but this command runs as uid {euid}.\nRun it as the service user, e.g.: sudo -u active-push active-push …",
dir.display(),
meta.uid()
);
}
}
}
Ok(())
}
async fn serve(app: Arc<App>) -> Result<()> {
let boot = app.boot.clone();
if !boot.admin_listen.ip().is_loopback() && !boot.allow_remote_admin {
bail!(
"refusing to expose the admin panel on {} - use a loopback address, an SSH tunnel/reverse proxy, or pass --allow-remote-admin",
boot.admin_listen
);
}
let admin_tls = match &boot.admin_tls {
Some((c, k)) => Some(tls::server_config(c, k)?),
None => None,
};
let eas_tls = match &boot.eas_tls {
Some((c, k)) => Some(tls::server_config(c, k)?),
None => None,
};
let eas_l = tokio::net::TcpListener::bind(boot.eas_listen)
.await
.with_context(|| format!("bind ActiveSync listener {}", boot.eas_listen))?;
let adm_l = tokio::net::TcpListener::bind(boot.admin_listen)
.await
.with_context(|| format!("bind admin listener {}", boot.admin_listen))?;
let s = app.settings();
tracing::info!(
version = env!("CARGO_PKG_VERSION"),
eas = %boot.eas_listen,
panel = %boot.admin_listen,
imap = %format!("{}:{}", s.imap.host, s.imap.port),
caldav = s.caldav.enabled,
carddav = s.carddav.enabled,
"ActivePush started"
);
let has_admin: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM admins")
.fetch_one(&app.db)
.await
.unwrap_or(0);
if has_admin == 0 {
tracing::warn!(
"no administrator yet - run `active-push admin setup-token` and open the panel"
);
}
// background housekeeping
let bg = app.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(60));
let mut n: u64 = 0;
loop {
tokio::select! {
_ = tick.tick() => {}
_ = bg.shutdown.cancelled() => break,
}
n += 1;
let s = bg.settings();
bg.pool
.evict_idle(Duration::from_secs(s.imap.pool_idle_secs));
bg.auth.housekeeping();
bg.metrics.prune();
if n % 5 == 0 {
bg.metrics.flush_hour(&bg.db).await;
}
if n % 60 == 1 {
if let Err(e) = db::housekeeping(&bg.db, &s).await {
tracing::warn!("housekeeping failed: {e:#}");
}
}
}
});
let shutdown = app.shutdown.clone();
let eas = tokio::spawn(server::serve(
"activesync",
eas_l,
public_router(app.clone()),
eas_tls,
shutdown.clone(),
));
let adm = tokio::spawn(server::serve(
"panel",
adm_l,
admin::router(app.clone()),
admin_tls,
shutdown.clone(),
));
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
tracing::info!("shutdown requested");
shutdown.cancel();
let _ = tokio::join!(eas, adm);
app.metrics.flush_hour(&app.db).await;
app.db.close().await;
tracing::info!("bye");
Ok(())
}
+479
View File
@@ -0,0 +1,479 @@
//! In-memory metrics, live request registry and per-device ping tracking.
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use serde::Serialize;
use tokio::sync::{broadcast, Notify};
use crate::db::Db;
use crate::util::now;
#[derive(Default)]
pub struct Counters {
pub requests: AtomicU64,
pub errors: AtomicU64,
pub auth_failures: AtomicU64,
pub bytes_in: AtomicU64,
pub bytes_out: AtomicU64,
pub items_out: AtomicU64,
pub items_in: AtomicU64,
pub mails_sent: AtomicU64,
pub autodiscover: AtomicU64,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct CmdStat {
pub count: u64,
pub errors: u64,
pub total_ms: u64,
pub max_ms: u64,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct Bucket {
pub t: i64,
pub requests: u64,
pub errors: u64,
pub auth_failures: u64,
pub items_out: u64,
pub items_in: u64,
pub bytes_in: u64,
pub bytes_out: u64,
pub total_ms: u64,
pub pings: u64,
pub mails_sent: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ActiveRequest {
pub id: u64,
pub user: String,
pub device_id: String,
pub device_type: String,
pub command: String,
pub ip: String,
pub started: i64,
pub version: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Event {
pub ts: i64,
pub user: String,
pub device_id: String,
pub device_type: String,
pub command: String,
pub ip: String,
pub http: u16,
pub status: String,
pub ms: u64,
pub bytes_in: u64,
pub bytes_out: u64,
pub items_out: u64,
pub items_in: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct PingInfo {
pub user: String,
pub device_id: String,
pub device_type: String,
pub started: i64,
pub lifetime: u64,
pub folders: usize,
pub kind: &'static str,
}
struct PingSlot {
info: PingInfo,
cancel: Arc<Notify>,
gen: u64,
}
pub struct Metrics {
pub c: Counters,
commands: Mutex<HashMap<String, CmdStat>>,
minutes: Mutex<VecDeque<Bucket>>,
hour_acc: Mutex<(i64, Bucket, HashMap<String, u64>)>,
active: Mutex<HashMap<u64, ActiveRequest>>,
next_id: AtomicU64,
recent: Mutex<VecDeque<Event>>,
pub events: broadcast::Sender<Event>,
pings: Mutex<HashMap<String, PingSlot>>,
ping_gen: AtomicU64,
users_seen: Mutex<HashMap<String, i64>>,
}
impl Default for Metrics {
fn default() -> Self {
Self::new()
}
}
impl Metrics {
pub fn new() -> Self {
let (tx, _) = broadcast::channel(512);
Metrics {
c: Counters::default(),
commands: Mutex::new(HashMap::new()),
minutes: Mutex::new(VecDeque::with_capacity(1441)),
hour_acc: Mutex::new((now() / 3600 * 3600, Bucket::default(), HashMap::new())),
active: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
recent: Mutex::new(VecDeque::with_capacity(501)),
events: tx,
pings: Mutex::new(HashMap::new()),
ping_gen: AtomicU64::new(1),
users_seen: Mutex::new(HashMap::new()),
}
}
fn with_bucket(&self, f: impl FnOnce(&mut Bucket)) {
let minute = now() / 60 * 60;
let mut m = self.minutes.lock().unwrap();
if m.back().map(|b| b.t) != Some(minute) {
m.push_back(Bucket {
t: minute,
..Default::default()
});
while m.len() > 1440 {
m.pop_front();
}
}
f(m.back_mut().unwrap());
}
pub fn begin(&self, r: ActiveRequest) -> u64 {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let mut r = r;
r.id = id;
if !r.user.is_empty() {
self.users_seen
.lock()
.unwrap()
.insert(r.user.clone(), now());
}
self.active.lock().unwrap().insert(id, r);
id
}
pub fn end(&self, id: u64, ev: Event) {
self.active.lock().unwrap().remove(&id);
self.record(ev);
}
pub fn record(&self, ev: Event) {
let failed = ev.http >= 500 || ev.status.starts_with("error");
self.c.requests.fetch_add(1, Ordering::Relaxed);
if failed {
self.c.errors.fetch_add(1, Ordering::Relaxed);
}
self.c.bytes_in.fetch_add(ev.bytes_in, Ordering::Relaxed);
self.c.bytes_out.fetch_add(ev.bytes_out, Ordering::Relaxed);
self.c.items_out.fetch_add(ev.items_out, Ordering::Relaxed);
self.c.items_in.fetch_add(ev.items_in, Ordering::Relaxed);
{
let mut cmds = self.commands.lock().unwrap();
let s = cmds.entry(ev.command.clone()).or_default();
s.count += 1;
if failed {
s.errors += 1;
}
// pings/hanging syncs would skew latency
if ev.command != "Ping" {
s.total_ms += ev.ms;
s.max_ms = s.max_ms.max(ev.ms);
}
}
let is_ping = ev.command == "Ping";
self.with_bucket(|b| {
b.requests += 1;
if failed {
b.errors += 1;
}
b.items_out += ev.items_out;
b.items_in += ev.items_in;
b.bytes_in += ev.bytes_in;
b.bytes_out += ev.bytes_out;
if is_ping {
b.pings += 1;
} else {
b.total_ms += ev.ms;
}
});
{
let mut h = self.hour_acc.lock().unwrap();
h.1.requests += 1;
if failed {
h.1.errors += 1;
}
h.1.bytes_in += ev.bytes_in;
h.1.bytes_out += ev.bytes_out;
h.1.items_out += ev.items_out;
h.1.items_in += ev.items_in;
if !is_ping {
h.1.total_ms += ev.ms;
}
*h.2.entry(ev.command.clone()).or_default() += 1;
}
{
let mut r = self.recent.lock().unwrap();
r.push_back(ev.clone());
while r.len() > 500 {
r.pop_front();
}
}
let _ = self.events.send(ev);
}
pub fn auth_failure(&self) {
self.c.auth_failures.fetch_add(1, Ordering::Relaxed);
self.with_bucket(|b| b.auth_failures += 1);
self.hour_acc.lock().unwrap().1.auth_failures += 1;
}
pub fn mail_sent(&self) {
self.c.mails_sent.fetch_add(1, Ordering::Relaxed);
self.with_bucket(|b| b.mails_sent += 1);
self.hour_acc.lock().unwrap().1.mails_sent += 1;
}
pub fn active(&self) -> Vec<ActiveRequest> {
let mut v: Vec<_> = self.active.lock().unwrap().values().cloned().collect();
v.sort_by_key(|a| a.started);
v
}
pub fn recent(&self, n: usize) -> Vec<Event> {
let r = self.recent.lock().unwrap();
r.iter().rev().take(n).cloned().collect()
}
pub fn minutes(&self, n: usize) -> Vec<Bucket> {
let m = self.minutes.lock().unwrap();
let skip = m.len().saturating_sub(n);
m.iter().skip(skip).cloned().collect()
}
pub fn commands(&self) -> HashMap<String, CmdStat> {
self.commands.lock().unwrap().clone()
}
/// Drop per-user activity entries older than a day (bounded memory).
pub fn prune(&self) {
let cutoff = now() - 86400;
self.users_seen.lock().unwrap().retain(|_, t| *t >= cutoff);
}
pub fn users_active(&self, window_secs: i64) -> usize {
let cutoff = now() - window_secs;
self.users_seen
.lock()
.unwrap()
.values()
.filter(|t| **t >= cutoff)
.count()
}
// ---------------------------------------------------------------- pings
/// Register a hanging request for a device. Any previous hanging request of the same
/// device is cancelled (the device opened a new connection).
pub fn ping_start(&self, key: &str, info: PingInfo) -> (Arc<Notify>, u64) {
let gen = self.ping_gen.fetch_add(1, Ordering::Relaxed);
let cancel = Arc::new(Notify::new());
let mut p = self.pings.lock().unwrap();
if let Some(old) = p.insert(
key.to_string(),
PingSlot {
info,
cancel: cancel.clone(),
gen,
},
) {
old.cancel.notify_one();
}
(cancel, gen)
}
pub fn ping_end(&self, key: &str, gen: u64) {
let mut p = self.pings.lock().unwrap();
if p.get(key).map(|s| s.gen) == Some(gen) {
p.remove(key);
}
}
/// A new request from a device interrupts its hanging Ping/Sync.
pub fn ping_interrupt(&self, key: &str) {
if let Some(s) = self.pings.lock().unwrap().remove(key) {
s.cancel.notify_one();
}
}
pub fn pings(&self) -> Vec<PingInfo> {
self.pings
.lock()
.unwrap()
.values()
.map(|s| s.info.clone())
.collect()
}
pub fn ping_count(&self) -> usize {
self.pings.lock().unwrap().len()
}
// ---------------------------------------------------------------- persistence
/// Flush the current hour accumulator into SQLite (called periodically).
pub async fn flush_hour(&self, db: &Db) {
let (hour, b, cmds) = {
let mut h = self.hour_acc.lock().unwrap();
let cur_hour = now() / 3600 * 3600;
let snapshot = (h.0, std::mem::take(&mut h.1), std::mem::take(&mut h.2));
h.0 = cur_hour;
snapshot
};
if b.requests == 0 && b.auth_failures == 0 && b.mails_sent == 0 {
return;
}
// merge command json
let existing: Option<String> =
sqlx::query_scalar("SELECT commands FROM stats_hourly WHERE hour = ?")
.bind(hour)
.fetch_optional(db)
.await
.ok()
.flatten();
let mut merged: HashMap<String, u64> = existing
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
for (k, v) in cmds {
*merged.entry(k).or_default() += v;
}
let _ = sqlx::query(
"INSERT INTO stats_hourly (hour, requests, errors, auth_failures, bytes_in, bytes_out, items_out, items_in, mails_sent, total_ms, commands)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(hour) DO UPDATE SET requests = requests + excluded.requests, errors = errors + excluded.errors,
auth_failures = auth_failures + excluded.auth_failures, bytes_in = bytes_in + excluded.bytes_in,
bytes_out = bytes_out + excluded.bytes_out, items_out = items_out + excluded.items_out,
items_in = items_in + excluded.items_in, mails_sent = mails_sent + excluded.mails_sent,
total_ms = total_ms + excluded.total_ms, commands = excluded.commands",
)
.bind(hour)
.bind(b.requests as i64)
.bind(b.errors as i64)
.bind(b.auth_failures as i64)
.bind(b.bytes_in as i64)
.bind(b.bytes_out as i64)
.bind(b.items_out as i64)
.bind(b.items_in as i64)
.bind(b.mails_sent as i64)
.bind(b.total_ms as i64)
.bind(serde_json::to_string(&merged).unwrap_or_default())
.execute(db)
.await;
}
}
/// Simple sliding-window failure counter + lockout (per IP and per user).
pub struct AuthGuard {
fails: Mutex<HashMap<String, VecDeque<i64>>>,
locked: Mutex<HashMap<String, i64>>,
ok_cache: Mutex<HashMap<[u8; 32], i64>>,
}
impl Default for AuthGuard {
fn default() -> Self {
AuthGuard {
fails: Mutex::new(HashMap::new()),
locked: Mutex::new(HashMap::new()),
ok_cache: Mutex::new(HashMap::new()),
}
}
}
impl AuthGuard {
pub fn locked_until(&self, key: &str) -> Option<i64> {
let mut l = self.locked.lock().unwrap();
match l.get(key) {
Some(t) if *t > now() => Some(*t),
Some(_) => {
l.remove(key);
None
}
None => None,
}
}
/// Record a failure; returns true if the key just became locked.
pub fn fail(&self, key: &str, max: u32, window: u64, lockout: u64) -> bool {
let t = now();
let mut f = self.fails.lock().unwrap();
let q = f.entry(key.to_string()).or_default();
q.push_back(t);
while q.front().map(|x| *x < t - window as i64).unwrap_or(false) {
q.pop_front();
}
if max > 0 && q.len() as u32 >= max {
q.clear();
self.locked
.lock()
.unwrap()
.insert(key.to_string(), t + lockout as i64);
return true;
}
false
}
pub fn success(&self, key: &str) {
self.fails.lock().unwrap().remove(key);
}
pub fn cache_ok(&self, k: [u8; 32], ttl: u64) {
if ttl > 0 {
self.ok_cache.lock().unwrap().insert(k, now() + ttl as i64);
}
}
pub fn is_cached(&self, k: &[u8; 32]) -> bool {
self.ok_cache
.lock()
.unwrap()
.get(k)
.map(|t| *t > now())
.unwrap_or(false)
}
pub fn clear_cache(&self) {
self.ok_cache.lock().unwrap().clear();
}
pub fn lockouts(&self) -> Vec<(String, i64)> {
let t = now();
self.locked
.lock()
.unwrap()
.iter()
.filter(|(_, v)| **v > t)
.map(|(k, v)| (k.clone(), *v))
.collect()
}
pub fn unlock(&self, key: &str) {
self.locked.lock().unwrap().remove(key);
self.fails.lock().unwrap().remove(key);
}
pub fn housekeeping(&self) {
let t = now();
self.locked.lock().unwrap().retain(|_, v| *v > t);
self.ok_cache.lock().unwrap().retain(|_, v| *v > t);
self.fails
.lock()
.unwrap()
.retain(|_, q| q.back().map(|x| *x > t - 86400).unwrap_or(false));
}
}
+266
View File
@@ -0,0 +1,266 @@
//! `active-push probe` - a small ActiveSync client used for smoke tests and diagnostics.
use anyhow::{bail, Context, Result};
use crate::wbxml::{self, Element};
pub struct Probe {
http: reqwest::Client,
url: String,
user: String,
pass: String,
device: String,
pub device_type: String,
pub model: String,
pub policy_key: String,
pub version: String,
}
impl Probe {
pub fn new(url: &str, user: &str, pass: &str, device: &str, insecure: bool) -> Result<Self> {
let http = reqwest::Client::builder()
.danger_accept_invalid_certs(insecure)
.timeout(std::time::Duration::from_secs(120))
.build()?;
Ok(Probe {
http,
url: url.trim_end_matches('/').to_string(),
user: user.into(),
pass: pass.into(),
device: device.into(),
device_type: "ActivePushProbe".into(),
model: "ActivePush probe".into(),
policy_key: "0".into(),
version: "14.1".into(),
})
}
pub async fn options(&self) -> Result<(u16, String, String)> {
let r = self
.http
.request(reqwest::Method::OPTIONS, &self.url)
.basic_auth(&self.user, Some(&self.pass))
.send()
.await?;
let h = |n: &str| {
r.headers()
.get(n)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string()
};
Ok((
r.status().as_u16(),
h("ms-asprotocolversions"),
h("ms-asprotocolcommands"),
))
}
pub async fn cmd(&self, cmd: &str, body: Option<Element>) -> Result<(u16, Option<Element>)> {
self.cmd_q(cmd, body, "").await
}
pub async fn cmd_q(
&self,
cmd: &str,
body: Option<Element>,
extra_q: &str,
) -> Result<(u16, Option<Element>)> {
let url = format!(
"{}?Cmd={cmd}&User={}&DeviceId={}&DeviceType={}{extra_q}",
self.url,
url::form_urlencoded::byte_serialize(self.user.as_bytes()).collect::<String>(),
self.device,
url::form_urlencoded::byte_serialize(self.device_type.as_bytes()).collect::<String>()
);
let mut rb = self
.http
.post(url)
.basic_auth(&self.user, Some(&self.pass))
.header("MS-ASProtocolVersion", &self.version)
.header("X-MS-PolicyKey", &self.policy_key)
.header("User-Agent", "ActivePush-Probe/1.0")
.header("Content-Type", "application/vnd.ms-sync.wbxml");
if let Some(b) = body {
rb = rb.body(wbxml::encode(&b));
}
let r = rb.send().await.with_context(|| format!("{cmd} request"))?;
let st = r.status().as_u16();
let bytes = r.bytes().await?;
if bytes.is_empty() || st != 200 {
return Ok((st, None));
}
Ok((st, Some(wbxml::decode(&bytes).context("decode response")?)))
}
pub async fn provision(&mut self) -> Result<String> {
let req = Element::new("Provision:Provision")
.with(
Element::new("Settings:DeviceInformation").with(
Element::new("Settings:Set")
.with_text("Settings:Model", self.model.clone())
.with_text("Settings:FriendlyName", self.model.clone())
.with_text("Settings:OS", "ActivePush probe")
.with_text("Settings:UserAgent", "ActivePush-Probe/1.0"),
),
)
.with(
Element::new("Provision:Policies").with(
Element::new("Provision:Policy")
.with_text("Provision:PolicyType", "MS-EAS-Provisioning-WBXML"),
),
);
let (_, r) = self.cmd("Provision", Some(req)).await?;
let r = r.context("empty provision response")?;
let pol = r
.path(&["Provision:Policies", "Provision:Policy"])
.context("no policy")?;
if pol.child_text("Provision:Status").as_deref() == Some("2") {
return Ok("provisioning disabled on server".into());
}
let temp = pol
.child_text("Provision:PolicyKey")
.context("no temp key")?;
self.policy_key = temp.clone();
let ack = Element::new("Provision:Provision").with(
Element::new("Provision:Policies").with(
Element::new("Provision:Policy")
.with_text("Provision:PolicyType", "MS-EAS-Provisioning-WBXML")
.with_text("Provision:PolicyKey", temp)
.with_text("Provision:Status", "1"),
),
);
let (_, r) = self.cmd("Provision", Some(ack)).await?;
let r = r.context("empty provision ack")?;
let key = r
.path(&["Provision:Policies", "Provision:Policy"])
.and_then(|p| p.child_text("Provision:PolicyKey"))
.context("no final key")?;
self.policy_key = key.clone();
Ok(key)
}
pub async fn folder_sync(&self, key: &str) -> Result<Element> {
let (st, r) = self
.cmd(
"FolderSync",
Some(
Element::new("FolderHierarchy:FolderSync")
.with_text("FolderHierarchy:SyncKey", key),
),
)
.await?;
r.with_context(|| format!("FolderSync returned HTTP {st}"))
}
pub async fn sync(
&self,
collection: &str,
key: &str,
window: u32,
commands: Option<Element>,
) -> Result<Element> {
let mut col = Element::new("AirSync:Collection")
.with_text("AirSync:SyncKey", key)
.with_text("AirSync:CollectionId", collection);
if key != "0" {
col.push(Element::new("AirSync:DeletesAsMoves"));
col.push(Element::new("AirSync:GetChanges"));
col.push_text("AirSync:WindowSize", window.to_string());
col.push(
Element::new("AirSync:Options").with(
Element::new("AirSyncBase:BodyPreference")
.with_text("AirSyncBase:Type", "1")
.with_text("AirSyncBase:TruncationSize", "2048"),
),
);
}
if let Some(c) = commands {
col.push(c);
}
let req = Element::new("AirSync:Sync").with(Element::new("AirSync:Collections").with(col));
let (st, r) = self.cmd("Sync", Some(req)).await?;
r.with_context(|| format!("Sync returned HTTP {st} without body"))
}
}
/// Run a read-only smoke test and print a report.
pub async fn run(
url: &str,
user: &str,
pass: &str,
insecure: bool,
device_id: &str,
device_type: &str,
model: &str,
) -> Result<()> {
let mut p = Probe::new(url, user, pass, device_id, insecure)?;
p.device_type = device_type.to_string();
p.model = model.to_string();
let (st, vers, _) = p.options().await?;
println!("OPTIONS HTTP {st} versions: {vers}");
if st == 401 {
bail!("authentication failed");
}
match p.provision().await {
Ok(k) => println!("Provision policy key {k}"),
Err(e) => println!("Provision failed: {e:#}"),
}
let fs = p.folder_sync("0").await?;
let status = fs.child_text("FolderHierarchy:Status").unwrap_or_default();
let mut inbox = None;
let mut n = 0;
if let Some(ch) = fs.child("FolderHierarchy:Changes") {
for a in ch.children_named("FolderHierarchy:Add") {
n += 1;
let t = a.child_text("FolderHierarchy:Type").unwrap_or_default();
let name = a
.child_text("FolderHierarchy:DisplayName")
.unwrap_or_default();
let id = a.child_text("FolderHierarchy:ServerId").unwrap_or_default();
println!(" folder {id:>4} type {t:>2} {name}");
if t == "2" {
inbox = Some(id);
}
}
}
println!("FolderSync status {status}, {n} folders");
if let Some(ib) = inbox {
let s0 = p.sync(&ib, "0", 0, None).await?;
let key = s0
.path(&["AirSync:Collections", "AirSync:Collection"])
.and_then(|c| c.child_text("AirSync:SyncKey"))
.context("no sync key")?;
let s1 = p.sync(&ib, &key, 5, None).await?;
let col = s1
.path(&["AirSync:Collections", "AirSync:Collection"])
.context("no collection")?;
let adds = col
.child("AirSync:Commands")
.map(|c| c.children.len())
.unwrap_or(0);
println!(
"Sync Inbox status {}, {adds} item(s){}",
col.child_text("AirSync:Status").unwrap_or_default(),
if col.has("AirSync:MoreAvailable") {
", more available"
} else {
""
}
);
if let Some(first) = col
.child("AirSync:Commands")
.and_then(|c| c.children.first())
{
if let Some(app) = first.child("AirSync:ApplicationData") {
println!(
" newest: {} from {}",
app.child_text("Email:Subject").unwrap_or_default(),
app.child_text("Email:From").unwrap_or_default()
);
}
}
}
println!("OK");
Ok(())
}
+97
View File
@@ -0,0 +1,97 @@
//! HTTP(S) listeners with graceful shutdown.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::ConnectInfo;
use axum::Router;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder;
use hyper_util::service::TowerToHyperService;
use tokio::net::TcpListener;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
pub async fn serve(
name: &'static str,
listener: TcpListener,
router: Router,
tls: Option<Arc<rustls::ServerConfig>>,
shutdown: CancellationToken,
) {
let acceptor = tls.map(tokio_rustls::TlsAcceptor::from);
let mut conns = JoinSet::new();
loop {
let (stream, peer) = tokio::select! {
r = listener.accept() => match r {
Ok(x) => x,
Err(e) => {
tracing::warn!("{name}: accept failed: {e}");
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
},
_ = shutdown.cancelled() => break,
};
let _ = stream.set_nodelay(true);
let svc = router
.clone()
.layer(axum::Extension(ConnectInfo::<SocketAddr>(peer)));
let acceptor = acceptor.clone();
let stop = shutdown.clone();
conns.spawn(async move {
let hyper_svc = TowerToHyperService::new(svc);
let mut builder = Builder::new(TokioExecutor::new());
builder
.http1()
.timer(hyper_util::rt::TokioTimer::new())
.header_read_timeout(Duration::from_secs(30))
.max_buf_size(256 * 1024);
builder.http2().max_concurrent_streams(64);
match acceptor {
Some(a) => {
let tls = match tokio::time::timeout(Duration::from_secs(15), a.accept(stream))
.await
{
Ok(Ok(s)) => s,
_ => return,
};
let conn = builder.serve_connection_with_upgrades(TokioIo::new(tls), hyper_svc);
tokio::pin!(conn);
tokio::select! {
_ = conn.as_mut() => {}
_ = stop.cancelled() => {
conn.as_mut().graceful_shutdown();
let _ = tokio::time::timeout(Duration::from_secs(10), conn).await;
}
}
}
None => {
let conn =
builder.serve_connection_with_upgrades(TokioIo::new(stream), hyper_svc);
tokio::pin!(conn);
tokio::select! {
_ = conn.as_mut() => {}
_ = stop.cancelled() => {
conn.as_mut().graceful_shutdown();
let _ = tokio::time::timeout(Duration::from_secs(10), conn).await;
}
}
}
}
});
// reap finished connections
while let Some(r) = conns.try_join_next() {
let _ = r;
}
}
tracing::info!(
"{name}: stopping, waiting for {} connection(s)",
conns.len()
);
let _ = tokio::time::timeout(Duration::from_secs(12), async {
while conns.join_next().await.is_some() {}
})
.await;
}
+112
View File
@@ -0,0 +1,112 @@
//! Shared rustls client configuration (system trust store + optional insecure mode).
use std::sync::{Arc, OnceLock};
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme};
fn provider() -> Arc<rustls::crypto::CryptoProvider> {
Arc::new(rustls::crypto::ring::default_provider())
}
fn roots() -> RootCertStore {
let mut store = RootCertStore::empty();
let native = rustls_native_certs::load_native_certs();
for cert in native.certs {
let _ = store.add(cert);
}
if store.is_empty() {
store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
}
store
}
pub fn client_config(verify: bool) -> Arc<ClientConfig> {
static VERIFIED: OnceLock<Arc<ClientConfig>> = OnceLock::new();
static INSECURE: OnceLock<Arc<ClientConfig>> = OnceLock::new();
if verify {
VERIFIED
.get_or_init(|| {
Arc::new(
ClientConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()
.expect("tls versions")
.with_root_certificates(roots())
.with_no_client_auth(),
)
})
.clone()
} else {
INSECURE
.get_or_init(|| {
Arc::new(
ClientConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()
.expect("tls versions")
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerify))
.with_no_client_auth(),
)
})
.clone()
}
}
/// Accepts any certificate. Only used when the administrator explicitly disables
/// verification for a backend (e.g. self-signed certificate on localhost).
#[derive(Debug)]
struct NoVerify;
impl ServerCertVerifier for NoVerify {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
/// Load a PEM certificate chain + key for the optional built-in HTTPS listeners.
pub fn server_config(cert_path: &str, key_path: &str) -> anyhow::Result<Arc<rustls::ServerConfig>> {
use anyhow::Context;
let certs = rustls_pemfile::certs(&mut std::io::BufReader::new(
std::fs::File::open(cert_path).with_context(|| format!("open {cert_path}"))?,
))
.collect::<Result<Vec<_>, _>>()?;
let key = rustls_pemfile::private_key(&mut std::io::BufReader::new(
std::fs::File::open(key_path).with_context(|| format!("open {key_path}"))?,
))?
.ok_or_else(|| anyhow::anyhow!("no private key in {key_path}"))?;
let mut cfg = rustls::ServerConfig::builder_with_provider(provider())
.with_safe_default_protocol_versions()?
.with_no_client_auth()
.with_single_cert(certs, key)?;
cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Ok(Arc::new(cfg))
}
+276
View File
@@ -0,0 +1,276 @@
use std::net::IpAddr;
use rand::RngCore;
use sha2::{Digest, Sha256};
pub fn now() -> i64 {
chrono::Utc::now().timestamp()
}
pub fn random_token(bytes: usize) -> String {
let mut b = vec![0u8; bytes];
rand::rngs::OsRng.fill_bytes(&mut b);
hex::encode(b)
}
pub fn sha256_hex(data: &[u8]) -> String {
hex::encode(Sha256::digest(data))
}
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
use subtle::ConstantTimeEq;
a.len() == b.len() && bool::from(a.ct_eq(b))
}
#[derive(Debug, Clone, Copy)]
pub struct Cidr {
addr: IpAddr,
prefix: u8,
}
impl Cidr {
pub fn parse(s: &str) -> Option<Cidr> {
let s = s.trim();
let (a, p) = match s.split_once('/') {
Some((a, p)) => (a, Some(p)),
None => (s, None),
};
let addr: IpAddr = a.parse().ok()?;
let max = if addr.is_ipv4() { 32 } else { 128 };
let prefix = match p {
Some(p) => p.parse().ok().filter(|x: &u8| *x <= max)?,
None => max,
};
Some(Cidr { addr, prefix })
}
pub fn contains(&self, ip: &IpAddr) -> bool {
let ip = normalize(*ip);
match (self.addr, ip) {
(IpAddr::V4(n), IpAddr::V4(i)) => {
let mask = if self.prefix == 0 {
0
} else {
u32::MAX << (32 - self.prefix)
};
(u32::from(n) & mask) == (u32::from(i) & mask)
}
(IpAddr::V6(n), IpAddr::V6(i)) => {
let mask = if self.prefix == 0 {
0
} else {
u128::MAX << (128 - self.prefix)
};
(u128::from(n) & mask) == (u128::from(i) & mask)
}
_ => false,
}
}
}
pub fn normalize(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip),
v4 => v4,
}
}
pub fn ip_in_any(ip: &IpAddr, nets: &[String]) -> bool {
nets.iter()
.filter_map(|n| Cidr::parse(n))
.any(|c| c.contains(ip))
}
pub fn html_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => out.push(c),
}
}
out
}
pub fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
c if (c as u32) < 0x20 && c != '\n' && c != '\r' && c != '\t' => {}
_ => out.push(c),
}
}
out
}
/// Truncate a string to at most `n` bytes on a char boundary.
pub fn truncate_bytes(s: &str, n: usize) -> &str {
if s.len() <= n {
return s;
}
let mut i = n;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
&s[..i]
}
/// Convert HTML to readable plain text (used when a device wants text but only HTML exists).
pub fn html_to_text(html: &str) -> String {
let mut out = String::with_capacity(html.len() / 2);
let lower = html.to_ascii_lowercase();
let bytes = html.as_bytes();
let mut i = 0;
let mut skip_until: Option<&str> = None;
while i < bytes.len() {
if let Some(end) = skip_until {
if lower[i..].starts_with(end) {
skip_until = None;
i += end.len();
} else {
i += 1;
}
continue;
}
if bytes[i] == b'<' {
let close = match html[i..].find('>') {
Some(c) => i + c,
None => break,
};
let tag = lower[i + 1..close].trim_start_matches('/');
let name: String = tag
.chars()
.take_while(|c| c.is_ascii_alphanumeric())
.collect();
match name.as_str() {
"script" => skip_until = Some("</script>"),
"style" => skip_until = Some("</style>"),
"head" => skip_until = Some("</head>"),
"br" => out.push('\n'),
"p" | "div" | "tr" | "li" | "h1" | "h2" | "h3" | "h4" | "blockquote" | "table" => {
if !out.ends_with('\n') {
out.push('\n');
}
if name == "li" && !lower[i + 1..].starts_with('/') {
out.push_str("• ");
}
}
"td" | "th" => out.push('\t'),
_ => {}
}
i = close + 1;
continue;
}
if bytes[i] == b'&' {
if let Some(semi) = html[i..].find(';').filter(|s| *s < 10) {
let ent = &html[i + 1..i + semi];
let rep = match ent {
"nbsp" => Some(' '),
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" | "#39" => Some('\''),
e if e.starts_with("#x") => u32::from_str_radix(&e[2..], 16)
.ok()
.and_then(char::from_u32),
e if e.starts_with('#') => e[1..].parse().ok().and_then(char::from_u32),
_ => None,
};
if let Some(c) = rep {
out.push(c);
i += semi + 1;
continue;
}
}
}
let ch = html[i..].chars().next().unwrap_or(' ');
if ch == '\r' || ch == '\n' || ch == '\t' {
if !out.ends_with(' ') && !out.ends_with('\n') {
out.push(' ');
}
} else {
out.push(ch);
}
i += ch.len_utf8();
}
// collapse 3+ newlines
let mut res = String::with_capacity(out.len());
let mut nl = 0;
for c in out.chars() {
if c == '\n' {
nl += 1;
if nl > 2 {
continue;
}
} else {
nl = 0;
}
res.push(c);
}
res.trim().to_string()
}
pub fn text_to_html(text: &str) -> String {
let mut out = String::from("<html><head><meta charset=\"utf-8\"></head><body><div style=\"white-space:pre-wrap;font-family:sans-serif\">");
out.push_str(&html_escape(text));
out.push_str("</div></body></html>");
out
}
/// Validate a login name received from a device.
pub fn valid_login(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 254
&& s.chars().all(|c| {
!c.is_control()
&& !c.is_whitespace()
&& !matches!(c, '/' | '\\' | '"' | '\'' | '<' | '>' | '%' | '*' | '?')
})
}
pub fn format_ts(ts: i64) -> String {
chrono::DateTime::from_timestamp(ts, 0)
.map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cidr() {
let c = Cidr::parse("10.0.0.0/8").unwrap();
assert!(c.contains(&"10.2.3.4".parse().unwrap()));
assert!(!c.contains(&"11.2.3.4".parse().unwrap()));
let l = Cidr::parse("127.0.0.1").unwrap();
assert!(l.contains(&"::ffff:127.0.0.1".parse().unwrap()));
assert!(Cidr::parse("::1/128")
.unwrap()
.contains(&"::1".parse().unwrap()));
assert!(Cidr::parse("1.2.3.4/33").is_none());
}
#[test]
fn html2text() {
let t = html_to_text("<html><head><style>x{}</style></head><body><p>Hello&nbsp;<b>World</b></p><br>a &amp; b</body></html>");
assert_eq!(t, "Hello World\n\na & b");
}
#[test]
fn logins() {
assert!(valid_login("jan.kowalski@firma.pl"));
assert!(!valid_login("a b"));
assert!(!valid_login("../etc"));
}
}
+763
View File
@@ -0,0 +1,763 @@
// Generated from Z-Push wbxmldefs.php with MS-ASWBXML standard tag names.
pub const NAMESPACES: &[&str] = &[
"AirSync",
"Contacts",
"Email",
"AirNotify",
"Calendar",
"Move",
"GetItemEstimate",
"FolderHierarchy",
"MeetingResponse",
"Tasks",
"ResolveRecipients",
"ValidateCert",
"Contacts2",
"Ping",
"Provision",
"Search",
"GAL",
"AirSyncBase",
"Settings",
"DocumentLibrary",
"ItemOperations",
"ComposeMail",
"Email2",
"Notes",
"RightsManagement",
"Find",
];
pub const PAGES: &[&[(u8, &str)]] = &[
// 0 AirSync
&[
(0x05, "Sync"),
(0x06, "Responses"),
(0x07, "Add"),
(0x08, "Change"),
(0x09, "Delete"),
(0x0A, "Fetch"),
(0x0B, "SyncKey"),
(0x0C, "ClientId"),
(0x0D, "ServerId"),
(0x0E, "Status"),
(0x0F, "Collection"),
(0x10, "Class"),
(0x11, "Version"),
(0x12, "CollectionId"),
(0x13, "GetChanges"),
(0x14, "MoreAvailable"),
(0x15, "WindowSize"),
(0x16, "Commands"),
(0x17, "Options"),
(0x18, "FilterType"),
(0x19, "Truncation"),
(0x1A, "RTFTruncation"),
(0x1B, "Conflict"),
(0x1C, "Collections"),
(0x1D, "ApplicationData"),
(0x1E, "DeletesAsMoves"),
(0x1F, "NotifyGUID"),
(0x20, "Supported"),
(0x21, "SoftDelete"),
(0x22, "MIMESupport"),
(0x23, "MIMETruncation"),
(0x24, "Wait"),
(0x25, "Limit"),
(0x26, "Partial"),
(0x27, "ConversationMode"),
(0x28, "MaxItems"),
(0x29, "HeartbeatInterval"),
],
// 1 Contacts
&[
(0x05, "Anniversary"),
(0x06, "AssistantName"),
(0x07, "AssistantPhoneNumber"),
(0x08, "Birthday"),
(0x09, "Body"),
(0x0A, "BodySize"),
(0x0B, "BodyTruncated"),
(0x0C, "Business2PhoneNumber"),
(0x0D, "BusinessCity"),
(0x0E, "BusinessCountry"),
(0x0F, "BusinessPostalCode"),
(0x10, "BusinessState"),
(0x11, "BusinessStreet"),
(0x12, "BusinessFaxNumber"),
(0x13, "BusinessPhoneNumber"),
(0x14, "CarPhoneNumber"),
(0x15, "Categories"),
(0x16, "Category"),
(0x17, "Children"),
(0x18, "Child"),
(0x19, "CompanyName"),
(0x1A, "Department"),
(0x1B, "Email1Address"),
(0x1C, "Email2Address"),
(0x1D, "Email3Address"),
(0x1E, "FileAs"),
(0x1F, "FirstName"),
(0x20, "Home2PhoneNumber"),
(0x21, "HomeCity"),
(0x22, "HomeCountry"),
(0x23, "HomePostalCode"),
(0x24, "HomeState"),
(0x25, "HomeStreet"),
(0x26, "HomeFaxNumber"),
(0x27, "HomePhoneNumber"),
(0x28, "JobTitle"),
(0x29, "LastName"),
(0x2A, "MiddleName"),
(0x2B, "MobilePhoneNumber"),
(0x2C, "OfficeLocation"),
(0x2D, "OtherCity"),
(0x2E, "OtherCountry"),
(0x2F, "OtherPostalCode"),
(0x30, "OtherState"),
(0x31, "OtherStreet"),
(0x32, "PagerNumber"),
(0x33, "RadioPhoneNumber"),
(0x34, "Spouse"),
(0x35, "Suffix"),
(0x36, "Title"),
(0x37, "WebPage"),
(0x38, "YomiCompanyName"),
(0x39, "YomiFirstName"),
(0x3A, "YomiLastName"),
(0x3B, "Rtf"),
(0x3C, "Picture"),
(0x3D, "Alias"),
(0x3E, "WeightedRank"),
],
// 2 Email
&[
(0x05, "Attachment"),
(0x06, "Attachments"),
(0x07, "AttName"),
(0x08, "AttSize"),
(0x09, "AttOid"),
(0x0A, "AttMethod"),
(0x0B, "AttRemoved"),
(0x0C, "Body"),
(0x0D, "BodySize"),
(0x0E, "BodyTruncated"),
(0x0F, "DateReceived"),
(0x10, "DisplayName"),
(0x11, "DisplayTo"),
(0x12, "Importance"),
(0x13, "MessageClass"),
(0x14, "Subject"),
(0x15, "Read"),
(0x16, "To"),
(0x17, "Cc"),
(0x18, "From"),
(0x19, "ReplyTo"),
(0x1A, "AllDayEvent"),
(0x1B, "Categories"),
(0x1C, "Category"),
(0x1D, "DtStamp"),
(0x1E, "EndTime"),
(0x1F, "InstanceType"),
(0x20, "BusyStatus"),
(0x21, "Location"),
(0x22, "MeetingRequest"),
(0x23, "Organizer"),
(0x24, "RecurrenceId"),
(0x25, "Reminder"),
(0x26, "ResponseRequested"),
(0x27, "Recurrences"),
(0x28, "Recurrence"),
(0x29, "Type"),
(0x2A, "Until"),
(0x2B, "Occurrences"),
(0x2C, "Interval"),
(0x2D, "DayOfWeek"),
(0x2E, "DayOfMonth"),
(0x2F, "WeekOfMonth"),
(0x30, "MonthOfYear"),
(0x31, "StartTime"),
(0x32, "Sensitivity"),
(0x33, "TimeZone"),
(0x34, "GlobalObjId"),
(0x35, "ThreadTopic"),
(0x36, "MIMEData"),
(0x37, "MIMETruncated"),
(0x38, "MIMESize"),
(0x39, "InternetCPID"),
(0x3A, "Flag"),
(0x3B, "FlagStatus"),
(0x3C, "ContentClass"),
(0x3D, "FlagType"),
(0x3E, "CompleteTime"),
(0x3F, "DisallowNewTimeProposal"),
],
// 3 AirNotify
&[
(0x05, "Notify"),
(0x06, "Notification"),
(0x07, "Version"),
(0x08, "Lifetime"),
(0x09, "DeviceInfo"),
(0x0A, "Enable"),
(0x0B, "Folder"),
(0x0C, "ServerEntryId"),
(0x0D, "DeviceAddress"),
(0x0E, "ValidCarrierProfiles"),
(0x0F, "CarrierProfile"),
(0x10, "Status"),
(0x11, "Replies"),
(0x12, "Devices"),
(0x13, "Device"),
(0x14, "Id"),
(0x15, "Expiry"),
(0x16, "NotifyGUID"),
],
// 4 Calendar
&[
(0x05, "TimeZone"),
(0x06, "AllDayEvent"),
(0x07, "Attendees"),
(0x08, "Attendee"),
(0x09, "Email"),
(0x0A, "Name"),
(0x0B, "Body"),
(0x0C, "BodyTruncated"),
(0x0D, "BusyStatus"),
(0x0E, "Categories"),
(0x0F, "Category"),
(0x10, "Rtf"),
(0x11, "DtStamp"),
(0x12, "EndTime"),
(0x13, "Exception"),
(0x14, "Exceptions"),
(0x15, "Deleted"),
(0x16, "ExceptionStartTime"),
(0x17, "Location"),
(0x18, "MeetingStatus"),
(0x19, "OrganizerEmail"),
(0x1A, "OrganizerName"),
(0x1B, "Recurrence"),
(0x1C, "Type"),
(0x1D, "Until"),
(0x1E, "Occurrences"),
(0x1F, "Interval"),
(0x20, "DayOfWeek"),
(0x21, "DayOfMonth"),
(0x22, "WeekOfMonth"),
(0x23, "MonthOfYear"),
(0x24, "Reminder"),
(0x25, "Sensitivity"),
(0x26, "Subject"),
(0x27, "StartTime"),
(0x28, "UID"),
(0x29, "AttendeeStatus"),
(0x2A, "AttendeeType"),
(0x2B, "Attachment"),
(0x2C, "Attachments"),
(0x2D, "AttName"),
(0x2E, "AttSize"),
(0x2F, "AttOid"),
(0x30, "AttMethod"),
(0x31, "AttRemoved"),
(0x32, "DisplayName"),
(0x33, "DisallowNewTimeProposal"),
(0x34, "ResponseRequested"),
(0x35, "AppointmentReplyTime"),
(0x36, "ResponseType"),
(0x37, "CalendarType"),
(0x38, "IsLeapMonth"),
(0x39, "FirstDayOfWeek"),
(0x3A, "OnlineMeetingConfLink"),
(0x3B, "OnlineMeetingExternalLink"),
(0x3C, "ClientUid"),
],
// 5 Move
&[
(0x05, "MoveItems"),
(0x06, "Move"),
(0x07, "SrcMsgId"),
(0x08, "SrcFldId"),
(0x09, "DstFldId"),
(0x0A, "Response"),
(0x0B, "Status"),
(0x0C, "DstMsgId"),
],
// 6 GetItemEstimate
&[
(0x05, "GetItemEstimate"),
(0x06, "Version"),
(0x07, "Collections"),
(0x08, "Collection"),
(0x09, "Class"),
(0x0A, "CollectionId"),
(0x0B, "DateTime"),
(0x0C, "Estimate"),
(0x0D, "Response"),
(0x0E, "Status"),
],
// 7 FolderHierarchy
&[
(0x05, "Folders"),
(0x06, "Folder"),
(0x07, "DisplayName"),
(0x08, "ServerId"),
(0x09, "ParentId"),
(0x0A, "Type"),
(0x0B, "Response"),
(0x0C, "Status"),
(0x0D, "ContentClass"),
(0x0E, "Changes"),
(0x0F, "Add"),
(0x10, "Delete"),
(0x11, "Update"),
(0x12, "SyncKey"),
(0x13, "FolderCreate"),
(0x14, "FolderDelete"),
(0x15, "FolderUpdate"),
(0x16, "FolderSync"),
(0x17, "Count"),
(0x18, "Version"),
],
// 8 MeetingResponse
&[
(0x05, "CalendarId"),
(0x06, "CollectionId"),
(0x07, "MeetingResponse"),
(0x08, "RequestId"),
(0x09, "Request"),
(0x0A, "Result"),
(0x0B, "Status"),
(0x0C, "UserResponse"),
(0x0D, "Version"),
(0x0E, "InstanceId"),
(0x10, "ProposedStartTime"),
(0x11, "ProposedEndTime"),
(0x12, "SendResponse"),
],
// 9 Tasks
&[
(0x05, "Body"),
(0x06, "BodySize"),
(0x07, "BodyTruncated"),
(0x08, "Categories"),
(0x09, "Category"),
(0x0A, "Complete"),
(0x0B, "DateCompleted"),
(0x0C, "DueDate"),
(0x0D, "UtcDueDate"),
(0x0E, "Importance"),
(0x0F, "Recurrence"),
(0x10, "Type"),
(0x11, "Start"),
(0x12, "Until"),
(0x13, "Occurrences"),
(0x14, "Interval"),
(0x15, "DayOfMonth"),
(0x16, "DayOfWeek"),
(0x17, "WeekOfMonth"),
(0x18, "MonthOfYear"),
(0x19, "Regenerate"),
(0x1A, "DeadOccur"),
(0x1B, "ReminderSet"),
(0x1C, "ReminderTime"),
(0x1D, "Sensitivity"),
(0x1E, "StartDate"),
(0x1F, "UtcStartDate"),
(0x20, "Subject"),
(0x21, "Rtf"),
(0x22, "OrdinalDate"),
(0x23, "SubOrdinalDate"),
(0x24, "CalendarType"),
(0x25, "IsLeapMonth"),
(0x26, "FirstDayOfWeek"),
],
// 10 ResolveRecipients
&[
(0x05, "ResolveRecipients"),
(0x06, "Response"),
(0x07, "Status"),
(0x08, "Type"),
(0x09, "Recipient"),
(0x0A, "DisplayName"),
(0x0B, "EmailAddress"),
(0x0C, "Certificates"),
(0x0D, "Certificate"),
(0x0E, "MiniCertificate"),
(0x0F, "Options"),
(0x10, "To"),
(0x11, "CertificateRetrieval"),
(0x12, "RecipientCount"),
(0x13, "MaxCertificates"),
(0x14, "MaxAmbiguousRecipients"),
(0x15, "CertificateCount"),
(0x16, "Availability"),
(0x17, "StartTime"),
(0x18, "EndTime"),
(0x19, "MergedFreeBusy"),
(0x1A, "Picture"),
(0x1B, "MaxSize"),
(0x1C, "Data"),
(0x1D, "MaxPictures"),
],
// 11 ValidateCert
&[
(0x05, "ValidateCert"),
(0x06, "Certificates"),
(0x07, "Certificate"),
(0x08, "CertificateChain"),
(0x09, "CheckCRL"),
(0x0A, "Status"),
],
// 12 Contacts2
&[
(0x05, "CustomerId"),
(0x06, "GovernmentId"),
(0x07, "IMAddress"),
(0x08, "IMAddress2"),
(0x09, "IMAddress3"),
(0x0A, "ManagerName"),
(0x0B, "CompanyMainPhone"),
(0x0C, "AccountName"),
(0x0D, "NickName"),
(0x0E, "MMS"),
],
// 13 Ping
&[
(0x05, "Ping"),
(0x06, "AutdState"),
(0x07, "Status"),
(0x08, "HeartbeatInterval"),
(0x09, "Folders"),
(0x0A, "Folder"),
(0x0B, "Id"),
(0x0C, "Class"),
(0x0D, "MaxFolders"),
(0x0E, "Version"),
],
// 14 Provision
&[
(0x05, "Provision"),
(0x06, "Policies"),
(0x07, "Policy"),
(0x08, "PolicyType"),
(0x09, "PolicyKey"),
(0x0A, "Data"),
(0x0B, "Status"),
(0x0C, "RemoteWipe"),
(0x0D, "EASProvisionDoc"),
(0x0E, "DevicePasswordEnabled"),
(0x0F, "AlphanumericDevicePasswordRequired"),
(0x10, "DeviceEncryptionEnabled"),
(0x11, "PasswordRecoveryEnabled"),
(0x12, "DocumentBrowseEnabled"),
(0x13, "AttachmentsEnabled"),
(0x14, "MinDevicePasswordLength"),
(0x15, "MaxInactivityTimeDeviceLock"),
(0x16, "MaxDevicePasswordFailedAttempts"),
(0x17, "MaxAttachmentSize"),
(0x18, "AllowSimpleDevicePassword"),
(0x19, "DevicePasswordExpiration"),
(0x1A, "DevicePasswordHistory"),
(0x1B, "AllowStorageCard"),
(0x1C, "AllowCamera"),
(0x1D, "RequireDeviceEncryption"),
(0x1E, "AllowUnsignedApplications"),
(0x1F, "AllowUnsignedInstallationPackages"),
(0x20, "MinDevicePasswordComplexCharacters"),
(0x21, "AllowWiFi"),
(0x22, "AllowTextMessaging"),
(0x23, "AllowPOPIMAPEmail"),
(0x24, "AllowBluetooth"),
(0x25, "AllowIrDA"),
(0x26, "RequireManualSyncWhenRoaming"),
(0x27, "AllowDesktopSync"),
(0x28, "MaxCalendarAgeFilter"),
(0x29, "AllowHTMLEmail"),
(0x2A, "MaxEmailAgeFilter"),
(0x2B, "MaxEmailBodyTruncationSize"),
(0x2C, "MaxEmailHTMLBodyTruncationSize"),
(0x2D, "RequireSignedSMIMEMessages"),
(0x2E, "RequireEncryptedSMIMEMessages"),
(0x2F, "RequireSignedSMIMEAlgorithm"),
(0x30, "RequireEncryptionSMIMEAlgorithm"),
(0x31, "AllowSMIMEEncryptionAlgorithmNegotiation"),
(0x32, "AllowSMIMESoftCerts"),
(0x33, "AllowBrowser"),
(0x34, "AllowConsumerEmail"),
(0x35, "AllowRemoteDesktop"),
(0x36, "AllowInternetSharing"),
(0x37, "UnapprovedInROMApplicationList"),
(0x38, "ApplicationName"),
(0x39, "ApprovedApplicationList"),
(0x3A, "Hash"),
(0x3B, "AccountOnlyRemoteWipe"),
],
// 15 Search
&[
(0x05, "Search"),
(0x07, "Store"),
(0x08, "Name"),
(0x09, "Query"),
(0x0A, "Options"),
(0x0B, "Range"),
(0x0C, "Status"),
(0x0D, "Response"),
(0x0E, "Result"),
(0x0F, "Properties"),
(0x10, "Total"),
(0x11, "EqualTo"),
(0x12, "Value"),
(0x13, "And"),
(0x14, "Or"),
(0x15, "FreeText"),
(0x17, "DeepTraversal"),
(0x18, "LongId"),
(0x19, "RebuildResults"),
(0x1A, "LessThan"),
(0x1B, "GreaterThan"),
(0x1C, "Schema"),
(0x1D, "Supported"),
(0x1E, "UserName"),
(0x1F, "Password"),
(0x20, "ConversationId"),
(0x21, "Picture"),
(0x22, "MaxSize"),
(0x23, "MaxPictures"),
],
// 16 GAL
&[
(0x05, "DisplayName"),
(0x06, "Phone"),
(0x07, "Office"),
(0x08, "Title"),
(0x09, "Company"),
(0x0A, "Alias"),
(0x0B, "FirstName"),
(0x0C, "LastName"),
(0x0D, "HomePhone"),
(0x0E, "MobilePhone"),
(0x0F, "EmailAddress"),
(0x10, "Picture"),
(0x11, "Status"),
(0x12, "Data"),
],
// 17 AirSyncBase
&[
(0x05, "BodyPreference"),
(0x06, "Type"),
(0x07, "TruncationSize"),
(0x08, "AllOrNone"),
(0x0A, "Body"),
(0x0B, "Data"),
(0x0C, "EstimatedDataSize"),
(0x0D, "Truncated"),
(0x0E, "Attachments"),
(0x0F, "Attachment"),
(0x10, "DisplayName"),
(0x11, "FileReference"),
(0x12, "Method"),
(0x13, "ContentId"),
(0x14, "ContentLocation"),
(0x15, "IsInline"),
(0x16, "NativeBodyType"),
(0x17, "ContentType"),
(0x18, "Preview"),
(0x19, "BodyPartPreference"),
(0x1A, "BodyPart"),
(0x1B, "Status"),
(0x1C, "Add"),
(0x1D, "Delete"),
(0x1E, "ClientId"),
(0x1F, "Content"),
(0x20, "Location"),
(0x21, "Annotation"),
(0x22, "Street"),
(0x23, "City"),
(0x24, "State"),
(0x25, "Country"),
(0x26, "PostalCode"),
(0x27, "Latitude"),
(0x28, "Longitude"),
(0x29, "Accuracy"),
(0x2A, "Altitude"),
(0x2B, "AltitudeAccuracy"),
(0x2C, "LocationUri"),
(0x2D, "InstanceId"),
],
// 18 Settings
&[
(0x05, "Settings"),
(0x06, "Status"),
(0x07, "Get"),
(0x08, "Set"),
(0x09, "Oof"),
(0x0A, "OofState"),
(0x0B, "StartTime"),
(0x0C, "EndTime"),
(0x0D, "OofMessage"),
(0x0E, "AppliesToInternal"),
(0x0F, "AppliesToExternalKnown"),
(0x10, "AppliesToExternalUnknown"),
(0x11, "Enabled"),
(0x12, "ReplyMessage"),
(0x13, "BodyType"),
(0x14, "DevicePassword"),
(0x15, "Password"),
(0x16, "DeviceInformation"),
(0x17, "Model"),
(0x18, "IMEI"),
(0x19, "FriendlyName"),
(0x1A, "OS"),
(0x1B, "OSLanguage"),
(0x1C, "PhoneNumber"),
(0x1D, "UserInformation"),
(0x1E, "EmailAddresses"),
(0x1F, "SmtpAddress"),
(0x20, "UserAgent"),
(0x21, "EnableOutboundSMS"),
(0x22, "MobileOperator"),
(0x23, "PrimarySmtpAddress"),
(0x24, "Accounts"),
(0x25, "Account"),
(0x26, "AccountId"),
(0x27, "AccountName"),
(0x28, "UserDisplayName"),
(0x29, "SendDisabled"),
(0x2B, "RightsManagementInformation"),
],
// 19 DocumentLibrary
&[
(0x05, "LinkId"),
(0x06, "DisplayName"),
(0x07, "IsFolder"),
(0x08, "CreationDate"),
(0x09, "LastModifiedDate"),
(0x0A, "IsHidden"),
(0x0B, "ContentLength"),
(0x0C, "ContentType"),
],
// 20 ItemOperations
&[
(0x05, "ItemOperations"),
(0x06, "Fetch"),
(0x07, "Store"),
(0x08, "Options"),
(0x09, "Range"),
(0x0A, "Total"),
(0x0B, "Properties"),
(0x0C, "Data"),
(0x0D, "Status"),
(0x0E, "Response"),
(0x0F, "Version"),
(0x10, "Schema"),
(0x11, "Part"),
(0x12, "EmptyFolderContents"),
(0x13, "DeleteSubFolders"),
(0x14, "UserName"),
(0x15, "Password"),
(0x16, "Move"),
(0x17, "DstFldId"),
(0x18, "ConversationId"),
(0x19, "MoveAlways"),
],
// 21 ComposeMail
&[
(0x05, "SendMail"),
(0x06, "SmartForward"),
(0x07, "SmartReply"),
(0x08, "SaveInSentItems"),
(0x09, "ReplaceMime"),
(0x0A, "Type"),
(0x0B, "Source"),
(0x0C, "FolderId"),
(0x0D, "ItemId"),
(0x0E, "LongId"),
(0x0F, "InstanceId"),
(0x10, "MIME"),
(0x11, "ClientId"),
(0x12, "Status"),
(0x13, "AccountId"),
(0x15, "Forwardees"),
(0x16, "Forwardee"),
(0x17, "Name"),
(0x18, "Email"),
],
// 22 Email2
&[
(0x05, "UmCallerId"),
(0x06, "UmUserNotes"),
(0x07, "UmAttDuration"),
(0x08, "UmAttOrder"),
(0x09, "ConversationId"),
(0x0A, "ConversationIndex"),
(0x0B, "LastVerbExecuted"),
(0x0C, "LastVerbExecutionTime"),
(0x0D, "ReceivedAsBcc"),
(0x0E, "Sender"),
(0x0F, "CalendarType"),
(0x10, "IsLeapMonth"),
(0x11, "AccountId"),
(0x12, "FirstDayOfWeek"),
(0x13, "MeetingMessageType"),
(0x15, "IsDraft"),
(0x16, "Bcc"),
(0x17, "Send"),
],
// 23 Notes
&[
(0x05, "Subject"),
(0x06, "MessageClass"),
(0x07, "LastModifiedDate"),
(0x08, "Categories"),
(0x09, "Category"),
],
// 24 RightsManagement
&[
(0x05, "RightsManagementSupport"),
(0x06, "RightsManagementTemplates"),
(0x07, "RightsManagementTemplate"),
(0x08, "RightsManagementLicense"),
(0x09, "EditAllowed"),
(0x0A, "ReplyAllowed"),
(0x0B, "ReplyAllAllowed"),
(0x0C, "ForwardAllowed"),
(0x0D, "ModifyRecipientsAllowed"),
(0x0E, "ExtractAllowed"),
(0x0F, "PrintAllowed"),
(0x10, "ExportAllowed"),
(0x11, "ProgrammaticAccessAllowed"),
(0x12, "Owner"),
(0x13, "ContentExpiryDate"),
(0x14, "TemplateID"),
(0x15, "TemplateName"),
(0x16, "TemplateDescription"),
(0x17, "ContentOwner"),
(0x18, "RemoveRightsManagementProtection"),
],
// 25 Find
&[
(0x05, "Find"),
(0x06, "SearchId"),
(0x07, "ExecuteSearch"),
(0x08, "MailBoxSearchCriterion"),
(0x09, "Query"),
(0x0A, "Status"),
(0x0B, "FreeText"),
(0x0C, "Options"),
(0x0D, "Range"),
(0x0E, "DeepTraversal"),
(0x11, "Response"),
(0x12, "Result"),
(0x13, "Properties"),
(0x14, "Preview"),
(0x15, "HasAttachments"),
(0x16, "Total"),
(0x17, "DisplayCc"),
(0x18, "DisplayBcc"),
(0x19, "GalSearchCriterion"),
(0x20, "MaxPictures"),
(0x21, "MaxSize"),
(0x22, "Picture"),
],
];
+556
View File
@@ -0,0 +1,556 @@
//! WBXML (MS-ASWBXML) encoder/decoder with a small element tree.
//!
//! Elements are addressed by `"Namespace:Tag"` names, e.g. `"AirSync:SyncKey"`.
mod codepages;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::sync::OnceLock;
pub use codepages::NAMESPACES;
const SWITCH_PAGE: u8 = 0x00;
const END: u8 = 0x01;
const ENTITY: u8 = 0x02;
const STR_I: u8 = 0x03;
const LITERAL: u8 = 0x04;
const STR_T: u8 = 0x83;
const OPAQUE: u8 = 0xC3;
const WITH_CONTENT: u8 = 0x40;
const WITH_ATTRIBUTES: u8 = 0x80;
/// Hard limits protecting the decoder against hostile input.
const MAX_DEPTH: usize = 64;
const MAX_ELEMENTS: usize = 500_000;
#[derive(Debug, thiserror::Error)]
pub enum WbxmlError {
#[error("unexpected end of WBXML stream")]
Eof,
#[error("invalid WBXML: {0}")]
Invalid(&'static str),
#[error("unknown WBXML tag {0}")]
UnknownName(String),
}
struct Tables {
by_name: HashMap<String, (u8, u8)>,
}
fn tables() -> &'static Tables {
static T: OnceLock<Tables> = OnceLock::new();
T.get_or_init(|| {
let mut by_name = HashMap::new();
for (page, tags) in codepages::PAGES.iter().enumerate() {
let ns = codepages::NAMESPACES[page];
for (code, tag) in tags.iter() {
by_name.insert(format!("{ns}:{tag}"), (page as u8, *code));
}
}
Tables { by_name }
})
}
/// Resolve a `Namespace:Tag` name into its (page, token) pair.
pub fn lookup(name: &str) -> Option<(u8, u8)> {
tables().by_name.get(name).copied()
}
fn tag_name(page: u8, code: u8) -> (&'static str, &'static str) {
let ns = codepages::NAMESPACES
.get(page as usize)
.copied()
.unwrap_or("Unknown");
let tag = codepages::PAGES
.get(page as usize)
.and_then(|t| t.iter().find(|(c, _)| *c == code).map(|(_, n)| *n))
.unwrap_or("Unknown");
(ns, tag)
}
#[derive(Debug, Clone, PartialEq)]
pub enum Content {
None,
Text(String),
Opaque(Vec<u8>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Element {
pub page: u8,
pub code: u8,
pub children: Vec<Element>,
pub content: Content,
}
impl Element {
/// Create a new element. Panics on unknown names: tag names are compile-time literals
/// in this code base and covered by tests.
pub fn new(name: &str) -> Self {
let (page, code) = lookup(name).unwrap_or_else(|| panic!("unknown WBXML tag name {name}"));
Element {
page,
code,
children: Vec::new(),
content: Content::None,
}
}
pub fn ns(&self) -> &'static str {
tag_name(self.page, self.code).0
}
pub fn tag(&self) -> &'static str {
tag_name(self.page, self.code).1
}
pub fn name(&self) -> String {
let (ns, tag) = tag_name(self.page, self.code);
format!("{ns}:{tag}")
}
pub fn is(&self, name: &str) -> bool {
lookup(name) == Some((self.page, self.code))
}
// ----- builder helpers -----
pub fn text(name: &str, value: impl Into<String>) -> Self {
let mut e = Element::new(name);
e.content = Content::Text(value.into());
e
}
pub fn opaque(name: &str, value: Vec<u8>) -> Self {
let mut e = Element::new(name);
e.content = Content::Opaque(value);
e
}
pub fn with(mut self, child: Element) -> Self {
self.children.push(child);
self
}
pub fn with_text(mut self, name: &str, value: impl Into<String>) -> Self {
self.children.push(Element::text(name, value));
self
}
pub fn with_opt(mut self, name: &str, value: Option<impl Into<String>>) -> Self {
if let Some(v) = value {
self.children.push(Element::text(name, v));
}
self
}
pub fn with_empty(mut self, name: &str) -> Self {
self.children.push(Element::new(name));
self
}
pub fn push(&mut self, child: Element) {
self.children.push(child);
}
pub fn push_text(&mut self, name: &str, value: impl Into<String>) {
self.children.push(Element::text(name, value));
}
pub fn push_opt(&mut self, name: &str, value: Option<impl Into<String>>) {
if let Some(v) = value {
self.children.push(Element::text(name, v));
}
}
// ----- query helpers -----
pub fn child(&self, name: &str) -> Option<&Element> {
let key = lookup(name)?;
self.children.iter().find(|c| (c.page, c.code) == key)
}
pub fn children_named<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a Element> + 'a {
let key = lookup(name);
self.children
.iter()
.filter(move |c| Some((c.page, c.code)) == key)
}
/// Text of a direct child element.
pub fn child_text(&self, name: &str) -> Option<String> {
self.child(name).map(|c| c.text_value())
}
pub fn child_u32(&self, name: &str) -> Option<u32> {
self.child_text(name).and_then(|t| t.trim().parse().ok())
}
pub fn has(&self, name: &str) -> bool {
self.child(name).is_some()
}
/// Follow a path of direct children.
pub fn path(&self, names: &[&str]) -> Option<&Element> {
let mut cur = self;
for n in names {
cur = cur.child(n)?;
}
Some(cur)
}
pub fn text_value(&self) -> String {
match &self.content {
Content::Text(s) => s.clone(),
Content::Opaque(b) => String::from_utf8_lossy(b).into_owned(),
Content::None => String::new(),
}
}
pub fn bytes_value(&self) -> Vec<u8> {
match &self.content {
Content::Text(s) => s.as_bytes().to_vec(),
Content::Opaque(b) => b.clone(),
Content::None => Vec::new(),
}
}
/// Human readable XML rendering for debug logs (data truncated).
pub fn to_xml(&self) -> String {
let mut out = String::new();
self.write_xml(&mut out, 0);
out
}
fn write_xml(&self, out: &mut String, depth: usize) {
let pad = " ".repeat(depth);
let (ns, tag) = tag_name(self.page, self.code);
let name = format!("{ns}:{tag}");
match (&self.content, self.children.is_empty()) {
(Content::None, true) => {
let _ = writeln!(out, "{pad}<{name}/>");
}
(Content::Text(t), _) => {
let shown = if tag == "Password" || tag == "Data" && t.len() > 256 {
format!("[{} chars]", t.len())
} else if t.len() > 256 {
format!("{}…", &t[..t.floor_char_boundary_compat(256)])
} else {
t.clone()
};
let _ = writeln!(out, "{pad}<{name}>{}</{name}>", xml_escape(&shown));
}
(Content::Opaque(b), _) => {
let _ = writeln!(out, "{pad}<{name}>[opaque {} bytes]</{name}>", b.len());
}
(Content::None, false) => {
let _ = writeln!(out, "{pad}<{name}>");
for c in &self.children {
c.write_xml(out, depth + 1);
}
let _ = writeln!(out, "{pad}</{name}>");
}
}
}
}
trait FloorBoundary {
fn floor_char_boundary_compat(&self, idx: usize) -> usize;
}
impl FloorBoundary for String {
fn floor_char_boundary_compat(&self, idx: usize) -> usize {
let mut i = idx.min(self.len());
while i > 0 && !self.is_char_boundary(i) {
i -= 1;
}
i
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
// ---------------------------------------------------------------- encoder
pub fn encode(root: &Element) -> Vec<u8> {
let mut out = Vec::with_capacity(512);
out.extend_from_slice(&[0x03, 0x01, 0x6A, 0x00]);
let mut page = 0u8;
encode_element(root, &mut out, &mut page);
out
}
fn encode_element(e: &Element, out: &mut Vec<u8>, page: &mut u8) {
if e.page != *page {
out.push(SWITCH_PAGE);
out.push(e.page);
*page = e.page;
}
let has_content = !e.children.is_empty() || !matches!(e.content, Content::None);
out.push(if has_content {
e.code | WITH_CONTENT
} else {
e.code
});
if !has_content {
return;
}
match &e.content {
Content::Text(t) => {
out.push(STR_I);
// NUL bytes would terminate the inline string; strip them.
out.extend(t.bytes().filter(|b| *b != 0));
out.push(0);
}
Content::Opaque(b) => {
out.push(OPAQUE);
write_mb_u32(out, b.len() as u32);
out.extend_from_slice(b);
}
Content::None => {}
}
for c in &e.children {
encode_element(c, out, page);
}
out.push(END);
}
fn write_mb_u32(out: &mut Vec<u8>, mut v: u32) {
let mut buf = [0u8; 5];
let mut i = 4;
buf[i] = (v & 0x7f) as u8;
v >>= 7;
while v > 0 {
i -= 1;
buf[i] = 0x80 | (v & 0x7f) as u8;
v >>= 7;
}
out.extend_from_slice(&buf[i..]);
}
// ---------------------------------------------------------------- decoder
struct Reader<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn byte(&mut self) -> Result<u8, WbxmlError> {
let b = *self.data.get(self.pos).ok_or(WbxmlError::Eof)?;
self.pos += 1;
Ok(b)
}
fn peek(&self) -> Option<u8> {
self.data.get(self.pos).copied()
}
fn mb_u32(&mut self) -> Result<u32, WbxmlError> {
let mut v: u32 = 0;
for _ in 0..5 {
let b = self.byte()?;
v = v
.checked_shl(7)
.ok_or(WbxmlError::Invalid("mb_u_int32 overflow"))?
| (b & 0x7f) as u32;
if b & 0x80 == 0 {
return Ok(v);
}
}
Err(WbxmlError::Invalid("mb_u_int32 too long"))
}
fn cstr(&mut self) -> Result<Vec<u8>, WbxmlError> {
let start = self.pos;
let rel = self.data[start..]
.iter()
.position(|b| *b == 0)
.ok_or(WbxmlError::Eof)?;
self.pos = start + rel + 1;
Ok(self.data[start..start + rel].to_vec())
}
fn take(&mut self, n: usize) -> Result<&'a [u8], WbxmlError> {
if self.pos + n > self.data.len() {
return Err(WbxmlError::Eof);
}
let s = &self.data[self.pos..self.pos + n];
self.pos += n;
Ok(s)
}
}
pub fn decode(data: &[u8]) -> Result<Element, WbxmlError> {
let mut r = Reader { data, pos: 0 };
let _version = r.byte()?;
let _public_id = r.mb_u32()?;
let _charset = r.mb_u32()?;
let strtbl_len = r.mb_u32()? as usize;
let strtbl = r.take(strtbl_len)?.to_vec();
let mut page = 0u8;
let mut count = 0usize;
// skip leading switch pages
loop {
match r.peek() {
Some(SWITCH_PAGE) => {
r.byte()?;
page = r.byte()?;
}
Some(_) => break,
None => return Err(WbxmlError::Eof),
}
}
decode_element(&mut r, &mut page, &strtbl, 0, &mut count)
}
fn decode_element(
r: &mut Reader,
page: &mut u8,
strtbl: &[u8],
depth: usize,
count: &mut usize,
) -> Result<Element, WbxmlError> {
if depth > MAX_DEPTH {
return Err(WbxmlError::Invalid("nesting too deep"));
}
*count += 1;
if *count > MAX_ELEMENTS {
return Err(WbxmlError::Invalid("too many elements"));
}
let tok = r.byte()?;
if tok == LITERAL {
return Err(WbxmlError::Invalid("literal tags not supported"));
}
if tok & WITH_ATTRIBUTES != 0 {
return Err(WbxmlError::Invalid("attributes not supported"));
}
let mut el = Element {
page: *page,
code: tok & 0x3f,
children: Vec::new(),
content: Content::None,
};
if tok & WITH_CONTENT == 0 {
return Ok(el);
}
let mut text: Option<Vec<u8>> = None;
let mut opaque: Option<Vec<u8>> = None;
loop {
let next = r.peek().ok_or(WbxmlError::Eof)?;
match next {
END => {
r.byte()?;
break;
}
SWITCH_PAGE => {
r.byte()?;
*page = r.byte()?;
}
STR_I => {
r.byte()?;
let s = r.cstr()?;
text.get_or_insert_with(Vec::new).extend_from_slice(&s);
}
STR_T => {
r.byte()?;
let off = r.mb_u32()? as usize;
let rest = strtbl
.get(off..)
.ok_or(WbxmlError::Invalid("bad string table offset"))?;
let end = rest.iter().position(|b| *b == 0).unwrap_or(rest.len());
text.get_or_insert_with(Vec::new)
.extend_from_slice(&rest[..end]);
}
ENTITY => {
r.byte()?;
let cp = r.mb_u32()?;
if let Some(c) = char::from_u32(cp) {
let mut b = [0u8; 4];
text.get_or_insert_with(Vec::new)
.extend_from_slice(c.encode_utf8(&mut b).as_bytes());
}
}
OPAQUE => {
r.byte()?;
let len = r.mb_u32()? as usize;
let b = r.take(len)?;
opaque.get_or_insert_with(Vec::new).extend_from_slice(b);
}
_ => {
let child = decode_element(r, page, strtbl, depth + 1, count)?;
el.children.push(child);
}
}
}
if let Some(o) = opaque {
el.content = Content::Opaque(o);
} else if let Some(t) = text {
el.content = Content::Text(String::from_utf8_lossy(&t).into_owned());
}
Ok(el)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let root = Element::new("AirSync:Sync").with(
Element::new("AirSync:Collections").with(
Element::new("AirSync:Collection")
.with_text("AirSync:SyncKey", "{abc}1")
.with_text("AirSync:CollectionId", "5")
.with_empty("AirSync:GetChanges")
.with(
Element::new("AirSync:Options")
.with(
Element::new("AirSyncBase:BodyPreference")
.with_text("AirSyncBase:Type", "2"),
)
.with(Element::opaque("ComposeMail:MIME", vec![0, 1, 2, 255])),
),
),
);
let bytes = encode(&root);
let back = decode(&bytes).unwrap();
assert_eq!(root, back);
let col = back
.path(&["AirSync:Collections", "AirSync:Collection"])
.unwrap();
assert_eq!(col.child_text("AirSync:SyncKey").unwrap(), "{abc}1");
assert!(col.has("AirSync:GetChanges"));
}
#[test]
fn mb_u32() {
for v in [0u32, 1, 127, 128, 300, 16383, 16384, 1 << 28, u32::MAX] {
let mut out = vec![];
write_mb_u32(&mut out, v);
let mut r = Reader { data: &out, pos: 0 };
assert_eq!(r.mb_u32().unwrap(), v);
}
}
#[test]
fn all_names_unique_per_page() {
for (p, tags) in codepages::PAGES.iter().enumerate() {
let mut seen = std::collections::HashSet::new();
for (c, _) in tags.iter() {
assert!(seen.insert(*c), "duplicate token {c:#x} on page {p}");
}
}
}
#[test]
fn rejects_garbage() {
assert!(decode(&[0x03, 0x01, 0x6a, 0x00, 0x45]).is_err());
assert!(decode(&[]).is_err());
}
}
+166
View File
@@ -0,0 +1,166 @@
//! Tiny namespace-aware XML DOM built on quick-xml (no DTD / entity expansion).
use anyhow::{bail, Result};
use quick_xml::events::Event;
use quick_xml::name::ResolveResult;
use quick_xml::NsReader;
#[derive(Debug, Clone, Default)]
pub struct XNode {
pub ns: String,
pub name: String,
pub text: String,
pub attrs: Vec<(String, String)>,
pub children: Vec<XNode>,
}
impl XNode {
pub fn attr(&self, name: &str) -> Option<&str> {
self.attrs
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
pub fn child(&self, name: &str) -> Option<&XNode> {
self.children
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
}
pub fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a XNode> + 'a {
self.children
.iter()
.filter(move |c| c.name.eq_ignore_ascii_case(name))
}
/// Depth-first search for the first descendant with this local name.
pub fn find(&self, name: &str) -> Option<&XNode> {
for c in &self.children {
if c.name.eq_ignore_ascii_case(name) {
return Some(c);
}
if let Some(f) = c.find(name) {
return Some(f);
}
}
None
}
pub fn find_all<'a>(&'a self, name: &str, out: &mut Vec<&'a XNode>) {
for c in &self.children {
if c.name.eq_ignore_ascii_case(name) {
out.push(c);
}
c.find_all(name, out);
}
}
pub fn text_of(&self, name: &str) -> Option<String> {
self.find(name).map(|n| n.text.trim().to_string())
}
}
fn attrs_of(e: &quick_xml::events::BytesStart) -> Vec<(String, String)> {
e.attributes()
.flatten()
.take(64)
.map(|a| {
(
String::from_utf8_lossy(a.key.local_name().as_ref()).into_owned(),
a.unescape_value()
.map(|v| v.into_owned())
.unwrap_or_default(),
)
})
.collect()
}
pub fn parse(xml: &[u8]) -> Result<XNode> {
if xml.len() > 64 * 1024 * 1024 {
bail!("XML document too large");
}
let text = String::from_utf8_lossy(xml);
// Refuse DTDs outright (XXE / billion laughs protection).
if text.contains("<!DOCTYPE") || text.contains("<!ENTITY") {
bail!("XML DTD not allowed");
}
let mut reader = NsReader::from_str(&text);
reader.config_mut().trim_text(false);
let mut stack: Vec<XNode> = vec![XNode::default()];
let mut depth = 0usize;
loop {
match reader.read_resolved_event()? {
(ns, Event::Start(e)) => {
depth += 1;
if depth > 128 {
bail!("XML nesting too deep");
}
let ns = match ns {
ResolveResult::Bound(n) => String::from_utf8_lossy(n.as_ref()).into_owned(),
_ => String::new(),
};
stack.push(XNode {
ns,
name: String::from_utf8_lossy(e.local_name().as_ref()).into_owned(),
attrs: attrs_of(&e),
..Default::default()
});
}
(ns, Event::Empty(e)) => {
let ns = match ns {
ResolveResult::Bound(n) => String::from_utf8_lossy(n.as_ref()).into_owned(),
_ => String::new(),
};
let node = XNode {
ns,
name: String::from_utf8_lossy(e.local_name().as_ref()).into_owned(),
attrs: attrs_of(&e),
..Default::default()
};
if let Some(top) = stack.last_mut() {
top.children.push(node);
}
}
(_, Event::End(_)) => {
depth = depth.saturating_sub(1);
let node = stack.pop().unwrap_or_default();
match stack.last_mut() {
Some(top) => top.children.push(node),
None => bail!("unbalanced XML"),
}
}
(_, Event::Text(t)) => {
if let Some(top) = stack.last_mut() {
let s = t.unescape().map(|c| c.into_owned()).unwrap_or_default();
top.text.push_str(&s);
}
}
(_, Event::CData(t)) => {
if let Some(top) = stack.last_mut() {
top.text.push_str(&String::from_utf8_lossy(&t));
}
}
(_, Event::Eof) => break,
(_, Event::DocType(_)) => bail!("XML DTD not allowed"),
_ => {}
}
}
let root = stack.pop().unwrap_or_default();
root.children
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("empty XML document"))
}
#[cfg(test)]
mod tests {
#[test]
fn parse_ns() {
let x = br#"<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/a/b.ics</d:href><d:propstat><d:prop><d:getetag>"x"</d:getetag></d:prop></d:propstat></d:response></d:multistatus>"#;
let n = super::parse(x).unwrap();
assert_eq!(n.name, "multistatus");
assert_eq!(n.ns, "DAV:");
assert_eq!(n.text_of("getetag").unwrap(), "\"x\"");
}
#[test]
fn rejects_dtd() {
assert!(super::parse(b"<!DOCTYPE x [<!ENTITY a \"b\">]><x>&a;</x>").is_err());
}
}