pidge 1.4.2

A fast CLI for e-mail and calendar
//! `pidge mail unsubscribe` — opt out of a sender using the message's
//! `List-Unsubscribe` / `List-Unsubscribe-Post` headers.
//!
//! Preference order: RFC 8058 one-click POST → RFC 2369 mailto → bail with
//! the HTTPS URL. See `pidge-client::unsubscribe` for the parser.

use anyhow::{Context, Result, anyhow};
use colored::Colorize;
use inquire::Confirm;

use pidge_client::{
    AuthClient, ClientError, GraphClient, Outgoing, UnsubscribeMethod, mailto_subject,
    parse_unsubscribe,
};

use crate::commands::mail_fragment::resolve;

pub async fn run(fragment: String, yes: bool) -> Result<()> {
    let gate = crate::guardrail::gate(
        crate::guardrail::GuardrailAction::Unsubscribe,
        &format!("unsubscribe from sender of message {fragment}"),
    )?;
    if gate == crate::guardrail::Gate::DryRun {
        return Ok(());
    }

    let (short, msg) = resolve(&fragment)?;
    let graph = GraphClient::new(AuthClient::from_env()?)?;

    let headers = graph
        .fetch_message_headers(&msg.account, &msg.graph_id)
        .await
        .context("fetching message headers from Microsoft Graph")?;

    let method = parse_unsubscribe(&headers);

    match method {
        UnsubscribeMethod::None => Err(anyhow!(
            "Message {short} has no `List-Unsubscribe` header — there is no \
             standard way to unsubscribe from this sender. Look for an \
             unsubscribe link in the body or contact the sender directly."
        )),
        UnsubscribeMethod::HttpsOnly(url) => {
            // GET on a bare HTTPS unsubscribe URL usually shows a "click to
            // confirm" page; pidge can't reliably drive that without a
            // browser. Surface the URL so the user can click it.
            println!(
                "{} Message {short}: only an HTTPS unsubscribe URL is offered, \
                 and there is no `List-Unsubscribe-Post: List-Unsubscribe=One-Click` \
                 marker.\n  Open this URL in a browser to finish:\n  {}",
                "⚠".yellow(),
                url.cyan().underline(),
            );
            Ok(())
        }
        UnsubscribeMethod::OneClickPost(url) => {
            if !confirm(
                yes,
                &format!(
                    "Unsubscribe from {} via one-click POST to {url}?",
                    msg.account
                ),
            )? {
                println!("Aborted.");
                return Ok(());
            }
            graph.unsubscribe_one_click(&url).await.map_err(|e| {
                if let ClientError::UnsubscribeRejected = &e {
                    anyhow!(
                        "The one-click unsubscribe request was refused or failed (pidge only \
                         POSTs over https to public hosts and does not follow redirects).\n\
                         Try the URL in a browser instead: {url}"
                    )
                } else {
                    anyhow::Error::new(e).context("one-click unsubscribe POST failed")
                }
            })?;
            println!(
                "{} Unsubscribed via one-click POST ({})",
                "✔".green(),
                url.dimmed()
            );
            Ok(())
        }
        // The header's `body` is ignored and its subject capped: the sender
        // chooses them, and the e-mail goes out as the user. Which also
        // makes this a send, so `guardrails.send` applies on top.
        UnsubscribeMethod::Mailto {
            address, subject, ..
        } => {
            let subject = mailto_subject(subject.as_deref());
            let body = "unsubscribe".to_string();
            if crate::guardrail::gate(
                crate::guardrail::GuardrailAction::Send,
                &format!(
                    "send unsubscribe e-mail to <{address}> from {}",
                    msg.account
                ),
            )? == crate::guardrail::Gate::DryRun
            {
                return Ok(());
            }
            if !confirm(
                yes,
                &format!(
                    "Send unsubscribe e-mail to <{address}> from {} (subject: \"{subject}\")?",
                    msg.account
                ),
            )? {
                println!("Aborted.");
                return Ok(());
            }
            let outgoing = Outgoing {
                subject,
                body_text: body,
                to: vec![address.clone()],
                cc: vec![],
                bcc: vec![],
            };
            graph
                .send_mail(&msg.account, &outgoing)
                .await
                .context("Graph rejected the unsubscribe e-mail")?;
            println!(
                "{} Sent unsubscribe e-mail to {} (audit copy in Sent Items)",
                "✔".green(),
                address.dimmed()
            );
            Ok(())
        }
    }
}

fn confirm(yes_flag: bool, prompt: &str) -> Result<bool> {
    if yes_flag {
        return Ok(true);
    }
    Confirm::new(prompt)
        .with_default(false)
        .prompt()
        .map_err(|e| anyhow!("prompt cancelled: {e}"))
}