// This file is part of Himalaya, a CLI to manage emails. // // Copyright (C) 2022-2026 soywod // // 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 . use std::{collections::HashMap, fs, path::Path, path::PathBuf}; use anyhow::{Context, Result}; use comfy_table::ContentArrangement; use crossterm::style::Color; use pimalaya_config::{ secret::Secret, toml::{shell_expanded_string, TomlConfig}, }; use pimalaya_stream::{ sasl::{ Sasl, SaslAnonymous, SaslLogin, SaslOauthbearer, SaslPlain, SaslScramSha256, SaslXoauth2, }, tls::{Rustls, RustlsCrypto, Tls, TlsProvider}, }; use serde::{Deserialize, Serialize}; /// Global configuration. /// /// Represents the whole TOML user's configuration file. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Config { pub downloads_dir: Option, #[serde(default)] pub table: TableConfig, #[serde(default)] pub envelope: EnvelopeConfig, #[serde(default)] pub mailbox: MailboxConfig, #[serde(default)] pub message: MessageConfig, #[serde(default)] pub attachment: AttachmentConfig, /// `account list` rendering options (global only — there is no /// per-account override for the listing of accounts). #[serde(default)] pub account: AccountListingConfig, pub accounts: HashMap, } impl TomlConfig for Config { type Account = AccountConfig; fn project_name() -> &'static str { env!("CARGO_PKG_NAME") } fn take_named_account(&mut self, name: &str) -> Option<(String, Self::Account)> { self.accounts.remove_entry(name) } fn take_default_account(&mut self) -> Option<(String, Self::Account)> { let name = self .accounts .iter() .find_map(|(name, account)| account.default.then(|| name.clone()))?; self.take_named_account(&name) } } impl Config { /// Serializes `self` to TOML and writes it to `path`, creating /// any missing parent directories. Used by the wizard to persist /// a freshly-built configuration. pub fn write(&self, path: &Path) -> Result<()> { let toml = toml::to_string_pretty(self).context("Serialize TOML config error")?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| { format!("Create TOML config parent `{}` error", parent.display()) })?; } fs::write(path, toml) .with_context(|| format!("Write TOML config `{}` error", path.display()))?; Ok(()) } } /// Account configuration. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct AccountConfig { #[serde(default)] pub default: bool, pub downloads_dir: Option, #[serde(default)] pub table: TableConfig, #[serde(default)] pub envelope: EnvelopeConfig, #[serde(default)] pub mailbox: MailboxConfig, #[serde(default)] pub attachment: AttachmentConfig, #[allow(unused)] pub imap: Option, #[allow(unused)] pub jmap: Option, #[allow(unused)] pub maildir: Option, #[allow(unused)] pub smtp: Option, } /// Envelope-level rendering options. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct EnvelopeConfig { #[serde(default)] pub list: EnvelopeListConfig, } /// Mailbox-level configuration. /// /// Exposes user-defined aliases mapping a friendly name to a /// backend-native id (looked up case-insensitively at resolution /// time; the `inbox` alias acts as the implicit default mailbox when /// a shared command omits `-m/--mailbox`) and the `mailboxes list` /// rendering options. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct MailboxConfig { #[serde(default, alias = "aliases")] pub alias: HashMap, #[serde(default)] pub list: MailboxListConfig, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct MailboxListConfig { #[serde(default)] pub table: MailboxListTableConfig, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct MailboxListTableConfig { pub id_color: Option, pub name_color: Option, pub total_color: Option, pub unread_color: Option, } /// `attachments list` rendering options. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct AttachmentConfig { #[serde(default)] pub list: AttachmentListConfig, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct AttachmentListConfig { #[serde(default)] pub table: AttachmentListTableConfig, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct AttachmentListTableConfig { pub id_color: Option, pub filename_color: Option, pub type_color: Option, pub size_color: Option, pub inline_color: Option, pub path_color: Option, } /// `account list` rendering options. Top-level only — there is no /// per-account override. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct AccountListingConfig { #[serde(default)] pub list: AccountListingListConfig, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct AccountListingListConfig { #[serde(default)] pub table: AccountListingTableConfig, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct AccountListingTableConfig { pub name_color: Option, pub backends_color: Option, pub default_color: Option, } /// `envelopes list` rendering options under `envelope.list.*`. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct EnvelopeListConfig { /// chrono `strftime` format used to render the DATE column. /// Defaults to `"%F %R%:z"` (e.g. `2026-05-06 14:30+02:00`) when /// neither the global nor the account config sets it. pub datetime_fmt: Option, /// When `true`, the `Date:` header timezone offset is converted /// to the system's local timezone before formatting. Defaults to /// `false`, which preserves the wire offset. pub datetime_local_tz: Option, /// Default `-s/--page-size` value for `envelopes list`. The CLI /// flag wins when passed; otherwise the merged account/global /// config wins; otherwise the hard fallback (25) is used. pub page_size: Option, /// Per-column color overrides + flag glyph customization for the /// rendered envelopes table. Keys mirror the v1.2.0 layout /// (`envelope.list.table.id-color`, `envelope.list.table.unseen-char`, /// etc.). Color values accept either a named [crossterm color] /// (`"red"`, `"dark-magenta"`, …) or an `{ Rgb = { r = .., g = .., /// b = .. } }`/`{ AnsiValue = N }` table. /// /// [crossterm color]: https://docs.rs/crossterm/latest/crossterm/style/enum.Color.html #[serde(default)] pub table: EnvelopeListTableConfig, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct EnvelopeListTableConfig { /// Single character used in the FLAGS column for messages that /// lack `\Seen`. Defaults to `*` (v1.2.0 default). pub unseen_char: Option, /// Single character used in the FLAGS column for messages with /// `\Answered`. Defaults to `R`. pub replied_char: Option, /// Single character used in the FLAGS column for messages with /// `\Flagged`. Defaults to `!`. pub flagged_char: Option, /// Single character used in the ATT column for messages with at /// least one attachment. Defaults to `@`. pub attachment_char: Option, pub id_color: Option, pub flags_color: Option, pub att_color: Option, pub subject_color: Option, pub from_color: Option, pub to_color: Option, pub date_color: Option, pub size_color: Option, } /// Message-level configuration: user-defined composers and readers. /// /// Composers produce a MIME draft on stdout (called by `compose-with`, /// `reply-with`, `forward-with`). Readers consume a MIME message from /// stdin and emit human-readable bytes on stdout (called by /// `read-with`). Both are looked up by name; the entry flagged /// `default = true` is used when no name is passed. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct MessageConfig { #[serde(default)] pub composer: HashMap, #[serde(default)] pub reader: HashMap, } /// Single composer entry under `[message.composer.]`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct ComposerConfig { /// Shell command line invoked via `sh -c`. Stdin carries the /// source MIME bytes (empty for new messages); stdout is /// captured as the MIME draft; stderr is inherited so the /// composer can prompt the user. pub command: String, /// Marks this entry as the fallback when `compose-with` / /// `reply-with` / `forward-with` are invoked without a name. /// Exactly one composer should set this; if several do, the /// first one returned by the config lookup wins. #[serde(default)] pub default: bool, } /// Single reader entry under `[message.reader.]`. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct ReaderConfig { /// Shell command line invoked via `sh -c`. Stdin carries the /// source MIME bytes; stdout is forwarded to the terminal (zero /// bytes is fine — the reader may have spawned its own UI); /// stderr is inherited. pub command: String, /// Marks this entry as the fallback when `read-with` is /// invoked without a name. #[serde(default)] pub default: bool, } /// Global / per-account table rendering knobs shared across every list /// command (envelopes, mailboxes, attachments). The per-column color /// blocks live under `*.list.table.*-color` (see [`EnvelopeListTableConfig`] /// & co.). #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct TableConfig { /// `comfy_table` preset string (chars for borders / corners / /// separators). Defaults to `UTF8_FULL_CONDENSED`. See /// . pub preset: Option, /// Column-arrangement strategy. Defaults to `Dynamic`. pub arrangement: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub enum TableArrangementConfig { #[default] Dynamic, DynamicFullWidth, Disabled, } impl From for ContentArrangement { fn from(arrangement: TableArrangementConfig) -> Self { match arrangement { TableArrangementConfig::Dynamic => ContentArrangement::Dynamic, TableArrangementConfig::DynamicFullWidth => ContentArrangement::DynamicFullWidth, TableArrangementConfig::Disabled => ContentArrangement::Disabled, } } } /// IMAP configuration. #[allow(unused)] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct ImapConfig { /// IMAP server address. Either a bare authority /// (`imap.example.com[:port]`, treated as `imaps://` by /// default), or a full URL with `imap://` (cleartext, with /// optional STARTTLS upgrade) or `imaps://` (implicit TLS) scheme /// used verbatim. Mirrors [`JmapConfig::server`]. pub server: String, #[serde(default)] pub tls: TlsConfig, #[serde(default)] pub starttls: bool, /// Optional SASL credentials. When omitted, the connection skips /// authentication entirely (no `AUTHENTICATE` command is sent); /// to advertise the ANONYMOUS mechanism explicitly, set /// `sasl.anonymous = {}`. pub sasl: Option, } /// Maildir configuration. #[allow(unused)] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct MaildirConfig { pub root: PathBuf, } /// SMTP configuration. #[allow(unused)] #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct SmtpConfig { /// SMTP server address. Either a bare authority /// (`smtp.example.com[:port]`, treated as `smtps://` /// by default), or a full URL with `smtp://` (cleartext, with /// optional STARTTLS upgrade) or `smtps://` (implicit TLS) scheme /// used verbatim. Mirrors [`JmapConfig::server`]. pub server: String, #[serde(default)] pub tls: TlsConfig, #[serde(default)] pub starttls: bool, /// Optional SASL credentials. See [`ImapConfig::sasl`]. pub sasl: Option, } /// SSL/TLS configuration. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct TlsConfig { pub provider: Option, #[serde(default)] pub rustls: RustlsConfig, pub cert: Option, } /// SSL/TLS provider configuration. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub enum TlsProviderConfig { Rustls, NativeTls, } /// Rustls configuration. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct RustlsConfig { pub crypto: Option, } /// Rustls crypto provider configuration. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub enum RustlsCryptoConfig { Aws, Ring, } impl From for Tls { fn from(config: TlsConfig) -> Self { Tls { provider: config.provider.map(|config| match config { TlsProviderConfig::Rustls => TlsProvider::Rustls, TlsProviderConfig::NativeTls => TlsProvider::NativeTls, }), rustls: Rustls { crypto: config.rustls.crypto.map(|config| match config { RustlsCryptoConfig::Aws => RustlsCrypto::Aws, RustlsCryptoConfig::Ring => RustlsCrypto::Ring, }), alpn: Vec::new(), }, cert: config.cert, } } } /// SASL configuration. /// /// Exactly one mechanism per `[*.sasl]` block. Each variant carries /// only the bits its mechanism actually transmits; serde picks the /// variant from the field name (`plain`, `login`, `anonymous`, /// `oauthbearer`, `xoauth2`, `scram-sha-256`). #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub enum SaslConfig { Anonymous(SaslAnonymousConfig), Login(SaslLoginConfig), Plain(SaslPlainConfig), Oauthbearer(SaslOauthbearerConfig), Xoauth2(SaslXoauth2Config), #[serde(rename = "scram-sha-256")] ScramSha256(SaslScramSha256Config), } /// SASL ANONYMOUS configuration [rfc4505]. /// /// [rfc4505]: https://www.iana.org/go/rfc4505 #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct SaslAnonymousConfig { pub message: Option, } /// SASL LOGIN configuration [draft]. /// /// [draft]: https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00 #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct SaslLoginConfig { #[serde(deserialize_with = "shell_expanded_string")] pub username: String, pub password: Secret, } /// SASL PLAIN configuration [rfc4616]. /// /// [rfc4616]: https://www.iana.org/go/rfc4616 #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct SaslPlainConfig { pub authzid: Option, #[serde(deserialize_with = "shell_expanded_string")] pub authcid: String, pub passwd: Secret, } /// SASL OAUTHBEARER configuration [rfc7628]. /// /// `host` and `port` are echoed verbatim in the GS2 header and should /// match the server the connection is actually opened against. /// /// [rfc7628]: https://www.iana.org/go/rfc7628 #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct SaslOauthbearerConfig { #[serde(deserialize_with = "shell_expanded_string")] pub username: String, pub host: String, pub port: u16, pub token: Secret, } /// SASL XOAUTH2 configuration. Google's pre-standard OAuth 2.0 SASL /// scheme; see . #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct SaslXoauth2Config { #[serde(deserialize_with = "shell_expanded_string")] pub username: String, pub token: Secret, } /// SASL SCRAM-SHA-256 configuration [rfc7677]. /// /// [rfc7677]: https://www.iana.org/go/rfc7677 #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct SaslScramSha256Config { #[serde(deserialize_with = "shell_expanded_string")] pub username: String, pub password: Secret, } impl TryFrom for Sasl { type Error = anyhow::Error; fn try_from(config: SaslConfig) -> Result { Ok(match config { SaslConfig::Anonymous(c) => Sasl::Anonymous(SaslAnonymous { message: c.message }), SaslConfig::Login(c) => Sasl::Login(SaslLogin { username: c.username, password: c.password.get()?, }), SaslConfig::Plain(c) => Sasl::Plain(SaslPlain { authzid: c.authzid, authcid: c.authcid, passwd: c.passwd.get()?, }), SaslConfig::Oauthbearer(c) => Sasl::Oauthbearer(SaslOauthbearer { username: c.username, host: c.host, port: c.port, token: c.token.get()?, }), SaslConfig::Xoauth2(c) => Sasl::Xoauth2(SaslXoauth2 { username: c.username, token: c.token.get()?, }), SaslConfig::ScramSha256(c) => Sasl::ScramSha256(SaslScramSha256 { username: c.username, password: c.password.get()?, }), }) } } /// JMAP configuration. #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct JmapConfig { /// The JMAP server address. /// /// 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)] pub tls: TlsConfig, /// Authentication configuration. pub auth: JmapAuthConfig, /// Identity id used by `messages send` to submit emails. Required /// only for JMAP send; can be discovered with `himalaya jmap /// identity get`. pub identity_id: Option, /// Drafts mailbox id used by `messages send` to stage emails before /// submission. Required only for JMAP send; can be discovered with /// `himalaya jmap mailbox query --role drafts`. pub drafts_mailbox_id: Option, } /// JMAP authentication configuration. // https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub enum JmapAuthConfig { Header(Secret), /// Bearer token (OAuth 2.0 access token). Bearer { token: Secret, }, /// HTTP Basic authentication (username + password). Basic { #[serde(deserialize_with = "shell_expanded_string")] username: String, password: Secret, }, }