use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc};
use colored::Colorize;
use futures::future::join_all;
use inquire::Confirm;
use pidge_client::{AuthClient, ClientError, GraphClient};
use pidge_core::Config;
use crate::commands::mail_fragment::{purge_from_cache, resolve};
pub async fn run(
fragment: Option<String>,
older_than: Option<String>,
accounts: Vec<String>,
yes: bool,
) -> Result<()> {
match (fragment, older_than) {
(Some(f), None) => delete_single(f, yes).await,
(None, Some(spec)) => delete_bulk(spec, accounts, yes).await,
(None, None) => Err(anyhow!(
"Specify a fragment or `--older-than <spec>`. Run `pidge mail delete --help`."
)),
(Some(_), Some(_)) => unreachable!("clap enforces conflicts_with"),
}
}
async fn delete_single(fragment: String, yes: bool) -> Result<()> {
let (short, msg) = resolve(&fragment)?;
if !yes {
let confirmed = Confirm::new(&format!(
"Delete message {} (moves to Deleted Items)?",
short.dimmed()
))
.with_default(false)
.prompt()
.map_err(|e| anyhow!("prompt cancelled: {e}"))?;
if !confirmed {
println!("Aborted.");
return Ok(());
}
}
let graph = GraphClient::new(AuthClient::from_env()?)?;
graph
.delete_message(&msg.account, &msg.graph_id)
.await
.context("Microsoft Graph rejected DELETE")?;
let _ = purge_from_cache(&short);
println!("{} Deleted {}.", "✔".green(), short.dimmed());
Ok(())
}
async fn delete_bulk(spec: String, account_filter: Vec<String>, yes: bool) -> Result<()> {
if !yes {
return Err(anyhow!(
"Bulk delete requires explicit `-y` confirmation — there is no \
interactive prompt. Re-run with `-y` if you really mean it."
));
}
let cutoff = parse_older_than(&spec)?;
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
};
println!(
"{} Deleting Inbox messages older than {} (cutoff {})…",
"Bulk".yellow().bold(),
spec,
cutoff.format("%Y-%m-%d %H:%M UTC")
);
let graph = GraphClient::new(AuthClient::from_env()?)?;
const PAGE_SIZE: usize = 50;
const MAX_PAGES: usize = 200;
let mut total_deleted = 0usize;
for email in &target_emails {
let count = delete_bulk_for_account(&graph, email, cutoff, PAGE_SIZE, MAX_PAGES).await?;
total_deleted += count;
println!(
"{} {}: removed {count} message{}",
"✔".green(),
email,
if count == 1 { "" } else { "s" }
);
}
println!("{} Total: {total_deleted}.", "✔".green().bold());
Ok(())
}
async fn delete_bulk_for_account(
graph: &GraphClient,
account: &str,
cutoff: DateTime<Utc>,
page_size: usize,
max_pages: usize,
) -> Result<usize> {
let mut deleted = 0usize;
for page in 0..max_pages {
let skip = page * page_size;
let result = graph
.list_inbox(account, page_size, skip, false)
.await
.context("listing inbox for bulk delete")?;
if result.messages.is_empty() {
break;
}
let to_delete: Vec<&pidge_core::Message> = result
.messages
.iter()
.filter(|m| m.received_at < cutoff)
.collect();
let futures = to_delete.iter().map(|m| {
let id = m.graph_id_alias();
async move { graph.delete_message(account, &id).await }
});
for (i, result) in join_all(futures).await.into_iter().enumerate() {
match result {
Ok(()) => {
deleted += 1;
let _ = purge_from_cache(&pidge_core::short_hash(&to_delete[i].id));
}
Err(ClientError::Graph { status: 404, .. }) => {
}
Err(e) => {
eprintln!(
" {} failed to delete {}: {e}",
"!".red(),
pidge_core::short_hash(&to_delete[i].id).dimmed()
);
}
}
}
let oldest = result
.messages
.iter()
.map(|m| m.received_at)
.min()
.unwrap_or(cutoff);
if oldest >= cutoff {
break;
}
if !result.has_more {
break;
}
}
Ok(deleted)
}
pub fn parse_older_than(spec: &str) -> Result<DateTime<Utc>> {
let trimmed = spec.trim();
if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
let dt = date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("invalid date"))?;
return Ok(DateTime::from_naive_utc_and_offset(dt, Utc));
}
if let Some(unit_pos) = trimmed.find(|c: char| !c.is_ascii_digit()) {
if unit_pos > 0 {
let n: i64 = trimmed[..unit_pos]
.parse()
.map_err(|_| anyhow!("'{spec}' is not a valid duration"))?;
let unit = &trimmed[unit_pos..];
let now = Utc::now();
let cutoff = match unit {
"d" => now - Duration::days(n),
"w" => now - Duration::weeks(n),
"m" => subtract_months(now, n)?,
"y" => subtract_months(now, n * 12)?,
other => {
return Err(anyhow!(
"unknown duration unit '{other}' in '{spec}'. Use d/w/m/y."
));
}
};
return Ok(cutoff);
}
}
Err(anyhow!(
"'{spec}' is not a date (YYYY-MM-DD) or duration (e.g. 30d, 6m, 1y)."
))
}
fn subtract_months(dt: DateTime<Utc>, months: i64) -> Result<DateTime<Utc>> {
let total = dt.year() as i64 * 12 + (dt.month() as i64 - 1) - months;
let new_year = (total / 12) as i32;
let new_month = (total % 12 + 1) as u32;
let last_day_of_month = match new_month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if new_year % 4 == 0 && (new_year % 100 != 0 || new_year % 400 == 0) {
29
} else {
28
}
}
_ => return Err(anyhow!("month overflow")),
};
let day = dt.day().min(last_day_of_month);
let nd = NaiveDate::from_ymd_opt(new_year, new_month, day)
.ok_or_else(|| anyhow!("date overflow"))?;
let naive = nd
.and_hms_opt(dt.hour(), dt.minute(), dt.second())
.ok_or_else(|| anyhow!("time overflow"))?;
Ok(DateTime::from_naive_utc_and_offset(naive, Utc))
}
trait MessageExt {
fn graph_id_alias(&self) -> String;
}
impl MessageExt for pidge_core::Message {
fn graph_id_alias(&self) -> String {
self.id.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_absolute_date() {
let d = parse_older_than("2026-01-01").unwrap();
assert_eq!(d.format("%Y-%m-%d").to_string(), "2026-01-01");
}
#[test]
fn parse_days() {
let now = Utc::now();
let d = parse_older_than("30d").unwrap();
let delta = now - d;
assert!(delta.num_hours() >= 29 * 24);
assert!(delta.num_hours() <= 30 * 24 + 1);
}
#[test]
fn parse_months() {
let now = Utc::now();
let d = parse_older_than("6m").unwrap();
let delta = now - d;
assert!(delta.num_days() > 150);
assert!(delta.num_days() < 200);
}
#[test]
fn parse_unknown_unit_rejects() {
assert!(parse_older_than("30x").is_err());
}
#[test]
fn parse_garbage_rejects() {
assert!(parse_older_than("yesterday").is_err());
assert!(parse_older_than("").is_err());
}
#[test]
fn subtract_months_clamps_day() {
let dt = DateTime::from_naive_utc_and_offset(
NaiveDate::from_ymd_opt(2026, 3, 31)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap(),
Utc,
);
let result = subtract_months(dt, 1).unwrap();
assert_eq!(result.month(), 2);
assert_eq!(result.day(), 28); }
}