agent-first-mail 0.2.0

Let your AI agent work your inbox — email pulled into plain files it reads, sorts, and drafts on your machine, with nothing sent until you confirm.
Documentation
use super::*;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PurgeDisposition {
    Spam,
    Trash,
    Deleted,
}

impl PurgeDisposition {
    fn status(self) -> MessageStatus {
        match self {
            Self::Spam => MessageStatus::Spam,
            Self::Trash => MessageStatus::Trashed,
            Self::Deleted => MessageStatus::DeletedRemote,
        }
    }

    fn target(self) -> &'static str {
        match self {
            Self::Spam => "spam",
            Self::Trash => "trash",
            Self::Deleted => "deleted",
        }
    }
}

#[derive(Default)]
struct PurgeTotals {
    purged_message_ids: Vec<String>,
    purged_spam_count: usize,
    purged_trash_count: usize,
    purged_deleted_count: usize,
    skipped_referenced_message_ids: Vec<String>,
    skipped_recent_message_ids: Vec<String>,
}

impl PurgeTotals {
    fn record_purged(&mut self, target: PurgeDisposition, message_id: String) {
        match target {
            PurgeDisposition::Spam => self.purged_spam_count += 1,
            PurgeDisposition::Trash => self.purged_trash_count += 1,
            PurgeDisposition::Deleted => self.purged_deleted_count += 1,
        }
        self.purged_message_ids.push(message_id);
    }
}

impl Workspace {
    pub fn purge_spam(&self, older_than_days: u64) -> Result<Value> {
        self.purge_dispositions(&[PurgeDisposition::Spam], older_than_days)
    }

    pub fn purge_trash(&self, older_than_days: u64) -> Result<Value> {
        self.purge_dispositions(&[PurgeDisposition::Trash], older_than_days)
    }

    pub fn purge_deleted(&self, older_than_days: u64) -> Result<Value> {
        self.purge_dispositions(&[PurgeDisposition::Deleted], older_than_days)
    }

    pub fn purge_discards(&self, older_than_days: u64) -> Result<Value> {
        self.purge_dispositions(
            &[
                PurgeDisposition::Spam,
                PurgeDisposition::Trash,
                PurgeDisposition::Deleted,
            ],
            older_than_days,
        )
    }

    fn purge_dispositions(
        &self,
        targets: &[PurgeDisposition],
        older_than_days: u64,
    ) -> Result<Value> {
        self.require_workspace()?;
        let cutoff = Utc::now() - Duration::days(older_than_days as i64);
        let targets_by_status = targets
            .iter()
            .copied()
            .map(|target| (target.status(), target))
            .collect::<BTreeMap<_, _>>();
        let mut totals = PurgeTotals::default();

        for path in message_json_paths(&self.root)? {
            let Some(message_id) = path.file_stem().and_then(|stem| stem.to_str()) else {
                continue;
            };
            let message = self.read_message_by_id(message_id)?;
            let status = MessageStatus::parse(&message.workspace.status)?;
            let Some(target) = targets_by_status.get(&status).copied() else {
                continue;
            };
            let age_time = self.message_purge_age_time(&message)?;
            if age_time > cutoff {
                totals
                    .skipped_recent_message_ids
                    .push(message.message_id.clone());
                continue;
            }
            if self.message_id_is_referenced(&message.message_id)? {
                totals
                    .skipped_referenced_message_ids
                    .push(message.message_id.clone());
                continue;
            }
            let message_id = message.message_id.clone();
            purge_message_artifacts(&self.root, &message_id)?;
            self.clear_message_disposition(target.status(), &message_id)?;
            totals.record_purged(target, message_id);
        }

        let dispositions = self.refresh_disposition_views()?;
        let mut result = json!({
            "code": "purged",
            "target": purge_target_name(targets),
            "targets": targets.iter().map(|target| target.target()).collect::<Vec<_>>(),
            "older_than_days": older_than_days,
            "purged_count": totals.purged_message_ids.len(),
            "purged_message_ids": totals.purged_message_ids,
            "purged_spam_count": totals.purged_spam_count,
            "purged_trash_count": totals.purged_trash_count,
            "purged_deleted_count": totals.purged_deleted_count,
            "skipped_referenced_count": totals.skipped_referenced_message_ids.len(),
            "skipped_referenced_message_ids": totals.skipped_referenced_message_ids,
            "skipped_recent_count": totals.skipped_recent_message_ids.len(),
            "skipped_recent_message_ids": totals.skipped_recent_message_ids,
        });
        merge_disposition_refresh_into_purge(&mut result, &dispositions);
        Ok(result)
    }

    fn message_purge_age_time(&self, message: &MessageFile) -> Result<DateTime<Utc>> {
        let status = MessageStatus::parse(&message.workspace.status)?;
        let value = self
            .disposition_state_for_message(&message.message_id)?
            .and_then(|(disposition_status, added_rfc3339)| {
                if disposition_status == status {
                    Some(added_rfc3339)
                } else {
                    None
                }
            })
            .or_else(|| message.received_rfc3339.clone())
            .or_else(|| message.sent_rfc3339.clone())
            .unwrap_or_else(now_rfc3339);
        Ok(DateTime::parse_from_rfc3339(&value)
            .map(|time| time.with_timezone(&Utc))
            .unwrap_or_else(|_| Utc::now()))
    }
}

fn purge_target_name(targets: &[PurgeDisposition]) -> &'static str {
    if targets == [PurgeDisposition::Spam] {
        "spam"
    } else if targets == [PurgeDisposition::Trash] {
        "trash"
    } else if targets == [PurgeDisposition::Deleted] {
        "deleted"
    } else {
        "discards"
    }
}

fn merge_disposition_refresh_into_purge(purge: &mut Value, dispositions: &Value) {
    let Some(purge_obj) = purge.as_object_mut() else {
        return;
    };
    let Some(disposition_obj) = dispositions.as_object() else {
        return;
    };
    for key in [
        "spam_count",
        "spam_written_count",
        "stale_spam_removed_count",
        "trash_count",
        "trash_written_count",
        "stale_trash_removed_count",
        "deleted_count",
        "deleted_written_count",
        "stale_deleted_removed_count",
    ] {
        if let Some(value) = disposition_obj.get(key) {
            purge_obj.insert(key.to_string(), value.clone());
        }
    }
}