An IMAP+SMTP gateway for less urgent communications with LLMs
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875//! JMAP (RFC 8620) HTTP server.//!//! JSON-based replacement for IMAP/SMTP. Serves the same `Store` that IMAP//! uses, so both protocols can run simultaneously.
use std::net::SocketAddr;use std::sync::Arc;
use axum::extract::{DefaultBodyLimit, State};use axum::http::HeaderValue;use axum::middleware;use axum::response::IntoResponse;use axum::routing::post;use axum::{Json, Router};use base64::Engine;use serde::{Deserialize, Serialize};use serde_json::{Map, Value, json};
use crate::store::Store;
// ── Config ─────────────────────────────────────────────────────────
#[derive(Debug, Clone)]pub struct JmapServer { cfg: crate::config::JmapConfig, store: Arc<Store>,}
impl JmapServer { pub fn new(cfg: crate::config::JmapConfig, store: Arc<Store>) -> Self { Self { cfg, store } }
/// Bind and start serving. Returns the bound address immediately; /// the serve loop runs on a spawned task, mirroring the IMAP/SMTP /// `listen` contract so `main` keeps running (the job dispatcher /// must still start). pub async fn listen(&self) -> anyhow::Result<SocketAddr> { let listener = tokio::net::TcpListener::bind((self.cfg.host, self.cfg.port)).await?; let addr = listener.local_addr()?;
let app = Router::new() .route("/jmap", post(jmap_handler)) .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) .layer(middleware::from_fn_with_state(self.store.clone(), auth_middleware)) .with_state(self.store.clone());
tracing::info!(%addr, "JMAP server listening"); tokio::spawn(async move { if let Err(e) = axum::serve(listener, app).await { tracing::error!(error = %e, "JMAP server stopped"); } }); Ok(addr) }}
// ── Auth middleware ────────────────────────────────────────────────
/// Extracted user email stored in request extensions.#[derive(Debug, Clone)]struct AuthUser(String);
async fn auth_middleware( State(store): State<Arc<Store>>, mut req: axum::http::Request<axum::body::Body>, next: middleware::Next,) -> axum::http::Response<axum::body::Body> { let Some(creds) = extract_basic_auth(req.headers().get(axum::http::header::AUTHORIZATION)) else { return unauthorized_response("Missing Authorization header"); }; // Same rules as IMAP LOGIN: user is trimmed/lowercased and the // password token must match (src/imap/mod.rs `auth`). let Some(user) = store.find_user_by_email(&creds.email).await else { return unauthorized_response("Invalid credentials"); }; if user.password != creds.password { return unauthorized_response("Invalid credentials"); }
req.extensions_mut().insert(AuthUser(user.email)); next.run(req).await}
struct BasicCreds { email: String, password: String,}
/// Parse an HTTP Basic header into (email, password). The user part is/// trimmed and lowercased to match IMAP LOGIN; the password keeps/// everything after the first ':' so passwords may contain ':'.fn extract_basic_auth(header: Option<&HeaderValue>) -> Option<BasicCreds> { let header = header?.to_str().ok()?; let without_prefix = header.strip_prefix("Basic ")?; let decoded = base64::engine::general_purpose::STANDARD.decode(without_prefix).ok()?; let creds = String::from_utf8(decoded).ok()?; let (email, password) = creds.split_once(':')?; let email = email.trim().to_lowercase(); if email.is_empty() { return None; } Some(BasicCreds { email, password: password.trim().to_string(), })}
fn unauthorized_response(msg: &str) -> axum::http::Response<axum::body::Body> { axum::http::Response::builder() .status(401) .header(axum::http::header::WWW_AUTHENTICATE, "Basic realm=\"JMAP\"") .header(axum::http::header::CONTENT_TYPE, "application/json") .body(axum::body::Body::from( serde_json::to_string(&json!({ "error": msg, "errorDescription": msg, })) .unwrap_or_default(), )) .unwrap()}
// ── JMAP wire types ────────────────────────────────────────────────
#[derive(Debug, Deserialize)]struct JmapRequest { #[serde(default)] #[allow(dead_code)] using: Vec<String>, /// Server-wide cap applied when a method call omits its own `limit`. #[serde(default)] limit: Option<u32>, #[serde(default, rename = "methodCalls")] method_calls: Vec<MethodCall>,}
#[derive(Debug, Deserialize)]struct MethodCall { method: String, #[serde(default)] args: Value, /// Client-chosen id, echoed back in the method response. /// RFC 8620 uses `id` — not `methodId`. #[serde(default)] id: Option<String>,}
#[derive(Debug, Serialize)]struct JmapResponse { #[serde(rename = "methodResponses")] method_responses: Vec<MethodResponse>, #[serde(rename = "sessionState")] session_state: String, #[serde(rename = "paginationLimit", skip_serializing_if = "Option::is_none")] pagination_limit: Option<u32>,}
/// RFC 8620 method response: `id`, `method`, `args`, plus `acb` //// `error` / `errorDescription` where applicable. Errors use JMAP/// string codes (e.g. `"unknownMethod"`), not HTTP numbers.#[derive(Debug)]struct MethodResponse { id: Option<String>, method: Option<String>, args: Option<Value>, acb: Option<Value>, acb_too_big: Option<bool>, error: Option<String>, error_description: Option<String>,}
impl Serialize for MethodResponse { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer, { use serde::ser::SerializeMap; let mut map = serializer.serialize_map(None)?; if let Some(ref id) = self.id { map.serialize_entry("id", id)?; } if let Some(ref method) = self.method { map.serialize_entry("method", method)?; } if let Some(ref args) = self.args { map.serialize_entry("args", args)?; } if let Some(ref acb) = self.acb { map.serialize_entry("acb", acb)?; } if let Some(true) = self.acb_too_big { map.serialize_entry("acbTooBig", &true)?; } if let Some(ref err) = self.error { map.serialize_entry("error", err)?; } if let Some(ref desc) = self.error_description { map.serialize_entry("errorDescription", desc)?; } map.end() }}
// ── Handler ────────────────────────────────────────────────────────
async fn jmap_handler( State(store): State<Arc<Store>>, axum::extract::Extension(AuthUser(user_email)): axum::extract::Extension<AuthUser>, Json(req): Json<JmapRequest>,) -> impl IntoResponse { Json(build_response(store, user_email, req).await)}
async fn build_response( store: Arc<Store>, user_email: String, req: JmapRequest,) -> JmapResponse { let default_limit = req.limit.unwrap_or(1000); let mut responses = Vec::new(); for call in req.method_calls { responses.push(dispatch_method(store.clone(), user_email.clone(), default_limit, call).await); }
let final_state = store.get_jmap_state(&user_email).await;
JmapResponse { method_responses: responses, session_state: final_state.to_string(), pagination_limit: Some(1000), }}
async fn dispatch_method( store: Arc<Store>, user_email: String, default_limit: u32, call: MethodCall,) -> MethodResponse { let id = call.id.clone(); let parts: Vec<&str> = call.method.split('/').collect(); if parts.len() < 2 { return error_response(id.as_deref(), "invalidMethod", "Invalid method name"); }
// A single account: the user's own email is its account id. match parts[0] { "Core" => match parts[1] { "getAllAccountIds" => core_get_all_account_ids(id.as_deref(), &user_email), _ => error_response(id.as_deref(), "unknownMethod", "Unknown Core method"), }, "Account" => match parts[1] { "get" => account_get(id.as_deref(), &user_email), _ => error_response(id.as_deref(), "unknownMethod", "Unknown Account method"), }, "Email" => email_dispatch(store, user_email, default_limit, call, id).await, "EmailState" => email_state_dispatch(store, user_email, call, id).await, _ => error_response(id.as_deref(), "unknownMethod", &format!("Unknown method: {}", call.method)), }}
fn ok_response(id: Option<&str>, method: &str, args: Value) -> MethodResponse { MethodResponse { id: id.map(String::from), method: Some(method.to_string()), args: Some(args), acb: None, acb_too_big: None, error: None, error_description: None, }}
fn error_response(id: Option<&str>, error: &str, description: &str) -> MethodResponse { MethodResponse { id: id.map(String::from), method: None, args: None, acb: None, acb_too_big: None, error: Some(error.to_string()), error_description: Some(description.to_string()), }}
// ── Core ───────────────────────────────────────────────────────────
fn core_get_all_account_ids(id: Option<&str>, user_email: &str) -> MethodResponse { ok_response( id, "Core/getAllAccountIds", json!({ "ids": [user_email], "accountId": user_email, }), )}
// ── Account ────────────────────────────────────────────────────────
fn account_get(id: Option<&str>, user_email: &str) -> MethodResponse { ok_response( id, "Account/get", json!({ "accountId": user_email, "list": [ { "id": user_email, "name": "Primary Account", "email": user_email, "primaryEmail": user_email, "emailSortOrder": user_email, "nameSortOrder": "Primary Account", "canUpload": true, "emailReaderSupported": true, "emailWriterSupported": true, "emailMaxSize": 25 * 1024 * 1024, "emailQuerySortOptions": { "isSeen": {"ascending": true, "descending": true}, "date": {"ascending": true, "descending": true}, "dateInternal": {"ascending": true, "descending": true}, "from": {"ascending": true, "descending": true}, "subject": {"ascending": true, "descending": true}, }, } ], }), )}
// ── Email ──────────────────────────────────────────────────────────
async fn email_dispatch( store: Arc<Store>, user_email: String, default_limit: u32, call: MethodCall, id: Option<String>,) -> MethodResponse { let parts: Vec<&str> = call.method.split('/').collect(); match parts[1] { "get" => email_get(store, user_email, default_limit, call, id.as_deref()).await, "query" => email_query(store, user_email, default_limit, call, id.as_deref()).await, "set" => email_set(store, user_email, call, id.as_deref()).await, _ => error_response(id.as_deref(), "unknownMethod", "Unknown Email method"), }}
/// Email/get — fetch email objects by ID with property filtering./// `position`/`limit` window the delivery-ordered id list; requested ids/// outside the window land in `notFound`, per RFC 8620.async fn email_get( store: Arc<Store>, user_email: String, default_limit: u32, call: MethodCall, id: Option<&str>,) -> MethodResponse { let ids = call.args.get("ids").and_then(|v| v.as_array()).map(|a| { a.iter() .filter_map(|v| v.as_str().map(String::from)) .collect::<Vec<_>>() });
let properties: Vec<String> = call .args .get("properties") .and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default();
let Some(ids) = ids else { return error_response(id, "invalidArguments", "ids is required"); };
let msgs = match store.list_user_messages(&user_email) { Ok(m) => m, Err(e) => { return error_response(id, "serverFail", &format!("Failed to list messages: {e}")); } };
let start = parse_position(call.args.get("position")); let limit = call .args .get("limit") .and_then(|v| v.as_u64()) .unwrap_or(default_limit as u64) as usize; let end = start.saturating_add(limit);
let mut list = Vec::new(); let mut remaining: std::collections::HashSet<String> = ids.into_iter().collect(); for (i, msg) in msgs.iter().enumerate() { if i < start || i >= end { continue; } if remaining.remove(&msg.message_id) { list.push(email_to_jmap(msg, &properties)); } }
// HashSet iteration order is nondeterministic — sort for stable output. let mut not_found: Vec<String> = remaining.into_iter().collect(); not_found.sort();
ok_response( id, "Email/get", json!({ "accountId": user_email, "list": list, "notFound": not_found, }), )}
/// Email/query — search/filter emails. `sort` is accepted but not/// applied: results always come back in delivery order.async fn email_query( store: Arc<Store>, user_email: String, default_limit: u32, call: MethodCall, id: Option<&str>,) -> MethodResponse { let filter = call.args.get("filter"); let _sort = call.args.get("sort");
let msgs = match store.list_user_messages(&user_email) { Ok(m) => m, Err(e) => { return error_response(id, "serverFail", &format!("Failed to list messages: {e}")); } };
let matched: Vec<String> = msgs .iter() .filter(|msg| matches_email_filter(msg, filter)) .map(|msg| msg.message_id.clone()) .collect();
let start = parse_position(call.args.get("position")); let limit = call .args .get("limit") .and_then(|v| v.as_u64()) .unwrap_or(default_limit as u64) as usize; let ids: Vec<Value> = matched .iter() .skip(start) .take(limit) .map(|s| json!(s)) .collect();
let state = store.get_jmap_state(&user_email).await; ok_response( id, "Email/query", json!({ "accountId": user_email, "queryState": state.to_string(), "ids": ids, }), )}
/// Email/set — update and destroy emails.////// Only `isSeen` is writable, and only in the set direction: unsetting/// (mark unread) is rejected with an acb error rather than silently/// dropped, since the maildir has no unmark path. Unknown properties/// are likewise rejected instead of pretending to succeed.async fn email_set( store: Arc<Store>, user_email: String, call: MethodCall, id: Option<&str>,) -> MethodResponse { let updates = call.args.get("update").and_then(|v| v.as_object()); let destroy = call.args.get("destroy").and_then(|v| v.as_array());
let mut update_acb: Map<String, Value> = Map::new(); let mut destroy_acb: Map<String, Value> = Map::new();
// One mailbox scan shared by both operations. let (msgs, list_err) = if updates.is_none() && destroy.is_none() { (Vec::new(), None) } else { match store.list_user_messages(&user_email) { Ok(m) => (m, None), Err(e) => (Vec::new(), Some(e.to_string())), } };
if let Some(err) = &list_err { if let Some(updates) = updates { for key in updates.keys() { update_acb.insert(key.clone(), server_fail(err)); } } if let Some(destroy) = destroy { for v in destroy { if let Some(s) = v.as_str() { destroy_acb.insert(s.to_string(), server_fail(err)); } } } } else { if let Some(updates) = updates { for (msg_id, update_obj) in updates { match update_obj.as_object() { Some(props) => { if let Some(err) = apply_update(&store, &user_email, msg_id, props, &msgs).await { update_acb.insert(msg_id.clone(), err); } } None => { update_acb.insert( msg_id.clone(), json!({"type": "invalidArguments", "description": "update value must be an object"}), ); } } } } if let Some(destroy_list) = destroy { for v in destroy_list { let Some(msg_id) = v.as_str() else { continue; }; match msgs.iter().find(|m| m.message_id == msg_id) { Some(msg) => match tokio::fs::remove_file(&msg.path).await { Ok(()) => { destroy_acb.insert(msg_id.to_string(), json!("true")); store.increment_jmap_state(&user_email).await; } // Concurrent delete (e.g. IMAP EXPUNGE) — idempotent success. Err(e) if e.kind() == std::io::ErrorKind::NotFound => { destroy_acb.insert(msg_id.to_string(), json!("true")); } Err(e) => { destroy_acb.insert( msg_id.to_string(), server_fail(&format!("delete: {e}")), ); } }, None => { destroy_acb.insert(msg_id.to_string(), invalid_id(msg_id)); } } } } }
let mut acb: Map<String, Value> = Map::new(); if updates.is_some() { acb.insert( "update".to_string(), if update_acb.is_empty() { Value::Null } else { Value::Object(update_acb) }, ); } if destroy.is_some() { acb.insert( "destroy".to_string(), if destroy_acb.is_empty() { Value::Null } else { Value::Object(destroy_acb) }, ); }
MethodResponse { id: id.map(String::from), method: Some("Email/set".to_string()), args: Some(json!({ "accountId": user_email, })), acb: if acb.is_empty() { None } else { Some(Value::Object(acb)) }, acb_too_big: None, error: None, error_description: None, }}
/// Apply one update object to one message. Returns an acb error object/// when something was rejected or failed; `None` on success or no-op.async fn apply_update( store: &Arc<Store>, user_email: &str, msg_id: &str, props: &Map<String, Value>, msgs: &[crate::store::StoredMessage],) -> Option<Value> { if props.is_empty() { return None; } for key in props.keys() { if key != "isSeen" { return Some(json!({ "type": "invalidProperties", "description": format!("unsupported property: {key}"), })); } } match props.get("isSeen").and_then(|v| v.as_bool()) { Some(true) => {} Some(false) => { return Some(json!({ "type": "notAllowed", "description": "unsetting isSeen is not supported", })); } None => { return Some(json!({ "type": "invalidArguments", "description": "isSeen must be a boolean", })); } } let Some(msg) = msgs.iter().find(|m| m.message_id == msg_id) else { return Some(invalid_id(msg_id)); }; if msg.seen { return None; // no-op } if store.maildir().mark_seen(&msg.path).is_err() { return Some(server_fail("mark_seen failed")); } store.increment_jmap_state(user_email).await; None}
fn invalid_id(msg_id: &str) -> Value { json!({"type": "invalidId", "description": format!("no such message: {msg_id}")})}
fn server_fail(description: &str) -> Value { json!({"type": "serverFail", "description": description})}
// ── EmailState ─────────────────────────────────────────────────────
async fn email_state_dispatch( store: Arc<Store>, user_email: String, call: MethodCall, id: Option<String>,) -> MethodResponse { let parts: Vec<&str> = call.method.split('/').collect(); match parts[1] { "get" => { let state = store.get_jmap_state(&user_email).await; email_state_get(id.as_deref(), &user_email, state) } _ => error_response(id.as_deref(), "unknownMethod", "Unknown EmailState method"), }}
fn email_state_get(id: Option<&str>, user_email: &str, state: u64) -> MethodResponse { ok_response( id, "EmailState/get", json!({ "accountId": user_email, "id": "emailStateId", "state": state.to_string(), }), )}
// ── Helpers ────────────────────────────────────────────────────────
/// JMAP positions are strings of the form `"N"` or `"N/<positionState>"`.fn parse_position(v: Option<&Value>) -> usize { v.and_then(|v| v.as_str()) .and_then(|s| s.split('/').next()) .and_then(|s| s.parse().ok()) .unwrap_or(0)}
/// Convert a StoredMessage to a JMAP Email object with property filtering.fn email_to_jmap(msg: &crate::store::StoredMessage, properties: &[String]) -> Value { let include_prop = |name: &str| properties.is_empty() || properties.iter().any(|p| p == name);
let mut map = Map::new(); map.insert("id".to_string(), json!(msg.message_id));
if include_prop("name") { map.insert("name".to_string(), json!(msg.subject)); } if include_prop("isSeen") { map.insert("isSeen".to_string(), json!(msg.seen)); } if include_prop("isDraft") { map.insert("isDraft".to_string(), json!(false)); } if include_prop("isSubmitted") { map.insert("isSubmitted".to_string(), json!(false)); } if include_prop("isTrashed") { map.insert("isTrashed".to_string(), json!(false)); } if include_prop("location") { map.insert("location".to_string(), json!("inbox")); } if include_prop("keywords") { map.insert("keywords".to_string(), json!({})); } if include_prop("notBefore") { map.insert("notBefore".to_string(), Value::Null); } if include_prop("priority") { map.insert("priority".to_string(), json!("normal")); } if include_prop("version") { map.insert("version".to_string(), json!(msg.uid.to_string())); } if include_prop("from") { map.insert( "from".to_string(), json!([{ "name": "", "email": extract_address(&msg.from_addr), }]), ); } if include_prop("to") { let addrs = parse_email_list(&msg.to_addrs); map.insert("to".to_string(), json!(addrs)); } if include_prop("cc") { map.insert("cc".to_string(), json!([])); } if include_prop("bcc") { map.insert("bcc".to_string(), json!([])); } if include_prop("replyTo") { map.insert("replyTo".to_string(), json!([])); } if include_prop("subject") { map.insert("subject".to_string(), json!(msg.subject)); } // No Date header is retained in StoredMessage yet, so the // date properties stay null until that lands. if include_prop("date") { map.insert("date".to_string(), Value::Null); } if include_prop("dateInternal") { map.insert("dateInternal".to_string(), Value::Null); } if include_prop("size") { let size = msg .path .metadata() .map(|m| m.len()) .unwrap_or(msg.body_text.len() as u64); map.insert("size".to_string(), json!(size)); } if include_prop("headerCount") { map.insert("headerCount".to_string(), json!(0)); } if include_prop("attachmentCount") { map.insert("attachmentCount".to_string(), json!(0)); } if include_prop("blobIds") { map.insert("blobIds".to_string(), json!([])); } if include_prop("blobIdToLocation") { map.insert("blobIdToLocation".to_string(), json!({})); } if include_prop("bodyValues") { map.insert("bodyValues".to_string(), json!({})); } if include_prop("bodyStructure") { map.insert("bodyStructure".to_string(), Value::Null); } if include_prop("preview") { let preview = msg.body_text.chars().take(500).collect::<String>(); map.insert("preview".to_string(), json!(preview)); } if include_prop("references") { map.insert( "references".to_string(), json!(msg.references.as_deref().unwrap_or("")), ); } if include_prop("inReplyTo") { map.insert( "inReplyTo".to_string(), json!(msg.in_reply_to.as_deref().unwrap_or("")), ); }
Value::Object(map)}
/// Pull the bare address out of a `Name <addr>` (or bare `addr`) string.fn extract_address(s: &str) -> &str { match (s.rfind('<'), s.find('>')) { (Some(lt), Some(gt)) if gt > lt => &s[lt + 1..gt], _ => s.trim(), }}
/// Parse a comma-separated email address list into JMAP address objects.fn parse_email_list(s: &str) -> Vec<Value> { s.split(',') .filter_map(|seg| { let email = extract_address(seg).trim().to_string(); if email.is_empty() { return None; } Some(json!({ "name": "", "email": email, })) }) .collect()}
/// Check if a message matches a JMAP filter.fn matches_email_filter(msg: &crate::store::StoredMessage, filter: Option<&Value>) -> bool { let filter = match filter { Some(f) => f, None => return true, };
// Handle "and" filter if let Some(and_list) = filter.get("and") { if let Some(list) = and_list.as_array() { return list.iter().all(|f| matches_email_filter(msg, Some(f))); } }
// Handle "or" filter if let Some(or_list) = filter.get("or") { if let Some(list) = or_list.as_array() { return list.iter().any(|f| matches_email_filter(msg, Some(f))); } }
// Handle "not" filter if let Some(not_filter) = filter.get("not") { return !matches_email_filter(msg, Some(not_filter)); }
// Simple property filters if let Some(is_seen) = filter.get("isSeen").and_then(|v| v.as_bool()) { return msg.seen == is_seen; } if let Some(subject) = filter.get("subject").and_then(|v| v.as_str()) { return msg.subject.to_lowercase().contains(&subject.to_lowercase()); } if let Some(from) = filter.get("from").and_then(|v| v.as_str()) { return msg.from_addr.to_lowercase().contains(&from.to_lowercase()); } if let Some(to) = filter.get("to").and_then(|v| v.as_str()) { return msg.to_addrs.to_lowercase().contains(&to.to_lowercase()); }
true}