mirror of
https://github.com/pimalaya/himalaya.git
synced 2026-06-16 04:17:56 +08:00
312 lines
9.7 KiB
Rust
312 lines
9.7 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::fmt;
|
|
|
|
use anyhow::Result;
|
|
use clap::{Parser, ValueEnum};
|
|
use comfy_table::{Cell, Color, ContentArrangement, Row, Table};
|
|
use io_jmap::rfc8621::email::{
|
|
Email, EmailAddress, EmailComparator, EmailFilter, EmailSortProperty,
|
|
};
|
|
use pimalaya_cli::printer::Printer;
|
|
use serde::Serialize;
|
|
|
|
use crate::jmap::client::JmapClient;
|
|
|
|
/// Query JMAP emails (Email/query + Email/get).
|
|
///
|
|
/// Lists, filters and sorts email envelopes.
|
|
#[derive(Debug, Parser)]
|
|
pub struct JmapEmailQueryCommand {
|
|
/// Filter by mailbox ID.
|
|
#[arg(long, short, value_name = "MAILBOX-ID")]
|
|
pub mailbox: Option<String>,
|
|
|
|
/// Filter by received-before date (RFC 3339, e.g. 2024-01-01T00:00:00Z).
|
|
#[arg(long, value_name = "DATE")]
|
|
pub before: Option<String>,
|
|
|
|
/// Filter by received-after date (RFC 3339, e.g. 2024-01-01T00:00:00Z).
|
|
#[arg(long, value_name = "DATE")]
|
|
pub after: Option<String>,
|
|
|
|
/// Filter by minimum size in bytes.
|
|
#[arg(long, value_name = "BYTES")]
|
|
pub min_size: Option<u64>,
|
|
|
|
/// Filter by maximum size in bytes.
|
|
#[arg(long, value_name = "BYTES")]
|
|
pub max_size: Option<u64>,
|
|
|
|
/// Filter to emails that have this keyword set.
|
|
#[arg(long, value_name = "KEYWORD")]
|
|
pub has_keyword: Option<String>,
|
|
|
|
/// Filter to emails that do not have this keyword set.
|
|
#[arg(long, value_name = "KEYWORD")]
|
|
pub not_keyword: Option<String>,
|
|
|
|
/// Filter to emails that have at least one attachment.
|
|
#[arg(long)]
|
|
pub has_attachment: bool,
|
|
|
|
/// Full-text search across all headers and body.
|
|
#[arg(long, value_name = "TEXT")]
|
|
pub text: Option<String>,
|
|
|
|
/// Filter by From header (substring match).
|
|
#[arg(long, value_name = "TEXT")]
|
|
pub from: Option<String>,
|
|
|
|
/// Filter by To header (substring match).
|
|
#[arg(long, value_name = "TEXT")]
|
|
pub to: Option<String>,
|
|
|
|
/// Filter by Subject header (substring match).
|
|
#[arg(long, value_name = "TEXT")]
|
|
pub subject: Option<String>,
|
|
|
|
/// Filter by email body (substring match).
|
|
#[arg(long, value_name = "TEXT")]
|
|
pub body: Option<String>,
|
|
|
|
/// Sort by property.
|
|
#[arg(long, value_name = "PROP", default_value_t)]
|
|
pub sort: SortArg,
|
|
|
|
/// Sort in descending order.
|
|
#[arg(long, default_value_t)]
|
|
pub desc: bool,
|
|
|
|
/// Number of emails to display per page.
|
|
#[arg(long, short = 's', value_name = "N", default_value = "10")]
|
|
pub page_size: u64,
|
|
|
|
/// Page index, starting from 1.
|
|
#[arg(long, short, value_name = "N", default_value = "1")]
|
|
pub page: u64,
|
|
}
|
|
|
|
impl JmapEmailQueryCommand {
|
|
pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> {
|
|
let filter = {
|
|
let f = EmailFilter {
|
|
in_mailbox: self.mailbox,
|
|
before: self.before,
|
|
after: self.after,
|
|
min_size: self.min_size,
|
|
max_size: self.max_size,
|
|
has_keyword: self.has_keyword,
|
|
not_keyword: self.not_keyword,
|
|
has_attachment: if self.has_attachment {
|
|
Some(true)
|
|
} else {
|
|
None
|
|
},
|
|
text: self.text,
|
|
from: self.from,
|
|
to: self.to,
|
|
subject: self.subject,
|
|
body: self.body,
|
|
..Default::default()
|
|
};
|
|
|
|
let has_one_filter = f.in_mailbox.is_some()
|
|
|| f.before.is_some()
|
|
|| f.after.is_some()
|
|
|| f.min_size.is_some()
|
|
|| f.max_size.is_some()
|
|
|| f.has_keyword.is_some()
|
|
|| f.not_keyword.is_some()
|
|
|| f.has_attachment.is_some()
|
|
|| f.text.is_some()
|
|
|| f.from.is_some()
|
|
|| f.to.is_some()
|
|
|| f.subject.is_some()
|
|
|| f.body.is_some();
|
|
|
|
if has_one_filter { Some(f) } else { None }
|
|
};
|
|
|
|
let sort = Some(vec![EmailComparator {
|
|
property: self.sort.into(),
|
|
is_ascending: Some(!self.desc),
|
|
collation: None,
|
|
keyword: None,
|
|
}]);
|
|
|
|
let output = client.email_query(
|
|
filter,
|
|
sort,
|
|
Some(self.page.saturating_sub(1) * self.page_size),
|
|
Some(self.page_size),
|
|
None,
|
|
)?;
|
|
|
|
let table = EmailsTable {
|
|
preset: client.account.table_preset().to_string(),
|
|
arrangement: client.account.table_arrangement(),
|
|
colors: EmailsColors {
|
|
id: client.account.envelopes_list_table_id_color(),
|
|
flags: client.account.envelopes_list_table_flags_color(),
|
|
subject: client.account.envelopes_list_table_subject_color(),
|
|
from: client.account.envelopes_list_table_from_color(),
|
|
date: client.account.envelopes_list_table_date_color(),
|
|
},
|
|
chars: EmailsChars {
|
|
unseen: client.account.envelopes_list_table_unseen_char(),
|
|
flagged: client.account.envelopes_list_table_flagged_char(),
|
|
attachment: client.account.envelopes_list_table_attachment_char(),
|
|
},
|
|
emails: output.emails,
|
|
};
|
|
|
|
printer.out(table)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct EmailsColors {
|
|
pub id: Color,
|
|
pub flags: Color,
|
|
pub subject: Color,
|
|
pub from: Color,
|
|
pub date: Color,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct EmailsChars {
|
|
pub unseen: char,
|
|
pub flagged: char,
|
|
pub attachment: char,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct EmailsTable {
|
|
#[serde(skip)]
|
|
pub preset: String,
|
|
#[serde(skip)]
|
|
pub arrangement: ContentArrangement,
|
|
#[serde(skip)]
|
|
pub colors: EmailsColors,
|
|
#[serde(skip)]
|
|
pub chars: EmailsChars,
|
|
pub emails: Vec<Email>,
|
|
}
|
|
|
|
impl fmt::Display for EmailsTable {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let mut table = Table::new();
|
|
|
|
table
|
|
.load_preset(&self.preset)
|
|
.set_content_arrangement(self.arrangement.clone())
|
|
.set_header(Row::from([
|
|
Cell::new("ID"),
|
|
Cell::new("FLAGS"),
|
|
Cell::new("SUBJECT"),
|
|
Cell::new("FROM"),
|
|
Cell::new("DATE"),
|
|
]));
|
|
|
|
for e in &self.emails {
|
|
let mut flags = String::new();
|
|
let kw = e.keywords.as_ref();
|
|
if !kw.and_then(|k| k.get("$seen")).copied().unwrap_or(false) {
|
|
flags.push(self.chars.unseen);
|
|
}
|
|
if kw.and_then(|k| k.get("$flagged")).copied().unwrap_or(false) {
|
|
flags.push(self.chars.flagged);
|
|
}
|
|
if e.has_attachment.unwrap_or(false) {
|
|
flags.push(self.chars.attachment);
|
|
}
|
|
|
|
let mut row = Row::new();
|
|
row.max_height(1);
|
|
row.add_cell(Cell::new(e.id.as_deref().unwrap_or("")).fg(self.colors.id));
|
|
row.add_cell(Cell::new(&flags).fg(self.colors.flags));
|
|
row.add_cell(Cell::new(e.subject.as_deref().unwrap_or("")).fg(self.colors.subject));
|
|
row.add_cell(
|
|
Cell::new(format_addresses(e.from.as_deref().unwrap_or(&[]))).fg(self.colors.from),
|
|
);
|
|
row.add_cell(Cell::new(e.received_at.as_deref().unwrap_or("")).fg(self.colors.date));
|
|
table.add_row(row);
|
|
}
|
|
|
|
writeln!(f)?;
|
|
writeln!(f, "{table}")
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, ValueEnum)]
|
|
#[clap(rename_all = "kebab-case")]
|
|
pub enum SortArg {
|
|
#[default]
|
|
ReceivedAt,
|
|
SentAt,
|
|
Size,
|
|
From,
|
|
To,
|
|
Subject,
|
|
HasAttachment,
|
|
}
|
|
|
|
impl fmt::Display for SortArg {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::ReceivedAt => write!(f, "received-at"),
|
|
Self::SentAt => write!(f, "sent-at"),
|
|
Self::Size => write!(f, "size"),
|
|
Self::From => write!(f, "from"),
|
|
Self::To => write!(f, "to"),
|
|
Self::Subject => write!(f, "subject"),
|
|
Self::HasAttachment => write!(f, "has-attachment"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SortArg> for EmailSortProperty {
|
|
fn from(arg: SortArg) -> Self {
|
|
match arg {
|
|
SortArg::ReceivedAt => EmailSortProperty::ReceivedAt,
|
|
SortArg::SentAt => EmailSortProperty::SentAt,
|
|
SortArg::Size => EmailSortProperty::Size,
|
|
SortArg::From => EmailSortProperty::From,
|
|
SortArg::To => EmailSortProperty::To,
|
|
SortArg::Subject => EmailSortProperty::Subject,
|
|
SortArg::HasAttachment => EmailSortProperty::HasAttachment,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn format_addresses(addrs: &[EmailAddress]) -> String {
|
|
addrs
|
|
.iter()
|
|
.map(|a| {
|
|
if let Some(name) = &a.name {
|
|
if !name.is_empty() {
|
|
return name.clone();
|
|
}
|
|
}
|
|
a.email.clone()
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|