use std::{ env, path::Path, time::{SystemTime, UNIX_EPOCH}, }; use assert_cmd::Command; use io_jmap::rfc8621::types::{ email::Email, email_submission::EmailSubmission, identity::Identity, mailbox::Mailbox, thread::Thread, vacation_response::VacationResponse, }; use serde::de::DeserializeOwned; use serde_json::Value; /// 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, email: String) { 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 ───────────────────────────────────────────────────────── let eml = [ &format!("From: Himalaya Test <{email}>"), &format!("To: Himalaya Test <{email}>"), "Subject: Himalaya integration test", "Date: Thu, 01 Jan 2026 00:00:00 +0000", "MIME-Version: 1.0", "Content-Type: text/plain; charset=utf-8", "", "This is a test email for himalaya integration tests.", ] .join("\r\n"); // import from stdin jmap(config) .args(["emails", "import", "--mailbox-id", &mbox_id]) .write_stdin(eml.as_bytes()) .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 ─────────────────────────────────────────────────────── // create jmap(config) .args([ "identity", "create", "Test", &email, "--text-signature", "Sent by himalaya integration tests", ]) .assert() .success(); // list — find by name and verify signature field let identities: Vec = parse_output(config, &["identity", "get"]); let identity = identities .iter() .find(|i| i.name == "Test") .expect("created identity not found in list"); assert_eq!( identity.text_signature.as_deref(), Some("Sent by himalaya integration tests"), "identity textSignature mismatch after create" ); let identity_id = identity.id.clone(); let identity_email = identity.email.clone(); cleanup.identity_id = Some(identity_id.clone()); // update: rename jmap(config) .args(["identity", "update", &identity_id, "--name", "Test Updated"]) .assert() .success(); // list — verify rename let identities: Vec = parse_output(config, &["identity", "get"]); assert!( identities.iter().any(|i| i.name == "Test 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", &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) }