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}