clean part 3

This commit is contained in:
Clément DOUIN
2026-05-19 01:01:44 +02:00
parent cd27969e14
commit 5500b02cfc
25 changed files with 567 additions and 1125 deletions
+68 -104
View File
@@ -1,151 +1,115 @@
//! Cross-protocol [`EmailClient`] for the shared subcommands
//! (`mailboxes`, `envelopes`, `flags`, `messages`, `attachments`).
//!
//! Wraps [`io_email::client::EmailClient`] and bundles the active
//! Wraps [`io_email::client::EmailClientStd`] and bundles the active
//! [`Account`] (display, identity, composer/reader registries) the
//! shared commands need alongside the I/O client. Implements
//! [`Deref`]/[`DerefMut`] onto the inner client so callers can call
//! its methods directly.
//!
//! Construction is backend-asymmetric (IMAP needs TLS + SASL, JMAP
//! needs an HTTP credential, Maildir just needs a root path). Each
//! `new_<protocol>` constructor delegates to the transitional
//! [`ImapSession`] / [`JmapSession`] helpers for the handshake/auth
//! flow then bridges the resulting `(stream, context)` pairs into
//! [`io_imap::client::ImapClient`] / [`io_jmap::client::JmapClient`]
//! via their `from_parts` constructors.
//!
//! [`ImapSession`]: crate::imap::session::ImapSession
//! [`JmapSession`]: crate::jmap::session::JmapSession
//! needs an HTTP credential, Maildir just needs a root path). The
//! single [`EmailClient::new`] entry-point loads the configuration,
//! picks the merged account, then walks the configured backends in
//! `jmap → imap → maildir` order and opens the first one allowed by
//! the `BackendFlag`.
use std::{
ops::{Deref, DerefMut},
path::PathBuf,
};
use std::ops::{Deref, DerefMut};
use anyhow::{anyhow, bail, Result};
use io_email::client::SendMessageOpts;
use pimalaya_config::toml::TomlConfig;
use anyhow::{bail, Result};
use crate::{
account::context::Account,
cli::{load_or_wizard, BackendFlag},
backend::Backend,
config::{AccountConfig, Config},
};
pub struct EmailClient {
inner: io_email::client::EmailClient,
inner: io_email::client::EmailClientStd,
pub account: Account,
/// Pre-computed options for [`io_email::client::EmailClient::send_message`].
/// Populated by the per-protocol constructors with the bits each
/// backend needs (currently only the JMAP identity / drafts
/// mailbox ids); other fields are filled in at send time from the
/// outgoing message itself.
pub send_opts: SendMessageOpts,
}
impl EmailClient {
/// Loads the configuration, picks the active account, builds the
/// merged [`Account`], then constructs an [`EmailClient`] for the
/// first backend allowed by `backend` that is configured on the
/// account. Selection order is `jmap → imap → maildir`. Bails when
/// no backend matches.
/// merged [`Account`], then opens the first backend allowed by
/// `backend` that is configured on the account. Selection order
/// is `jmap → imap → maildir`. Bails when no backend matches.
pub fn new(
config_paths: &[PathBuf],
account_name: Option<&str>,
backend: BackendFlag,
config: Config,
mut account_config: AccountConfig,
backend: Backend,
) -> Result<Self> {
let mut config = load_or_wizard(config_paths)?;
let (_, mut ac) = config
.take_account(account_name)?
.ok_or_else(|| anyhow!("Cannot find account"))?;
#[cfg(feature = "jmap")]
if backend.allows_jmap() {
if let Some(jmap_config) = ac.jmap.take() {
let account = Account::from(config).merge(Account::from(ac));
return EmailClient::new_jmap(jmap_config, account);
if let Some(jmap_config) = account_config.jmap.take() {
use io_email::client::EmailClientStd;
use io_jmap::client::JmapClientStd;
use pimalaya_stream::tls::Tls;
use crate::jmap::client::{jmap_http_auth, parse_server_url};
let account = Account::from(config).merge(Account::from(account_config));
let mut tls: Tls = jmap_config.tls.clone().into();
tls.rustls.alpn = vec!["http/1.1".into()];
let http_auth = jmap_http_auth(jmap_config.auth.clone())?;
let url = parse_server_url(&jmap_config.server)?;
let mut client = JmapClientStd::connect(&url, &tls, http_auth)?;
client.session_get(&url)?;
return Ok(Self {
inner: EmailClientStd::Jmap(client),
account,
});
}
}
#[cfg(feature = "imap")]
if backend.allows_imap() {
if let Some(imap_config) = ac.imap.take() {
let account = Account::from(config).merge(Account::from(ac));
return EmailClient::new_imap(imap_config, account);
if let Some(imap_config) = account_config.imap.take() {
use io_email::client::EmailClientStd;
use io_imap::client::ImapClientStd;
use pimalaya_stream::{sasl::Sasl, std::stream::StreamStd, tls::Tls};
let account = Account::from(config).merge(Account::from(account_config));
let mut tls: Tls = imap_config.tls.into();
tls.rustls.alpn = vec!["imap".into()];
let sasl: Sasl = imap_config.sasl.try_into()?;
let client = ImapClientStd::<StreamStd>::connect(
&imap_config.url,
&tls,
imap_config.starttls,
Some(sasl),
)?;
return Ok(Self {
inner: EmailClientStd::Imap(client),
account,
});
}
}
#[cfg(feature = "maildir")]
if backend.allows_maildir() {
if let Some(maildir_config) = ac.maildir.take() {
let account = Account::from(config).merge(Account::from(ac));
return EmailClient::new_maildir(maildir_config, account);
if let Some(maildir_config) = account_config.maildir.take() {
use io_email::client::EmailClientStd;
use io_maildir::client::MaildirClient;
let account = Account::from(config).merge(Account::from(account_config));
let client = MaildirClient::new(maildir_config.root);
return Ok(Self {
inner: EmailClientStd::Maildir(client),
account,
});
}
}
bail!("no backend matching `{backend}` is configured for this account")
}
#[cfg(feature = "imap")]
pub fn new_imap(config: crate::config::ImapConfig, account: Account) -> Result<Self> {
use io_imap::client::ImapClient;
use crate::imap::session::ImapSession;
let session = ImapSession::new(
config.url,
config.tls.try_into()?,
config.starttls,
config.sasl.try_into()?,
)?;
let client = ImapClient::from_parts(session.stream, session.context);
Ok(Self {
inner: client.into(),
account,
send_opts: SendMessageOpts::default(),
})
}
#[cfg(feature = "jmap")]
pub fn new_jmap(config: crate::config::JmapConfig, account: Account) -> Result<Self> {
use io_jmap::client::JmapClient;
use crate::jmap::session::JmapSession;
let send_opts = SendMessageOpts {
jmap_identity_id: config.identity_id.clone(),
jmap_drafts_mailbox_id: config.drafts_mailbox_id.clone(),
..SendMessageOpts::default()
};
let session = JmapSession::new(
config.server,
config.tls.try_into()?,
config.auth.try_into()?,
)?;
let client = JmapClient::from_parts(session.stream, session.http_auth, session.session);
Ok(Self {
inner: client.into(),
account,
send_opts,
})
}
#[cfg(feature = "maildir")]
pub fn new_maildir(config: crate::config::MaildirConfig, account: Account) -> Result<Self> {
use io_maildir::client::MaildirClient;
let client = MaildirClient::new(config.root);
Ok(Self {
inner: client.into(),
account,
send_opts: SendMessageOpts::default(),
})
}
}
impl Deref for EmailClient {
type Target = io_email::client::EmailClient;
type Target = io_email::client::EmailClientStd;
fn deref(&self) -> &Self::Target {
&self.inner
+61 -3
View File
@@ -8,7 +8,8 @@
use std::io::{stdout, Write};
use anyhow::Result;
use anyhow::{anyhow, bail, Result};
use mail_parser::{Address as ParserAddress, HeaderValue, MessageParser};
use pimalaya_cli::printer::{Message, Printer};
use crate::shared::client::EmailClient;
@@ -35,10 +36,67 @@ pub fn route(
}
if send {
let opts = client.send_opts.clone();
client.send_message(raw, opts)?;
let (from, to) = extract_envelope(&raw)?;
let to_refs: Vec<&str> = to.iter().map(String::as_str).collect();
client.send_message(raw, &from, &to_refs)?;
return printer.out(Message::new("Message successfully sent"));
}
printer.out(Message::new("Message saved"))
}
/// Extracts the envelope sender from `From:` and envelope recipients
/// from `To:` / `Cc:` / `Bcc:`. Returns an error when `From:` is
/// missing or no recipient header carries at least one address.
pub fn extract_envelope(raw: &[u8]) -> Result<(String, Vec<String>)> {
let parsed = MessageParser::default()
.parse(raw)
.ok_or_else(|| anyhow!("failed to parse outgoing message"))?;
let mut from_emails = Vec::new();
if let Some(header) = parsed.header("From").cloned() {
if let HeaderValue::Address(addr) = header {
collect_emails(addr, &mut from_emails);
}
}
let from = from_emails
.into_iter()
.next()
.ok_or_else(|| anyhow!("outgoing message is missing a `From:` header"))?;
let mut to = Vec::new();
for name in ["To", "Cc", "Bcc"] {
if let Some(header) = parsed.header(name).cloned() {
if let HeaderValue::Address(addr) = header {
collect_emails(addr, &mut to);
}
}
}
if to.is_empty() {
bail!("outgoing message has no recipients (`To:` / `Cc:` / `Bcc:`)");
}
Ok((from, to))
}
fn collect_emails(addr: ParserAddress<'_>, out: &mut Vec<String>) {
match addr {
ParserAddress::List(list) => {
for a in list {
if let Some(email) = a.address {
out.push(email.into_owned());
}
}
}
ParserAddress::Group(groups) => {
for g in groups {
for a in g.addresses {
if let Some(email) = a.address {
out.push(email.into_owned());
}
}
}
}
}
}
+8 -5
View File
@@ -4,12 +4,13 @@ use anyhow::Result;
use clap::Parser;
use pimalaya_cli::printer::{Message, Printer};
use crate::shared::client::EmailClient;
use crate::shared::{client::EmailClient, messages::output::extract_envelope};
/// Send a message via the active account.
///
/// Supported over JMAP. JMAP requires `identity-id` and
/// `drafts-mailbox-id` to be set on the account's `[jmap]` config block.
/// Routes through SMTP or JMAP depending on the account's configured
/// outgoing backend. The envelope sender is taken from the `From:`
/// header and recipients are collected from `To:` / `Cc:` / `Bcc:`.
#[derive(Debug, Parser)]
pub struct MessageSendCommand {
/// The raw message, including headers and body.
@@ -34,8 +35,10 @@ impl MessageSendCommand {
.join("\r\n")
};
let opts = client.send_opts.clone();
client.send_message(raw.into_bytes(), opts)?;
let raw = raw.into_bytes();
let (from, to) = extract_envelope(&raw)?;
let to_refs: Vec<&str> = to.iter().map(String::as_str).collect();
client.send_message(raw, &from, &to_refs)?;
printer.out(Message::new("Message successfully sent"))
}
}