//! SPLIT-EVAL: 已评估暂缓拆分——jmap-client 0.4.2 的适配、请求建模与降级策略必须贴在一起才能对照 crate 文档逐字段验证,拆开会切断这条对照链。 //! Concrete read-only adapter (MOD-JMAP-CLIENT, GATE-G1-JMAP-READONLY). use super::{ EmailChanges, EmailContent, EmailMetadata, Folder, JmapBackend, JmapError, SearchResult, SearchSnippet, }; use crate::state::{runtime_provider, OutboundConfig, RuntimeConfigProvider}; use async_trait::async_trait; use jmap_client::{email, mailbox, DataType, Get}; pub struct JmapClientBackend { client: jmap_client::client::Client, account_id: String, runtime: RuntimeConfigProvider, } impl JmapClientBackend { pub async fn connect( session_url: &str, username: &str, password: &str, account_id: Option<&str>, ) -> Result { Self::connect_with_runtime( session_url, username, password, account_id, runtime_provider(OutboundConfig::default()), ) .await } pub async fn connect_with_runtime( session_url: &str, username: &str, password: &str, account_id: Option<&str>, runtime: RuntimeConfigProvider, ) -> Result { let session_base = normalize_session_url(session_url)?; let timeout_ms = runtime .read() .map(|config| config.jmap_timeout_ms) .unwrap_or(15_000); let timeout = std::time::Duration::from_millis(timeout_ms.max(100)); let connect = jmap_client::client::Client::new() .credentials((username, password)) .follow_redirects([trusted_redirect_host(&session_base)]) .connect(&session_base); let mut client = tokio::time::timeout(timeout, connect) .await .map_err(|_| JmapError::Timeout)??; let selected = select_account( account_id, client.default_account_id(), client.session().accounts(), )?; client.set_default_account_id(selected.clone()); Ok(Self { client, account_id: selected, runtime, }) } pub fn account_id(&self) -> &str { &self.account_id } } #[async_trait] impl JmapBackend for JmapClientBackend { async fn destroy_push_subscription(&self, subscription_id: &str) -> Result<(), JmapError> { if subscription_id.trim().is_empty() { return Err(JmapError::InvalidRequest("push subscription id")); } tokio::time::timeout( self.timeout(), self.client.push_subscription_destroy(subscription_id), ) .await .map_err(|_| JmapError::Timeout)??; Ok(()) } async fn create_push_subscription(&self, callback_url: &str) -> Result { let parsed = url::Url::parse(callback_url) .map_err(|_| JmapError::InvalidRequest("push callback URL"))?; if parsed.scheme() != "https" || parsed.username() != "" || parsed.password().is_some() { return Err(JmapError::InvalidRequest("push callback URL")); } let subscription = tokio::time::timeout( self.timeout(), self.client.push_subscription_create( format!("message-weave-{}", self.account_id), callback_url, None, ), ) .await .map_err(|_| JmapError::Timeout)??; let subscription_id = subscription .id() .map(ToOwned::to_owned) .ok_or(JmapError::MissingField("push subscription id"))?; // JMAP PushSubscription/set create has no `types` argument in // jmap-client 0.4.2. Restrict the newly-created subscription with the // follow-up update before exposing its id (REQ-PUSH-TYPES). if tokio::time::timeout( self.timeout(), self.client.push_subscription_update_types( &subscription_id, Some([DataType::Email, DataType::EmailDelivery]), ), ) .await .map_err(|_| JmapError::Timeout)? .is_err() { let _ = self .client .push_subscription_destroy(&subscription_id) .await; return Err(JmapError::InvalidRequest("push subscription types")); } Ok(subscription_id) } async fn verify_push_subscription( &self, subscription_id: &str, verification_code: &str, ) -> Result<(), JmapError> { if subscription_id.trim().is_empty() || verification_code.trim().is_empty() { return Err(JmapError::InvalidRequest("push verification")); } let verified = tokio::time::timeout( self.timeout(), self.client .push_subscription_verify(subscription_id, verification_code), ) .await .map_err(|_| JmapError::Timeout)??; if verified.is_some() { Ok(()) } else { Err(JmapError::NotFound) } } async fn current_state(&self) -> Result { Ok(self.client.session().state().to_owned()) } async fn email_changes( &self, account_id: &str, since_state: &str, max_changes: usize, ) -> Result { if account_id != self.account_id || since_state.trim().is_empty() || max_changes == 0 { return Err(JmapError::InvalidRequest("email changes")); } let mut request = self.client.build(); request.changes_email(since_state).max_changes(max_changes); let response = tokio::time::timeout(self.timeout(), request.send_changes_email()) .await .map_err(|_| JmapError::Timeout)??; Ok(EmailChanges { new_state: response.new_state().to_owned(), created: response.created().to_owned(), updated: response.updated().to_owned(), has_more: response.has_more_changes(), }) } async fn list_folders(&self, account_id: &str) -> Result, JmapError> { if account_id != self.account_id { return Err(JmapError::InvalidRequest("account_id")); } let ids = tokio::time::timeout( self.timeout(), self.client.mailbox_query( None::, None::>>, ), ) .await .map_err(|_| JmapError::Timeout)?? .take_ids(); let mut folders = Vec::with_capacity(ids.len()); for id in ids { if let Some(mailbox) = tokio::time::timeout( self.timeout(), self.client.mailbox_get(&id, None::>), ) .await .map_err(|_| JmapError::Timeout)?? { folders.push(Folder { id: mailbox .id() .ok_or(JmapError::MissingField("mailbox id"))? .to_owned(), name: mailbox.name().unwrap_or_default().to_owned(), role: role_name(mailbox.role()), total_emails: mailbox.total_emails(), unread_emails: mailbox.unread_emails(), }); } } Ok(folders) } async fn list_emails( &self, account_id: &str, folder_id: Option<&str>, limit: usize, ) -> Result, JmapError> { if account_id != self.account_id || limit == 0 { return Err(JmapError::InvalidRequest("email query")); } // Query in bounded pages. A large limit (used for the cold-start // baseline) therefore cannot silently stop at the server default page. let page_size = limit.clamp(1, 100); let mut position = 0_i32; let mut ids = Vec::new(); while ids.len() < limit { let mut request = self.client.build(); let query = request.query_email(); if let Some(folder_id) = folder_id { query.filter(email::query::Filter::in_mailbox(folder_id)); } query.position(position).limit(page_size); let mut response = tokio::time::timeout(self.timeout(), request.send_query_email()) .await .map_err(|_| JmapError::Timeout)??; let page = response.take_ids(); if page.is_empty() { break; } position = position.saturating_add(page.len() as i32); ids.extend(page); if ids.len() >= limit || position <= 0 { break; } } ids.truncate(limit); let mut out = Vec::new(); for id in ids.into_iter().take(limit) { if let Some(email) = tokio::time::timeout( self.timeout(), self.client.email_get(&id, None::>), ) .await .map_err(|_| JmapError::Timeout)?? { out.push(metadata(&email)); } } Ok(out) } async fn list_emails_page( &self, account_id: &str, folder_id: Option<&str>, position: usize, limit: usize, ) -> Result, JmapError> { self.fetch_email_page(account_id, folder_id, position, limit) .await } async fn read_email( &self, account_id: &str, email_id: &str, ) -> Result, JmapError> { if account_id != self.account_id { return Err(JmapError::InvalidRequest("account_id")); } let mut request = self.client.build(); let get = request.get_email().ids([email_id]).properties([ email::Property::From, email::Property::Subject, email::Property::Preview, email::Property::TextBody, email::Property::BodyValues, email::Property::Size, email::Property::ReceivedAt, email::Property::HasAttachment, ]); get.arguments().fetch_text_body_values(true); let Some(email) = tokio::time::timeout(self.timeout(), request.send_get_email()) .await .map_err(|_| JmapError::Timeout)?? .take_list() .pop() else { return Ok(None); }; let text = join_body_values(email.text_body().into_iter().flatten().map(|part| { part.part_id() .and_then(|id| email.body_value(id)) .map(|body| body.value()) }))?; Ok(Some(EmailContent { metadata: metadata(&email), character_count: text.chars().count(), is_long: false, text, })) } async fn search_emails( &self, account_id: &str, query: &str, limit: usize, ) -> Result { if account_id != self.account_id || limit == 0 || query.trim().is_empty() { return Err(JmapError::InvalidRequest("email search")); } // JMAP caps a single Email/query page; a bounded search never pages, // so clamp instead of issuing a follow-up position request. let limit = limit.min(100); let filter = email::query::Filter::text(query); let mut request = self.client.build(); let email_query = request.query_email(); email_query.filter(filter.clone()).position(0).limit(limit); let mut response = tokio::time::timeout(self.timeout(), request.send_query_email()) .await .map_err(|_| JmapError::Timeout)??; let mut ids = response.take_ids(); ids.truncate(limit); // Snippets are display-only: when the server cannot answer // SearchSnippet/get (e.g. `unknownMethod`) we still return the matched // ids so the caller can degrade to a result list without highlights. let snippets = match tokio::time::timeout( self.timeout(), self.client .search_snippet_get(Some(filter), ids.iter().cloned()), ) .await { Ok(Ok(response)) => response .list() .iter() .map(|snippet| SearchSnippet { email_id: snippet.email_id().to_owned(), subject: snippet.subject().map(str::to_owned), preview: snippet.preview().map(str::to_owned), }) .collect(), Ok(Err(_)) | Err(_) => Vec::new(), }; Ok(SearchResult { ids, snippets }) } } impl JmapClientBackend { async fn fetch_email_page( &self, account_id: &str, folder_id: Option<&str>, position: usize, limit: usize, ) -> Result, JmapError> { if account_id != self.account_id || limit == 0 { return Err(JmapError::InvalidRequest("email query")); } let mut request = self.client.build(); let query = request.query_email(); if let Some(folder_id) = folder_id { query.filter(email::query::Filter::in_mailbox(folder_id)); } query.position(position as i32).limit(limit.min(100)); let mut response = tokio::time::timeout(self.timeout(), request.send_query_email()) .await .map_err(|_| JmapError::Timeout)??; let ids = response.take_ids(); let mut out = Vec::with_capacity(ids.len()); for id in ids { if let Some(email) = tokio::time::timeout( self.timeout(), self.client.email_get(&id, None::>), ) .await .map_err(|_| JmapError::Timeout)?? { out.push(metadata(&email)); } } Ok(out) } fn timeout(&self) -> std::time::Duration { std::time::Duration::from_millis( self.runtime .read() .map(|config| config.jmap_timeout_ms) .unwrap_or(15_000) .max(100), ) } } fn join_body_values<'a>( values: impl Iterator>, ) -> Result { let text = values.flatten().collect::(); if text.is_empty() { Err(JmapError::MissingField("text body value")) } else { Ok(text) } } fn role_name(role: mailbox::Role) -> Option { match role { mailbox::Role::Archive => Some("archive".into()), mailbox::Role::Drafts => Some("drafts".into()), mailbox::Role::Important => Some("important".into()), mailbox::Role::Inbox => Some("inbox".into()), mailbox::Role::Junk => Some("junk".into()), mailbox::Role::Sent => Some("sent".into()), mailbox::Role::Trash => Some("trash".into()), mailbox::Role::Other(value) => Some(value), mailbox::Role::None => None, } } pub(crate) fn normalize_session_url(value: &str) -> Result { let parsed = url::Url::parse(value).map_err(|_| JmapError::InvalidRequest("JMAP_SESSION_URL"))?; if parsed.scheme() != "https" { return Err(JmapError::InvalidRequest("JMAP_SESSION_URL must use HTTPS")); } if !parsed.username().is_empty() || parsed.password().is_some() || parsed.query().is_some() || parsed.fragment().is_some() { return Err(JmapError::InvalidRequest( "JMAP_SESSION_URL must not contain credentials or query", )); } if !matches!(parsed.path(), "" | "/" | "/.well-known/jmap") { return Err(JmapError::InvalidRequest("invalid JMAP_SESSION_URL path")); } Ok(parsed.origin().ascii_serialization()) } /// Hosts the JMAP client is allowed to follow redirects to. /// /// jmap-client ships with an empty trust list and aborts any redirect to a host it was not /// told to trust. Stalwart always 307-redirects `/.well-known/jmap` to `/jmap/session`, so /// leaving the list empty makes every Stalwart deployment fail at connect with /// "Aborting redirect request to unknown host". We trust the single origin host we were /// handed and nothing else — the client still refuses to be pointed at a different host. pub(crate) fn trusted_redirect_host(session_base: &str) -> String { url::Url::parse(session_base) .ok() .and_then(|url| url.host_str().map(str::to_owned)) .unwrap_or_default() } fn select_account<'a>( requested: Option<&str>, primary: &'a str, mut accounts: impl Iterator, ) -> Result { let selected = requested .filter(|id| !id.trim().is_empty()) .unwrap_or(primary); if selected.is_empty() { return Err(JmapError::MissingField("primary account")); } if !accounts.any(|id| id == selected) { return Err(JmapError::InvalidRequest( "ACCOUNT_ID is not in session accounts", )); } Ok(selected.to_owned()) } /// One from-address the way RFC 5322 prints it, which is what a reader expects /// on a `From:` line. With no display name, the address is the whole thing. fn sender_display(address: &jmap_client::email::EmailAddress) -> String { match address.name() { Some(name) => format!("{name} <{}>", address.email()), None => address.email().to_owned(), } } fn metadata(email: &jmap_client::email::Email) -> EmailMetadata { EmailMetadata { id: email.id().unwrap_or_default().to_owned(), subject: email.subject().map(str::to_owned), sender: email .from() .and_then(|addresses| addresses.first()) .map(sender_display), received_at: email.received_at(), preview: email.preview().map(str::to_owned), size: Some(email.size()), has_attachment: email.has_attachment(), } } #[cfg(test)] mod real_server_tests { use super::*; /// Opt-in smoke test for a real Stalwart instance; credentials are never printed. #[tokio::test] #[ignore = "requires an explicitly configured JMAP test server"] async fn session_list_and_read_smoke() { let Some(url) = std::env::var_os("JMAP_SESSION_URL") else { eprintln!("skipped: JMAP_SESSION_URL is not configured"); return; }; let Some(user) = std::env::var_os("JMAP_USERNAME") else { eprintln!("skipped: JMAP_USERNAME is not configured"); return; }; let Some(password) = std::env::var_os("JMAP_PASSWORD") else { eprintln!("skipped: JMAP_PASSWORD is not configured"); return; }; let account = std::env::var("ACCOUNT_ID").ok(); let backend = JmapClientBackend::connect( url.to_str().unwrap_or_default(), user.to_str().unwrap_or_default(), password.to_str().unwrap_or_default(), account.as_deref(), ) .await .expect("configured JMAP session must connect"); let folders = backend.list_folders(backend.account_id()).await.unwrap(); let emails = backend .list_emails(backend.account_id(), None, 1) .await .unwrap(); if let Some(email) = emails.first() { let _ = backend .read_email(backend.account_id(), &email.id) .await .unwrap(); } assert!(!folders.is_empty() || emails.is_empty()); } } #[cfg(test)] mod tests { use super::*; use crate::domain::jmap::JmapService; fn accounts(values: &[&str]) -> impl Iterator { let owned = values .iter() .map(|value| (*value).to_owned()) .collect::>(); Box::leak(owned.into_boxed_slice()).iter() } #[test] fn redirect_trust_is_derived_from_the_given_origin_only() { assert_eq!( trusted_redirect_host("https://mail.example.com"), "mail.example.com" ); // normalize_session_url collapses the accepted paths to the origin, so the trust // list stays a single host even when the caller typed the well-known path. assert_eq!( trusted_redirect_host( &normalize_session_url("https://mail.example.com/.well-known/jmap").unwrap() ), "mail.example.com" ); // host_str() excludes the port, and jmap-client compares against host_str() too, // so a non-standard port must not leak into the trust list. assert_eq!( trusted_redirect_host("https://mail.example.com:8443"), "mail.example.com" ); // An unparseable base yields an empty host, which matches no redirect target, so the // client falls back to trusting nobody — the pre-existing fail-closed behaviour. assert_eq!(trusted_redirect_host("not a url"), ""); } #[test] fn account_selection_supports_primary_and_validates_explicit() { assert_eq!( select_account(None, "primary", accounts(&["primary"])).unwrap(), "primary" ); assert_eq!( select_account( Some("secondary"), "primary", accounts(&["primary", "secondary"]) ) .unwrap(), "secondary" ); assert!(select_account(Some("missing"), "primary", accounts(&["primary"])).is_err()); } #[test] fn session_url_and_limit_boundaries_are_rejected() { assert!(normalize_session_url("http://mail.example").is_err()); assert_eq!( normalize_session_url("https://mail.example/.well-known/jmap").unwrap(), "https://mail.example" ); assert_eq!( normalize_session_url("https://mail.example").unwrap(), "https://mail.example" ); assert!(normalize_session_url("https://u:p@mail.example").is_err()); assert!(normalize_session_url("https://mail.example/?token=x").is_err()); assert!(normalize_session_url("https://mail.example/#fragment").is_err()); assert!(normalize_session_url("https://mail.example/mail/jmap").is_err()); assert!(JmapService::with_long_email_limit((), "account", 0).is_err()); } #[test] fn body_values_join_in_order_and_empty_is_explicit_error() { assert_eq!( join_body_values([Some("first "), None, Some("second")].into_iter()).unwrap(), "first second" ); assert!(matches!( join_body_values([None, None].into_iter()), Err(JmapError::MissingField("text body value")) )); } #[test] fn roles_are_normalized() { assert_eq!(role_name(mailbox::Role::Inbox).as_deref(), Some("inbox")); assert_eq!(role_name(mailbox::Role::None), None); } fn email_with_from(addresses: &[serde_json::Value]) -> email::Email { serde_json::from_value(serde_json::json!({ "id": "m1", "subject": "hello", "preview": "hi", "from": addresses, })) .expect("a minimal Email/get payload must deserialize") } #[test] fn sender_keeps_the_display_name_in_rfc5322_form() { let address = serde_json::from_value::(serde_json::json!({ "email": "zhang@example.com", "name": "张三", })) .expect("a minimal from-address must deserialize"); assert_eq!(sender_display(&address), "张三 "); } #[test] fn sender_without_a_display_name_is_the_bare_address() { let address = serde_json::from_value::(serde_json::json!({ "email": "noreply@example.com", })) .expect("a nameless from-address must deserialize"); assert_eq!(sender_display(&address), "noreply@example.com"); } #[test] fn metadata_takes_the_sender_from_the_from_address() { // The address is only readable if Email/get actually asked for the "from" // property, which is why `metadata` must read it from `from` and not just // keep the bare address. let email = email_with_from(&[serde_json::json!({"email": "zhang@example.com", "name": "张三"})]); let meta = metadata(&email); assert_eq!(meta.sender.as_deref(), Some("张三 ")); // No display name, so the address stands alone. let email = email_with_from(&[serde_json::json!({"email": "noreply@example.com"})]); assert_eq!( metadata(&email).sender.as_deref(), Some("noreply@example.com") ); // Multiple addresses: the notification shows the first one. let email = email_with_from(&[ serde_json::json!({"email": "first@example.com"}), serde_json::json!({"email": "second@example.com"}), ]); assert_eq!( metadata(&email).sender.as_deref(), Some("first@example.com") ); // No `from` at all means `sender` stays `None` and the caller supplies // its fallback, so this assertion would also hold before the fix. let email = email_with_from(&[]); assert_eq!(metadata(&email).sender, None); } }