Skip to main content

agent_first_mail/
frontmatter.rs

1//! Typed metadata for markdown frontmatter and adjacent JSON state.
2//!
3//! Draft and triage markdown use `markdown::read_doc::<T>` and
4//! `markdown::render_frontmatter`; cases store the same typed metadata in
5//! `data/case.json`. Body content (conversation blocks, notes sections) is never
6//! modeled here; it stays raw markdown.
7
8use serde::{Deserialize, Deserializer, Serialize};
9
10pub use crate::types::MessageCollection as CaseFrontmatter;
11
12/// Deserialize a sequence that may be written as YAML `null` (a bare `key:`
13/// with no value) into an empty `Vec`. Agents hand-write drafts and frequently
14/// leave `attachments:` / `cc:` empty.
15fn de_null_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
16where
17    D: Deserializer<'de>,
18{
19    Ok(Option::<Vec<String>>::deserialize(deserializer)?.unwrap_or_default())
20}
21
22/// Frontmatter of an agent-authored `drafts/*.md`. Shared by draft validation
23/// and outbound message building so both agree on one schema.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
25pub struct DraftFrontmatter {
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub kind: Option<String>,
28    pub case_uid: String,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub send_intent: Option<String>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub reply_to_message_id: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub identity: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub subject: Option<String>,
37    #[serde(
38        default,
39        deserialize_with = "de_null_seq",
40        skip_serializing_if = "Vec::is_empty"
41    )]
42    pub to: Vec<String>,
43    #[serde(
44        default,
45        deserialize_with = "de_null_seq",
46        skip_serializing_if = "Vec::is_empty"
47    )]
48    pub cc: Vec<String>,
49    #[serde(
50        default,
51        deserialize_with = "de_null_seq",
52        skip_serializing_if = "Vec::is_empty"
53    )]
54    pub attachments: Vec<String>,
55}
56
57/// Optional `identities/<identity>.md` persona override.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
59#[serde(deny_unknown_fields)]
60pub struct IdentityFileFrontmatter {
61    pub kind: String,
62    pub identity: String,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub name: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub footer: Option<String>,
67}
68
69/// Frontmatter of a generated `triage/message_*.md` view.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
71pub struct TriageFrontmatter {
72    pub kind: String,
73    pub message_id: String,
74    #[serde(
75        default,
76        deserialize_with = "de_null_seq",
77        skip_serializing_if = "Vec::is_empty"
78    )]
79    pub message_ids: Vec<String>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub generated_rfc3339: Option<String>,
82    #[serde(default)]
83    pub message_count: usize,
84    #[serde(default)]
85    pub attachment_count: usize,
86    #[serde(
87        default,
88        deserialize_with = "de_null_seq",
89        skip_serializing_if = "Vec::is_empty"
90    )]
91    pub suggested_case_uids: Vec<String>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub suggested_reason: Option<String>,
94}
95
96/// Frontmatter of generated `cases/<group>/<case-uid>/views/messages/<message-id>.md`.
97#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
98pub struct CaseMessageFrontmatter {
99    pub kind: String,
100    pub case_uid: String,
101    pub message_id: String,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub generated_rfc3339: Option<String>,
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::markdown::{read_doc, render_frontmatter};
110
111    #[test]
112    fn draft_tolerates_empty_arrays() {
113        let text = "---\nkind: draft\ncase_uid: c20260521001\nsubject: Hello\nto:\n  - a@example.com\ncc:\nattachments:\n---\nbody";
114        let parsed = read_doc::<DraftFrontmatter>(text);
115        assert!(parsed.is_ok());
116        if let Ok((fm, body)) = parsed {
117            assert_eq!(fm.case_uid, "c20260521001");
118            assert_eq!(fm.subject.as_deref(), Some("Hello"));
119            assert_eq!(fm.to, vec!["a@example.com".to_string()]);
120            assert!(fm.cc.is_empty());
121            assert!(fm.attachments.is_empty());
122            assert_eq!(body.trim(), "body");
123        }
124    }
125
126    #[test]
127    fn case_round_trips_through_render() {
128        let mut fm = CaseFrontmatter::new_case("c20260521001", "Acme", "2026-05-30T00:00:00Z");
129        fm.tags = vec!["legal".to_string()];
130        fm.attachment_count = 1;
131        let body = "\n# Title\n\n<!-- afmail:conversation:start -->\nhi\n<!-- afmail:conversation:end -->\n";
132        let rendered = render_frontmatter(&fm, body);
133        assert!(rendered.is_ok());
134        if let Ok(rendered) = rendered {
135            let reparsed = read_doc::<CaseFrontmatter>(&rendered);
136            assert!(reparsed.is_ok());
137            if let Ok((parsed, parsed_body)) = reparsed {
138                assert_eq!(parsed, fm);
139                assert_eq!(parsed_body, body.trim_start());
140            }
141        }
142    }
143
144    #[test]
145    fn case_collection_uses_shared_schema() {
146        let fm = CaseFrontmatter::new_case("c20260530001", "Bare", "2026-05-30T00:00:00Z");
147        assert_eq!(fm.schema_name, crate::types::CASE_SCHEMA_NAME);
148        assert_eq!(fm.status, "active");
149    }
150}