Skip to main content

agent_first_mail/config/
identity.rs

1use super::validation::validate_config_id;
2use crate::error::{AppError, Result};
3use crate::frontmatter::IdentityFileFrontmatter;
4use crate::markdown::read_doc;
5use crate::types::MessageFile;
6use lettre::address::Address;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9use std::fs;
10use std::path::Path;
11
12#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
13#[serde(deny_unknown_fields)]
14pub struct IdentityConfig {
15    pub identity: String,
16    pub name: String,
17    pub email: String,
18    #[serde(default)]
19    pub default: bool,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
23pub struct ResolvedIdentity {
24    pub identity: String,
25    pub name: String,
26    pub email: String,
27    pub default: bool,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub footer: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub notes: Option<String>,
32}
33
34#[derive(Clone, Debug, Default, PartialEq, Eq)]
35pub struct MessageIdentityMatch {
36    pub identity: Option<String>,
37    pub identity_email: Option<String>,
38    pub identity_match: String,
39    pub identity_candidates: Vec<String>,
40    pub observed_recipient_emails: Vec<String>,
41}
42
43#[derive(Clone, Debug)]
44struct RecipientObservation {
45    email: String,
46    display_name: Option<String>,
47}
48
49#[derive(Clone, Debug)]
50struct IdentityFile {
51    identity: String,
52    name: Option<String>,
53    footer: Option<String>,
54    notes: Option<String>,
55}
56
57impl IdentityConfig {
58    pub(super) fn validate(&self) -> Result<()> {
59        validate_config_id("identity slug", &self.identity)
60            .map_err(|err| AppError::new("config_invalid", err.message))?;
61        if self.name.trim().is_empty() {
62            return Err(AppError::new(
63                "config_invalid",
64                format!("identities.{} name is required", self.identity),
65            ));
66        }
67        validate_identity_email(&self.identity, &self.email)
68    }
69}
70
71impl super::MailConfig {
72    pub(super) fn validate_identities(&self) -> Result<()> {
73        if self.identities.is_empty() {
74            return Err(AppError::new(
75                "config_invalid",
76                "identities must contain at least one workspace identity",
77            ));
78        }
79        let mut seen = BTreeSet::new();
80        let mut default_count = 0usize;
81        for identity in &self.identities {
82            identity.validate()?;
83            if !seen.insert(identity.identity.clone()) {
84                return Err(AppError::new(
85                    "config_invalid",
86                    format!("duplicate identity slug: {}", identity.identity),
87                ));
88            }
89            if identity.default {
90                default_count += 1;
91            }
92        }
93        if default_count != 1 {
94            return Err(AppError::new(
95                "config_invalid",
96                "identities must contain exactly one default identity",
97            ));
98        }
99        Ok(())
100    }
101
102    pub(super) fn validate_identity_files(&self, workspace_root: &Path) -> Result<()> {
103        self.load_identity_files(workspace_root).map(|_| ())
104    }
105
106    pub fn default_identity(&self) -> Result<&IdentityConfig> {
107        self.identities
108            .iter()
109            .find(|identity| identity.default)
110            .ok_or_else(|| AppError::new("config_invalid", "no default identity configured"))
111    }
112
113    pub fn identity_emails(&self) -> Vec<String> {
114        let mut out = Vec::new();
115        for identity in &self.identities {
116            let email = normalize_email(&identity.email);
117            if !email.is_empty() && !out.iter().any(|existing| existing == &email) {
118                out.push(email);
119            }
120        }
121        out
122    }
123
124    pub fn resolve_identity(
125        &self,
126        workspace_root: &Path,
127        identity: Option<&str>,
128    ) -> Result<ResolvedIdentity> {
129        let slug = identity
130            .map(str::trim)
131            .filter(|value| !value.is_empty())
132            .map(ToString::to_string)
133            .unwrap_or_else(|| {
134                self.default_identity()
135                    .map(|identity| identity.identity.clone())
136                    .unwrap_or_default()
137            });
138        let config = self
139            .identities
140            .iter()
141            .find(|candidate| candidate.identity == slug)
142            .ok_or_else(|| {
143                AppError::new("unknown_identity", format!("unknown identity: {slug}"))
144            })?;
145        let files = self.load_identity_files(workspace_root)?;
146        Ok(resolve_identity_from_file(
147            config,
148            files.get(&config.identity),
149        ))
150    }
151
152    pub fn identity_profiles(&self, workspace_root: &Path) -> Result<Vec<ResolvedIdentity>> {
153        let files = self.load_identity_files(workspace_root)?;
154        Ok(self
155            .identities
156            .iter()
157            .map(|identity| resolve_identity_from_file(identity, files.get(&identity.identity)))
158            .collect())
159    }
160
161    pub fn match_message_identity(
162        &self,
163        workspace_root: &Path,
164        message: &MessageFile,
165    ) -> Result<MessageIdentityMatch> {
166        Ok(match_message_identity(
167            &self.identity_profiles(workspace_root)?,
168            message,
169        ))
170    }
171
172    fn load_identity_files(&self, workspace_root: &Path) -> Result<BTreeMap<String, IdentityFile>> {
173        let dir = workspace_root.join("identities");
174        if !dir.exists() {
175            return Ok(BTreeMap::new());
176        }
177        if !dir.is_dir() {
178            return Err(AppError::new(
179                "config_invalid",
180                "identities path must be a directory",
181            ));
182        }
183        let configured = self
184            .identities
185            .iter()
186            .map(|identity| identity.identity.as_str())
187            .collect::<BTreeSet<_>>();
188        let mut paths = fs::read_dir(&dir)
189            .map_err(|e| AppError::io("read identities", &e))?
190            .map(|entry| entry.map(|entry| entry.path()))
191            .collect::<std::result::Result<Vec<_>, _>>()
192            .map_err(|e| AppError::io("read identities", &e))?;
193        paths.sort();
194        let mut files = BTreeMap::new();
195        for path in paths {
196            if path.extension().and_then(|value| value.to_str()) != Some("md") {
197                continue;
198            }
199            let file = read_identity_file(&path, &configured)?;
200            if files.insert(file.identity.clone(), file).is_some() {
201                return Err(AppError::new(
202                    "config_invalid",
203                    format!("duplicate identity file for {}", path.display()),
204                ));
205            }
206        }
207        Ok(files)
208    }
209}
210
211fn validate_identity_email(identity: &str, email: &str) -> Result<()> {
212    email.parse::<Address>().map(|_| ()).map_err(|e| {
213        AppError::new(
214            "config_invalid",
215            format!("invalid email for identity {identity}: {e}"),
216        )
217    })
218}
219
220fn read_identity_file(path: &Path, configured: &BTreeSet<&str>) -> Result<IdentityFile> {
221    let stem = path
222        .file_stem()
223        .and_then(|value| value.to_str())
224        .ok_or_else(|| {
225            AppError::new(
226                "config_invalid",
227                format!("invalid identity file name: {}", path.display()),
228            )
229        })?;
230    let text = fs::read_to_string(path).map_err(|e| AppError::io("read identity", &e))?;
231    let (frontmatter, body) = read_doc::<IdentityFileFrontmatter>(&text).map_err(|e| {
232        AppError::new(
233            "config_invalid",
234            format!("invalid identity file {}: {}", path.display(), e.message),
235        )
236    })?;
237    if frontmatter.kind != "identity" {
238        return Err(AppError::new(
239            "config_invalid",
240            format!("identity file {} kind must be identity", path.display()),
241        ));
242    }
243    if frontmatter.identity != stem {
244        return Err(AppError::new(
245            "config_invalid",
246            format!(
247                "identity file {} identity must match file stem {stem}",
248                path.display()
249            ),
250        ));
251    }
252    if !configured.contains(frontmatter.identity.as_str()) {
253        return Err(AppError::new(
254            "config_invalid",
255            format!(
256                "identity file {} references unknown identity {}",
257                path.display(),
258                frontmatter.identity
259            ),
260        ));
261    }
262    Ok(IdentityFile {
263        identity: frontmatter.identity,
264        name: frontmatter
265            .name
266            .map(|value| value.trim().to_string())
267            .filter(|value| !value.is_empty()),
268        footer: normalize_optional_block(frontmatter.footer.as_deref()),
269        notes: normalize_optional_block(Some(&body)),
270    })
271}
272
273fn resolve_identity_from_file(
274    identity: &IdentityConfig,
275    file: Option<&IdentityFile>,
276) -> ResolvedIdentity {
277    ResolvedIdentity {
278        identity: identity.identity.clone(),
279        name: file
280            .and_then(|file| file.name.clone())
281            .unwrap_or_else(|| identity.name.clone()),
282        email: identity.email.clone(),
283        default: identity.default,
284        footer: file.and_then(|file| file.footer.clone()),
285        notes: file.and_then(|file| file.notes.clone()),
286    }
287}
288
289fn match_message_identity(
290    profiles: &[ResolvedIdentity],
291    message: &MessageFile,
292) -> MessageIdentityMatch {
293    let observations = recipient_observations(message);
294    let observed_recipient_emails =
295        unique_emails(observations.iter().map(|obs| obs.email.as_str()));
296    let by_email = profiles_by_email(profiles);
297    for email in &observed_recipient_emails {
298        let Some(candidates) = by_email.get(email) else {
299            continue;
300        };
301        if candidates.len() == 1 {
302            let identity = candidates[0];
303            return MessageIdentityMatch {
304                identity: Some(identity.identity.clone()),
305                identity_email: Some(identity.email.clone()),
306                identity_match: "email".to_string(),
307                identity_candidates: Vec::new(),
308                observed_recipient_emails: Vec::new(),
309            };
310        }
311        let names = observations
312            .iter()
313            .filter(|obs| &obs.email == email)
314            .filter_map(|obs| obs.display_name.as_deref())
315            .map(normalize_display_name)
316            .filter(|name| !name.is_empty())
317            .collect::<BTreeSet<_>>();
318        let name_matches = candidates
319            .iter()
320            .filter(|identity| names.contains(&normalize_display_name(&identity.name)))
321            .map(|identity| (*identity).clone())
322            .collect::<Vec<_>>();
323        if name_matches.len() == 1 {
324            let identity = name_matches[0].clone();
325            return MessageIdentityMatch {
326                identity: Some(identity.identity),
327                identity_email: Some(identity.email),
328                identity_match: "name".to_string(),
329                identity_candidates: Vec::new(),
330                observed_recipient_emails: Vec::new(),
331            };
332        }
333        return MessageIdentityMatch {
334            identity: None,
335            identity_email: Some(candidates[0].email.clone()),
336            identity_match: "multiple".to_string(),
337            identity_candidates: candidates
338                .iter()
339                .map(|identity| identity.identity.clone())
340                .collect(),
341            observed_recipient_emails: Vec::new(),
342        };
343    }
344    MessageIdentityMatch {
345        identity: None,
346        identity_email: None,
347        identity_match: "unmatched".to_string(),
348        identity_candidates: Vec::new(),
349        observed_recipient_emails,
350    }
351}
352
353fn recipient_observations(message: &MessageFile) -> Vec<RecipientObservation> {
354    let mut out = Vec::new();
355    for value in message
356        .delivered_to
357        .iter()
358        .chain(message.x_original_to.iter())
359        .chain(message.envelope_to.iter())
360        .chain(message.to.iter())
361        .chain(message.cc.iter())
362    {
363        if let Some(obs) = parse_recipient_observation(value) {
364            out.push(obs);
365        }
366    }
367    out
368}
369
370fn parse_recipient_observation(value: &str) -> Option<RecipientObservation> {
371    let email = extract_email(value);
372    if email.is_empty() {
373        return None;
374    }
375    Some(RecipientObservation {
376        email,
377        display_name: extract_display_name(value),
378    })
379}
380
381fn extract_email(value: &str) -> String {
382    let trimmed = value.trim();
383    if let (Some(start), Some(end)) = (trimmed.rfind('<'), trimmed.rfind('>')) {
384        if start < end {
385            return normalize_email(&trimmed[start + 1..end]);
386        }
387    }
388    normalize_email(trimmed)
389}
390
391fn extract_display_name(value: &str) -> Option<String> {
392    let trimmed = value.trim();
393    let start = trimmed.rfind('<')?;
394    let name = trimmed[..start].trim().trim_matches('"').trim().to_string();
395    if name.is_empty() {
396        None
397    } else {
398        Some(name)
399    }
400}
401
402fn normalize_email(value: &str) -> String {
403    value.trim().to_ascii_lowercase()
404}
405
406fn normalize_display_name(value: &str) -> String {
407    value.trim().trim_matches('"').trim().to_ascii_lowercase()
408}
409
410fn normalize_optional_block(value: Option<&str>) -> Option<String> {
411    value
412        .map(|value| value.trim_matches('\n').trim_end().to_string())
413        .filter(|value| !value.trim().is_empty())
414}
415
416fn unique_emails<'a>(values: impl Iterator<Item = &'a str>) -> Vec<String> {
417    let mut seen = BTreeSet::new();
418    let mut out = Vec::new();
419    for value in values {
420        let normalized = normalize_email(value);
421        if !normalized.is_empty() && seen.insert(normalized.clone()) {
422            out.push(normalized);
423        }
424    }
425    out
426}
427
428fn profiles_by_email(profiles: &[ResolvedIdentity]) -> BTreeMap<String, Vec<&ResolvedIdentity>> {
429    let mut by_email: BTreeMap<String, Vec<&ResolvedIdentity>> = BTreeMap::new();
430    for identity in profiles {
431        by_email
432            .entry(normalize_email(&identity.email))
433            .or_default()
434            .push(identity);
435    }
436    by_email
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::types::{MessageAuthentication, RemoteState, WorkspaceState};
443    use std::time::{SystemTime, UNIX_EPOCH};
444
445    fn temp_root(name: &str) -> std::path::PathBuf {
446        let stamp = SystemTime::now()
447            .duration_since(UNIX_EPOCH)
448            .map(|d| d.as_nanos())
449            .unwrap_or(0);
450        std::env::temp_dir().join(format!("afmail-identity-{name}-{stamp}"))
451    }
452
453    fn message(to: Vec<String>, delivered_to: Vec<String>) -> MessageFile {
454        MessageFile {
455            schema_name: "message".to_string(),
456            schema_version: 1,
457            message_id: "message_id".to_string(),
458            rfc822_message_id: None,
459            in_reply_to: None,
460            references: Vec::new(),
461            remote: None::<RemoteState>,
462            direction: Some("inbound".to_string()),
463            subject: None,
464            from: Some("sender@example.com".to_string()),
465            to,
466            cc: Vec::new(),
467            bcc: Vec::new(),
468            reply_to: Vec::new(),
469            sender: None,
470            delivered_to,
471            x_original_to: Vec::new(),
472            envelope_to: Vec::new(),
473            list_id: None,
474            mailing_list_headers: Vec::new(),
475            authentication: MessageAuthentication::default(),
476            received_rfc3339: None,
477            sent_rfc3339: None,
478            body_text: String::new(),
479            eml_path: None,
480            attachments: Vec::new(),
481            contact: None,
482            identity: None,
483            identity_email: None,
484            identity_match: None,
485            identity_candidates: Vec::new(),
486            observed_recipient_emails: Vec::new(),
487            workspace: WorkspaceState {
488                status: "triage".to_string(),
489                archive_uid: None,
490                archived_rfc3339: None,
491                origin: None,
492                remote_sync: None,
493                push: None,
494            },
495        }
496    }
497
498    fn profiles() -> Vec<ResolvedIdentity> {
499        vec![
500            ResolvedIdentity {
501                identity: "support".to_string(),
502                name: "Support Team".to_string(),
503                email: "hello@example.com".to_string(),
504                default: true,
505                footer: None,
506                notes: None,
507            },
508            ResolvedIdentity {
509                identity: "sales".to_string(),
510                name: "Sales Team".to_string(),
511                email: "hello@example.com".to_string(),
512                default: false,
513                footer: None,
514                notes: None,
515            },
516        ]
517    }
518
519    #[test]
520    fn same_email_matches_display_name() {
521        let result = match_message_identity(
522            &profiles(),
523            &message(
524                vec!["Support Team <hello@example.com>".to_string()],
525                Vec::new(),
526            ),
527        );
528        assert_eq!(result.identity.as_deref(), Some("support"));
529        assert_eq!(result.identity_match, "name");
530    }
531
532    #[test]
533    fn same_email_without_name_is_multiple() {
534        let result = match_message_identity(
535            &profiles(),
536            &message(vec!["hello@example.com".to_string()], Vec::new()),
537        );
538        assert_eq!(result.identity_match, "multiple");
539        assert_eq!(result.identity_candidates, vec!["support", "sales"]);
540    }
541
542    #[test]
543    fn unknown_email_is_unmatched_with_observed_recipients() {
544        let result = match_message_identity(
545            &profiles(),
546            &message(Vec::new(), vec!["other@example.com".to_string()]),
547        );
548        assert_eq!(result.identity_match, "unmatched");
549        assert_eq!(result.observed_recipient_emails, vec!["other@example.com"]);
550    }
551
552    #[test]
553    fn identity_file_enriches_without_overriding_address() {
554        let root = temp_root("file");
555        let _ = fs::create_dir_all(root.join("identities"));
556        let _ = fs::write(
557            root.join("identities/support.md"),
558            "---\nkind: identity\nidentity: support\nname: Help Desk\nfooter: |\n  --\n  Help Desk\n---\nUse for support mail.\n",
559        );
560        let config = super::super::MailConfig {
561            identities: vec![IdentityConfig {
562                identity: "support".to_string(),
563                name: "Support Team".to_string(),
564                email: "hello@example.com".to_string(),
565                default: true,
566            }],
567            ..super::super::MailConfig::default()
568        };
569        let resolved = config.resolve_identity(&root, Some("support"));
570        assert!(resolved.is_ok());
571        if let Ok(identity) = resolved {
572            assert_eq!(identity.name, "Help Desk");
573            assert_eq!(identity.email, "hello@example.com");
574            assert_eq!(identity.footer.as_deref(), Some("--\nHelp Desk"));
575            assert_eq!(identity.notes.as_deref(), Some("Use for support mail."));
576        }
577        let _ = fs::remove_dir_all(root);
578    }
579}