Files
himalaya/src/jmap/client.rs
T
2026-06-01 20:47:41 +02:00

134 lines
4.5 KiB
Rust

// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
// details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! Himalaya wrapper around [`io_jmap::client::JmapClientStd`] that
//! bundles the merged [`Account`] alongside the live JMAP client.
//!
//! Built up front by the dispatch layer (`crate::cli`) via
//! [`build_jmap_client`] and handed down to every JMAP-specific
//! subcommand.
use std::{
ops::{Deref, DerefMut},
path::PathBuf,
};
use anyhow::{Result, anyhow};
use base64::{Engine, prelude::BASE64_STANDARD};
use io_jmap::client::JmapClientStd as Inner;
use pimalaya_config::toml::TomlConfig;
use secrecy::{ExposeSecret, SecretString};
use url::Url;
use crate::{
account::context::Account,
cli::load_or_wizard,
config::{JmapAuthConfig, JmapConfig},
};
pub struct JmapClient {
inner: Inner,
/// The original JMAP config block, kept around so commands like
/// `email import` / `email export` can spin up their own
/// auxiliary sessions (e.g. against the upload/download URL when
/// it lives on a different authority than the API URL).
pub config: JmapConfig,
}
impl JmapClient {
/// Establishes the JMAP session (TLS, `/.well-known/jmap`
/// discovery).
pub fn new(config: JmapConfig) -> Result<Self> {
let tls = config.tls.clone().into_tls(config.alpn.clone());
let http_auth = jmap_http_auth(config.auth.clone())?;
let url = parse_server_url(&config.server)?;
let mut inner = Inner::connect(&url, &tls, http_auth)?;
inner.session_get(&url)?;
Ok(Self { inner, config })
}
}
impl Deref for JmapClient {
type Target = Inner;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for JmapClient {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
/// Loads the configuration, picks the active account, builds the
/// merged [`Account`] then opens the JMAP session. Bails when the
/// account has no `[jmap]` block. Returns the live client paired
/// with the merged account so subcommands receive both as sibling
/// arguments.
pub fn build_jmap_client(
config_paths: &[PathBuf],
account_name: Option<&str>,
) -> Result<(Account, JmapClient)> {
let mut config = load_or_wizard(config_paths)?;
let (name, mut ac) = config
.take_account(account_name)?
.ok_or_else(|| anyhow!("Cannot find account"))?;
let jmap_config = ac
.jmap
.take()
.ok_or_else(|| anyhow!("JMAP config is missing for account `{name}`"))?;
let account = Account::from(config).merge(Account::from(ac));
let client = JmapClient::new(jmap_config)?;
Ok((account, client))
}
/// Parses the JMAP `server` field into a [`Url`], defaulting bare
/// authorities (e.g. `mail.example.com`) to `https://`.
pub fn parse_server_url(server: &str) -> Result<Url> {
match Url::parse(server) {
Ok(url) => Ok(url),
Err(url::ParseError::RelativeUrlWithoutBase) => {
Ok(Url::parse(&format!("https://{server}"))?)
}
Err(err) => Err(err.into()),
}
}
/// Converts a [`JmapAuthConfig`] into the pre-formatted HTTP
/// `Authorization` header value [`JmapClientStd::connect`] expects.
///
/// [`JmapClientStd::connect`]: io_jmap::client::JmapClientStd::connect
pub fn jmap_http_auth(config: JmapAuthConfig) -> Result<SecretString> {
match config {
JmapAuthConfig::Header(token) => Ok(token.get()?),
JmapAuthConfig::Bearer { token } => {
let token = token.get()?;
Ok(format!("Bearer {}", token.expose_secret()).into())
}
JmapAuthConfig::Basic { username, password } => {
let creds = format!("{}:{}", username, password.get()?.expose_secret());
let encoded = BASE64_STANDARD.encode(creds.into_bytes());
Ok(format!("Basic {encoded}").into())
}
}
}