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 serde::{Deserialize, Serialize};

pub const CASE_SCHEMA_NAME: &str = "case";
pub const ARCHIVE_NOTIFICATION_SCHEMA_NAME: &str = "notification";
pub const MESSAGE_COLLECTION_SCHEMA_VERSION: u64 = 1;

fn is_zero(value: &usize) -> bool {
    *value == 0
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MessageCollectionItem {
    pub message_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
    pub added_rfc3339: String,
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct MessageCollection {
    pub schema_name: String,
    pub schema_version: u64,
    pub collection_uid: String,
    pub collection_name: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub status: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub created_rfc3339: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub updated_rfc3339: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub archived_rfc3339: Option<String>,
    #[serde(default, skip_serializing_if = "is_zero")]
    pub message_count: usize,
    #[serde(default, skip_serializing_if = "is_zero")]
    pub thread_count: usize,
    #[serde(default, skip_serializing_if = "is_zero")]
    pub attachment_count: usize,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_message_rfc3339: Option<String>,
    #[serde(default)]
    pub items: Vec<MessageCollectionItem>,
}

pub type CaseMessages = MessageCollection;
pub type ArchiveMessages = MessageCollection;
pub type ArchiveMessageItem = MessageCollectionItem;

impl MessageCollection {
    pub fn new(schema_name: &str, collection_uid: &str, collection_name: &str) -> Self {
        Self {
            schema_name: schema_name.to_string(),
            schema_version: MESSAGE_COLLECTION_SCHEMA_VERSION,
            collection_uid: collection_uid.to_string(),
            collection_name: collection_name.to_string(),
            status: String::new(),
            tags: Vec::new(),
            created_rfc3339: None,
            updated_rfc3339: None,
            archived_rfc3339: None,
            message_count: 0,
            thread_count: 0,
            attachment_count: 0,
            last_message_rfc3339: None,
            items: Vec::new(),
        }
    }

    pub fn new_case(case_uid: &str, case_name: &str, now_rfc3339: &str) -> Self {
        let mut collection = Self::new(CASE_SCHEMA_NAME, case_uid, case_name);
        collection.status = "active".to_string();
        collection.created_rfc3339 = Some(now_rfc3339.to_string());
        collection.updated_rfc3339 = Some(now_rfc3339.to_string());
        collection
    }

    pub fn new_notification(archive_uid: &str, archive_name: &str, now_rfc3339: &str) -> Self {
        let mut collection = Self::new(ARCHIVE_NOTIFICATION_SCHEMA_NAME, archive_uid, archive_name);
        collection.created_rfc3339 = Some(now_rfc3339.to_string());
        collection.updated_rfc3339 = Some(now_rfc3339.to_string());
        collection
    }

    pub fn message_ids(&self) -> Vec<String> {
        self.items
            .iter()
            .map(|item| item.message_id.clone())
            .collect()
    }

    pub fn contains_message(&self, message_id: &str) -> bool {
        self.items.iter().any(|item| item.message_id == message_id)
    }

    pub fn upsert_item(
        &mut self,
        message_id: &str,
        summary: Option<&str>,
        added_rfc3339: &str,
    ) -> bool {
        let summary = clean_summary(summary);
        if let Some(item) = self
            .items
            .iter_mut()
            .find(|item| item.message_id == message_id)
        {
            if summary.is_some() {
                item.summary = summary;
            }
            return false;
        }
        self.items.push(MessageCollectionItem {
            message_id: message_id.to_string(),
            summary,
            added_rfc3339: added_rfc3339.to_string(),
        });
        self.message_count = self.items.len();
        true
    }

    pub fn merge_items(&mut self, items: &[MessageCollectionItem]) {
        for item in items {
            if self.contains_message(&item.message_id) {
                continue;
            }
            self.items.push(item.clone());
        }
        self.message_count = self.items.len();
    }

    pub fn normalize(&mut self, schema_name: &str, collection_uid: &str, collection_name: &str) {
        self.schema_name = schema_name.to_string();
        self.schema_version = MESSAGE_COLLECTION_SCHEMA_VERSION;
        self.collection_uid = collection_uid.to_string();
        self.collection_name = collection_name.to_string();
        let mut seen = Vec::new();
        self.items.retain(|item| {
            if seen.iter().any(|message_id| message_id == &item.message_id) {
                false
            } else {
                seen.push(item.message_id.clone());
                true
            }
        });
        self.message_count = self.items.len();
    }
}

fn clean_summary(summary: Option<&str>) -> Option<String> {
    summary
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
}