From 6cde5dfe381fa554dcc0ec703bb29d6e703d01d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Tue, 31 Mar 2026 16:55:21 +0200 Subject: [PATCH] refactor: clean serializers --- src/imap/envelope/sort.rs | 28 +-- src/imap/envelope/thread.rs | 6 +- src/imap/id.rs | 10 +- src/imap/mailbox/list.rs | 17 +- src/imap/message/read.rs | 4 - src/imap/stream.rs | 378 ----------------------------------- src/jmap/email/export.rs | 8 +- src/jmap/email/import.rs | 14 +- src/jmap/email/parse.rs | 19 +- src/jmap/email/query.rs | 1 - src/jmap/identity/get.rs | 1 - src/jmap/mailbox/query.rs | 1 - src/jmap/query.rs | 39 ++-- src/jmap/submission/query.rs | 1 - src/jmap/thread/get.rs | 1 - tests/common/imap.rs | 36 ++-- tests/common/jmap.rs | 81 ++++++-- 17 files changed, 159 insertions(+), 486 deletions(-) delete mode 100644 src/imap/stream.rs diff --git a/src/imap/envelope/sort.rs b/src/imap/envelope/sort.rs index 0894e3f5..4ddab3c2 100644 --- a/src/imap/envelope/sort.rs +++ b/src/imap/envelope/sort.rs @@ -12,7 +12,7 @@ use io_imap::{ }; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::Printer; -use serde::{Serialize, Serializer}; +use serde::Serialize; use crate::imap::{ account::ImapAccount, envelope::search::parse_query, mailbox::arg::MailboxNameOptionalArg, @@ -116,22 +116,15 @@ fn parse_sort_key(s: &str) -> Result { } #[derive(Clone, Debug, Serialize)] -pub struct SortResult { - pub id: u32, -} - pub struct SortResultsTable { - results: Vec, + ids: Vec, uid_mode: bool, } impl SortResultsTable { pub fn new(ids: Vec, uid_mode: bool) -> Self { - let results = ids - .into_iter() - .map(|id| SortResult { id: id.get() }) - .collect(); - Self { results, uid_mode } + let ids = ids.into_iter().map(|id| id.get()).collect(); + Self { ids, uid_mode } } } @@ -146,20 +139,13 @@ impl fmt::Display for SortResultsTable { .set_content_arrangement(ContentArrangement::DynamicFullWidth) .set_header(Row::from([Cell::new(id_header)])); - for result in &self.results { - table.add_row(Row::from([Cell::new(result.id)])); + for id in &self.ids { + table.add_row(Row::from([Cell::new(id)])); } writeln!(f)?; write!(f, "{table}")?; writeln!(f)?; - writeln!(f, "Found {} message(s)", self.results.len())?; - Ok(()) - } -} - -impl Serialize for SortResultsTable { - fn serialize(&self, serializer: S) -> Result { - self.results.serialize(serializer) + writeln!(f, "Found {} message(s)", self.ids.len()) } } diff --git a/src/imap/envelope/thread.rs b/src/imap/envelope/thread.rs index 3b768e01..31cd7b5c 100644 --- a/src/imap/envelope/thread.rs +++ b/src/imap/envelope/thread.rs @@ -12,7 +12,7 @@ use io_imap::{ }; use io_stream::runtimes::std::handle; use pimalaya_toolbox::{stream::imap::ImapSession, terminal::printer::Printer}; -use serde::{Serialize, Serializer}; +use serde::{ser::SerializeStruct, Serialize, Serializer}; use crate::imap::{ account::ImapAccount, @@ -320,6 +320,8 @@ impl ThreadResultsTable { impl Serialize for ThreadResultsTable { fn serialize(&self, serializer: S) -> Result { - self.build_entries().serialize(serializer) + let mut s = serializer.serialize_struct("ThreadResultsTable", 1)?; + s.serialize_field("threads", &self.build_entries())?; + s.end() } } diff --git a/src/imap/id.rs b/src/imap/id.rs index a7b20189..df2f706a 100644 --- a/src/imap/id.rs +++ b/src/imap/id.rs @@ -74,14 +74,14 @@ impl IdCommand { server_id: params .unwrap_or_default() .into_iter() - .map(|(key, val)| { - ( - String::from_utf8(key.into_inner().into_owned()).unwrap(), + .filter_map(|(key, val)| { + Some(( + String::from_utf8(key.into_inner().into_owned()).ok()?, match val.into_option() { - Some(val) => Some(String::from_utf8(val.into_owned()).unwrap()), + Some(val) => Some(String::from_utf8(val.into_owned()).ok()?), None => None, }, - ) + )) }) .collect(), }; diff --git a/src/imap/mailbox/list.rs b/src/imap/mailbox/list.rs index 42a78d63..f4df106e 100644 --- a/src/imap/mailbox/list.rs +++ b/src/imap/mailbox/list.rs @@ -9,7 +9,7 @@ use io_imap::{ }; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::Printer; -use serde::{Serialize, Serializer}; +use serde::Serialize; use crate::imap::account::ImapAccount; @@ -65,17 +65,18 @@ impl ListMailboxesCommand { let table = MailboxesTable { preset: account.table_preset, - rows: mailboxes.into_iter().map(From::from).collect(), + mailboxes: mailboxes.into_iter().map(From::from).collect(), }; printer.out(table) } } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, Serialize)] pub struct MailboxesTable { + #[serde(skip)] pub preset: String, - pub rows: Vec, + pub mailboxes: Vec, } impl fmt::Display for MailboxesTable { @@ -89,7 +90,7 @@ impl fmt::Display for MailboxesTable { Cell::new("DELIMITER"), Cell::new("ATTRIBUTES"), ])) - .add_rows(self.rows.iter().map(|mbox| { + .add_rows(self.mailboxes.iter().map(|mbox| { let mut row = Row::new(); row.max_height(1) @@ -107,12 +108,6 @@ impl fmt::Display for MailboxesTable { } } -impl Serialize for MailboxesTable { - fn serialize(&self, serializer: S) -> Result { - self.rows.serialize(serializer) - } -} - #[derive(Clone, Debug, Serialize)] pub struct MailboxRow { pub name: String, diff --git a/src/imap/message/read.rs b/src/imap/message/read.rs index 18ea0b4b..9dc26d8a 100644 --- a/src/imap/message/read.rs +++ b/src/imap/message/read.rs @@ -38,10 +38,6 @@ pub struct ReadMessageCommand { /// Show HTML content instead of plain text. #[arg(long)] pub html: bool, - - /// Terminal width for text wrapping. - #[arg(long, short = 'w', default_value = "80")] - pub width: usize, } impl ReadMessageCommand { diff --git a/src/imap/stream.rs b/src/imap/stream.rs deleted file mode 100644 index 9dc4504f..00000000 --- a/src/imap/stream.rs +++ /dev/null @@ -1,378 +0,0 @@ -#[cfg(unix)] -use std::os::unix::net::UnixStream; -use std::{ - fs, - io::{self, Read, Write}, - net::TcpStream, - sync::Arc, -}; - -use anyhow::{bail, Result}; -use io_imap::{ - context::ImapContext, - coroutines::{ - authenticate::*, authenticate_anonymous::ImapAuthenticateAnonymousParams, - authenticate_plain::ImapAuthenticatePlainParams, capability::*, - greeting_with_capability::*, login::ImapLoginParams, starttls::*, - }, - types::{auth::AuthMechanism, response::Capability}, -}; -use io_stream::runtimes::std::handle; -use log::{debug, info}; -#[cfg(feature = "native-tls")] -use native_tls::TlsConnector; -#[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] -use rustls::{ - crypto::{self, CryptoProvider}, - pki_types::{pem::PemObject, CertificateDer}, - ClientConfig, ClientConnection, StreamOwned, -}; -#[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] -use rustls_platform_verifier::{ConfigVerifierExt, Verifier}; -#[cfg(windows)] -use uds_windows::UnixStream; - -use crate::config::{ImapConfig, RustlsCryptoConfig, SaslMechanismConfig, TlsProviderConfig}; - -pub enum Stream { - Tcp(TcpStream), - Unix(UnixStream), - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Rustls(StreamOwned), - #[cfg(feature = "native-tls")] - NativeTls(native_tls::TlsStream), -} - -pub fn connect(mut config: ImapConfig) -> Result<(ImapContext, Stream)> { - info!("connecting to IMAP server using {}", config.url); - - let mut context = ImapContext::new(); - let host = config.url.host_str().unwrap_or("127.0.0.1"); - - let (mut context, mut stream) = match config.url.scheme() { - scheme if scheme.eq_ignore_ascii_case("imap") => { - let port = config.url.port().unwrap_or(143); - let mut stream = TcpStream::connect((host, port))?; - - let mut coroutine = GetImapGreetingWithCapability::new(context); - let mut arg = None; - - loop { - match coroutine.resume(arg.take()) { - GetImapGreetingWithCapabilityResult::Io { io } => { - arg = Some(handle(&mut stream, io)?) - } - GetImapGreetingWithCapabilityResult::Ok { context: c } => break context = c, - GetImapGreetingWithCapabilityResult::Err { err, .. } => Err(err)?, - } - } - - (context, Stream::Tcp(stream)) - } - scheme if scheme.eq_ignore_ascii_case("imaps") => { - let port = config.url.port().unwrap_or(993); - let mut stream = TcpStream::connect((host, port))?; - - if config.starttls { - let mut coroutine = ImapStartTls::new(context); - let mut arg = None; - - loop { - match coroutine.resume(arg.take()) { - ImapStartTlsResult::Io { io } => arg = Some(handle(&mut stream, io)?), - ImapStartTlsResult::Ok { context: c } => break context = c, - ImapStartTlsResult::Err { err, .. } => Err(err)?, - } - } - } - - let tls_provider = match config.tls.provider { - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Some(TlsProviderConfig::Rustls) => TlsProviderConfig::Rustls, - #[cfg(not(feature = "rustls-aws"))] - #[cfg(not(feature = "rustls-ring"))] - Some(TlsProviderConfig::Rustls) => { - bail!("Required cargo feature: `rustls-aws` or `rustls-ring`") - } - #[cfg(feature = "native-tls")] - Some(TlsProviderConfig::NativeTls) => TlsProviderConfig::NativeTls, - #[cfg(not(feature = "native-tls"))] - Some(TlsProviderConfig::NativeTls) => { - bail!("Required cargo feature: `native-tls`") - } - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - None => TlsProviderConfig::Rustls, - #[cfg(not(feature = "rustls-aws"))] - #[cfg(not(feature = "rustls-ring"))] - #[cfg(feature = "native-tls")] - None => TlsProviderConfig::NativeTls, - #[cfg(not(feature = "rustls-aws"))] - #[cfg(not(feature = "rustls-ring"))] - #[cfg(not(feature = "native-tls"))] - None => { - bail!("Required cargo feature: `rustls-aws`, `rustls-ring` or `native-tls`") - } - }; - - debug!("using TLS provider: {tls_provider:?}"); - - let mut stream = match tls_provider { - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - TlsProviderConfig::Rustls => { - let crypto_provider = match config.tls.rustls.crypto { - #[cfg(feature = "rustls-aws")] - Some(RustlsCryptoConfig::Aws) => RustlsCryptoConfig::Aws, - #[cfg(not(feature = "rustls-aws"))] - Some(RustlsCryptoConfig::Aws) => { - bail!("Required cargo feature: `rustls-aws`"); - } - #[cfg(feature = "rustls-ring")] - Some(RustlsCryptoConfig::Ring) => RustlsCryptoConfig::Ring, - #[cfg(not(feature = "rustls-ring"))] - Some(RustlsCryptoConfig::Ring) => { - bail!("Required cargo feature: `rustls-ring`"); - } - #[cfg(feature = "rustls-ring")] - None => RustlsCryptoConfig::Ring, - #[cfg(not(feature = "rustls-ring"))] - #[cfg(feature = "rustls-aws")] - None => RustlsCryptoConfig::Aws, - #[cfg(not(feature = "rustls-aws"))] - #[cfg(not(feature = "rustls-ring"))] - None => { - bail!("Required cargo feature: `rustls-aws` or `rustls-ring`"); - } - }; - - debug!("using rustls crypto provider: {crypto_provider:?}"); - - let crypto_provider = match crypto_provider { - #[cfg(feature = "rustls-aws")] - RustlsCryptoConfig::Aws => crypto::aws_lc_rs::default_provider(), - #[cfg(feature = "rustls-ring")] - RustlsCryptoConfig::Ring => crypto::ring::default_provider(), - #[allow(unreachable_patterns)] - _ => unreachable!(), - }; - - let crypto_provider = match crypto_provider.install_default() { - Ok(()) => CryptoProvider::get_default().unwrap().clone(), - Err(crypto_provider) => crypto_provider, - }; - - let mut config = if let Some(pem_path) = &config.tls.cert { - debug!("using TLS cert at {}", pem_path.display()); - let pem = fs::read(pem_path)?; - - let Some(cert) = CertificateDer::pem_slice_iter(&pem).next() else { - bail!("empty TLS cert at {}", pem_path.display()) - }; - - let verifier = - Verifier::new_with_extra_roots(vec![cert?], crypto_provider)?; - - ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(verifier)) - .with_no_client_auth() - } else { - debug!("using OS TLS certs"); - ClientConfig::with_platform_verifier()? - }; - - config.alpn_protocols = vec![b"imap".to_vec()]; - - let server_name = host.to_string().try_into()?; - let conn = ClientConnection::new(Arc::new(config), server_name)?; - Stream::Rustls(StreamOwned::new(conn, stream)) - } - #[cfg(feature = "native-tls")] - TlsProviderConfig::NativeTls => { - let mut builder = TlsConnector::builder(); - - if let Some(pem_path) = &config.tls.cert { - debug!("using TLS cert at {}", pem_path.display()); - let pem = fs::read(pem_path)?; - let cert = native_tls::Certificate::from_pem(&pem)?; - builder.add_root_certificate(cert); - } - - let connector = builder.build()?; - Stream::NativeTls(connector.connect(host, stream)?) - } - #[allow(unreachable_patterns)] - _ => unreachable!(), - }; - - if config.starttls { - let mut coroutine = GetImapCapability::new(context); - let mut arg = None; - - loop { - match coroutine.resume(arg.take()) { - GetImapCapabilityResult::Io { io } => arg = Some(handle(&mut stream, io)?), - GetImapCapabilityResult::Ok { context: c } => break context = c, - GetImapCapabilityResult::Err { err, .. } => Err(err)?, - } - } - } else { - let mut coroutine = GetImapGreetingWithCapability::new(context); - let mut arg = None; - - loop { - match coroutine.resume(arg.take()) { - GetImapGreetingWithCapabilityResult::Io { io } => { - arg = Some(handle(&mut stream, io)?) - } - GetImapGreetingWithCapabilityResult::Ok { context: c } => { - break context = c - } - GetImapGreetingWithCapabilityResult::Err { err, .. } => Err(err)?, - } - } - } - - (context, stream) - } - scheme if scheme.eq_ignore_ascii_case("unix") => { - let sock_path = config.url.path(); - let mut stream = UnixStream::connect(&sock_path)?; - - let mut coroutine = GetImapGreetingWithCapability::new(context); - let mut arg = None; - - loop { - match coroutine.resume(arg.take()) { - GetImapGreetingWithCapabilityResult::Io { io } => { - arg = Some(handle(&mut stream, io)?) - } - GetImapGreetingWithCapabilityResult::Ok { context: c } => break context = c, - GetImapGreetingWithCapabilityResult::Err { err, .. } => Err(err)?, - } - } - - (context, Stream::Unix(stream)) - } - scheme => { - bail!("Unknown scheme `{scheme}`, expected imap, imaps or unix"); - } - }; - - if !context.authenticated { - let mut candidates = vec![]; - - let ir = context.capability.contains(&Capability::SaslIr); - - for mechanism in config.sasl.mechanisms { - match mechanism { - SaslMechanismConfig::Login => { - let Some(auth) = config.sasl.login.take() else { - debug!("missing SASL LOGIN configuration, skipping it"); - continue; - }; - - if context.capability.contains(&Capability::LoginDisabled) { - debug!("SASL LOGIN disabled by the server, skipping it"); - continue; - } - - let login = Capability::Auth(AuthMechanism::Login); - if !context.capability.contains(&login) { - debug!("SASL LOGIN disabled by the server, skipping it"); - continue; - } - - candidates.push(ImapAuthenticateCandidate::Login(ImapLoginParams::new( - auth.username, - auth.password.get()?, - )?)); - } - SaslMechanismConfig::Plain => { - let Some(auth) = config.sasl.plain.take() else { - debug!("missing SASL PLAIN configuration, skipping it"); - continue; - }; - - let plain = Capability::Auth(AuthMechanism::Plain); - if !context.capability.contains(&plain) { - debug!("SASL PLAIN disabled by the server, skipping it"); - continue; - } - - candidates.push(ImapAuthenticateCandidate::Plain( - ImapAuthenticatePlainParams::new( - auth.authzid, - auth.authcid, - auth.passwd.get()?, - ir, - ), - )); - } - SaslMechanismConfig::Anonymous => { - // TODO: check if capability available - - let message = config - .sasl - .anonymous - .take() - .and_then(|auth| auth.message) - .unwrap_or_default(); - - candidates.push(ImapAuthenticateCandidate::Anonymous( - ImapAuthenticateAnonymousParams::new(message, ir), - )); - } - }; - } - - let mut arg = None; - let mut coroutine = ImapAuthenticate::new(context, candidates); - - loop { - match coroutine.resume(arg.take()) { - ImapAuthenticateResult::Io { io } => arg = Some(handle(&mut stream, io)?), - ImapAuthenticateResult::Ok { context: c, .. } => break context = c, - ImapAuthenticateResult::Err { err, .. } => bail!(err), - } - } - } - - Ok((context, stream)) -} - -impl Read for Stream { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - match self { - Self::Tcp(s) => s.read(buf), - Self::Unix(s) => s.read(buf), - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Self::Rustls(s) => s.read(buf), - #[cfg(feature = "native-tls")] - Self::NativeTls(s) => s.read(buf), - } - } -} - -impl Write for Stream { - fn write(&mut self, buf: &[u8]) -> io::Result { - match self { - Self::Tcp(s) => s.write(buf), - Self::Unix(s) => s.write(buf), - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Self::Rustls(s) => s.write(buf), - #[cfg(feature = "native-tls")] - Self::NativeTls(s) => s.write(buf), - } - } - - fn flush(&mut self) -> io::Result<()> { - match self { - Self::Tcp(s) => s.flush(), - Self::Unix(s) => s.flush(), - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Self::Rustls(s) => s.flush(), - #[cfg(feature = "native-tls")] - Self::NativeTls(s) => s.flush(), - } - } -} diff --git a/src/jmap/email/export.rs b/src/jmap/email/export.rs index 9cdcbc48..4611b184 100644 --- a/src/jmap/email/export.rs +++ b/src/jmap/email/export.rs @@ -1,8 +1,10 @@ use anyhow::{anyhow, bail, Result}; use clap::Parser; use io_jmap::{ - rfc8620::coroutines::blob_download::{JmapBlobDownload, JmapBlobDownloadResult}, - rfc8620::types::session::capabilities, + rfc8620::{ + coroutines::blob_download::{JmapBlobDownload, JmapBlobDownloadResult}, + types::session::capabilities::{self, MAIL}, + }, rfc8621::coroutines::email_get::{JmapEmailGet, JmapEmailGetResult}, }; use io_stream::runtimes::std::handle; @@ -52,7 +54,7 @@ impl ExportEmailCommand { let account_id = jmap .session .primary_accounts - .get(capabilities::MAIL) + .get(MAIL) .map(|s| s.as_str()) .unwrap_or(""); let blob_id = emails diff --git a/src/jmap/email/import.rs b/src/jmap/email/import.rs index e2aaadbc..d8fb192a 100644 --- a/src/jmap/email/import.rs +++ b/src/jmap/email/import.rs @@ -6,10 +6,14 @@ use std::{ use anyhow::{bail, Result}; use clap::Parser; use io_jmap::{ - rfc8620::coroutines::blob_upload::{JmapBlobUpload, JmapBlobUploadResult}, - rfc8620::types::session::capabilities, - rfc8621::coroutines::email_import::{JmapEmailImport, JmapEmailImportResult}, - rfc8621::types::email::EmailImport, + rfc8620::{ + coroutines::blob_upload::{JmapBlobUpload, JmapBlobUploadResult}, + types::session::capabilities::{self, MAIL}, + }, + rfc8621::{ + coroutines::email_import::{JmapEmailImport, JmapEmailImportResult}, + types::email::EmailImport, + }, }; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; @@ -64,7 +68,7 @@ impl ImportEmailCommand { let account_id = jmap .session .primary_accounts - .get(capabilities::MAIL) + .get(MAIL) .map(|s| s.as_str()) .unwrap_or(""); let url: Url = jmap diff --git a/src/jmap/email/parse.rs b/src/jmap/email/parse.rs index 1113c968..d8a3906b 100644 --- a/src/jmap/email/parse.rs +++ b/src/jmap/email/parse.rs @@ -4,6 +4,7 @@ use io_jmap::rfc8621::coroutines::email_parse::{JmapEmailParse, JmapEmailParseRe use io_stream::runtimes::std::handle; use log::warn; use pimalaya_toolbox::terminal::printer::Printer; +use serde::Serialize; use crate::jmap::account::JmapAccount; @@ -49,13 +50,15 @@ impl ParseEmailCommand { warn!("blob `{id}` not valid MIME message, ignoring it"); } + let mut bodies = Vec::new(); + for (_blob_id, email) in parsed { if let Some(body_values) = &email.body_values { if let Some(text_parts) = &email.text_body { for part in text_parts { if let Some(part_id) = &part.part_id { if let Some(body_value) = body_values.get(part_id) { - printer.out(&body_value.value)?; + bodies.push(body_value.value.clone()); } } } @@ -63,6 +66,20 @@ impl ParseEmailCommand { } } + printer.out(ParsedBodies { bodies }) + } +} + +#[derive(Serialize)] +struct ParsedBodies { + bodies: Vec, +} + +impl std::fmt::Display for ParsedBodies { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for body in &self.bodies { + write!(f, "{body}")?; + } Ok(()) } } diff --git a/src/jmap/email/query.rs b/src/jmap/email/query.rs index 76f6a67c..ecef6974 100644 --- a/src/jmap/email/query.rs +++ b/src/jmap/email/query.rs @@ -171,7 +171,6 @@ impl JmapEmailQueryCommand { } #[derive(Clone, Debug, Serialize)] -#[serde(transparent)] pub struct EmailsTable { #[serde(skip)] pub preset: String, diff --git a/src/jmap/identity/get.rs b/src/jmap/identity/get.rs index 16ae208f..e1137cb0 100644 --- a/src/jmap/identity/get.rs +++ b/src/jmap/identity/get.rs @@ -58,7 +58,6 @@ impl GetIdentityCommand { } #[derive(Clone, Debug, Serialize)] -#[serde(transparent)] pub struct IdentitiesTable { #[serde(skip)] pub preset: String, diff --git a/src/jmap/mailbox/query.rs b/src/jmap/mailbox/query.rs index 5d9e2230..d6cbe7a4 100644 --- a/src/jmap/mailbox/query.rs +++ b/src/jmap/mailbox/query.rs @@ -118,7 +118,6 @@ impl JmapMailboxQueryCommand { } #[derive(Clone, Debug, Default, Serialize)] -#[serde(transparent)] pub struct MailboxesTable { #[serde(skip)] pub preset: String, diff --git a/src/jmap/query.rs b/src/jmap/query.rs index 59bd50c9..19dd407c 100644 --- a/src/jmap/query.rs +++ b/src/jmap/query.rs @@ -7,11 +7,12 @@ use anyhow::{bail, Context, Result}; use clap::Parser; use io_jmap::rfc8620::{ coroutines::send::{JmapRequest, JmapSend, JmapSendResult}, - types::session::capabilities, + types::session::capabilities::{CORE, MAIL}, }; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; +use serde_json::Value; use crate::jmap::account::JmapAccount; @@ -53,50 +54,52 @@ impl QueryCommand { self.method_calls.join(" ") }; - let calls_value: serde_json::Value = + let calls_value: Value = serde_json::from_str(&raw).context("METHOD_CALLS is not valid JSON")?; - let serde_json::Value::Array(calls_arr) = calls_value else { + let Value::Array(calls_arr) = calls_value else { bail!("METHOD_CALLS must be a JSON array"); }; let account_id = jmap .session .primary_accounts - .get(capabilities::MAIL) + .get(MAIL) .cloned() .unwrap_or_default(); // Parse and inject accountId into each call's args. let mut method_calls = Vec::with_capacity(calls_arr.len()); for (i, call) in calls_arr.into_iter().enumerate() { - let serde_json::Value::Array(mut tuple) = call else { + let Value::Array(mut tuple) = call else { bail!("method call #{i} must be a JSON array [name, args, callId]"); }; + if tuple.len() != 3 { bail!("method call #{i} must have exactly 3 elements [name, args, callId]"); } + let call_id = match tuple.remove(2) { - serde_json::Value::String(s) => s, + Value::String(s) => s, v => bail!("method call #{i} callId must be a string, got {v}"), }; + let mut args = tuple.remove(1); let name = match tuple.remove(0) { - serde_json::Value::String(s) => s, + Value::String(s) => s, v => bail!("method call #{i} name must be a string, got {v}"), }; + // Inject accountId if the args object doesn't already have it. - if let serde_json::Value::Object(ref mut map) = args { + if let Value::Object(ref mut map) = args { map.entry("accountId") - .or_insert_with(|| serde_json::Value::String(account_id.clone())); + .or_insert_with(|| Value::String(account_id.clone())); } + method_calls.push((name, args, call_id)); } - let mut using = vec![ - capabilities::CORE.to_string(), - capabilities::MAIL.to_string(), - ]; + let mut using = vec![CORE.to_string(), MAIL.to_string()]; for extra in self.using { if !using.contains(&extra) { using.push(extra); @@ -122,17 +125,21 @@ impl QueryCommand { } }; - printer.out(RawResponse(response.method_responses)) + printer.out(RawResponse { + method_responses: response.method_responses, + }) } } /// Wraps the raw method_responses for display. #[derive(Serialize)] -struct RawResponse(Vec<(String, serde_json::Value, String)>); +struct RawResponse { + method_responses: Vec<(String, Value, String)>, +} impl fmt::Display for RawResponse { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match serde_json::to_string_pretty(&self.0) { + match serde_json::to_string_pretty(&self.method_responses) { Ok(s) => write!(f, "{s}"), Err(e) => write!(f, ""), } diff --git a/src/jmap/submission/query.rs b/src/jmap/submission/query.rs index 50932178..fc4bab48 100644 --- a/src/jmap/submission/query.rs +++ b/src/jmap/submission/query.rs @@ -108,7 +108,6 @@ impl QuerySubmissionCommand { } #[derive(Clone, Debug, Serialize)] -#[serde(transparent)] pub struct SubmissionsTable { #[serde(skip)] pub preset: String, diff --git a/src/jmap/thread/get.rs b/src/jmap/thread/get.rs index 7a837bef..2ff6f6a3 100644 --- a/src/jmap/thread/get.rs +++ b/src/jmap/thread/get.rs @@ -53,7 +53,6 @@ impl GetThreadCommand { } #[derive(Clone, Debug, Serialize)] -#[serde(transparent)] pub struct ThreadsTable { #[serde(skip)] pub preset: String, diff --git a/tests/common/imap.rs b/tests/common/imap.rs index c7debc57..0fe94b7d 100644 --- a/tests/common/imap.rs +++ b/tests/common/imap.rs @@ -4,6 +4,7 @@ use std::{ }; use assert_cmd::Command; +use serde_json::Value; /// Resources to clean up after the test, even on failure. struct Cleanup<'a> { @@ -148,7 +149,7 @@ pub fn run(config: &Path, email: impl ToString) { .stdout .clone(); - let envelopes: Vec = serde_json::from_slice::(&stdout) + let envelopes: Vec = serde_json::from_slice::(&stdout) .unwrap_or_else(|e| { panic!( "failed to parse envelope list output: {e}\nstdout: {}", @@ -201,7 +202,7 @@ pub fn run(config: &Path, email: impl ToString) { .stdout .clone(); - let results: Vec = serde_json::from_slice::(&stdout) + let results: Vec = serde_json::from_slice::(&stdout) .unwrap_or_else(|e| { panic!( "failed to parse search output: {e}\nstdout: {}", @@ -273,22 +274,21 @@ pub fn run(config: &Path, email: impl ToString) { .stdout .clone(); - let dest_envelopes: Vec = - serde_json::from_slice::(&stdout) - .unwrap_or_else(|e| { - panic!( - "failed to parse destination envelope list: {e}\nstdout: {}", - String::from_utf8_lossy(&stdout) - ) - }) - .get("envelopes") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_else(|| { - panic!( - "missing `envelopes` key in destination output: {}", - String::from_utf8_lossy(&stdout) - ) - }); + let dest_envelopes: Vec = serde_json::from_slice::(&stdout) + .unwrap_or_else(|e| { + panic!( + "failed to parse destination envelope list: {e}\nstdout: {}", + String::from_utf8_lossy(&stdout) + ) + }) + .get("envelopes") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_else(|| { + panic!( + "missing `envelopes` key in destination output: {}", + String::from_utf8_lossy(&stdout) + ) + }); assert_eq!( dest_envelopes.len(), diff --git a/tests/common/jmap.rs b/tests/common/jmap.rs index bd344715..95a420cd 100644 --- a/tests/common/jmap.rs +++ b/tests/common/jmap.rs @@ -68,6 +68,38 @@ fn parse_output(config: &Path, args: &[&str]) -> T { }) } +/// Runs a JSON-mode command, asserts success, extracts `key` from the wrapper +/// object, and deserializes the value into `Vec`. +fn parse_list(config: &Path, args: &[&str], key: &str) -> Vec { + let stdout = jmap_json(config) + .args(args) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let value: Value = serde_json::from_slice(&stdout).unwrap_or_else(|e| { + panic!( + "failed to parse output for {:?}: {e}\nstdout: {}", + args, + String::from_utf8_lossy(&stdout) + ) + }); + + serde_json::from_value( + value + .get(key) + .cloned() + .unwrap_or_else(|| panic!("missing `{key}` key in output for {args:?}: {value}")), + ) + .unwrap_or_else(|e| { + panic!( + "failed to deserialize `{key}` from output for {args:?}: {e}\nvalue: {value}" + ) + }) +} + /// Shared JMAP integration test suite. /// /// Exercises every command in a single ordered flow. Pass a path to a @@ -91,7 +123,7 @@ pub fn run(config: &Path, email: impl ToString) { // ── 1. MAILBOXES ────────────────────────────────────────────────────── // baseline list — must return at least one mailbox (e.g. INBOX) - let mboxes: Vec = parse_output(config, &["mailboxes", "query"]); + let mboxes: Vec = parse_list(config, &["mailboxes", "query"], "mailboxes"); assert!( !mboxes.is_empty(), @@ -105,7 +137,8 @@ pub fn run(config: &Path, email: impl ToString) { .success(); // query by name — verify name matches - let mboxes: Vec = parse_output(config, &["mailboxes", "query", "--name", &mbox_name]); + let mboxes: Vec = + parse_list(config, &["mailboxes", "query", "--name", &mbox_name], "mailboxes"); assert_eq!( mboxes[0].name.as_deref(), @@ -117,7 +150,7 @@ pub fn run(config: &Path, email: impl ToString) { cleanup.mbox_id = Some(mbox_id.clone()); // get by id — verify id and name - let got: Vec = parse_output(config, &["mailboxes", "get", &mbox_id]); + let got: Vec = parse_list(config, &["mailboxes", "get", &mbox_id], "mailboxes"); assert_eq!( got[0].id.as_deref(), @@ -140,7 +173,7 @@ pub fn run(config: &Path, email: impl ToString) { .success(); // get by id again — verify the rename took effect - let got: Vec = parse_output(config, &["mailboxes", "get", &mbox_id]); + let got: Vec = parse_list(config, &["mailboxes", "get", &mbox_id], "mailboxes"); assert_eq!( got[0].name.as_deref(), @@ -170,14 +203,15 @@ pub fn run(config: &Path, email: impl ToString) { .success(); // query — verify exactly one email landed in the mailbox - let emails: Vec = parse_output(config, &["emails", "query", "--mailbox", &mbox_id]); + let emails: Vec = + parse_list(config, &["emails", "query", "--mailbox", &mbox_id], "emails"); assert_eq!(emails.len(), 1, "expected exactly one email after import"); let email_id = emails[0].id.clone().expect("email id"); let thread_id = emails[0].thread_id.clone().expect("thread id"); // get by id — verify the returned row matches the imported email - let got: Vec = parse_output(config, &["emails", "get", &email_id]); + let got: Vec = parse_list(config, &["emails", "get", &email_id], "emails"); assert_eq!( got[0].id.as_deref(), @@ -215,7 +249,7 @@ pub fn run(config: &Path, email: impl ToString) { .assert() .success(); - let seen: Vec = parse_output( + let seen: Vec = parse_list( config, &[ "emails", @@ -225,6 +259,7 @@ pub fn run(config: &Path, email: impl ToString) { "--has-keyword", "$seen", ], + "emails", ); assert!( @@ -251,7 +286,7 @@ pub fn run(config: &Path, email: impl ToString) { .assert() .success(); - let flagged: Vec = parse_output( + let flagged: Vec = parse_list( config, &[ "emails", @@ -261,6 +296,7 @@ pub fn run(config: &Path, email: impl ToString) { "--has-keyword", "$flagged", ], + "emails", ); assert!( @@ -324,7 +360,8 @@ pub fn run(config: &Path, email: impl ToString) { // ── 3. THREADS ──────────────────────────────────────────────────────── // get thread — verify it references the imported email - let threads: Vec = parse_output(config, &["threads", "get", &thread_id]); + let threads: Vec = + parse_list(config, &["threads", "get", &thread_id], "threads"); assert_eq!(threads[0].id, thread_id, "thread: id mismatch"); @@ -349,7 +386,7 @@ pub fn run(config: &Path, email: impl ToString) { .success(); // list — find by name and verify signature field - let identities: Vec = parse_output(config, &["identity", "get"]); + let identities: Vec = parse_list(config, &["identity", "get"], "identities"); let identity = identities .iter() .find(|i| i.name == "Test") @@ -372,7 +409,7 @@ pub fn run(config: &Path, email: impl ToString) { .success(); // list — verify rename - let identities: Vec = parse_output(config, &["identity", "get"]); + let identities: Vec = parse_list(config, &["identity", "get"], "identities"); assert!( identities.iter().any(|i| i.name == "Test Updated"), @@ -407,7 +444,7 @@ pub fn run(config: &Path, email: impl ToString) { .success(); // query to get draft id — verify it is flagged $draft - let emails: Vec = parse_output( + let emails: Vec = parse_list( config, &[ "emails", @@ -417,6 +454,7 @@ pub fn run(config: &Path, email: impl ToString) { "--has-keyword", "$draft", ], + "emails", ); assert!(!emails.is_empty(), "draft email not found after import"); @@ -424,7 +462,7 @@ pub fn run(config: &Path, email: impl ToString) { let draft_id = emails[0].id.clone().expect("draft id"); // create submission (send) — JSON mode returns the created submission(s) - let created: Vec = parse_output( + let created: Vec = parse_list( config, &[ "submission", @@ -433,6 +471,7 @@ pub fn run(config: &Path, email: impl ToString) { "--identity-id", &identity_id, ], + "submissions", ); assert!( @@ -445,7 +484,8 @@ pub fn run(config: &Path, email: impl ToString) { // get the submission by ID — EmailSubmission objects are short-lived on // some servers (e.g. Fastmail) and may already be gone by the time we // query; accept both found and not-found outcomes. - let got: Vec = parse_output(config, &["submission", "get", &sub_id]); + let got: Vec = + parse_list(config, &["submission", "get", &sub_id], "submissions"); if !got.is_empty() { assert_eq!( @@ -460,7 +500,8 @@ pub fn run(config: &Path, email: impl ToString) { // Requires JMAP_FROM_ACCOUNT_ID env var (the server-side JMAP accountId, // e.g. "u1d764051" for FastMail). Set it to enable this step. if let Ok(from_account) = env::var("JMAP_FROM_ACCOUNT_ID") { - let before: Vec = parse_output(config, &["emails", "query", "--mailbox", &mbox_id]); + let before: Vec = + parse_list(config, &["emails", "query", "--mailbox", &mbox_id], "emails"); let count_before = before.len(); jmap(config) @@ -476,7 +517,8 @@ pub fn run(config: &Path, email: impl ToString) { .assert() .success(); - let after: Vec = parse_output(config, &["emails", "query", "--mailbox", &mbox_id]); + let after: Vec = + parse_list(config, &["emails", "query", "--mailbox", &mbox_id], "emails"); assert!( after.len() > count_before, @@ -548,8 +590,13 @@ pub fn run(config: &Path, email: impl ToString) { &["query", r#"[["Mailbox/get", {"ids": null}, "c0"]]"#], ); + let responses = raw + .get("method_responses") + .and_then(|v| v.as_array()) + .expect("method_responses should be an array in raw query output"); + assert!( - raw.as_array().map(|a| !a.is_empty()).unwrap_or(false), + !responses.is_empty(), "raw query response should be a non-empty array" );