refactor: clean jmap api

This commit is contained in:
Clément DOUIN
2026-03-26 00:40:16 +01:00
parent c720e6e36b
commit 1f3e96e263
39 changed files with 1397 additions and 547 deletions
+7 -4
View File
@@ -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
View File
@@ -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()?,
)
+9 -5
View File
@@ -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
View File
@@ -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 &not_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"))
+15 -8
View File
@@ -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 &not_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"))
+88
View File
@@ -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
View File
@@ -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
View File
@@ -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"))
}
}
+2
View File
@@ -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
View File
@@ -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 &not_found {
printer.log(format!("Blob `{id}` not found."))?;
for id in not_found {
warn!("blob `{id}` not found, ignoring it");
}
for id in &not_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
View File
@@ -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(", ")
}
+115
View File
@@ -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
View File
@@ -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 &not_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"))
+2 -2
View File
@@ -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),
+14 -7
View File
@@ -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);
+12 -5
View File
@@ -37,14 +37,21 @@ impl DeleteIdentityCommand {
}
};
for (id, err) in &not_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"))
+1 -1
View File
@@ -44,7 +44,7 @@ impl GetIdentityCommand {
}
};
for id in &not_found {
for id in not_found {
warn!("identity `{id}` not found");
}
+9 -4
View File
@@ -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);
-63
View File
@@ -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 &not_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()
))
}
}
-26
View File
@@ -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),
}
}
}
-4
View File
@@ -1,4 +0,0 @@
pub mod add;
pub mod command;
pub mod remove;
pub mod set;
-61
View File
@@ -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 &not_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()
))
}
}
-62
View File
@@ -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 &not_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()))
}
}
+11 -8
View File
@@ -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"))
+9 -8
View File
@@ -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"))
+1 -1
View File
@@ -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 {
+5 -4
View File
@@ -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);
+28 -65
View File
@@ -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 &not_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())))
}
}
-1
View File
@@ -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),
+37 -21
View File
@@ -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
View File
@@ -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 &not_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)
}
}
+34 -14
View File
@@ -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
View File
@@ -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 &not_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
View File
@@ -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
View File
@@ -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"))
}
}