1use crate::extracted::ExtractedEntities;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Email {
11 pub message_id: MessageId,
13
14 pub uid: u32,
16
17 pub from: EmailAddress,
19
20 pub to: Vec<EmailAddress>,
22
23 pub cc: Vec<EmailAddress>,
25
26 pub bcc: Vec<EmailAddress>,
28
29 pub reply_to: Option<EmailAddress>,
31
32 pub subject: Subject,
34
35 pub body: Body,
37
38 pub date: DateTime<Utc>,
40
41 pub headers: Headers,
43
44 pub thread: ThreadInfo,
46
47 pub extracted: ExtractedEntities,
49
50 pub metadata: EmailMetadata,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
56pub struct MessageId(pub String);
57
58impl MessageId {
59 pub fn new(id: impl Into<String>) -> Self {
60 Self(id.into())
61 }
62
63 #[must_use]
65 pub fn synthetic(uid: u32) -> Self {
66 Self(format!("<synthetic-{uid}@local>"))
67 }
68
69 #[must_use]
70 pub fn as_str(&self) -> &str {
71 &self.0
72 }
73}
74
75impl fmt::Display for MessageId {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 write!(f, "{}", self.0)
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83pub struct EmailAddress {
84 pub name: Option<PersonName>,
86
87 pub address: String,
89
90 pub domain: String,
92
93 pub local_part: String,
95}
96
97impl EmailAddress {
98 #[must_use]
100 pub fn parse(s: &str) -> Option<Self> {
101 let s = s.trim();
102
103 if let Some(start) = s.find('<')
105 && let Some(end) = s.find('>')
106 {
107 let name_part = s[..start].trim().trim_matches('"');
108 let address = s[start + 1..end].trim().to_string();
109
110 if let Some((local, domain)) = address.split_once('@') {
111 return Some(Self {
112 name: if name_part.is_empty() {
113 None
114 } else {
115 Some(PersonName::parse(name_part))
116 },
117 local_part: local.to_string(),
118 domain: domain.to_string(),
119 address,
120 });
121 }
122 }
123
124 if let Some((local, domain)) = s.split_once('@') {
126 return Some(Self {
127 name: None,
128 local_part: local.to_string(),
129 domain: domain.to_string(),
130 address: s.to_string(),
131 });
132 }
133
134 None
135 }
136
137 #[must_use]
139 pub fn is_noreply(&self) -> bool {
140 let lower = self.local_part.to_lowercase();
141 lower.contains("noreply")
142 || lower.contains("no-reply")
143 || lower.contains("donotreply")
144 || lower.contains("automated")
145 || lower.contains("mailer-daemon")
146 }
147
148 #[must_use]
150 pub fn is_freemail(&self) -> bool {
151 let domain = self.domain.to_lowercase();
152 matches!(
153 domain.as_str(),
154 "gmail.com"
155 | "yahoo.com"
156 | "outlook.com"
157 | "hotmail.com"
158 | "protonmail.com"
159 | "proton.me"
160 | "icloud.com"
161 | "aol.com"
162 )
163 }
164}
165
166impl fmt::Display for EmailAddress {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 match &self.name {
169 Some(name) => write!(f, "{} <{}>", name, self.address),
170 None => write!(f, "{}", self.address),
171 }
172 }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
177pub struct PersonName {
178 pub full: String,
180
181 pub first: Option<String>,
183
184 pub last: Option<String>,
186}
187
188impl PersonName {
189 #[must_use]
191 pub fn parse(s: &str) -> Self {
192 let s = s.trim().trim_matches('"');
193 let parts: Vec<&str> = s.split_whitespace().collect();
194
195 match parts.len() {
196 0 => Self {
197 full: String::new(),
198 first: None,
199 last: None,
200 },
201 1 => Self {
202 full: parts[0].to_string(),
203 first: Some(parts[0].to_string()),
204 last: None,
205 },
206 _ => Self {
207 full: s.to_string(),
208 first: Some(parts[0].to_string()),
209 last: Some(parts[parts.len() - 1].to_string()),
210 },
211 }
212 }
213}
214
215impl fmt::Display for PersonName {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 write!(f, "{}", self.full)
218 }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct Subject {
224 pub original: String,
226
227 pub normalized: String,
229
230 pub reply_depth: u32,
232
233 pub is_forward: bool,
235
236 pub language: Option<String>,
238}
239
240impl Subject {
241 #[must_use]
243 pub fn parse(s: &str) -> Self {
244 let mut normalized = s.to_string();
245 let mut reply_depth = 0;
246 let mut is_forward = false;
247
248 loop {
250 let lower = normalized.to_lowercase();
251 if lower.starts_with("re:") {
252 normalized = normalized[3..].trim_start().to_string();
253 reply_depth += 1;
254 } else if lower.starts_with("re[") {
255 if let Some(end) = normalized.find("]:") {
257 if let Ok(count) = normalized[3..end].parse::<u32>() {
258 reply_depth += count;
259 }
260 normalized = normalized[end + 2..].trim_start().to_string();
261 } else {
262 break;
263 }
264 } else {
265 break;
266 }
267 }
268
269 let lower = normalized.to_lowercase();
271 if lower.starts_with("fwd:") || lower.starts_with("fw:") {
272 is_forward = true;
273 normalized = normalized
274 .trim_start_matches(|c| {
275 c == 'F' || c == 'f' || c == 'w' || c == 'W' || c == 'd' || c == 'D' || c == ':'
276 })
277 .trim_start()
278 .to_string();
279 }
280
281 Self {
282 original: s.to_string(),
283 normalized,
284 reply_depth,
285 is_forward,
286 language: None, }
288 }
289}
290
291impl fmt::Display for Subject {
292 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 write!(f, "{}", self.original)
294 }
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct Body {
300 pub text: String,
302
303 pub html: Option<String>,
305
306 pub text_from_html: Option<String>,
308
309 pub word_count: usize,
311
312 pub char_count: usize,
314
315 pub line_count: usize,
317
318 pub language: Option<String>,
320
321 pub has_attachments: bool,
323
324 pub signature: Option<String>,
326
327 pub content_without_signature: String,
329}
330
331impl Body {
332 #[must_use]
334 pub fn is_empty(&self) -> bool {
335 self.text.trim().is_empty() && self.html.is_none()
336 }
337
338 #[must_use]
340 pub fn best_text(&self) -> &str {
341 if !self.text.is_empty() {
342 &self.text
343 } else if let Some(ref html_text) = self.text_from_html {
344 html_text
345 } else {
346 ""
347 }
348 }
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct Headers {
354 pub all: Vec<(String, String)>,
356
357 pub content_type: Option<String>,
359
360 pub mailer: Option<String>,
362
363 pub priority: Option<Priority>,
365
366 pub list_unsubscribe: Option<String>,
368
369 pub authentication: AuthenticationResults,
371
372 pub custom: Vec<(String, String)>,
374}
375
376#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
378pub enum Priority {
379 Highest,
380 High,
381 Normal,
382 Low,
383 Lowest,
384}
385
386impl Priority {
387 #[must_use]
388 pub fn from_header(value: &str) -> Self {
389 match value.trim() {
390 "1" => Self::Highest,
391 "2" => Self::High,
392 "4" => Self::Low,
393 "5" => Self::Lowest,
394 _ => Self::Normal,
395 }
396 }
397}
398
399#[derive(Debug, Clone, Default, Serialize, Deserialize)]
401pub struct AuthenticationResults {
402 pub spf: Option<AuthResult>,
404
405 pub dkim: Option<AuthResult>,
407
408 pub dmarc: Option<AuthResult>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
414pub enum AuthResult {
415 Pass,
416 Fail,
417 Neutral,
418 None,
419 Unknown(String),
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct ThreadInfo {
425 pub in_reply_to: Option<MessageId>,
427
428 pub references: Vec<MessageId>,
430
431 pub is_reply: bool,
433
434 pub thread_position: u32,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct EmailMetadata {
441 pub spam_score: f32,
443
444 pub spam_indicators: Vec<SpamIndicator>,
446
447 pub urgency: Urgency,
449
450 pub category_hints: Vec<CategoryHint>,
452
453 pub is_automated: bool,
455
456 pub is_mailing_list: bool,
458
459 pub sentiment: Sentiment,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct SpamIndicator {
466 pub indicator: String,
467 pub weight: f32,
468}
469
470#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
472pub enum Urgency {
473 Critical,
474 High,
475 Normal,
476 Low,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct CategoryHint {
482 pub category: String,
483 pub confidence: f32,
484 pub reason: String,
485}
486
487#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
489pub enum Sentiment {
490 Positive,
491 Negative,
492 #[default]
493 Neutral,
494 Mixed,
495}