diff --git a/Cargo.lock b/Cargo.lock index bb00693d..494ce9a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,21 @@ dependencies = [ "object", ] +[[package]] +name = "assert_cmd" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -148,6 +163,17 @@ dependencies = [ "syn", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata 0.4.14", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -361,6 +387,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "dirs" version = "6.0.0" @@ -602,6 +634,7 @@ name = "himalaya" version = "1.2.0" dependencies = [ "anyhow", + "assert_cmd", "chrono", "clap", "comfy-table", @@ -625,6 +658,7 @@ dependencies = [ "serde", "serde_json", "shellexpand", + "tempfile", "uds_windows", "url", ] @@ -796,6 +830,7 @@ dependencies = [ [[package]] name = "io-fs" version = "0.0.1" +source = "git+https://github.com/pimalaya/io-fs#6c2305c52fdd5ec9ed05e902183fdf2942ea0590" dependencies = [ "log", "thiserror 2.0.18", @@ -804,6 +839,7 @@ dependencies = [ [[package]] name = "io-http" version = "0.0.3" +source = "git+https://github.com/pimalaya/io-http#4123d10e62a05245c19292f1c88cf0f3ef24f4fc" dependencies = [ "http", "httparse", @@ -816,6 +852,7 @@ dependencies = [ [[package]] name = "io-imap" version = "0.0.1" +source = "git+https://github.com/pimalaya/io-imap?branch=io#356d1349d1956be668321657853d91fe33d98a16" dependencies = [ "imap-codec", "io-stream", @@ -828,6 +865,7 @@ dependencies = [ [[package]] name = "io-jmap" version = "0.0.1" +source = "git+https://github.com/pimalaya/io-jmap#bbc42f310ef6914af7d73ab2ff417a2d91183b87" dependencies = [ "http", "io-http", @@ -843,6 +881,7 @@ dependencies = [ [[package]] name = "io-maildir" version = "0.0.1" +source = "git+https://github.com/pimalaya/io-maildir#75e5b0dc4fd8ce7ac71578f22f5fb2d89831efac" dependencies = [ "gethostname", "io-fs", @@ -869,6 +908,7 @@ dependencies = [ [[package]] name = "io-smtp" version = "0.0.1" +source = "git+https://github.com/pimalaya/io-smtp#3a74933b9c6e98723c31c6f8a68b366657ccb395" dependencies = [ "io-stream", "log", @@ -1298,6 +1338,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pimalaya-toolbox" version = "0.0.4" +source = "git+https://github.com/pimalaya/toolbox#c16e4e530d5c303d61db09b5dd99a7d23d704dda" dependencies = [ "anyhow", "base64", @@ -1367,6 +1408,33 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1796,6 +1864,7 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smtp-codec" version = "0.2.0" +source = "git+https://github.com/pimalaya/smtp-codec#baeb185ad0a63d43c48b05f7f7a4d9e81b291332" dependencies = [ "abnf-core", "base64", @@ -1807,6 +1876,7 @@ dependencies = [ [[package]] name = "smtp-types" version = "0.2.0" +source = "git+https://github.com/pimalaya/smtp-codec#baeb185ad0a63d43c48b05f7f7a4d9e81b291332" dependencies = [ "base64", "bounded-static", @@ -1890,6 +1960,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -2070,6 +2146,15 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index a6146c72..d579fcd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,13 @@ url = { version = "2.2", features = ["serde"] } [target.'cfg(windows)'.dependencies] uds_windows = "1" +[dev-dependencies] +assert_cmd = "2" +io-jmap = { version = "0.0.1", default-features = false } +serde = "1" +serde_json = "1" +tempfile = "3" + [patch.crates-io] io-fs.git = "https://github.com/pimalaya/io-fs" io-imap = { git = "https://github.com/pimalaya/io-imap", branch = "io" } diff --git a/src/config.rs b/src/config.rs index 0357c418..886b0135 100644 --- a/src/config.rs +++ b/src/config.rs @@ -267,11 +267,14 @@ impl TryFrom for Sasl { #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct JmapConfig { - /// The HTTPS base URL of the JMAP server. + /// The JMAP server address. /// - /// Must use the `https://` or `jmap://` scheme. Session discovery - /// (`GET /.well-known/jmap`) is performed automatically on connection. - pub url: Url, + /// Accepts either a bare authority (`fastmail.com`, `mail.example.com:8080`) + /// for automatic discovery via `GET /.well-known/jmap`, or a full URL + /// (`https://api.fastmail.com/jmap/api/`) to connect directly to the + /// session endpoint. Supported schemes: `http`, `https`, `jmap` (→ http), + /// `jmaps` (→ https). + pub server: String, /// TLS configuration. #[serde(default)] diff --git a/src/jmap/account.rs b/src/jmap/account.rs index 0cc8c6fe..c4eca4fc 100644 --- a/src/jmap/account.rs +++ b/src/jmap/account.rs @@ -8,7 +8,7 @@ pub type JmapAccount = Account; impl JmapAccount { pub fn new_jmap_session(&self) -> Result { JmapSession::new( - self.backend.url.clone(), + self.backend.server.clone(), self.backend.tls.clone().try_into()?, self.backend.auth.clone().try_into()?, ) diff --git a/src/jmap/email/command.rs b/src/jmap/email/command.rs index 41a9b560..b23d36ce 100644 --- a/src/jmap/email/command.rs +++ b/src/jmap/email/command.rs @@ -5,9 +5,9 @@ use pimalaya_toolbox::terminal::printer::Printer; use crate::jmap::{ account::JmapAccount, email::{ - copy::CopyEmailCommand, delete::DeleteEmailCommand, get::JmapEmailGetCommand, - import::ImportEmailCommand, parse::ParseEmailCommand, query::JmapEmailQueryCommand, - update::JmapEmailUpdateCommand, + copy::JmapEmailCopyCommand, delete::JmapEmailDestroyCommand, export::ExportEmailCommand, + get::JmapEmailGetCommand, import::ImportEmailCommand, parse::ParseEmailCommand, + query::JmapEmailQueryCommand, read::ReadEmailCommand, update::JmapEmailUpdateCommand, }, }; @@ -17,11 +17,13 @@ use crate::jmap::{ pub enum JmapEmailCommand { Get(JmapEmailGetCommand), Query(JmapEmailQueryCommand), + Read(ReadEmailCommand), #[command(alias = "edit")] Update(JmapEmailUpdateCommand), #[command(aliases = ["remove", "rm"])] - Delete(DeleteEmailCommand), - Copy(CopyEmailCommand), + Delete(JmapEmailDestroyCommand), + Copy(JmapEmailCopyCommand), + Export(ExportEmailCommand), Import(ImportEmailCommand), Parse(ParseEmailCommand), } @@ -31,9 +33,11 @@ impl JmapEmailCommand { match self { Self::Get(cmd) => cmd.execute(printer, account), Self::Query(cmd) => cmd.execute(printer, account), + Self::Read(cmd) => cmd.execute(printer, account), Self::Update(cmd) => cmd.execute(printer, account), Self::Delete(cmd) => cmd.execute(printer, account), Self::Copy(cmd) => cmd.execute(printer, account), + Self::Export(cmd) => cmd.execute(printer, account), Self::Import(cmd) => cmd.execute(printer, account), Self::Parse(cmd) => cmd.execute(printer, account), } diff --git a/src/jmap/email/copy.rs b/src/jmap/email/copy.rs index 74555337..ad3b515b 100644 --- a/src/jmap/email/copy.rs +++ b/src/jmap/email/copy.rs @@ -13,9 +13,9 @@ use crate::jmap::account::JmapAccount; /// Copy JMAP emails from another account (Email/copy). #[derive(Debug, Parser)] -pub struct CopyEmailCommand { +pub struct JmapEmailCopyCommand { /// Email ID(s) to copy. - #[arg(value_name = "EMAIL-ID", required = true, num_args = 1..)] + #[arg(value_name = "ID", required = true)] pub ids: Vec, /// Source account ID to copy from. @@ -23,25 +23,25 @@ pub struct CopyEmailCommand { pub from_account: String, /// Destination mailbox ID(s) to place copies in. - #[arg(long, value_name = "MAILBOX-ID", num_args = 0..)] + #[arg(long, value_name = "MAILBOX-ID", required = false)] pub mailbox_id: Vec, } -impl CopyEmailCommand { +impl JmapEmailCopyCommand { pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { let mut jmap = account.new_jmap_session()?; let mailbox_ids: HashMap = - self.mailbox_id.iter().map(|m| (m.clone(), true)).collect(); + self.mailbox_id.into_iter().map(|m| (m, true)).collect(); let emails: HashMap = self .ids - .iter() + .into_iter() .map(|id| { ( id.clone(), EmailCopy { - id: id.clone(), + id, mailbox_ids: mailbox_ids.clone(), keywords: None, received_at: None, @@ -61,14 +61,21 @@ impl CopyEmailCommand { } }; - for (id, err) in ¬_created { - let mut ctx = anyhow!("Failed to copy email `{id}`"); + if !not_created.is_empty() { + let mut ctx = anyhow!("Copy JMAP email(s) error"); - if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); + for (id, err) in not_created { + if let Some(desc) = &err.description { + ctx = anyhow!("{id}: {desc}").context(ctx); + } + + if !err.properties.is_empty() { + let props = err.properties.join(", "); + ctx = anyhow!("{id}: Invalid properties {props}").context(ctx); + } } - bail!(ctx); + bail!(ctx) } printer.out(Message::new("Email(s) successfully copied")) diff --git a/src/jmap/email/delete.rs b/src/jmap/email/delete.rs index 9b0a0252..359e2c0b 100644 --- a/src/jmap/email/delete.rs +++ b/src/jmap/email/delete.rs @@ -8,13 +8,13 @@ use crate::jmap::account::JmapAccount; /// Delete JMAP emails (Email/set destroy). #[derive(Debug, Parser)] -pub struct DeleteEmailCommand { +pub struct JmapEmailDestroyCommand { /// Email ID(s) to delete. - #[arg(value_name = "ID", required = true, num_args = 1..)] + #[arg(value_name = "ID", required = true)] pub ids: Vec, } -impl DeleteEmailCommand { +impl JmapEmailDestroyCommand { pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { let mut jmap = account.new_jmap_session()?; @@ -35,14 +35,21 @@ impl DeleteEmailCommand { } }; - for (id, err) in ¬_destroyed { - let mut ctx = anyhow!("Failed to delete email `{id}`"); + if !not_destroyed.is_empty() { + let mut ctx = anyhow!("Destroy JMAP email(s) error"); - if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); + for (id, err) in not_destroyed { + if let Some(desc) = &err.description { + ctx = anyhow!("{id}: {desc}").context(ctx); + } + + if !err.properties.is_empty() { + let props = err.properties.join(", "); + ctx = anyhow!("{id}: Invalid properties {props}").context(ctx); + } } - bail!(ctx); + bail!(ctx) } printer.out(Message::new("Email(s) successfully deleted")) diff --git a/src/jmap/email/export.rs b/src/jmap/email/export.rs new file mode 100644 index 00000000..dcad4a5c --- /dev/null +++ b/src/jmap/email/export.rs @@ -0,0 +1,88 @@ +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::coroutines::{ + blob_download::{DownloadJmapBlob, DownloadJmapBlobResult}, + email_get::{GetJmapEmails, GetJmapEmailsResult}, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; +use url::Url; + +use crate::jmap::account::JmapAccount; + +/// Export a raw RFC 5322 message to stdout (Email/get + blob download). +/// +/// Fetches the blobId via Email/get then downloads the raw message blob. +#[derive(Debug, Parser)] +pub struct ExportEmailCommand { + /// The email ID to export. + #[arg(value_name = "ID")] + pub id: String, +} + +impl ExportEmailCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let tls = account.backend.tls.clone().try_into()?; + let mut jmap = account.new_jmap_session()?; + + let properties = Some(vec!["id".to_owned(), "blobId".to_owned()]); + + let mut arg = None; + let mut coroutine = GetJmapEmails::new( + jmap.context, + vec![self.id.clone()], + properties, + false, + false, + 0, + )?; + + let emails = loop { + match coroutine.resume(arg.take()) { + GetJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + GetJmapEmailsResult::Ok { + context, emails, .. + } => { + jmap.context = context; + break emails; + } + GetJmapEmailsResult::Err { err, .. } => bail!(err), + } + }; + + let account_id = jmap.context.account_id.as_deref().unwrap_or(""); + let blob_id = emails + .into_iter() + .next() + .and_then(|e| e.blob_id) + .ok_or_else(|| anyhow!("Email `{}` not found or has no blobId", self.id))?; + + let url: Url = jmap + .context + .session + .as_ref() + .unwrap() + .download_url + .replace("{accountId}", account_id) + .replace("{blobId}", &blob_id) + .replace("{type}", "message%2Frfc822") + .replace("{name}", "message.eml") + .parse()?; + + let mut stream = jmap.connect_if_different(&url, &tls)?; + let stream = stream.as_mut().unwrap_or(&mut jmap.stream); + + let mut coroutine = DownloadJmapBlob::new(jmap.context, &url)?; + let mut arg = None; + + let data = loop { + match coroutine.resume(arg.take()) { + DownloadJmapBlobResult::Io(io) => arg = Some(handle(&mut *stream, io)?), + DownloadJmapBlobResult::Ok { data, .. } => break data, + DownloadJmapBlobResult::Err { err, .. } => bail!(err), + } + }; + + printer.out(Message::new(String::from_utf8(data)?)) + } +} diff --git a/src/jmap/email/get.rs b/src/jmap/email/get.rs index 81e0bac6..37f48da1 100644 --- a/src/jmap/email/get.rs +++ b/src/jmap/email/get.rs @@ -5,20 +5,16 @@ use io_stream::runtimes::std::handle; use log::warn; use pimalaya_toolbox::terminal::printer::Printer; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, email::query::EmailsTable}; -/// Get a JMAP email by ID (Email/get). +/// Get JMAP emails by ID (Email/get). /// -/// Downloads and displays the full message content including body. +/// Fetches and displays email envelopes as a table. #[derive(Debug, Parser)] pub struct JmapEmailGetCommand { /// The email ID(s) to retrieve. #[arg(value_name = "ID", required = true)] pub ids: Vec, - - /// Output raw RFC 5322 message headers. - #[arg(long, short)] - pub raw: bool, } impl JmapEmailGetCommand { @@ -26,7 +22,7 @@ impl JmapEmailGetCommand { let mut jmap = account.new_jmap_session()?; let mut coroutine = - GetJmapEmails::new(jmap.context, self.ids.clone(), None, true, true, None)?; + GetJmapEmails::new(jmap.context, self.ids.clone(), None, false, false, 0)?; let mut arg = None; let (emails, not_found) = loop { @@ -40,44 +36,15 @@ impl JmapEmailGetCommand { }; for id in not_found { - warn!("email `{id}` not found"); + warn!("email `{id}` not found, ignoring it"); } - for email in emails { - if self.raw { - if let Some(headers) = &email.headers { - for h in headers { - printer.log(format!("{}: {}", h.name, h.value))?; - } - } - printer.log("")?; - } + let table = EmailsTable { + preset: account.table_preset, + arrangement: account.table_arrangement, + emails, + }; - 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)?; - continue; - } - } - } - } - - if let Some(html_parts) = &email.html_body { - for part in html_parts { - if let Some(part_id) = &part.part_id { - if let Some(body_value) = body_values.get(part_id) { - printer.out(&body_value.value)?; - continue; - } - } - } - } - } - } - - Ok(()) + printer.out(table) } } diff --git a/src/jmap/email/import.rs b/src/jmap/email/import.rs index 5cc099ca..cbb03f56 100644 --- a/src/jmap/email/import.rs +++ b/src/jmap/email/import.rs @@ -1,44 +1,104 @@ -use std::collections::HashMap; +use std::{ + collections::HashMap, + io::{stdin, BufRead, IsTerminal}, +}; use anyhow::{anyhow, bail, Result}; use clap::Parser; use io_jmap::{ - coroutines::email_import::{ImportJmapEmail, ImportJmapEmailResult}, + coroutines::{ + blob_upload::{UploadJmapBlob, UploadJmapBlobResult}, + email_import::{ImportJmapEmail, ImportJmapEmailResult}, + }, types::email::EmailImport, }; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; +use url::Url; use crate::jmap::account::JmapAccount; -/// Import an RFC 5322 message blob into a mailbox (Email/import). +/// Import an RFC 5322 message into a mailbox (upload + Email/import). /// -/// The blob must already be uploaded to the JMAP server. +/// Reads the raw message from stdin or as trailing arguments. Use +/// `--upload-only` to stop after the upload and print the blobId. #[derive(Debug, Parser)] pub struct ImportEmailCommand { - /// Blob ID of the RFC 5322 message to import. - #[arg(value_name = "BLOB-ID")] - pub blob_id: String, - /// Mailbox ID(s) to place the imported email in. - #[arg(long, value_name = "MAILBOX-ID", num_args = 0..)] + #[arg(long, value_name = "MAILBOX-ID")] pub mailbox_id: Vec, /// Keywords to set on the imported email (e.g. `$seen`). - #[arg(long, value_name = "KEYWORD", num_args = 0..)] + #[arg(long, value_name = "KEYWORD")] pub keyword: Vec, - /// Override the `receivedAt` time (RFC 3339). + /// Override the `receivedAt` timestamp (RFC 3339). #[arg(long, value_name = "DATE")] pub received_at: Option, + + /// Only upload the blob and print the blobId; skip Email/import. + #[arg(long)] + pub upload_only: bool, + + /// The raw RFC 5322 message (headers + body). Read from stdin if omitted. + #[arg(trailing_var_arg = true)] + #[arg(name = "message", value_name = "MESSAGE")] + pub message: Vec, } impl ImportEmailCommand { pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let tls = account.backend.tls.clone().try_into()?; let mut jmap = account.new_jmap_session()?; + let data: Vec = if stdin().is_terminal() || printer.is_json() { + self.message + .join(" ") + .replace('\r', "") + .replace('\n', "\r\n") + .into_bytes() + } else { + let lines: Vec = stdin() + .lock() + .lines() + .map_while(Result::ok) + .collect(); + lines.join("\r\n").into_bytes() + }; + + let account_id = jmap.context.account_id.as_deref().unwrap_or(""); + let url: Url = jmap + .context + .session + .as_ref() + .unwrap() + .upload_url + .replace("{accountId}", account_id) + .parse()?; + + let mut extra_stream = jmap.connect_if_different(&url, &tls)?; + let upload_stream = extra_stream.as_mut().unwrap_or(&mut jmap.stream); + + let mut coroutine = UploadJmapBlob::new(jmap.context, &url, "message/rfc822", data)?; + let mut arg = None; + + let blob_id = loop { + match coroutine.resume(arg.take()) { + UploadJmapBlobResult::Io(io) => arg = Some(handle(&mut *upload_stream, io)?), + UploadJmapBlobResult::Ok { context, blob_id, .. } => { + jmap.context = context; + break blob_id; + } + UploadJmapBlobResult::Err { err, .. } => bail!(err), + } + }; + + if self.upload_only { + return printer.out(Message::new(blob_id)); + } + let mailbox_ids: HashMap = - self.mailbox_id.iter().map(|m| (m.clone(), true)).collect(); + self.mailbox_id.into_iter().map(|m| (m, true)).collect(); let keywords = if self.keyword.is_empty() { None @@ -47,19 +107,19 @@ impl ImportEmailCommand { }; let import = EmailImport { - blob_id: self.blob_id.clone(), + blob_id: blob_id.clone(), mailbox_ids, keywords, received_at: self.received_at, }; let mut emails = HashMap::new(); - emails.insert(self.blob_id.clone(), import); + emails.insert(blob_id.clone(), import); let mut coroutine = ImportJmapEmail::new(jmap.context, emails)?; let mut arg = None; - let not_created = loop { + let errs = loop { match coroutine.resume(arg.take()) { ImportJmapEmailResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), ImportJmapEmailResult::Ok { not_created, .. } => break not_created, @@ -67,16 +127,21 @@ impl ImportEmailCommand { } }; - if let Some(err) = not_created.get(&self.blob_id) { - let mut ctx = anyhow!("Failed to import email from blob `{}`", self.blob_id); + if let Some(err) = errs.get(&blob_id) { + let mut ctx = anyhow!("Import JMAP email from blob `{blob_id}` error"); if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); + ctx = anyhow!("{desc}").context(ctx); + } + + if !err.properties.is_empty() { + let props = err.properties.join(", "); + ctx = anyhow!("Invalid properties {props}").context(ctx); } bail!(ctx); } - printer.out(Message::new("Email successfully imported from blob")) + printer.out(Message::new("Email successfully imported")) } } diff --git a/src/jmap/email/mod.rs b/src/jmap/email/mod.rs index 9054925f..084aa253 100644 --- a/src/jmap/email/mod.rs +++ b/src/jmap/email/mod.rs @@ -1,8 +1,10 @@ pub mod command; pub mod copy; pub mod delete; +pub mod export; pub mod get; pub mod import; pub mod parse; pub mod query; +pub mod read; pub mod update; diff --git a/src/jmap/email/parse.rs b/src/jmap/email/parse.rs index 148c9624..db7f8179 100644 --- a/src/jmap/email/parse.rs +++ b/src/jmap/email/parse.rs @@ -2,6 +2,7 @@ use anyhow::{bail, Result}; use clap::Parser; use io_jmap::coroutines::email_parse::{ParseJmapEmails, ParseJmapEmailsResult}; use io_stream::runtimes::std::handle; +use log::warn; use pimalaya_toolbox::terminal::printer::Printer; use crate::jmap::account::JmapAccount; @@ -13,7 +14,7 @@ use crate::jmap::account::JmapAccount; #[derive(Debug, Parser)] pub struct ParseEmailCommand { /// Blob ID(s) to parse as RFC 5322 messages. - #[arg(value_name = "BLOB-ID", required = true, num_args = 1..)] + #[arg(value_name = "ID", required = true)] pub blob_ids: Vec, } @@ -27,26 +28,19 @@ impl ParseEmailCommand { let (parsed, not_parsable, not_found) = loop { match coroutine.resume(arg.take()) { ParseJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), - ParseJmapEmailsResult::Ok { - context, - parsed, - not_parsable, - not_found, - .. - } => { - jmap.context = context; + ParseJmapEmailsResult::Ok { parsed, not_parsable, not_found, .. } => { break (parsed, not_parsable, not_found); } ParseJmapEmailsResult::Err { err, .. } => bail!(err), } }; - for id in ¬_found { - printer.log(format!("Blob `{id}` not found."))?; + for id in not_found { + warn!("blob `{id}` not found, ignoring it"); } - for id in ¬_parsable { - printer.log(format!("Blob `{id}` is not a valid RFC 5322 message."))?; + for id in not_parsable { + warn!("blob `{id}` not valid MIME message, ignoring it"); } for (_blob_id, email) in parsed { diff --git a/src/jmap/email/query.rs b/src/jmap/email/query.rs index 016f82c4..826cf2e9 100644 --- a/src/jmap/email/query.rs +++ b/src/jmap/email/query.rs @@ -169,21 +169,6 @@ impl JmapEmailQueryCommand { } } -fn format_addresses(addrs: &[EmailAddress]) -> String { - addrs - .iter() - .map(|a| { - if let Some(name) = &a.name { - if !name.is_empty() { - return name.clone(); - } - } - a.email.clone() - }) - .collect::>() - .join(", ") -} - #[derive(Clone, Debug, Serialize)] #[serde(transparent)] pub struct EmailsTable { @@ -279,3 +264,18 @@ impl From for EmailSortProperty { } } } + +fn format_addresses(addrs: &[EmailAddress]) -> String { + addrs + .iter() + .map(|a| { + if let Some(name) = &a.name { + if !name.is_empty() { + return name.clone(); + } + } + a.email.clone() + }) + .collect::>() + .join(", ") +} diff --git a/src/jmap/email/read.rs b/src/jmap/email/read.rs new file mode 100644 index 00000000..2ce73d68 --- /dev/null +++ b/src/jmap/email/read.rs @@ -0,0 +1,115 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_jmap::{ + coroutines::email_get::{GetJmapEmails, GetJmapEmailsResult}, + types::email::EmailAddress, +}; +use io_stream::runtimes::std::handle; +use log::warn; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Read the content of a JMAP email (Email/get with body). +/// +/// Shows headers and plain text body by default. +#[derive(Debug, Parser)] +pub struct ReadEmailCommand { + /// The email ID(s) to read. + #[arg(value_name = "ID", required = true)] + pub ids: Vec, + + /// Show HTML body instead of plain text. + #[arg(long)] + pub html: bool, +} + +impl ReadEmailCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut arg = None; + let mut coroutine = GetJmapEmails::new( + jmap.context, + self.ids.clone(), + None, + !self.html, + self.html, + 0, + )?; + + let (emails, not_found) = loop { + match coroutine.resume(arg.take()) { + GetJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + GetJmapEmailsResult::Ok { + emails, not_found, .. + } => break (emails, not_found), + GetJmapEmailsResult::Err { err, .. } => bail!(err), + } + }; + + for id in not_found { + warn!("email `{id}` not found, ignoring it"); + } + + let mut content = String::new(); + + for email in &emails { + if self.html { + if let Some(body_values) = &email.body_values { + if let Some(html_parts) = &email.html_body { + for part in html_parts { + if let Some(part_id) = &part.part_id { + if let Some(body_value) = body_values.get(part_id) { + content.push_str(&body_value.value); + } + } + } + } + } + } else { + if let Some(addrs) = &email.from { + content.push_str(&format!("From: {}\n", format_addresses(addrs))); + } + if let Some(addrs) = &email.to { + content.push_str(&format!("To: {}\n", format_addresses(addrs))); + } + if let Some(addrs) = &email.cc { + content.push_str(&format!("Cc: {}\n", format_addresses(addrs))); + } + if let Some(subject) = &email.subject { + content.push_str(&format!("Subject: {subject}\n")); + } + if let Some(date) = &email.sent_at { + content.push_str(&format!("Date: {date}\n")); + } + + 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) { + content.push('\n'); + content.push_str(&body_value.value); + } + } + } + } + } + } + } + + printer.out(Message::new(content)) + } +} + +fn format_addresses(addrs: &[EmailAddress]) -> String { + addrs + .iter() + .map(|a| match &a.name { + Some(name) if !name.is_empty() => format!("{name} <{}>", a.email), + _ => a.email.clone(), + }) + .collect::>() + .join(", ") +} diff --git a/src/jmap/email/update.rs b/src/jmap/email/update.rs index 57b25c2f..ef391607 100644 --- a/src/jmap/email/update.rs +++ b/src/jmap/email/update.rs @@ -12,31 +12,31 @@ use crate::jmap::account::JmapAccount; #[derive(Debug, Parser)] pub struct JmapEmailUpdateCommand { /// Email ID(s) to update. - #[arg(value_name = "EMAIL_ID", required = true, num_args = 1..)] + #[arg(value_name = "ID", required = true)] pub ids: Vec, /// Add keyword(s) to the email(s). - #[arg(long, value_name = "KEYWORD", num_args = 0..)] + #[arg(long, value_name = "KEYWORD", required = false)] pub add_keyword: Vec, /// Remove keyword(s) from the email(s). - #[arg(long, value_name = "KEYWORD", num_args = 0..)] + #[arg(long, value_name = "KEYWORD", required = false)] pub remove_keyword: Vec, - /// Replace all keywords atomically (no fetch required). - #[arg(long, value_name = "KEYWORD", num_args = 0..)] + /// Replace all keywords atomically. + #[arg(long, value_name = "KEYWORD")] pub keywords: Option>, /// Add email(s) to a mailbox. - #[arg(long, value_name = "MAILBOX-ID", num_args = 1..)] + #[arg(long, value_name = "MAILBOX-ID", required = false)] pub add_mailbox: Vec, /// Remove email(s) from a mailbox. - #[arg(long, value_name = "MAILBOX-ID", num_args = 1..)] + #[arg(long, value_name = "MAILBOX-ID", required = false)] pub remove_mailbox: Vec, /// Replace all mailbox memberships atomically. - #[arg(long, value_name = "MAILBOX-ID", num_args = 0..)] + #[arg(long, value_name = "MAILBOX-ID")] pub mailboxes: Option>, } @@ -84,18 +84,21 @@ impl JmapEmailUpdateCommand { } }; - for (id, err) in ¬_updated { - let mut ctx = anyhow!("Failed to update email `{id}`"); + if !not_updated.is_empty() { + let mut ctx = anyhow!("Update JMAP email(s) error"); - if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); + for (id, err) in not_updated { + if let Some(desc) = &err.description { + ctx = anyhow!("{id}: {desc}").context(ctx); + } + + if !err.properties.is_empty() { + let props = err.properties.join(", "); + ctx = anyhow!("{id}: Invalid properties {props}").context(ctx); + } } - if !err.properties.is_empty() { - ctx = anyhow!("Invalid properties: {}", err.properties.join(", ")).context(ctx); - } - - bail!(ctx); + bail!(ctx) } printer.out(Message::new("Email(s) successfully updated")) diff --git a/src/jmap/identity/command.rs b/src/jmap/identity/command.rs index abc19696..625dd0fe 100644 --- a/src/jmap/identity/command.rs +++ b/src/jmap/identity/command.rs @@ -5,7 +5,7 @@ use pimalaya_toolbox::terminal::printer::Printer; use crate::jmap::{ account::JmapAccount, identity::{ - create::CreateIdentityCommand, delete::DeleteIdentityCommand, get::GetIdentityCommand, + create::JmapIdentityCreateCommand, delete::DeleteIdentityCommand, get::GetIdentityCommand, update::UpdateIdentityCommand, }, }; @@ -18,7 +18,7 @@ pub enum IdentityCommand { Get(GetIdentityCommand), /// Create a new identity (Identity/set). #[command(aliases = ["add", "new"])] - Create(CreateIdentityCommand), + Create(JmapIdentityCreateCommand), /// Update an existing identity (Identity/set). #[command(alias = "edit")] Update(UpdateIdentityCommand), diff --git a/src/jmap/identity/create.rs b/src/jmap/identity/create.rs index cf036797..97f2aad3 100644 --- a/src/jmap/identity/create.rs +++ b/src/jmap/identity/create.rs @@ -11,7 +11,7 @@ use crate::jmap::account::JmapAccount; /// Create a JMAP sender identity (Identity/set). #[derive(Debug, Parser)] -pub struct CreateIdentityCommand { +pub struct JmapIdentityCreateCommand { /// Display name for the sender. pub name: String, @@ -27,7 +27,7 @@ pub struct CreateIdentityCommand { pub html_signature: Option, } -impl CreateIdentityCommand { +impl JmapIdentityCreateCommand { pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { let mut jmap = account.new_jmap_session()?; @@ -40,13 +40,15 @@ impl CreateIdentityCommand { html_signature: self.html_signature, }; + let create_id = "new"; + let mut args = IdentitySetArgs::default(); - args.create(self.email.clone(), identity); + args.create(create_id, identity); let mut coroutine = SetJmapIdentities::new(jmap.context, args)?; let mut arg = None; - let not_created = loop { + let errs = loop { match coroutine.resume(arg.take()) { SetJmapIdentitiesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), SetJmapIdentitiesResult::Ok { not_created, .. } => break not_created, @@ -54,11 +56,16 @@ impl CreateIdentityCommand { } }; - if let Some(err) = not_created.get(&self.email) { - let mut ctx = anyhow!("Failed to create identity `{}`", self.email); + if let Some(err) = errs.get(create_id) { + let mut ctx = anyhow!("Create identity for `{}` error", &self.email); if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); + ctx = anyhow!("{desc}").context(ctx); + } + + if !err.properties.is_empty() { + let props = err.properties.join(", "); + ctx = anyhow!("Invalid properties {props}").context(ctx); } bail!(ctx); diff --git a/src/jmap/identity/delete.rs b/src/jmap/identity/delete.rs index 563d9a6d..c82210f5 100644 --- a/src/jmap/identity/delete.rs +++ b/src/jmap/identity/delete.rs @@ -37,14 +37,21 @@ impl DeleteIdentityCommand { } }; - for (id, err) in ¬_destroyed { - let mut ctx = anyhow!("Failed to delete identity `{id}`"); + if !not_destroyed.is_empty() { + let mut ctx = anyhow!("Destroy JMAP identities error"); - if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); + for (id, err) in not_destroyed { + if let Some(desc) = &err.description { + ctx = anyhow!("{id}: {desc}").context(ctx); + } + + if !err.properties.is_empty() { + let props = err.properties.join(", "); + ctx = anyhow!("{id}: Invalid properties {props}").context(ctx); + } } - bail!(ctx); + bail!(ctx) } printer.out(Message::new("Identity successfully deleted")) diff --git a/src/jmap/identity/get.rs b/src/jmap/identity/get.rs index 83e0358b..eccf28d6 100644 --- a/src/jmap/identity/get.rs +++ b/src/jmap/identity/get.rs @@ -44,7 +44,7 @@ impl GetIdentityCommand { } }; - for id in ¬_found { + for id in not_found { warn!("identity `{id}` not found"); } diff --git a/src/jmap/identity/update.rs b/src/jmap/identity/update.rs index 45f10a6e..99465c1a 100644 --- a/src/jmap/identity/update.rs +++ b/src/jmap/identity/update.rs @@ -46,7 +46,7 @@ impl UpdateIdentityCommand { let mut coroutine = SetJmapIdentities::new(jmap.context, args)?; let mut arg = None; - let not_updated = loop { + let errs = loop { match coroutine.resume(arg.take()) { SetJmapIdentitiesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), SetJmapIdentitiesResult::Ok { not_updated, .. } => break not_updated, @@ -54,11 +54,16 @@ impl UpdateIdentityCommand { } }; - if let Some(err) = not_updated.get(&self.id) { - let mut ctx = anyhow!("Failed to update identity `{}`", self.id); + if let Some(err) = errs.get(&self.id) { + let mut ctx = anyhow!("Update identity `{}` error", &self.id); if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); + ctx = anyhow!("{desc}").context(ctx); + } + + if !err.properties.is_empty() { + let props = err.properties.join(", "); + ctx = anyhow!("Invalid properties {props}").context(ctx); } bail!(ctx); diff --git a/src/jmap/keyword/add.rs b/src/jmap/keyword/add.rs deleted file mode 100644 index 14b02f91..00000000 --- a/src/jmap/keyword/add.rs +++ /dev/null @@ -1,63 +0,0 @@ -use anyhow::{anyhow, bail, Result}; -use clap::Parser; -use io_jmap::coroutines::email_set::{EmailSetArgs, SetJmapEmails, SetJmapEmailsResult}; -use io_stream::runtimes::std::handle; -use pimalaya_toolbox::terminal::printer::Printer; - -use crate::jmap::account::JmapAccount; - -/// Add keywords to JMAP emails. -/// -/// Standard JMAP keywords: `$seen`, `$flagged`, `$answered`, `$draft`. -#[derive(Debug, Parser)] -pub struct AddKeywordCommand { - /// Email ID(s) to add the keyword(s) to. - #[arg(value_name = "EMAIL_ID", num_args = 1..)] - pub ids: Vec, - - /// The keyword(s) to add. - #[arg(long, short, num_args = 1..)] - pub keyword: Vec, -} - -impl AddKeywordCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut jmap = account.new_jmap_session()?; - - let mut args = EmailSetArgs::default(); - - for id in &self.ids { - for kw in &self.keyword { - args.set_keyword(id.clone(), kw.clone()); - } - } - - let mut coroutine = SetJmapEmails::new(jmap.context, args)?; - let mut arg = None; - - let not_updated = loop { - match coroutine.resume(arg.take()) { - SetJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), - SetJmapEmailsResult::Ok { not_updated, .. } => break not_updated, - SetJmapEmailsResult::Err { err, .. } => bail!(err), - } - }; - - for (id, err) in ¬_updated { - let mut ctx = anyhow!("failed to add keyword to email `{id}`"); - if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); - } - if !err.properties.is_empty() { - ctx = anyhow!("invalid properties: {}", err.properties.join(", ")).context(ctx); - } - bail!(ctx); - } - - printer.log(format!( - "Keyword(s) `{}` added to {} email(s).", - self.keyword.join(", "), - self.ids.len() - )) - } -} diff --git a/src/jmap/keyword/command.rs b/src/jmap/keyword/command.rs deleted file mode 100644 index e015aea1..00000000 --- a/src/jmap/keyword/command.rs +++ /dev/null @@ -1,26 +0,0 @@ -use anyhow::Result; -use clap::Subcommand; -use pimalaya_toolbox::terminal::printer::Printer; - -use crate::jmap::{ - account::JmapAccount, - keyword::{add::AddKeywordCommand, remove::RemoveKeywordCommand, set::SetKeywordsCommand}, -}; - -/// Manage JMAP email keywords (flags). -#[derive(Debug, Subcommand)] -pub enum KeywordCommand { - Add(AddKeywordCommand), - Remove(RemoveKeywordCommand), - Set(SetKeywordsCommand), -} - -impl KeywordCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - match self { - Self::Add(cmd) => cmd.execute(printer, account), - Self::Remove(cmd) => cmd.execute(printer, account), - Self::Set(cmd) => cmd.execute(printer, account), - } - } -} diff --git a/src/jmap/keyword/mod.rs b/src/jmap/keyword/mod.rs deleted file mode 100644 index 3d56c5ee..00000000 --- a/src/jmap/keyword/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod add; -pub mod command; -pub mod remove; -pub mod set; diff --git a/src/jmap/keyword/remove.rs b/src/jmap/keyword/remove.rs deleted file mode 100644 index 50bf7a42..00000000 --- a/src/jmap/keyword/remove.rs +++ /dev/null @@ -1,61 +0,0 @@ -use anyhow::{anyhow, bail, Result}; -use clap::Parser; -use io_jmap::coroutines::email_set::{EmailSetArgs, SetJmapEmails, SetJmapEmailsResult}; -use io_stream::runtimes::std::handle; -use pimalaya_toolbox::terminal::printer::Printer; - -use crate::jmap::account::JmapAccount; - -/// Remove keywords from JMAP emails. -#[derive(Debug, Parser)] -pub struct RemoveKeywordCommand { - /// Email ID(s) to remove the keyword(s) from. - #[arg(value_name = "EMAIL_ID", num_args = 1..)] - pub ids: Vec, - - /// The keyword(s) to remove. - #[arg(long, short, num_args = 1..)] - pub keyword: Vec, -} - -impl RemoveKeywordCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut jmap = account.new_jmap_session()?; - - let mut args = EmailSetArgs::default(); - - for id in &self.ids { - for kw in &self.keyword { - args.unset_keyword(id.clone(), kw.clone()); - } - } - - let mut coroutine = SetJmapEmails::new(jmap.context, args)?; - let mut arg = None; - - let not_updated = loop { - match coroutine.resume(arg.take()) { - SetJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), - SetJmapEmailsResult::Ok { not_updated, .. } => break not_updated, - SetJmapEmailsResult::Err { err, .. } => bail!(err), - } - }; - - for (id, err) in ¬_updated { - let mut ctx = anyhow!("failed to remove keyword from email `{id}`"); - if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); - } - if !err.properties.is_empty() { - ctx = anyhow!("invalid properties: {}", err.properties.join(", ")).context(ctx); - } - bail!(ctx); - } - - printer.log(format!( - "Keyword(s) `{}` removed from {} email(s).", - self.keyword.join(", "), - self.ids.len() - )) - } -} diff --git a/src/jmap/keyword/set.rs b/src/jmap/keyword/set.rs deleted file mode 100644 index 1edf314a..00000000 --- a/src/jmap/keyword/set.rs +++ /dev/null @@ -1,62 +0,0 @@ -use std::collections::HashMap; - -use anyhow::{anyhow, bail, Result}; -use clap::Parser; -use io_jmap::coroutines::email_set::{EmailSetArgs, SetJmapEmails, SetJmapEmailsResult}; -use io_stream::runtimes::std::handle; -use pimalaya_toolbox::terminal::printer::Printer; - -use crate::jmap::account::JmapAccount; - -/// Replace all keywords on JMAP emails. -/// -/// Replaces the entire set of keywords atomically — no need to know -/// the current keywords first. -#[derive(Debug, Parser)] -pub struct SetKeywordsCommand { - /// Email ID(s) to update. - #[arg(value_name = "EMAIL_ID", num_args = 1..)] - pub ids: Vec, - - /// Keywords to set (replaces all existing keywords). - #[arg(long, short, num_args = 1..)] - pub keyword: Vec, -} - -impl SetKeywordsCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut jmap = account.new_jmap_session()?; - - let keywords: HashMap = - self.keyword.iter().map(|kw| (kw.clone(), true)).collect(); - - let mut args = EmailSetArgs::default(); - for id in &self.ids { - args.replace_keywords(id.clone(), keywords.clone()); - } - - let mut coroutine = SetJmapEmails::new(jmap.context, args)?; - let mut arg = None; - - let not_updated = loop { - match coroutine.resume(arg.take()) { - SetJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), - SetJmapEmailsResult::Ok { not_updated, .. } => break not_updated, - SetJmapEmailsResult::Err { err, .. } => bail!(err), - } - }; - - for (id, err) in ¬_updated { - let mut ctx = anyhow!("failed to set keywords on email `{id}`"); - if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); - } - if !err.properties.is_empty() { - ctx = anyhow!("invalid properties: {}", err.properties.join(", ")).context(ctx); - } - bail!(ctx); - } - - printer.log(format!("Keywords set on {} email(s).", self.ids.len())) - } -} diff --git a/src/jmap/mailbox/create.rs b/src/jmap/mailbox/create.rs index 19357935..1855cdde 100644 --- a/src/jmap/mailbox/create.rs +++ b/src/jmap/mailbox/create.rs @@ -56,18 +56,21 @@ impl JmapMailboxCreateCommand { } }; - if let Some(err) = not_created.get(&self.name) { + if !not_created.is_empty() { let mut ctx = anyhow!("Create JMAP mailbox `{}` error", self.name); - if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); + for (_, err) in not_created { + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + + if !err.properties.is_empty() { + let props = err.properties.join(", "); + ctx = anyhow!("Invalid properties {props}").context(ctx); + } } - if !err.properties.is_empty() { - ctx = anyhow!("Invalid properties: {}", err.properties.join(", ")).context(ctx); - } - - bail!(ctx); + bail!(ctx) } printer.out(Message::new("Mailbox successfully created")) diff --git a/src/jmap/mailbox/destroy.rs b/src/jmap/mailbox/destroy.rs index 5165909d..bc3ab583 100644 --- a/src/jmap/mailbox/destroy.rs +++ b/src/jmap/mailbox/destroy.rs @@ -10,7 +10,7 @@ use crate::jmap::account::JmapAccount; #[derive(Debug, Parser)] pub struct JmapMailboxDestroyCommand { /// The ID of the mailbox to delete. - #[arg(value_name = "ID", required = true, num_args = 1..)] + #[arg(value_name = "ID", required = true)] pub ids: Vec, /// Destroy all emails in the mailbox when deleting. @@ -37,20 +37,21 @@ impl JmapMailboxDestroyCommand { } }; - for ref id in self.ids { - if let Some(err) = not_destroyed.get(id) { - let mut ctx = anyhow!("Update JMAP mailbox `{id}` error"); + if !not_destroyed.is_empty() { + let mut ctx = anyhow!("Destroy JMAP mailbox(es) error"); + for (id, err) in not_destroyed { if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); + ctx = anyhow!("{id}: {desc}").context(ctx); } if !err.properties.is_empty() { - ctx = anyhow!("Invalid properties: {}", err.properties.join(", ")).context(ctx); + let props = err.properties.join(", "); + ctx = anyhow!("{id}: Invalid properties {props}").context(ctx); } - - bail!(ctx); } + + bail!(ctx) } printer.out(Message::new("Mailbox successfully deleted")) diff --git a/src/jmap/mailbox/get.rs b/src/jmap/mailbox/get.rs index 20445337..be43c203 100644 --- a/src/jmap/mailbox/get.rs +++ b/src/jmap/mailbox/get.rs @@ -35,7 +35,7 @@ impl JmapMailboxGetCommand { }; for id in not_found { - warn!("mailbox `{id}` not found"); + warn!("mailbox `{id}` not found, ignoring it"); } let table = MailboxesTable { diff --git a/src/jmap/mailbox/update.rs b/src/jmap/mailbox/update.rs index ff7f8767..a88e465f 100644 --- a/src/jmap/mailbox/update.rs +++ b/src/jmap/mailbox/update.rs @@ -72,7 +72,7 @@ impl JmapMailboxUpdateCommand { let mut arg = None; let mut coroutine = SetJmapMailboxes::new(jmap.context, args)?; - let not_updated = loop { + let errs = loop { match coroutine.resume(arg.take()) { SetJmapMailboxesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), SetJmapMailboxesResult::Ok { not_updated, .. } => break not_updated, @@ -80,15 +80,16 @@ impl JmapMailboxUpdateCommand { } }; - if let Some(err) = not_updated.get(&self.id) { + if let Some(err) = errs.get(&self.id) { let mut ctx = anyhow!("Update JMAP mailbox `{}` error", self.id); if let Some(desc) = &err.description { - ctx = anyhow!(desc.clone()).context(ctx); + ctx = anyhow!("{desc}").context(ctx); } if !err.properties.is_empty() { - ctx = anyhow!("Invalid properties: {}", err.properties.join(", ")).context(ctx); + let props = err.properties.join(", "); + ctx = anyhow!("Invalid properties {props}").context(ctx); } bail!(ctx); diff --git a/src/jmap/submission/cancel.rs b/src/jmap/submission/cancel.rs index 41936533..1c93ad64 100644 --- a/src/jmap/submission/cancel.rs +++ b/src/jmap/submission/cancel.rs @@ -1,5 +1,8 @@ use anyhow::{anyhow, bail, Result}; use clap::Parser; +use io_jmap::coroutines::email_submission_cancel::{ + CancelJmapEmailSubmissions, CancelJmapEmailSubmissionsResult, +}; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; @@ -12,7 +15,7 @@ use crate::jmap::account::JmapAccount; #[derive(Debug, Parser)] pub struct CancelSubmissionCommand { /// Submission ID(s) to cancel. - #[arg(value_name = "SUBMISSION_ID", num_args = 1..)] + #[arg(value_name = "ID", required = true)] pub ids: Vec, } @@ -20,75 +23,35 @@ impl CancelSubmissionCommand { pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { let mut jmap = account.new_jmap_session()?; - // EmailSubmission/set update: set undoStatus to "canceled" - let update: std::collections::HashMap = self - .ids - .iter() - .map(|id| { - ( - id.clone(), - serde_json::json!({ "undoStatus": "canceled" }), - ) - }) - .collect(); - - let args = serde_json::json!({ - "update": update - }); - - // Use the raw query approach via SubmitJmapEmail isn't suitable here; - // we need a direct EmailSubmission/set update. Use the query command - // pattern with a raw request instead. - // - // For now, build the request directly via the send coroutine. - use io_jmap::{ - coroutines::send::{JmapBatch, SendJmapRequest, SendJmapRequestResult}, - types::session::capabilities, - }; - - let account_id = jmap.context.account_id.clone().unwrap_or_default(); - let api_url = jmap - .context - .api_url() - .cloned() - .unwrap_or_else(|| "http://localhost".parse().unwrap()); - - let mut json_args = args.clone(); - json_args["accountId"] = serde_json::json!(account_id); - - let mut batch = JmapBatch::new(); - batch.add("EmailSubmission/set", json_args); - let request = batch.into_request(vec![ - capabilities::CORE.into(), - capabilities::MAIL.into(), - capabilities::SUBMISSION.into(), - ]); - - let mut send = SendJmapRequest::new(jmap.context, &api_url, request) - .map_err(|e| anyhow!("{e}"))?; + let mut coroutine = + CancelJmapEmailSubmissions::new(jmap.context, self.ids.clone()) + .map_err(|e| anyhow!("{e}"))?; let mut arg = None; - loop { - match send.resume(arg.take()) { - SendJmapRequestResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), - SendJmapRequestResult::Ok { context, response, .. } => { - jmap.context = context; - if let Some((name, args, _)) = - response.method_responses.into_iter().next() - { - if name == "error" { - bail!("EmailSubmission/set error: {args}"); - } - } - break; + let not_updated = loop { + match coroutine.resume(arg.take()) { + CancelJmapEmailSubmissionsResult::Io(io) => { + arg = Some(handle(&mut jmap.stream, io)?) } - SendJmapRequestResult::Err { err, .. } => bail!(err), + CancelJmapEmailSubmissionsResult::Ok { not_updated, .. } => { + break not_updated + } + CancelJmapEmailSubmissionsResult::Err { err, .. } => bail!(err), } + }; + + for (id, err) in ¬_updated { + let mut ctx = anyhow!("Cancel submission `{id}` error"); + if let Some(desc) = &err.description { + ctx = anyhow!("{desc}").context(ctx); + } + if !err.properties.is_empty() { + let props = err.properties.join(", "); + ctx = anyhow!("Invalid properties {props}").context(ctx); + } + bail!(ctx); } - printer.out(Message::new(format!( - "{} submission(s) canceled.", - self.ids.len() - ))) + printer.out(Message::new(format!("{} submission(s) canceled.", self.ids.len()))) } } diff --git a/src/jmap/submission/command.rs b/src/jmap/submission/command.rs index 86924e0f..9fa972d4 100644 --- a/src/jmap/submission/command.rs +++ b/src/jmap/submission/command.rs @@ -12,7 +12,6 @@ use crate::jmap::{ /// Manage JMAP email submissions. #[derive(Debug, Subcommand)] -#[command(rename_all = "kebab-case")] pub enum SubmissionCommand { /// Fetch submissions by ID (EmailSubmission/get). Get(GetSubmissionCommand), diff --git a/src/jmap/submission/create.rs b/src/jmap/submission/create.rs index 992d7005..4af00e23 100644 --- a/src/jmap/submission/create.rs +++ b/src/jmap/submission/create.rs @@ -1,4 +1,6 @@ -use anyhow::{bail, Result}; +use std::collections::HashMap; + +use anyhow::{anyhow, bail, Result}; use clap::Parser; use io_jmap::{ coroutines::email_submission_set::{SubmitJmapEmail, SubmitJmapEmailResult}, @@ -7,7 +9,7 @@ use io_jmap::{ use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::Printer; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, submission::query::SubmissionsTable}; /// Submit a JMAP email for sending (EmailSubmission/set). /// @@ -40,7 +42,10 @@ impl CreateSubmissionCommand { let rcpt_to = self .rcpt_to .into_iter() - .map(|addr| EmailAddressWithParameters { email: addr, parameters: None }) + .map(|addr| EmailAddressWithParameters { + email: addr, + parameters: None, + }) .collect(); Some(Envelope { mail_from: EmailAddressWithParameters { @@ -59,33 +64,44 @@ impl CreateSubmissionCommand { envelope, }; - let mut submissions = std::collections::HashMap::new(); - submissions.insert("send-1".to_string(), submission); + let mut submissions = HashMap::new(); + submissions.insert(self.email_id.clone(), submission); let mut coroutine = SubmitJmapEmail::new(jmap.context, submissions)?; let mut arg = None; - loop { + let (created, errs) = loop { match coroutine.resume(arg.take()) { SubmitJmapEmailResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), - SubmitJmapEmailResult::Ok { context, not_created, .. } => { - jmap.context = context; - - if let Some(err) = not_created.get("send-1") { - bail!( - "failed to send email `{}`: {} — {}", - self.email_id, - err.error_type, - err.description.as_deref().unwrap_or("no description") - ); - } - - break; - } + SubmitJmapEmailResult::Ok { + created, + not_created, + .. + } => break (created, not_created), SubmitJmapEmailResult::Err { err, .. } => bail!(err), } + }; + + if let Some(err) = errs.get(&self.email_id) { + let mut ctx = anyhow!("Send email `{}` error", &self.email_id); + + if let Some(desc) = &err.description { + ctx = anyhow!("{desc}").context(ctx); + } + + if !err.properties.is_empty() { + let props = err.properties.join(", "); + ctx = anyhow!("Invalid properties {props}").context(ctx); + } + + bail!(ctx); } - printer.log(format!("Email `{}` successfully sent.", self.email_id)) + let table = SubmissionsTable { + preset: account.table_preset, + submissions: created.into_values().collect(), + }; + + printer.out(table) } } diff --git a/src/jmap/submission/get.rs b/src/jmap/submission/get.rs index 77ee5947..4bfaa9e8 100644 --- a/src/jmap/submission/get.rs +++ b/src/jmap/submission/get.rs @@ -4,15 +4,16 @@ use io_jmap::coroutines::email_submission_get::{ GetJmapEmailSubmissions, GetJmapEmailSubmissionsResult, }; use io_stream::runtimes::std::handle; +use log::warn; use pimalaya_toolbox::terminal::printer::Printer; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, submission::query::SubmissionsTable}; /// Get JMAP email submissions by ID (EmailSubmission/get). #[derive(Debug, Parser)] pub struct GetSubmissionCommand { /// Submission ID(s) to retrieve. - #[arg(value_name = "SUBMISSION_ID", num_args = 1..)] + #[arg(value_name = "ID", required = true)] pub ids: Vec, } @@ -20,32 +21,30 @@ impl GetSubmissionCommand { pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { let mut jmap = account.new_jmap_session()?; - let mut coroutine = - GetJmapEmailSubmissions::new(jmap.context, Some(self.ids.clone()))?; + let mut coroutine = GetJmapEmailSubmissions::new(jmap.context, Some(self.ids.clone()))?; let mut arg = None; let (submissions, not_found) = loop { match coroutine.resume(arg.take()) { - GetJmapEmailSubmissionsResult::Io(io) => { - arg = Some(handle(&mut jmap.stream, io)?) - } + GetJmapEmailSubmissionsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), GetJmapEmailSubmissionsResult::Ok { - context, submissions, not_found, .. - } => { - jmap.context = context; - break (submissions, not_found); - } + } => break (submissions, not_found), GetJmapEmailSubmissionsResult::Err { err, .. } => bail!(err), } }; - for id in ¬_found { - printer.log(format!("Submission `{id}` not found."))?; + for id in not_found { + warn!("submission `{id}` not found, ignoring it"); } - printer.out(serde_json::to_value(&submissions)?) + let table = SubmissionsTable { + preset: account.table_preset, + submissions, + }; + + printer.out(table) } } diff --git a/src/jmap/submission/query.rs b/src/jmap/submission/query.rs index 288c6a98..73f47132 100644 --- a/src/jmap/submission/query.rs +++ b/src/jmap/submission/query.rs @@ -1,13 +1,13 @@ use std::fmt; use anyhow::{bail, Result}; -use clap::Parser; +use clap::{Parser, ValueEnum}; use comfy_table::{Cell, Row, Table}; use io_jmap::{ coroutines::email_submission_query::{ QueryJmapEmailSubmissions, QueryJmapEmailSubmissionsResult, }, - types::email_submission::EmailSubmission, + types::email_submission::{EmailSubmission, EmailSubmissionFilter, UndoStatus}, }; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::Printer; @@ -15,12 +15,30 @@ use serde::Serialize; use crate::jmap::account::JmapAccount; +/// CLI proxy for [`UndoStatus`]. +#[derive(Clone, Debug, ValueEnum)] +pub enum UndoStatusArg { + Pending, + Final, + Canceled, +} + +impl From for UndoStatus { + fn from(arg: UndoStatusArg) -> Self { + match arg { + UndoStatusArg::Pending => UndoStatus::Pending, + UndoStatusArg::Final => UndoStatus::Final, + UndoStatusArg::Canceled => UndoStatus::Canceled, + } + } +} + /// Query JMAP email submissions (EmailSubmission/query + EmailSubmission/get). #[derive(Debug, Parser)] pub struct QuerySubmissionCommand { /// Filter by undo status (`pending`, `final`, `canceled`). #[arg(long, value_name = "STATUS")] - pub undo_status: Option, + pub undo_status: Option, /// Filter by sent-before date (RFC 3339). #[arg(long, value_name = "DATE")] @@ -44,17 +62,23 @@ impl QuerySubmissionCommand { let mut jmap = account.new_jmap_session()?; let filter = { - use io_jmap::types::email_submission::EmailSubmissionFilter; let f = EmailSubmissionFilter { - undo_status: self.undo_status, + undo_status: self.undo_status.map(Into::into), before: self.before, after: self.after, ..Default::default() }; + let has_one = f.undo_status.is_some() || f.before.is_some() || f.after.is_some(); - if has_one { Some(f) } else { None } + + if has_one { + Some(f) + } else { + None + } }; + let mut arg = None; let mut coroutine = QueryJmapEmailSubmissions::new( jmap.context, filter, @@ -62,17 +86,13 @@ impl QuerySubmissionCommand { Some(self.page.saturating_sub(1) * self.page_size), Some(self.page_size), )?; - let mut arg = None; let submissions = loop { match coroutine.resume(arg.take()) { QueryJmapEmailSubmissionsResult::Io(io) => { arg = Some(handle(&mut jmap.stream, io)?) } - QueryJmapEmailSubmissionsResult::Ok { context, submissions, .. } => { - jmap.context = context; - break submissions; - } + QueryJmapEmailSubmissionsResult::Ok { submissions, .. } => break submissions, QueryJmapEmailSubmissionsResult::Err { err, .. } => bail!(err), } }; @@ -110,9 +130,9 @@ impl fmt::Display for SubmissionsTable { .add_rows(self.submissions.iter().map(|s| { Row::from([ Cell::new(s.id.as_deref().unwrap_or("")), - Cell::new(&s.email_id), - Cell::new(&s.identity_id), - Cell::new(s.undo_status.as_deref().unwrap_or("")), + Cell::new(s.email_id.as_deref().unwrap_or("")), + Cell::new(s.identity_id.as_deref().unwrap_or("")), + Cell::new(s.undo_status.as_ref().map(|s| s.to_string()).unwrap_or_default()), Cell::new(s.send_at.as_deref().unwrap_or("")), ]) })); diff --git a/src/jmap/thread/get.rs b/src/jmap/thread/get.rs index b53cc71f..467847c5 100644 --- a/src/jmap/thread/get.rs +++ b/src/jmap/thread/get.rs @@ -1,8 +1,16 @@ +use std::fmt; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::coroutines::thread_get::{GetJmapThreads, GetJmapThreadsResult}; +use comfy_table::{Cell, Row, Table}; +use io_jmap::{ + coroutines::thread_get::{GetJmapThreads, GetJmapThreadsResult}, + types::thread::Thread, +}; use io_stream::runtimes::std::handle; +use log::warn; use pimalaya_toolbox::terminal::printer::Printer; +use serde::Serialize; use crate::jmap::account::JmapAccount; @@ -33,17 +41,39 @@ impl GetThreadCommand { } }; - for id in ¬_found { - printer.log(format!("Thread `{id}` not found."))?; + for id in not_found { + warn!("thread `{id}` not found, ignoring it"); } - for thread in threads { - printer.out(serde_json::json!({ - "id": thread.id, - "emailIds": thread.email_ids, - }))?; - } - - Ok(()) + printer.out(ThreadsTable { + preset: account.table_preset, + threads, + }) + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(transparent)] +pub struct ThreadsTable { + #[serde(skip)] + pub preset: String, + pub threads: Vec, +} + +impl fmt::Display for ThreadsTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + table + .load_preset(&self.preset) + .set_header(Row::from([Cell::new("ID"), Cell::new("EMAIL IDS")])) + .add_rows( + self.threads + .iter() + .map(|t| Row::from([Cell::new(&t.id), Cell::new(t.email_ids.join(", "))])), + ); + + writeln!(f)?; + writeln!(f, "{table}") } } diff --git a/src/jmap/vacation/get.rs b/src/jmap/vacation/get.rs index 9fe4d6eb..bdb69a21 100644 --- a/src/jmap/vacation/get.rs +++ b/src/jmap/vacation/get.rs @@ -1,10 +1,15 @@ +use std::fmt; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::coroutines::vacation_response_get::{ - GetJmapVacationResponse, GetJmapVacationResponseResult, +use comfy_table::{Cell, Row, Table}; +use io_jmap::{ + coroutines::vacation_response_get::{GetJmapVacationResponse, GetJmapVacationResponseResult}, + types::{session::capabilities::VACATION_RESPONSE, vacation_response::VacationResponse}, }; use io_stream::runtimes::std::handle; -use pimalaya_toolbox::terminal::printer::Printer; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; +use serde::Serialize; use crate::jmap::account::JmapAccount; @@ -16,25 +21,89 @@ impl GetVacationCommand { pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { let mut jmap = account.new_jmap_session()?; + // Skip the request if the server does not advertise the + // vacation-response capability. + let has_vacation = jmap + .context + .session + .as_ref() + .unwrap() + .capabilities + .contains_key(VACATION_RESPONSE); + + if !has_vacation { + bail!("Vacation response is not supported by the server"); + } + let mut coroutine = GetJmapVacationResponse::new(jmap.context)?; let mut arg = None; let vacation = loop { match coroutine.resume(arg.take()) { - GetJmapVacationResponseResult::Io(io) => { - arg = Some(handle(&mut jmap.stream, io)?) - } - GetJmapVacationResponseResult::Ok { context, vacation_response, .. } => { - jmap.context = context; - break vacation_response; - } + GetJmapVacationResponseResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + GetJmapVacationResponseResult::Ok { + vacation_response, .. + } => break vacation_response, GetJmapVacationResponseResult::Err { err, .. } => bail!(err), } }; - match vacation { - Some(v) => printer.out(serde_json::to_value(&v)?), - None => printer.log("No vacation response configured."), - } + let Some(vacation) = vacation else { + return printer.out(Message::new("No vacation response configured")); + }; + + let table = VacationTable { + preset: account.table_preset, + vacation, + }; + + printer.out(table) + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(transparent)] +pub struct VacationTable { + #[serde(skip)] + pub preset: String, + pub vacation: VacationResponse, +} + +impl fmt::Display for VacationTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + let v = &self.vacation; + + table + .load_preset(&self.preset) + .set_header(Row::from([Cell::new("KEY"), Cell::new("VALUE")])); + + table.add_row(Row::from([ + Cell::new("Enabled"), + Cell::new(if v.is_enabled { "true" } else { "" }), + ])); + + if let Some(d) = &v.from_date { + table.add_row(Row::from([Cell::new("From"), Cell::new(d)])); + } + + if let Some(d) = &v.to_date { + table.add_row(Row::from([Cell::new("To"), Cell::new(d)])); + } + + if let Some(s) = &v.subject { + table.add_row(Row::from([Cell::new("Subject"), Cell::new(s)])); + } + + if let Some(b) = &v.text_body { + table.add_row(Row::from([Cell::new("Body (plain)"), Cell::new(b)])); + } + + if let Some(b) = &v.html_body { + table.add_row(Row::from([Cell::new("Body (HTML)"), Cell::new(b)])); + } + + writeln!(f)?; + writeln!(f, "{table}") } } diff --git a/src/jmap/vacation/set.rs b/src/jmap/vacation/set.rs index fcab72a1..e7613e60 100644 --- a/src/jmap/vacation/set.rs +++ b/src/jmap/vacation/set.rs @@ -1,10 +1,8 @@ use anyhow::{bail, Result}; use clap::Parser; use io_jmap::{ - coroutines::vacation_response_set::{ - SetJmapVacationResponse, SetJmapVacationResponseResult, - }, - types::vacation_response::VacationResponseUpdate, + coroutines::vacation_response_set::{SetJmapVacationResponse, SetJmapVacationResponseResult}, + types::{session::capabilities::VACATION_RESPONSE, vacation_response::VacationResponseUpdate}, }; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; @@ -47,6 +45,20 @@ impl SetVacationCommand { pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { let mut jmap = account.new_jmap_session()?; + // Skip the request if the server does not advertise the + // vacation-response capability. + let has_vacation = jmap + .context + .session + .as_ref() + .unwrap() + .capabilities + .contains_key(VACATION_RESPONSE); + + if !has_vacation { + bail!("Vacation response is not supported by the server"); + } + let is_enabled = if self.enable { Some(true) } else if self.disable { @@ -69,17 +81,12 @@ impl SetVacationCommand { loop { match coroutine.resume(arg.take()) { - SetJmapVacationResponseResult::Io(io) => { - arg = Some(handle(&mut jmap.stream, io)?) - } - SetJmapVacationResponseResult::Ok { context, .. } => { - jmap.context = context; - break; - } + SetJmapVacationResponseResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapVacationResponseResult::Ok { .. } => break, SetJmapVacationResponseResult::Err { err, .. } => bail!(err), } } - printer.out(Message::new("Vacation response updated.")) + printer.out(Message::new("Vacation response successfully updated")) } } diff --git a/tests/common/jmap.rs b/tests/common/jmap.rs new file mode 100644 index 00000000..866570b2 --- /dev/null +++ b/tests/common/jmap.rs @@ -0,0 +1,568 @@ +use std::{ + env, + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; + +use assert_cmd::Command; +use io_jmap::types::{ + email::Email, email_submission::EmailSubmission, identity::Identity, mailbox::Mailbox, + thread::Thread, vacation_response::VacationResponse, +}; +use serde::de::DeserializeOwned; +use serde_json::Value; + +/// Minimal RFC 5322 message used as the email fixture throughout the suite. +const EML: &str = concat!( + "From: Himalaya Test \r\n", + "To: Himalaya Test \r\n", + "Subject: Himalaya integration test\r\n", + "Date: Thu, 01 Jan 2026 00:00:00 +0000\r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: text/plain; charset=utf-8\r\n", + "\r\n", + "This is a test email for himalaya integration tests.\r\n", +); + +/// Resources to clean up after the test, even on failure. +struct Cleanup<'a> { + config: &'a Path, + /// Test mailbox ID — destroyed with --purge (removes all emails inside). + mbox_id: Option, + /// Identity created during the test. + identity_id: Option, +} + +impl Drop for Cleanup<'_> { + fn drop(&mut self) { + if let Some(id) = &self.identity_id { + let _ = jmap(self.config).args(["identity", "delete", id]).output(); + } + + if let Some(id) = &self.mbox_id { + let _ = jmap(self.config) + .args(["mailboxes", "destroy", "--purge", id]) + .output(); + } + } +} + +/// Builds a `himalaya jmap` command with the given config path. +fn jmap(config: &Path) -> Command { + let mut cmd = Command::cargo_bin("himalaya").unwrap(); + cmd.args(["-c", config.to_str().unwrap(), "jmap"]); + cmd +} + +/// Builds a `himalaya --json jmap` command (JSON output mode). +fn jmap_json(config: &Path) -> Command { + let mut cmd = Command::cargo_bin("himalaya").unwrap(); + cmd.args(["--json", "-c", config.to_str().unwrap(), "jmap"]); + cmd +} + +/// Runs a JSON-mode command, asserts success, and deserializes stdout into T. +fn parse_output(config: &Path, args: &[&str]) -> T { + let stdout = jmap_json(config) + .args(args) + .assert() + .success() + .get_output() + .stdout + .clone(); + + serde_json::from_slice(&stdout).unwrap_or_else(|e| { + panic!( + "failed to parse output for {:?}: {e}\nstdout: {}", + args, + String::from_utf8_lossy(&stdout) + ) + }) +} + +/// Shared JMAP integration test suite. +/// +/// Exercises every command in a single ordered flow. Pass a path to a +/// valid TOML config file with a default JMAP account configured. +pub fn run(config: &Path) { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + + let mbox_name = format!("himalaya-test-{ts}"); + + let mut cleanup = Cleanup { + config, + mbox_id: None, + identity_id: None, + }; + + // ── 1. MAILBOXES ────────────────────────────────────────────────────── + + // baseline list — must return at least one mailbox (e.g. INBOX) + let mboxes: Vec = parse_output(config, &["mailboxes", "query"]); + + assert!( + !mboxes.is_empty(), + "mailboxes query should return at least one mailbox" + ); + + // create test mailbox (subscribed so it shows up in the default query) + jmap(config) + .args(["mailboxes", "create", &mbox_name, "--subscribe"]) + .assert() + .success(); + + // query by name — verify name matches + let mboxes: Vec = parse_output(config, &["mailboxes", "query", "--name", &mbox_name]); + + assert_eq!( + mboxes[0].name.as_deref(), + Some(mbox_name.as_str()), + "created mailbox name mismatch" + ); + + let mbox_id = mboxes[0].id.clone().expect("mailbox id"); + cleanup.mbox_id = Some(mbox_id.clone()); + + // get by id — verify id and name + let got: Vec = parse_output(config, &["mailboxes", "get", &mbox_id]); + + assert_eq!( + got[0].id.as_deref(), + Some(mbox_id.as_str()), + "get: id mismatch" + ); + + assert_eq!( + got[0].name.as_deref(), + Some(mbox_name.as_str()), + "get: name mismatch" + ); + + // update: rename + let mbox_name_2 = format!("{mbox_name}-renamed"); + + jmap(config) + .args(["mailboxes", "update", &mbox_id, "--name", &mbox_name_2]) + .assert() + .success(); + + // get by id again — verify the rename took effect + let got: Vec = parse_output(config, &["mailboxes", "get", &mbox_id]); + + assert_eq!( + got[0].name.as_deref(), + Some(mbox_name_2.as_str()), + "mailbox rename not reflected in get" + ); + + // ── 2. EMAILS ───────────────────────────────────────────────────────── + + // import from stdin + jmap(config) + .args(["emails", "import", "--mailbox-id", &mbox_id]) + .write_stdin(EML) + .assert() + .success(); + + // query — verify exactly one email landed in the mailbox + let emails: Vec = parse_output(config, &["emails", "query", "--mailbox", &mbox_id]); + 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]); + + assert_eq!( + got[0].id.as_deref(), + Some(email_id.as_str()), + "emails get: id mismatch" + ); + + // read: plain text — verify headers + body are present + let stdout = jmap(config) + .args(["emails", "read", &email_id]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let text = String::from_utf8(stdout).unwrap(); + + assert!( + text.contains("Himalaya integration test"), + "read: subject missing" + ); + + assert!(text.contains("This is a test email"), "read: body missing"); + + // read: html (no html part in fixture — command still succeeds) + jmap(config) + .args(["emails", "read", "--html", &email_id]) + .assert() + .success(); + + // update: add $seen — then verify via query with --has-keyword + jmap(config) + .args(["emails", "update", &email_id, "--add-keyword", "$seen"]) + .assert() + .success(); + + let seen: Vec = parse_output( + config, + &[ + "emails", + "query", + "--mailbox", + &mbox_id, + "--has-keyword", + "$seen", + ], + ); + + assert!( + seen.iter() + .any(|e| e.id.as_deref() == Some(email_id.as_str())), + "email should have $seen keyword after update" + ); + + // update: add $flagged + jmap(config) + .args(["emails", "update", &email_id, "--add-keyword", "$flagged"]) + .assert() + .success(); + + // update: remove $flagged — then verify it is gone + jmap(config) + .args([ + "emails", + "update", + &email_id, + "--remove-keyword", + "$flagged", + ]) + .assert() + .success(); + + let flagged: Vec = parse_output( + config, + &[ + "emails", + "query", + "--mailbox", + &mbox_id, + "--has-keyword", + "$flagged", + ], + ); + + assert!( + !flagged + .iter() + .any(|e| e.id.as_deref() == Some(email_id.as_str())), + "email should not have $flagged keyword after remove" + ); + + // export: raw RFC 5322 to stdout — verify original headers are present + let stdout = jmap(config) + .args(["emails", "export", &email_id]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let raw = String::from_utf8(stdout).unwrap(); + + assert!( + raw.contains("Subject: Himalaya integration test"), + "export: subject missing" + ); + + assert!( + raw.contains("From: Himalaya Test"), + "export: From header missing" + ); + + // import --upload-only: upload blob and get its id + let stdout = jmap(config) + .args(["emails", "import", "--upload-only"]) + .write_stdin(EML) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let blob_id = String::from_utf8(stdout).unwrap().trim().to_owned(); + + assert!(!blob_id.is_empty(), "upload-only must return a blob id"); + + // parse the uploaded blob — verify subject is present in output + let stdout = jmap(config) + .args(["emails", "parse", &blob_id]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let body = String::from_utf8(stdout).unwrap(); + + assert!( + body.contains("This is a test email"), + "parse: body missing from output" + ); + + // ── 3. THREADS ──────────────────────────────────────────────────────── + + // get thread — verify it references the imported email + let threads: Vec = parse_output(config, &["threads", "get", &thread_id]); + + assert_eq!(threads[0].id, thread_id, "thread: id mismatch"); + + assert!( + threads[0].email_ids.contains(&email_id), + "thread should reference the imported email id" + ); + + // ── 4. IDENTITY ─────────────────────────────────────────────────────── + + // list all identities + let identities: Vec = parse_output(config, &["identity", "get"]); + assert!(!identities.is_empty(), "expected at least one identity"); + + let primary_identity_id = identities[0].id.clone(); + let identity_email = identities[0].email.clone(); + + // create a new identity + jmap(config) + .args([ + "identity", + "create", + "Himalaya Test Identity", + &identity_email, + "--text-signature", + "Sent by himalaya integration tests", + ]) + .assert() + .success(); + + // list again — find by name and verify signature field + let identities: Vec = parse_output(config, &["identity", "get"]); + let new_identity = identities + .iter() + .find(|i| i.name == "Himalaya Test Identity") + .expect("created identity not found in list"); + + assert_eq!( + new_identity.text_signature.as_deref(), + Some("Sent by himalaya integration tests"), + "identity textSignature mismatch after create" + ); + + let identity_id = new_identity.id.clone(); + cleanup.identity_id = Some(identity_id.clone()); + + // update: rename — then verify the new name appears in the list + jmap(config) + .args([ + "identity", + "update", + &identity_id, + "--name", + "Himalaya Test Identity Updated", + ]) + .assert() + .success(); + + let identities: Vec = parse_output(config, &["identity", "get"]); + + assert!( + identities + .iter() + .any(|i| i.name == "Himalaya Test Identity Updated"), + "updated identity name not found in list" + ); + + // ── 5. SUBMISSION ───────────────────────────────────────────────────── + + // import a draft addressed to the account itself + let draft = format!( + "From: {identity_email}\r\n\ + To: {identity_email}\r\n\ + Subject: Himalaya submission test\r\n\ + Date: Thu, 01 Jan 2026 00:00:00 +0000\r\n\ + MIME-Version: 1.0\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\ + \r\n\ + Submission test by himalaya integration tests.\r\n" + ); + + jmap(config) + .args([ + "emails", + "import", + "--mailbox-id", + &mbox_id, + "--keyword", + "$draft", + ]) + .write_stdin(draft.as_bytes()) + .assert() + .success(); + + // query to get draft id — verify it is flagged $draft + let emails: Vec = parse_output( + config, + &[ + "emails", + "query", + "--mailbox", + &mbox_id, + "--has-keyword", + "$draft", + ], + ); + + assert!(!emails.is_empty(), "draft email not found after import"); + + 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( + config, + &[ + "submission", + "create", + &draft_id, + "--identity-id", + &primary_identity_id, + ], + ); + + assert!( + !created.is_empty(), + "expected at least one created submission in response" + ); + + let sub_id = created[0].id.clone().expect("submission id"); + + // 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]); + + if !got.is_empty() { + assert_eq!( + got[0].id.as_deref(), + Some(sub_id.as_str()), + "submission get: id mismatch" + ); + } + + // ── 6. COPY (optional) ──────────────────────────────────────────────── + + // 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 count_before = before.len(); + + jmap(config) + .args([ + "emails", + "copy", + &email_id, + "--from-account", + &from_account, + "--mailbox-id", + &mbox_id, + ]) + .assert() + .success(); + + let after: Vec = parse_output(config, &["emails", "query", "--mailbox", &mbox_id]); + + assert!( + after.len() > count_before, + "email copy should increase mailbox count" + ); + } + + // ── 7. VACATION ─────────────────────────────────────────────────────── + + // Check whether the server supports vacation response. Servers that do + // not advertise the vacationresponse capability return a non-zero exit + // code; in that case we skip the vacation assertions entirely. + let vacation_supported = jmap_json(config) + .args(["vacation", "get"]) + .output() + .expect("failed to run vacation get") + .status + .success(); + + if vacation_supported { + // enable vacation response + jmap(config) + .args([ + "vacation", + "set", + "--enable", + "--subject", + "Away (himalaya test)", + "--text-body", + "I am away for himalaya integration testing.", + ]) + .assert() + .success(); + + // verify enabled and subject + let vacation: VacationResponse = parse_output(config, &["vacation", "get"]); + + assert!( + vacation.is_enabled, + "vacation should be enabled after set --enable" + ); + + assert_eq!( + vacation.subject.as_deref(), + Some("Away (himalaya test)"), + "vacation subject mismatch" + ); + + // disable vacation response + jmap(config) + .args(["vacation", "set", "--disable"]) + .assert() + .success(); + + // verify disabled + let vacation: VacationResponse = parse_output(config, &["vacation", "get"]); + + assert!( + !vacation.is_enabled, + "vacation should be disabled after set --disable" + ); + } + + // ── 8. RAW QUERY ────────────────────────────────────────────────────── + + // raw Mailbox/get — shape is dynamic, use Value; verify response is a non-empty array + let raw: Value = parse_output( + config, + &["query", r#"[["Mailbox/get", {"ids": null}, "c0"]]"#], + ); + + assert!( + raw.as_array().map(|a| !a.is_empty()).unwrap_or(false), + "raw query response should be a non-empty array" + ); + + // cleanup via Drop (identity delete + mailbox destroy --purge) +} diff --git a/tests/fastmail-jmap.rs b/tests/fastmail-jmap.rs new file mode 100644 index 00000000..6076316b --- /dev/null +++ b/tests/fastmail-jmap.rs @@ -0,0 +1,24 @@ +#[path = "common/jmap.rs"] +mod jmap; + +use std::{env, io::Write}; + +use tempfile::NamedTempFile; + +#[test] +#[ignore = "requires FASTMAIL_API_TOKEN env var and --ignored"] +fn fastmail_jmap() { + let token = env::var("FASTMAIL_API_TOKEN").expect("FASTMAIL_API_TOKEN env var"); + + let mut config = NamedTempFile::new().unwrap(); + let config_tpl = format!( + r#"[accounts.fastmail] +default = true +jmap.server = "https://api.fastmail.com/jmap/session" +jmap.auth.bearer.token.raw = "{token}""# + ); + + config.write(&config_tpl.into_bytes()).unwrap(); + + jmap::run(config.path()); +}