mirror of
https://github.com/pimalaya/himalaya.git
synced 2026-06-17 13:17:55 +08:00
refactor: clean jmap api
This commit is contained in:
Generated
+85
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+7
-4
@@ -267,11 +267,14 @@ impl TryFrom<SaslConfig> 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)]
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ pub type JmapAccount = Account<JmapConfig>;
|
||||
impl JmapAccount {
|
||||
pub fn new_jmap_session(&self) -> Result<JmapSession> {
|
||||
JmapSession::new(
|
||||
self.backend.url.clone(),
|
||||
self.backend.server.clone(),
|
||||
self.backend.tls.clone().try_into()?,
|
||||
self.backend.auth.clone().try_into()?,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
+19
-12
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String, bool> =
|
||||
self.mailbox_id.iter().map(|m| (m.clone(), true)).collect();
|
||||
self.mailbox_id.into_iter().map(|m| (m, true)).collect();
|
||||
|
||||
let emails: HashMap<String, EmailCopy> = 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"))
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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"))
|
||||
|
||||
@@ -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)?))
|
||||
}
|
||||
}
|
||||
+11
-44
@@ -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<String>,
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
+84
-19
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Override the `receivedAt` time (RFC 3339).
|
||||
/// Override the `receivedAt` timestamp (RFC 3339).
|
||||
#[arg(long, value_name = "DATE")]
|
||||
pub received_at: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<u8> = if stdin().is_terminal() || printer.is_json() {
|
||||
self.message
|
||||
.join(" ")
|
||||
.replace('\r', "")
|
||||
.replace('\n', "\r\n")
|
||||
.into_bytes()
|
||||
} else {
|
||||
let lines: Vec<String> = 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<String, bool> =
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+7
-13
@@ -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<String>,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+15
-15
@@ -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::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct EmailsTable {
|
||||
@@ -279,3 +264,18 @@ impl From<SortArg> 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::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
+20
-17
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<Vec<String>>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Replace all mailbox memberships atomically.
|
||||
#[arg(long, value_name = "MAILBOX-ID", num_args = 0..)]
|
||||
#[arg(long, value_name = "MAILBOX-ID")]
|
||||
pub mailboxes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -44,7 +44,7 @@ impl GetIdentityCommand {
|
||||
}
|
||||
};
|
||||
|
||||
for id in ¬_found {
|
||||
for id in not_found {
|
||||
warn!("identity `{id}` not found");
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// The keyword(s) to add.
|
||||
#[arg(long, short, num_args = 1..)]
|
||||
pub keyword: Vec<String>,
|
||||
}
|
||||
|
||||
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()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod add;
|
||||
pub mod command;
|
||||
pub mod remove;
|
||||
pub mod set;
|
||||
@@ -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<String>,
|
||||
|
||||
/// The keyword(s) to remove.
|
||||
#[arg(long, short, num_args = 1..)]
|
||||
pub keyword: Vec<String>,
|
||||
}
|
||||
|
||||
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()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
|
||||
/// Keywords to set (replaces all existing keywords).
|
||||
#[arg(long, short, num_args = 1..)]
|
||||
pub keyword: Vec<String>,
|
||||
}
|
||||
|
||||
impl SetKeywordsCommand {
|
||||
pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> {
|
||||
let mut jmap = account.new_jmap_session()?;
|
||||
|
||||
let keywords: HashMap<String, bool> =
|
||||
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()))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
@@ -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<String, serde_json::Value> = 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())))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+14
-15
@@ -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<String>,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UndoStatusArg> 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<String>,
|
||||
pub undo_status: Option<UndoStatusArg>,
|
||||
|
||||
/// 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("")),
|
||||
])
|
||||
}));
|
||||
|
||||
+41
-11
@@ -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<Thread>,
|
||||
}
|
||||
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
+83
-14
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
+19
-12
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <himalaya@test.invalid>\r\n",
|
||||
"To: Himalaya Test <himalaya@test.invalid>\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<String>,
|
||||
/// Identity created during the test.
|
||||
identity_id: Option<String>,
|
||||
}
|
||||
|
||||
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<T: DeserializeOwned>(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<Mailbox> = 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<Mailbox> = 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<Mailbox> = 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<Mailbox> = 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<Email> = 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<Email> = 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<Email> = 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<Email> = 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<Thread> = 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<Identity> = 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<Identity> = 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<Identity> = 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<Email> = 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<EmailSubmission> = 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<EmailSubmission> = 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<Email> = 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<Email> = 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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user