use anyhow::{Result, anyhow};
use chrono::{DateTime, Datelike, Local, Utc};
use colored::Colorize;
use futures::future::join_all;
use serde::Serialize;
use pidge_client::{AuthClient, ClientError, GraphClient};
use pidge_core::{Config, Message, MessageCache, short_hash};
use crate::cli::MailCommands;
use crate::output::linkify_text;
pub(crate) struct MessageRow {
pub(crate) message: Message,
pub(crate) short_hash: String,
}
pub async fn run(command: MailCommands, json: bool) -> Result<()> {
match command {
MailCommands::List {
account,
folder,
limit,
page,
unread,
compact,
table,
full,
} => {
list(
account, folder, limit, page, unread, compact, table, full, json,
)
.await
}
MailCommands::Show {
fragment,
mark_read,
show_images,
raw_html,
} => {
crate::commands::mail_show::run(fragment, mark_read, show_images, raw_html, json).await
}
MailCommands::Search {
query,
account,
limit,
compact,
table,
full,
} => {
crate::commands::mail_search::run(query, account, limit, compact, table, full, json)
.await
}
MailCommands::MarkRead { fragment } => {
crate::commands::mail_actions::mark_read(fragment).await
}
MailCommands::MarkUnread { fragment } => {
crate::commands::mail_actions::mark_unread(fragment).await
}
MailCommands::Flag { fragment } => crate::commands::mail_actions::flag(fragment).await,
MailCommands::Unflag { fragment } => crate::commands::mail_actions::unflag(fragment).await,
MailCommands::Archive {
fragment,
from,
older_than,
account,
yes,
} => {
crate::commands::mail_actions::archive_dispatch(
fragment, from, older_than, account, yes,
)
.await
}
MailCommands::Move {
fragment,
to,
from,
older_than,
account,
yes,
} => crate::commands::mail_move::run(fragment, from, older_than, account, to, yes).await,
MailCommands::Folders { account } => {
crate::commands::mail_folders::run(account, json).await
}
MailCommands::Mkdir { name, account } => {
crate::commands::mail_folders::mkdir(name, account).await
}
MailCommands::Rmdir { name, account, yes } => {
crate::commands::mail_folders::rmdir(name, account, yes).await
}
MailCommands::New(args) => crate::commands::mail_compose::send(args).await,
MailCommands::Reply { fragment, compose } => {
crate::commands::mail_compose::reply(fragment, compose, false).await
}
MailCommands::ReplyAll { fragment, compose } => {
crate::commands::mail_compose::reply(fragment, compose, true).await
}
MailCommands::Forward { fragment, compose } => {
crate::commands::mail_compose::forward(fragment, compose).await
}
MailCommands::Delete {
fragment,
from,
older_than,
account,
yes,
} => crate::commands::mail_delete::run(fragment, from, older_than, account, yes).await,
MailCommands::Unsubscribe { fragment, yes } => {
crate::commands::mail_unsubscribe::run(fragment, yes).await
}
MailCommands::Attachments { command } => {
crate::commands::mail_attachments::run(command, json).await
}
}
}
#[allow(clippy::too_many_arguments)]
async fn list(
account_filter: Vec<String>,
folder: Option<String>,
limit: usize,
page: usize,
unread_only: bool,
compact: bool,
table: bool,
full: bool,
json: bool,
) -> Result<()> {
let config = Config::load()?;
if config.accounts.is_empty() {
return Err(anyhow!(
"No accounts signed in. Run `pidge account add` to add one."
));
}
let target_emails: Vec<String> = if account_filter.is_empty() {
config.accounts.iter().map(|a| a.email.clone()).collect()
} else {
for f in &account_filter {
if config.find(f).is_none() {
return Err(anyhow!("not signed in to {f}"));
}
}
account_filter
};
let per_account = compute_per_account_fetch(limit, target_emails.len());
let skip = compute_per_account_skip(per_account, page);
let graph = GraphClient::new(AuthClient::from_env()?)?;
let futures = target_emails.iter().map(|email| {
let graph = &graph;
let e = email.clone();
let folder = folder.clone();
async move {
let result = match folder {
None => graph.list_inbox(&e, per_account, skip, unread_only).await,
Some(path) => {
match crate::commands::mail_folders::resolve_folder_path(graph, &e, &path).await
{
Ok(Some(id)) => {
graph
.list_folder(&e, &id, per_account, skip, unread_only)
.await
}
Ok(None) => Err(pidge_client::ClientError::Graph {
status: 404,
message: format!("no folder '{path}' in {e}"),
}),
Err(err) => Err(pidge_client::ClientError::Graph {
status: 500,
message: err.to_string(),
}),
}
}
};
(e, result)
}
});
let results = join_all(futures).await;
let mut all_messages: Vec<Message> = Vec::new();
let mut had_success = false;
for (email, result) in results {
match result {
Ok(page) => {
had_success = true;
all_messages.extend(page.messages);
}
Err(ClientError::SessionExpired { email: e }) => {
eprintln!(
"{} {e}: session expired, run `pidge account add`",
"WARNING:".yellow().bold()
);
}
Err(e) => {
eprintln!("{} {email}: {e}", "WARNING:".yellow().bold());
}
}
}
if !had_success {
return Err(anyhow!("All accounts failed."));
}
all_messages.sort_by_key(|b| std::cmp::Reverse(b.received_at));
all_messages.truncate(limit);
let rows: Vec<MessageRow> = all_messages
.into_iter()
.map(|m| {
let h = short_hash(&m.id);
MessageRow {
message: m,
short_hash: h,
}
})
.collect();
update_cache(&rows)?;
let single_account = target_emails.len() == 1;
let labels = account_labels(&target_emails);
render(&rows, single_account, &labels, compact, table, full, json)
}
pub(crate) fn render(
rows: &[MessageRow],
hide_account: bool,
labels: &std::collections::HashMap<String, String>,
compact: bool,
table: bool,
full: bool,
json: bool,
) -> Result<()> {
if json {
return render_json(rows);
}
if table {
return render_table(rows, hide_account, labels);
}
let preview_lines = if compact {
1
} else if full {
usize::MAX
} else {
DEFAULT_PREVIEW_LINES
};
render_cards(rows, hide_account, labels, preview_lines)
}
const DEFAULT_PREVIEW_LINES: usize = 8;
pub(crate) fn account_labels(accounts: &[String]) -> std::collections::HashMap<String, String> {
use std::collections::HashMap;
let mut domain_count: HashMap<&str, usize> = HashMap::new();
for email in accounts {
if let Some((_, domain)) = email.split_once('@') {
*domain_count.entry(domain).or_insert(0) += 1;
}
}
accounts
.iter()
.map(|email| {
let label = email
.split_once('@')
.and_then(|(_, d)| {
if domain_count.get(d) == Some(&1) {
Some(d.to_string())
} else {
None
}
})
.unwrap_or_else(|| email.clone());
(email.clone(), label)
})
.collect()
}
fn update_cache(rows: &[MessageRow]) -> Result<()> {
let mut cache = MessageCache::load()?;
let pairs: Vec<(String, String)> = rows
.iter()
.map(|r| (r.message.id.clone(), r.message.account.clone()))
.collect();
cache.insert_many(&pairs);
cache.save()?;
Ok(())
}
fn compute_per_account_fetch(limit: usize, num_accounts: usize) -> usize {
if num_accounts == 0 {
return limit;
}
let calc = (limit as f64 * 1.2 / num_accounts as f64).ceil() as usize;
calc.max(10)
}
fn compute_per_account_skip(per_account: usize, page: usize) -> usize {
page.saturating_sub(1) * per_account
}
fn from_display(from: &pidge_core::MessageFrom) -> &str {
if from.name.is_empty() {
&from.address
} else {
&from.name
}
}
fn style_subject(subject: &str, is_read: bool) -> String {
let linked = linkify_text(subject);
if is_read {
linked.cyan().to_string()
} else {
linked.bold().magenta().to_string()
}
}
pub fn flag_marker(status: pidge_core::FlagStatus) -> String {
match status {
pidge_core::FlagStatus::Flagged => format!("{} ", "⚑".yellow().bold()),
pidge_core::FlagStatus::Complete => format!("{} ", "✓".green()),
pidge_core::FlagStatus::NotFlagged => String::new(),
}
}
fn render_cards(
rows: &[MessageRow],
hide_account: bool,
labels: &std::collections::HashMap<String, String>,
preview_lines: usize,
) -> Result<()> {
const INDENT: &str = " ";
let width = terminal_width();
let sep = format!(" {} ", "·".dimmed());
let rule_str: String = "─".repeat(width);
for (i, row) in rows.iter().enumerate() {
if i > 0 {
println!("{}", rule_str.dimmed());
}
let account_label = labels
.get(&row.message.account)
.cloned()
.unwrap_or_else(|| row.message.account.clone());
let mut header_parts: Vec<String> = Vec::with_capacity(5);
header_parts.push(row.short_hash.dimmed().to_string());
if !hide_account {
header_parts.push(format!("{}{}", "Account: ".dimmed(), account_label.cyan()));
}
header_parts.push(format!(
"{}{}",
"From: ".dimmed(),
from_display(&row.message.from).yellow().bold()
));
if row.message.has_attachments {
header_parts.push("📎".to_string());
}
header_parts.push(
relative_received(row.message.received_at)
.dimmed()
.italic()
.to_string(),
);
println!("{}", header_parts.join(&sep));
let flag = flag_marker(row.message.flag_status);
println!(
"{INDENT}{flag}{}",
style_subject(&row.message.subject, row.message.is_read)
);
if preview_lines > 0 {
let max_chars = width.saturating_sub(INDENT.len());
let lines = render_preview_lines(&row.message, max_chars, preview_lines);
for line in lines {
println!("{INDENT}{line}");
}
}
}
Ok(())
}
fn render_preview_lines(message: &Message, width: usize, max_lines: usize) -> Vec<String> {
use pidge_core::BodyContentType;
let has_body = !message.body.is_empty();
match (has_body, message.body_content_type) {
(true, BodyContentType::Html) => render_html_preview(&message.body, width, max_lines),
(true, BodyContentType::Text) => wrap_preview(&message.body, width, max_lines)
.into_iter()
.map(|l| style_preview_line(&l))
.collect(),
(false, _) => {
if message.preview.is_empty() {
return Vec::new();
}
wrap_preview(&message.preview, width, max_lines)
.into_iter()
.map(|l| style_preview_line(&l))
.collect()
}
}
}
fn render_html_preview(html: &str, width: usize, max_lines: usize) -> Vec<String> {
use html2text::render::text_renderer::{RichAnnotation, TaggedLineElement};
let no_color = crate::output::no_color();
let raw_lines = match html2text::config::rich()
.raw_mode(true)
.lines_from_read(html.as_bytes(), width)
{
Ok(l) => l,
Err(_) => return Vec::new(),
};
let mut staged: Vec<Vec<TaggedLinePiece>> = Vec::new();
for line in raw_lines {
let mut pieces: Vec<TaggedLinePiece> = Vec::new();
for elem in line.iter() {
let TaggedLineElement::Str(ts) = elem else {
continue;
};
let mut url: Option<&str> = None;
let mut is_image = false;
for ann in &ts.tag {
match ann {
RichAnnotation::Image(_) => is_image = true,
RichAnnotation::Link(u) => url = Some(u.as_str()),
_ => {}
}
}
if is_image {
continue;
}
let cleaned: String =
ts.s.chars()
.filter(|c| !matches!(c, '\u{200C}' | '\u{200B}' | '\u{200A}' | '\u{034F}'))
.collect();
if cleaned.is_empty() {
continue;
}
pieces.push(TaggedLinePiece {
text: cleaned,
url: url.map(str::to_string),
});
}
staged.push(pieces);
}
let mut compact: Vec<Vec<TaggedLinePiece>> = staged
.into_iter()
.filter(|pieces| !pieces.is_empty() && pieces.iter().any(|p| !p.text.trim().is_empty()))
.collect();
let total = compact.len();
let truncated = total > max_lines;
compact.truncate(max_lines);
let mut out: Vec<String> = compact
.into_iter()
.map(|pieces| style_html_line(pieces, no_color))
.collect();
if truncated {
if let Some(last) = out.last_mut() {
if no_color {
last.push('…');
} else {
last.push_str(&"…".dimmed().to_string());
}
}
}
out
}
struct TaggedLinePiece {
text: String,
url: Option<String>,
}
fn style_html_line(pieces: Vec<TaggedLinePiece>, no_color: bool) -> String {
let mut out = String::new();
for piece in pieces {
if no_color {
out.push_str(&piece.text);
continue;
}
if let Some(url) = piece.url {
out.push_str("\x1b]8;;");
out.push_str(&url);
out.push_str("\x1b\\");
out.push_str(&piece.text.cyan().underline().to_string());
out.push_str("\x1b]8;;\x1b\\");
} else {
out.push_str(&piece.text.dimmed().to_string());
}
}
out
}
fn style_preview_line(line: &str) -> String {
use linkify::{LinkFinder, LinkKind};
if crate::output::no_color() {
return line.to_string();
}
let finder = LinkFinder::new();
let mut out = String::with_capacity(line.len());
let mut cursor = 0;
for link in finder.links(line) {
if !matches!(link.kind(), LinkKind::Url) {
continue;
}
let (start, end) = (link.start(), link.end());
if cursor < start {
out.push_str(&line[cursor..start].dimmed().to_string());
}
out.push_str("\x1b]8;;");
out.push_str(link.as_str());
out.push_str("\x1b\\");
out.push_str(&link.as_str().cyan().underline().to_string());
out.push_str("\x1b]8;;\x1b\\");
cursor = end;
}
if cursor < line.len() {
out.push_str(&line[cursor..].dimmed().to_string());
}
out
}
fn wrap_preview(text: &str, width: usize, max_lines: usize) -> Vec<String> {
if width == 0 || max_lines == 0 {
return Vec::new();
}
let tokens = tokenize_with_urls(text);
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
let mut idx = 0;
while idx < tokens.len() {
let token = &tokens[idx];
let tlen = token.text.chars().count();
if current.is_empty() {
if !token.is_url && tlen > width {
let chars: Vec<char> = token.text.chars().collect();
let mut start = 0;
while start < chars.len() {
let end = (start + width).min(chars.len());
lines.push(chars[start..end].iter().collect());
if lines.len() == max_lines {
return finalize(lines, width, idx + 1 < tokens.len() || end < chars.len());
}
start = end;
}
} else {
current.push_str(&token.text);
}
idx += 1;
} else if current.chars().count() + 1 + tlen <= width {
current.push(' ');
current.push_str(&token.text);
idx += 1;
} else {
lines.push(std::mem::take(&mut current));
if lines.len() == max_lines {
return finalize(lines, width, idx < tokens.len());
}
}
}
if !current.is_empty() {
lines.push(current);
}
finalize(lines, width, false)
}
struct PreviewToken {
text: String,
is_url: bool,
}
fn tokenize_with_urls(text: &str) -> Vec<PreviewToken> {
use linkify::{LinkFinder, LinkKind};
let finder = LinkFinder::new();
let mut tokens: Vec<PreviewToken> = Vec::new();
for word in text.split_whitespace() {
let urls: Vec<_> = finder
.links(word)
.filter(|l| matches!(l.kind(), LinkKind::Url))
.collect();
if urls.is_empty() {
tokens.push(PreviewToken {
text: word.to_string(),
is_url: false,
});
continue;
}
let mut cursor = 0;
for url in urls {
if cursor < url.start() {
tokens.push(PreviewToken {
text: word[cursor..url.start()].to_string(),
is_url: false,
});
}
tokens.push(PreviewToken {
text: url.as_str().to_string(),
is_url: true,
});
cursor = url.end();
}
if cursor < word.len() {
tokens.push(PreviewToken {
text: word[cursor..].to_string(),
is_url: false,
});
}
}
tokens
}
fn finalize(mut lines: Vec<String>, width: usize, truncated: bool) -> Vec<String> {
if !truncated {
return lines;
}
if let Some(last) = lines.last_mut() {
if last.chars().count() < width {
last.push('…');
} else {
let mut chars: Vec<char> = last.chars().collect();
if !chars.is_empty() {
chars.pop();
}
chars.push('…');
*last = chars.into_iter().collect();
}
}
lines
}
fn terminal_width() -> usize {
crossterm::terminal::size()
.map(|(c, _)| c as usize)
.unwrap_or(80)
}
fn render_table(
rows: &[MessageRow],
hide_account: bool,
labels: &std::collections::HashMap<String, String>,
) -> Result<()> {
use comfy_table::{ContentArrangement, Table};
let mut table = Table::new();
table.load_preset(comfy_table::presets::UTF8_HORIZONTAL_ONLY);
table.set_content_arrangement(ContentArrangement::Dynamic);
let mut header = vec!["ID", "ACCOUNT", "FROM", "SUBJECT", "RECEIVED"];
if hide_account {
header.remove(1);
}
table.set_header(header);
for row in rows {
let attach = if row.message.has_attachments {
" 📎"
} else {
""
};
let subject = format!(
"{}{}{}",
flag_marker(row.message.flag_status),
style_subject(&row.message.subject, row.message.is_read),
attach,
);
let account_label = labels
.get(&row.message.account)
.cloned()
.unwrap_or_else(|| row.message.account.clone());
let mut cells = vec![
row.short_hash.dimmed().to_string(),
account_label,
from_display(&row.message.from).to_string(),
subject,
relative_received(row.message.received_at),
];
if hide_account {
cells.remove(1);
}
table.add_row(cells);
}
println!("{table}");
Ok(())
}
#[derive(Serialize)]
struct MessageOut<'a> {
id: &'a str,
graph_id: &'a str,
account: &'a str,
from: &'a pidge_core::MessageFrom,
subject: &'a str,
received_at: chrono::DateTime<chrono::Utc>,
is_read: bool,
preview: &'a str,
body_text: String,
#[serde(rename = "flagStatus")]
flag_status: pidge_core::FlagStatus,
has_attachments: bool,
}
fn render_json(rows: &[MessageRow]) -> Result<()> {
let out: Vec<MessageOut<'_>> = rows
.iter()
.map(|r| MessageOut {
id: &r.short_hash,
graph_id: &r.message.id,
account: &r.message.account,
from: &r.message.from,
subject: &r.message.subject,
received_at: r.message.received_at,
is_read: r.message.is_read,
preview: &r.message.preview,
body_text: body_as_plain_text(&r.message),
flag_status: r.message.flag_status,
has_attachments: r.message.has_attachments,
})
.collect();
println!("{}", serde_json::to_string_pretty(&out)?);
Ok(())
}
fn body_as_plain_text(m: &Message) -> String {
use pidge_core::BodyContentType;
if m.body.is_empty() {
return String::new();
}
match m.body_content_type {
BodyContentType::Text => m.body.clone(),
BodyContentType::Html => {
html2text::from_read(m.body.as_bytes(), 100)
}
}
}
fn relative_received(then: DateTime<Utc>) -> String {
let now = Local::now();
let then_local: DateTime<Local> = then.with_timezone(&Local);
let delta = now - then_local;
if delta.num_seconds() < 60 {
return "just now".to_string();
}
if delta.num_minutes() < 60 {
return format!("{}m ago", delta.num_minutes());
}
if delta.num_hours() < 24 {
return format!("{}h ago", delta.num_hours());
}
if now.date_naive().pred_opt() == Some(then_local.date_naive()) {
return "yesterday".to_string();
}
if delta.num_days() < 7 {
return then_local.format("%a").to_string();
}
if now.year() == then_local.year() {
return then_local.format("%b %-d").to_string();
}
then_local.format("%Y-%m-%d").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn per_account_fetch_at_least_10() {
assert_eq!(compute_per_account_fetch(5, 1), 10);
}
#[test]
fn per_account_fetch_scales_with_limit_and_accounts() {
assert_eq!(compute_per_account_fetch(25, 3), 10);
assert_eq!(compute_per_account_fetch(100, 3), 40);
}
#[test]
fn wrap_preview_fits_in_one_line() {
assert_eq!(wrap_preview("hello world", 40, 4), vec!["hello world"]);
}
#[test]
fn wrap_preview_wraps_at_word_boundary() {
let out = wrap_preview("the quick brown fox jumps", 10, 4);
assert_eq!(out, vec!["the quick", "brown fox", "jumps"]);
}
#[test]
fn wrap_preview_collapses_newlines_and_extra_spaces() {
let out = wrap_preview("Hello\n\nworld here", 40, 4);
assert_eq!(out, vec!["Hello world here"]);
}
#[test]
fn wrap_preview_truncates_with_ellipsis_when_more_lines_remain() {
let out = wrap_preview("aaa bbb ccc ddd eee fff ggg", 7, 2);
assert_eq!(out.len(), 2);
assert!(
out[1].ends_with('…'),
"expected truncation marker, got {out:?}"
);
}
#[test]
fn wrap_preview_returns_empty_when_max_lines_is_zero() {
assert!(wrap_preview("anything", 40, 0).is_empty());
}
#[test]
fn default_preview_lines_is_in_the_5_to_10_range() {
assert!(
(5..=10).contains(&DEFAULT_PREVIEW_LINES),
"default preview line count should stay inside the 5-10 sweet spot"
);
}
#[test]
fn wrap_preview_keeps_long_url_whole() {
let long_url = "https://example.com/path/with/many/segments?q=a&also=this-is-extra-padding";
let text = format!("see {long_url} for details");
let out = wrap_preview(&text, 20, 5);
assert!(
out.iter().any(|l| l == long_url),
"expected the long URL to appear as its own line, got: {out:?}"
);
assert!(
!out.iter()
.any(|l| l.contains('…') && l.contains("https://")),
"URL was truncated mid-string: {out:?}"
);
}
#[test]
fn wrap_preview_splits_around_url_inside_word() {
let out = wrap_preview("Visit:https://example.com,thanks for reading", 80, 5);
assert!(out[0].contains("https://example.com"), "got: {out:?}");
}
#[test]
fn wrap_preview_splits_oversized_word() {
let out = wrap_preview("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 10, 4);
assert!(out.iter().all(|l| l.chars().count() <= 10));
assert_eq!(out.iter().map(|l| l.len()).sum::<usize>(), 30);
}
}