Files
himalaya/src/jmap/email/import.rs
T
2026-05-20 02:36:43 +02:00

143 lines
4.8 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/>.
use std::{
collections::BTreeMap,
io::{stdin, BufRead, IsTerminal},
};
use anyhow::{bail, Result};
use clap::Parser;
use io_jmap::{
client::JmapClientStd,
rfc8621::{capabilities::MAIL, email::EmailImport},
};
use pimalaya_cli::printer::{Message, Printer};
use pimalaya_stream::tls::Tls;
use url::Url;
use crate::jmap::{
client::{jmap_http_auth, JmapClient},
error::format_set_error,
};
/// 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.
#[derive(Debug, Parser)]
pub struct JmapEmailImportCommand {
/// Mailbox ID(s) to place the imported email in.
#[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")]
pub keyword: Vec<String>,
/// 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 JmapEmailImportCommand {
pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> {
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 session = client.session().expect("session loaded by new_jmap_client");
let api_url = session.api_url.clone();
let account_id = session
.primary_accounts
.get(MAIL)
.map(|s| s.as_str())
.unwrap_or("");
let upload_url: Url = session
.upload_url
.replace("{accountId}", account_id)
.parse()?;
let blob_id = if same_authority(&api_url, &upload_url) {
client
.blob_upload(&upload_url, "message/rfc822", data)?
.blob_id
} else {
let mut tls: Tls = client.config.tls.clone().into();
tls.rustls.alpn = vec!["http/1.1".into()];
let http_auth = jmap_http_auth(client.config.auth.clone())?;
let mut upload_client = JmapClientStd::connect(&upload_url, &tls, http_auth)?;
upload_client
.blob_upload(&upload_url, "message/rfc822", data)?
.blob_id
};
if self.upload_only {
return printer.out(Message::new(blob_id));
}
let mailbox_ids: BTreeMap<String, bool> =
self.mailbox_id.into_iter().map(|m| (m, true)).collect();
let keywords = if self.keyword.is_empty() {
None
} else {
Some(self.keyword.iter().map(|kw| (kw.clone(), true)).collect())
};
let import = EmailImport {
blob_id: blob_id.clone(),
mailbox_ids,
keywords,
received_at: self.received_at,
};
let mut emails = BTreeMap::new();
emails.insert(blob_id.clone(), import);
let output = client.email_import(emails)?;
if let Some(err) = output.not_created.get(&blob_id) {
let mut msg = format!("Import JMAP email from blob `{blob_id}` error");
msg.push_str(&format_set_error(err));
bail!(msg);
}
printer.out(Message::new("Email successfully imported"))
}
}
fn same_authority(a: &Url, b: &Url) -> bool {
a.host() == b.host() && a.port_or_known_default() == b.port_or_known_default()
}