diff --git a/CHANGELOG.md b/CHANGELOG.md index 644a38dd..1a7a6000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Restored the RFC 2971 `ID`-after-auth quirk under the new shape `imap.id.{auto, fields}`. Set `imap.id.auto = true` to chain an `ID` exchange straight after IMAP authentication (required by mail.qq.com, fastmail). `imap.id.fields` is a `{ name = bool, … }` map: missing keys are not transmitted, `false` sends `NIL`, `true` sends himalaya's canned value for the well-known keys (`name`, `version`, `vendor`, `support-url`) or `NIL` (with a warning) for any other key. Replaces the v1.2.0 `imap.extensions.id.send-after-auth` flag dropped during the v2 migration. +### Changed + +- Unified raw-message input across `messages add`, `messages send`, `imap message save`, `maildir message save`, `jmap email import` and `smtp message send` behind a single `MessageArg` (ported from `mml::cli::args::MessageArg`). Every command now accepts the same three forms: a positional file path, a positional inline raw message (with `\r` / `\n` literals normalized to `\r\n`), or stdin when piped. The legacy `--file ` flag on `messages add` is gone (positional path replaces it). + ### Fixed - Fixed compilation error when `wizard` feature was disabled ([#634]). diff --git a/src/imap/message/save.rs b/src/imap/message/save.rs index 6420351a..6baf5fe9 100644 --- a/src/imap/message/save.rs +++ b/src/imap/message/save.rs @@ -15,8 +15,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::io::{BufRead, IsTerminal, stdin}; - use anyhow::Result; use clap::Parser; use io_imap::types::{ @@ -24,12 +22,16 @@ use io_imap::types::{ }; use pimalaya_cli::printer::{Message, Printer}; -use crate::imap::{client::ImapClient, mailbox::arg::MailboxNameArg}; +use crate::{ + imap::{client::ImapClient, mailbox::arg::MailboxNameArg}, + shared::messages::arg::MessageArg, +}; /// Save a message to a mailbox. /// -/// This command appends a message to the specified mailbox. The -/// message is read from stdin in RFC 5322 format (raw email). +/// Appends a message to the specified mailbox. The message can be +/// passed as a positional file path, an inline raw string, or piped +/// via stdin (see [`MessageArg`] for resolution order). #[derive(Debug, Parser)] pub struct ImapMessageSaveCommand { #[command(flatten)] @@ -39,28 +41,14 @@ pub struct ImapMessageSaveCommand { #[arg(short, long, num_args = 0..)] pub flag: Vec, - /// The raw message, including headers and body. - #[arg(trailing_var_arg = true)] - #[arg(name = "message", value_name = "MESSAGE")] - pub message: Vec, + #[command(flatten)] + pub message: MessageArg, } impl ImapMessageSaveCommand { pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox: Mailbox<'static> = self.mailbox.inner.try_into()?; - let message = if !self.message.is_empty() || stdin().is_terminal() || printer.is_json() { - self.message - .join(" ") - .replace('\r', "") - .replace('\n', "\r\n") - } else { - stdin() - .lock() - .lines() - .map_while(Result::ok) - .collect::>() - .join("\r\n") - }; + let message = self.message.parse()?; let message = Literal::try_from(message)?; let message = LiteralOrLiteral8::Literal(message); diff --git a/src/jmap/email/import.rs b/src/jmap/email/import.rs index 3cdf8248..dcdde2b6 100644 --- a/src/jmap/email/import.rs +++ b/src/jmap/email/import.rs @@ -15,10 +15,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{ - collections::BTreeMap, - io::{BufRead, IsTerminal, stdin}, -}; +use std::collections::BTreeMap; use anyhow::{Result, bail}; use clap::Parser; @@ -30,15 +27,20 @@ use pimalaya_cli::printer::{Message, Printer}; use pimalaya_stream::tls::Tls; use url::Url; -use crate::jmap::{ - client::{JmapClient, jmap_http_auth}, - error::format_set_error, +use crate::{ + jmap::{ + client::{JmapClient, jmap_http_auth}, + error::format_set_error, + }, + shared::messages::arg::MessageArg, }; /// Import an RFC 5322 message into a mailbox (upload + Email/import). /// -/// Reads the raw message from stdin or as trailing arguments. Use -/// `--upload-only` to stop after the upload and print the blobId. +/// The message can be passed as a positional file path, an inline +/// raw string, or piped via stdin (see [`MessageArg`] for resolution +/// order). Use `--upload-only` to stop after the upload and print +/// the blobId. #[derive(Debug, Parser)] pub struct JmapEmailImportCommand { /// Mailbox ID(s) to place the imported email in. @@ -57,24 +59,13 @@ pub struct JmapEmailImportCommand { #[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, + #[command(flatten)] + pub message: MessageArg, } impl JmapEmailImportCommand { pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { - let data: Vec = if stdin().is_terminal() || printer.is_json() { - self.message - .join(" ") - .replace('\r', "") - .replace('\n', "\r\n") - .into_bytes() - } else { - let lines: Vec = stdin().lock().lines().map_while(Result::ok).collect(); - lines.join("\r\n").into_bytes() - }; + let data = self.message.parse()?.into_bytes(); let session = client.session().expect("session loaded by new_jmap_client"); let api_url = session.api_url.clone(); diff --git a/src/maildir/message/save.rs b/src/maildir/message/save.rs index bffc7cd7..b2baa470 100644 --- a/src/maildir/message/save.rs +++ b/src/maildir/message/save.rs @@ -15,11 +15,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{ - fmt, - io::{BufRead, IsTerminal, stdin}, - path::PathBuf, -}; +use std::{fmt, path::PathBuf}; use anyhow::Result; use clap::Parser; @@ -27,16 +23,20 @@ use io_maildir::flag::MaildirFlags; use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::maildir::{ - arg::{MaildirPathFlag, MaildirSubdirArg}, - client::MaildirClient, - flag::arg::FlagArg, +use crate::{ + maildir::{ + arg::{MaildirPathFlag, MaildirSubdirArg}, + client::MaildirClient, + flag::arg::FlagArg, + }, + shared::messages::arg::MessageArg, }; /// Save a message to a mailbox. /// -/// This command appends a message to the specified mailbox. The -/// message is read from stdin in RFC 5322 format (raw email). +/// Appends a message to the specified maildir. The message can be +/// passed as a positional file path, an inline raw string, or piped +/// via stdin (see [`MessageArg`] for resolution order). #[derive(Debug, Parser)] pub struct MaildirMessageSaveCommand { #[command(flatten)] @@ -51,30 +51,14 @@ pub struct MaildirMessageSaveCommand { #[arg(long = "flag", short, num_args = 0..)] pub flags: Vec, - /// The raw message, including headers and body. - #[arg(trailing_var_arg = true)] - #[arg(name = "message", value_name = "MESSAGE")] - pub message: Vec, + #[command(flatten)] + pub message: MessageArg, } impl MaildirMessageSaveCommand { pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let maildir = client.resolve_maildir(&self.maildir.inner)?; - - let msg = if stdin().is_terminal() || printer.is_json() { - self.message - .join(" ") - .replace('\r', "") - .replace('\n', "\r\n") - } else { - stdin() - .lock() - .lines() - .map_while(Result::ok) - .collect::>() - .join("\r\n") - }; - + let msg = self.message.parse()?; let flags = MaildirFlags::from_iter(self.flags.into_iter().map(Into::into)); let (id, path) = client.store(maildir, self.subdir.into(), flags, msg.into_bytes())?; diff --git a/src/shared/messages/add.rs b/src/shared/messages/add.rs index e51da518..a2d4aca2 100644 --- a/src/shared/messages/add.rs +++ b/src/shared/messages/add.rs @@ -15,28 +15,25 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{ - fmt, - io::{IsTerminal, Read, stdin}, - path::PathBuf, -}; +use std::fmt; -use anyhow::{Result, bail}; +use anyhow::Result; use clap::Parser; use io_email::flag::Flag; use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::shared::{client::EmailClient, flags::arg::FlagArg}; +use crate::shared::{client::EmailClient, flags::arg::FlagArg, messages::arg::MessageArg}; /// Add a raw RFC 5322 message to a mailbox. /// -/// The message body is read from stdin by default; pass `--file -/// ` to read from a file instead. IMAP appends via `APPEND` -/// (RFC 3501); JMAP uploads the blob and imports it via `Email/import` -/// (the destination mailbox is resolved from `--mailbox` by exact-match -/// name); Maildir writes a new file under the target maildir's `cur/` -/// subdir using the standard tmp-then-rename delivery protocol. +/// The message can be passed as a positional file path, an inline raw +/// string, or piped via stdin (see [`MessageArg`] for resolution +/// order). IMAP appends via `APPEND` (RFC 3501); JMAP uploads the +/// blob and imports it via `Email/import` (the destination mailbox +/// is resolved from `--mailbox` by exact-match name); Maildir writes +/// a new file under the target maildir's `cur/` subdir using the +/// standard tmp-then-rename delivery protocol. #[derive(Debug, Parser)] pub struct MessageAddCommand { /// Destination mailbox name or path. Mandatory. @@ -47,14 +44,13 @@ pub struct MessageAddCommand { #[arg(long = "flag", short = 'f', value_name = "FLAG", num_args = 0..)] pub flag: Vec, - /// Read the raw message from this file instead of stdin. - #[arg(long = "file", value_name = "PATH")] - pub file: Option, + #[command(flatten)] + pub message: MessageArg, } impl MessageAddCommand { pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { - let raw = read_raw(&self.file)?; + let raw = self.message.parse()?.into_bytes(); let flags: Vec = self.flag.iter().map(Into::into).collect(); let id = client.add_message(&self.mailbox, &flags, raw)?; printer.out(MessageAddOutput { id }) @@ -71,20 +67,3 @@ impl fmt::Display for MessageAddOutput { write!(f, "Message {} successfully added", self.id) } } - -fn read_raw(file: &Option) -> Result> { - if let Some(path) = file { - return Ok(std::fs::read(path)?); - } - - if stdin().is_terminal() { - bail!( - "`messages add` reads the raw message from stdin or `--file ` — \ - nothing was provided" - ); - } - - let mut buf = Vec::new(); - stdin().read_to_end(&mut buf)?; - Ok(buf) -} diff --git a/src/shared/messages/arg.rs b/src/shared/messages/arg.rs new file mode 100644 index 00000000..abe65930 --- /dev/null +++ b/src/shared/messages/arg.rs @@ -0,0 +1,78 @@ +// 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 . + +//! Reusable clap arg for raw RFC 5322 message input. +//! +//! Ported verbatim from `mml::cli::args::MessageArg` so every +//! message-source command (shared `messages add`/`send`, per-protocol +//! `imap message save`, `maildir message save`, `jmap email import`, +//! `smtp message send`) accepts the same three forms: a file path, an +//! inline raw message, or stdin. + +use std::{ + fs, + io::{IsTerminal, stdin}, +}; + +use anyhow::bail; +use clap::Parser; +use pimalaya_cli::clap::parsers::path_parser; + +/// Trailing positional that resolves to a raw RFC 5322 message. +/// +/// Resolution order: +/// +/// 1. When the positional arg is non-empty: join the tokens with a +/// space, replace `\r` / `\n` literals with `\r\n`, and treat the +/// result as a path. If the path parses and the file is readable, +/// return its contents; otherwise treat the joined value as the +/// raw message verbatim. +/// 2. Otherwise, when stdin is piped, return stdin lines joined with +/// `\r\n`. +/// 3. Otherwise, bail. +#[derive(Debug, Parser)] +pub struct MessageArg { + /// Can be a path to a file, raw message contents or nothing if + /// piped via standard input. + #[arg(name = "message-raw", value_name = "MESSAGE", trailing_var_arg = true)] + pub raw: Vec, +} + +impl MessageArg { + pub fn parse(&self) -> anyhow::Result { + if !self.raw.is_empty() { + let mime = self.raw.join(" ").replace("\\r", "").replace("\\n", "\r\n"); + + let Ok(path) = path_parser(&mime) else { + return Ok(mime); + }; + + let Ok(mime) = fs::read_to_string(path) else { + return Ok(mime); + }; + + return Ok(mime); + } + + if !stdin().is_terminal() { + let lines: Vec<_> = stdin().lines().map_while(Result::ok).collect(); + return Ok(lines.join("\r\n")); + } + + bail!("Message cannot be empty"); + } +} diff --git a/src/shared/messages/mod.rs b/src/shared/messages/mod.rs index bf49cd19..1f6c725e 100644 --- a/src/shared/messages/mod.rs +++ b/src/shared/messages/mod.rs @@ -16,6 +16,7 @@ // along with this program. If not, see . pub mod add; +pub mod arg; pub mod builder; pub mod cli; pub mod compose; diff --git a/src/shared/messages/send.rs b/src/shared/messages/send.rs index 182eb60f..116fc45a 100644 --- a/src/shared/messages/send.rs +++ b/src/shared/messages/send.rs @@ -15,16 +15,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{ - io::{BufRead, IsTerminal, stdin}, - path::PathBuf, -}; - use anyhow::Result; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::shared::client::EmailClient; +use crate::shared::{client::EmailClient, messages::arg::MessageArg}; /// Send a message via the active account. /// @@ -32,42 +27,18 @@ use crate::shared::client::EmailClient; /// outgoing backend. The envelope sender is taken from the `From:` /// header and recipients are collected from `To:` / `Cc:` / `Bcc:`. /// -/// Source priority: `--file ` (read from file), otherwise stdin -/// when piped, otherwise the positional `` args joined with -/// CRLF. +/// The message can be passed as a positional file path, an inline +/// raw string, or piped via stdin (see [`MessageArg`] for resolution +/// order). #[derive(Debug, Parser)] pub struct MessageSendCommand { - /// Read the raw message from this file instead of stdin or the - /// positional argument. - #[arg(long = "file", value_name = "PATH")] - pub file: Option, - - /// The raw message, including headers and body. - #[arg(trailing_var_arg = true)] - #[arg(name = "message", value_name = "MESSAGE")] - pub message: Vec, + #[command(flatten)] + pub message: MessageArg, } impl MessageSendCommand { pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { - let raw: Vec = if let Some(path) = self.file.as_deref() { - std::fs::read(path)? - } else if stdin().is_terminal() || printer.is_json() { - self.message - .join(" ") - .replace('\r', "") - .replace('\n', "\r\n") - .into_bytes() - } else { - stdin() - .lock() - .lines() - .map_while(Result::ok) - .collect::>() - .join("\r\n") - .into_bytes() - }; - + let raw = self.message.parse()?.into_bytes(); client.send_message(raw)?; printer.out(Message::new("Message successfully sent")) } diff --git a/src/smtp/message/send.rs b/src/smtp/message/send.rs index 2cdea61f..fa248b93 100644 --- a/src/smtp/message/send.rs +++ b/src/smtp/message/send.rs @@ -15,11 +15,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{ - borrow::Cow, - collections::HashSet, - io::{BufRead, IsTerminal, stdin}, -}; +use std::{borrow::Cow, collections::HashSet}; use anyhow::{Result, bail}; use clap::Parser; @@ -30,40 +26,25 @@ use io_smtp::rfc5321::types::{ use mail_parser::{Addr, Address, HeaderName, HeaderValue, MessageParser}; use pimalaya_cli::printer::{Message, Printer}; -use crate::smtp::client::SmtpClient; +use crate::{shared::messages::arg::MessageArg, smtp::client::SmtpClient}; -/// Send a message to a mailbox. +/// Send a raw RFC 5322 message via SMTP. /// -/// This command appends a message to the specified mailbox. The -/// message is read from stdin in RFC 5322 format (raw email). +/// The message can be passed as a positional file path, an inline +/// raw string, or piped via stdin (see [`MessageArg`] for resolution +/// order). The envelope sender is taken from the `From:` header and +/// recipients are collected from `To:` / `Cc:` / `Bcc:`. #[derive(Debug, Parser)] pub struct SmtpMessageSendCommand { - /// The raw message, including headers and body. - #[arg(trailing_var_arg = true)] - #[arg(name = "message", value_name = "MESSAGE")] - pub message: Vec, + #[command(flatten)] + pub message: MessageArg, } impl SmtpMessageSendCommand { pub fn execute(self, printer: &mut impl Printer, mut client: SmtpClient) -> Result<()> { - let message = if stdin().is_terminal() || printer.is_json() { - self.message - .join(" ") - .replace('\r', "") - .replace('\n', "\r\n") - } else { - stdin() - .lock() - .lines() - .map_while(Result::ok) - .collect::>() - .join("\r\n") - }; - + let message = self.message.parse()?; let (reverse_path, forward_paths) = into_smtp_msg(message.as_bytes())?; - client.send(reverse_path, forward_paths, message.into_bytes())?; - printer.out(Message::new("Message successfully sent")) } }