agent-first-mail 0.1.0

Give your AI agent a mailbox it can actually work in — your mail pulled down into plain files it reads, triages, drafts, and files entirely on your machine, with nothing sent or changed on the real mailbox until you confirm.
Documentation
use super::*;

#[derive(Clone, Copy, Debug)]
pub(super) struct DispositionViewSpec {
    status: MessageStatus,
    dir: &'static str,
    title_en: &'static str,
    title_zh: &'static str,
    label_en: &'static str,
    label_zh: &'static str,
    frontmatter_kind: &'static str,
}

const DISPOSITION_VIEW_SPECS: [DispositionViewSpec; 3] = [
    DispositionViewSpec {
        status: MessageStatus::Spam,
        dir: "spam",
        title_en: "Spam",
        title_zh: "垃圾邮件",
        label_en: "Spam",
        label_zh: "垃圾邮件",
        frontmatter_kind: "spam_message",
    },
    DispositionViewSpec {
        status: MessageStatus::Trashed,
        dir: "trash",
        title_en: "Trash",
        title_zh: "废弃邮件",
        label_en: "Trash",
        label_zh: "废弃邮件",
        frontmatter_kind: "trash_message",
    },
    DispositionViewSpec {
        status: MessageStatus::DeletedRemote,
        dir: "deleted",
        title_en: "Remote Deleted",
        title_zh: "远端已删除",
        label_en: "Remote deleted",
        label_zh: "远端已删除",
        frontmatter_kind: "deleted_message",
    },
];

impl DispositionViewSpec {
    fn title(self, language: TemplateLanguage) -> &'static str {
        match language {
            TemplateLanguage::EnUs => self.title_en,
            TemplateLanguage::ZhCn => self.title_zh,
        }
    }

    fn label(self, language: TemplateLanguage) -> &'static str {
        match language {
            TemplateLanguage::EnUs => self.label_en,
            TemplateLanguage::ZhCn => self.label_zh,
        }
    }
}

impl Workspace {
    pub(super) fn refresh_disposition_views(&self) -> Result<Value> {
        self.require_workspace()?;
        let config = MailConfig::load(&self.root)?;
        let language = config.template_language();
        let mut renderer = MarkdownTemplateRenderer::new(&self.root, language);
        let mut groups: BTreeMap<&'static str, Vec<MessageFile>> = BTreeMap::new();
        let mut desired: BTreeMap<&'static str, BTreeSet<String>> = BTreeMap::new();
        let mut written: BTreeMap<&'static str, usize> = BTreeMap::new();

        for spec in DISPOSITION_VIEW_SPECS {
            create_dir_all(&self.root.join(spec.dir))?;
            groups.insert(spec.dir, Vec::new());
            desired.insert(spec.dir, BTreeSet::new());
            written.insert(spec.dir, 0);
        }

        for path in message_json_paths(&self.root)? {
            let message = read_message(&path)?;
            let status = MessageStatus::parse(&message.workspace.status)?;
            let Some(spec) = disposition_spec_for_status(status) else {
                continue;
            };
            desired
                .entry(spec.dir)
                .or_default()
                .insert(message.message_id.clone());
            self.write_disposition_message_view_with_renderer(
                spec,
                &message,
                &config,
                &mut renderer,
            )?;
            *written.entry(spec.dir).or_default() += 1;
            groups.entry(spec.dir).or_default().push(message);
        }

        let mut stale_spam_removed_count = 0usize;
        let mut stale_trash_removed_count = 0usize;
        let mut stale_deleted_removed_count = 0usize;
        let mut index_written_count = 0usize;
        for spec in DISPOSITION_VIEW_SPECS {
            let desired = desired.get(spec.dir).cloned().unwrap_or_default();
            let stale_count = self.remove_stale_disposition_message_views(spec, &desired)?;
            match spec.status {
                MessageStatus::Spam => stale_spam_removed_count = stale_count,
                MessageStatus::Trashed => stale_trash_removed_count = stale_count,
                MessageStatus::DeletedRemote => stale_deleted_removed_count = stale_count,
                _ => {}
            }
            let mut messages = groups.remove(spec.dir).unwrap_or_default();
            self.write_disposition_index_with_renderer(
                spec,
                &mut messages,
                &config,
                &mut renderer,
            )?;
            index_written_count += 1;
        }

        let spam_written_count = written.get("spam").copied().unwrap_or_default();
        let trash_written_count = written.get("trash").copied().unwrap_or_default();
        let deleted_written_count = written.get("deleted").copied().unwrap_or_default();
        Ok(json!({
            "code": "disposition_views_refreshed",
            "spam_count": spam_written_count,
            "spam_written_count": spam_written_count,
            "stale_spam_removed_count": stale_spam_removed_count,
            "trash_count": trash_written_count,
            "trash_written_count": trash_written_count,
            "stale_trash_removed_count": stale_trash_removed_count,
            "deleted_count": deleted_written_count,
            "deleted_written_count": deleted_written_count,
            "stale_deleted_removed_count": stale_deleted_removed_count,
            "index_written_count": index_written_count,
            "message_written_count": spam_written_count + trash_written_count + deleted_written_count,
        }))
    }

    pub(super) fn write_disposition_message_view_with_renderer(
        &self,
        spec: DispositionViewSpec,
        message: &MessageFile,
        config: &MailConfig,
        renderer: &mut MarkdownTemplateRenderer<'_>,
    ) -> Result<()> {
        let path = self.disposition_message_view_path(spec, &message.message_id);
        let generated_rfc3339 = now_rfc3339();
        let conversation =
            self.message_conversation_with_renderer(message, config, renderer, path.parent())?;
        let context = json!({
            "frontmatter_kind": spec.frontmatter_kind,
            "language": config.resolved_language_bcp47(),
            "message_id": message.message_id.as_str(),
            "status": message.workspace.status.as_str(),
            "status_label": spec.label(config.template_language()),
            "title": message.subject.as_deref().unwrap_or(""),
            "generated_rfc3339": generated_rfc3339.as_str(),
            "conversation": conversation.trim(),
            "message": message_template_value(message)?,
        });
        let rendered = renderer.render(TemplateKey::StatusMessage, &context)?;
        write_string(&path, &rendered)
    }

    pub(super) fn write_disposition_index_with_renderer(
        &self,
        spec: DispositionViewSpec,
        messages: &mut [MessageFile],
        config: &MailConfig,
        renderer: &mut MarkdownTemplateRenderer<'_>,
    ) -> Result<()> {
        messages.sort_by(|a, b| compare_message_time_asc(b, a));
        let offset = config.resolved_timezone_offset();
        let language = config.template_language();
        let items = messages
            .iter()
            .map(|message| {
                let title = message
                    .subject
                    .as_deref()
                    .filter(|value| !value.trim().is_empty())
                    .unwrap_or(message.message_id.as_str())
                    .to_string();
                let mut item = thread_item_common(
                    message,
                    &offset,
                    language,
                    format!("{}.md", message.message_id),
                    title,
                )?;
                if let Value::Object(map) = &mut item {
                    map.insert("message".to_string(), message_template_value(message)?);
                }
                Ok(item)
            })
            .collect::<Result<Vec<_>>>()?;
        let generated_rfc3339 = now_rfc3339();
        let context = json!({
            "frontmatter_kind": format!("{}_index", spec.frontmatter_kind),
            "language": config.resolved_language_bcp47(),
            "status": spec.status.as_str(),
            "status_label": spec.label(language),
            "status_dir": spec.dir,
            "title": spec.title(language),
            "generated_rfc3339": generated_rfc3339.as_str(),
            "message_count": items.len(),
            "items": items,
        });
        let rendered = renderer.render(TemplateKey::StatusIndex, &context)?;
        write_string(&self.root.join(spec.dir).join("index.md"), &rendered)
    }

    pub(super) fn remove_stale_disposition_message_views(
        &self,
        spec: DispositionViewSpec,
        desired: &BTreeSet<String>,
    ) -> Result<usize> {
        let dir = self.root.join(spec.dir);
        if !dir.exists() {
            return Ok(0);
        }
        let mut removed = 0usize;
        for entry in read_dir(&dir, "read disposition view directory")? {
            let path = entry.path();
            if path.extension().and_then(|s| s.to_str()) != Some("md") {
                continue;
            }
            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
                continue;
            };
            if stem == "index" {
                continue;
            }
            if !desired.contains(stem) {
                remove_file(&path)?;
                removed += 1;
            }
        }
        Ok(removed)
    }

    pub(super) fn disposition_message_view_path(
        &self,
        spec: DispositionViewSpec,
        message_id: &str,
    ) -> PathBuf {
        self.root.join(spec.dir).join(format!("{message_id}.md"))
    }
}

pub(super) fn disposition_spec_for_status(status: MessageStatus) -> Option<DispositionViewSpec> {
    DISPOSITION_VIEW_SPECS
        .iter()
        .copied()
        .find(|spec| spec.status == status)
}