Skip to main content

agent_first_mail/types/
message.rs

1use crate::error::{AppError, Result};
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
6pub enum MessageStatus {
7    Triage,
8    Case,
9    Archived,
10    Spam,
11    Trashed,
12    DeletedRemote,
13    Sent,
14    Draft,
15    Flagged,
16    PushQueued,
17}
18
19impl MessageStatus {
20    pub fn parse(value: &str) -> Result<Self> {
21        match value.trim() {
22            "" | "triage" => Ok(Self::Triage),
23            "case" => Ok(Self::Case),
24            "archived" => Ok(Self::Archived),
25            "spam" => Ok(Self::Spam),
26            "trashed" => Ok(Self::Trashed),
27            "deleted_remote" => Ok(Self::DeletedRemote),
28            "sent" => Ok(Self::Sent),
29            "draft" => Ok(Self::Draft),
30            "flagged" => Ok(Self::Flagged),
31            "push_queued" => Ok(Self::PushQueued),
32            other => Err(AppError::new(
33                "message_status_invalid",
34                format!("unsupported message workspace status: {other}"),
35            )),
36        }
37    }
38
39    pub fn as_str(self) -> &'static str {
40        match self {
41            Self::Triage => "triage",
42            Self::Case => "case",
43            Self::Archived => "archived",
44            Self::Spam => "spam",
45            Self::Trashed => "trashed",
46            Self::DeletedRemote => "deleted_remote",
47            Self::Sent => "sent",
48            Self::Draft => "draft",
49            Self::Flagged => "flagged",
50            Self::PushQueued => "push_queued",
51        }
52    }
53
54    pub fn is_terminal_local(self) -> bool {
55        matches!(
56            self,
57            Self::Spam
58                | Self::Trashed
59                | Self::DeletedRemote
60                | Self::Sent
61                | Self::Draft
62                | Self::Flagged
63                | Self::PushQueued
64        )
65    }
66}
67
68impl fmt::Display for MessageStatus {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(self.as_str())
71    }
72}
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
75pub enum MailDirection {
76    Inbound,
77    Outbound,
78}
79
80impl MailDirection {
81    pub fn parse(value: &str) -> Result<Self> {
82        match value.trim().to_ascii_lowercase().as_str() {
83            "inbound" | "received" => Ok(Self::Inbound),
84            "outbound" | "sent" => Ok(Self::Outbound),
85            other => Err(AppError::new(
86                "mail_direction_invalid",
87                format!("unsupported mail direction: {other}"),
88            )),
89        }
90    }
91
92    pub fn as_str(self) -> &'static str {
93        match self {
94            Self::Inbound => "inbound",
95            Self::Outbound => "outbound",
96        }
97    }
98}
99
100#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
101#[serde(deny_unknown_fields)]
102pub struct MessageFile {
103    pub schema_name: String,
104    pub schema_version: u64,
105    pub message_id: String,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub rfc822_message_id: Option<String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub in_reply_to: Option<String>,
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub references: Vec<String>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub remote: Option<RemoteState>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub direction: Option<String>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub subject: Option<String>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub from: Option<String>,
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub to: Vec<String>,
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub cc: Vec<String>,
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub bcc: Vec<String>,
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub reply_to: Vec<String>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub sender: Option<String>,
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub delivered_to: Vec<String>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub x_original_to: Vec<String>,
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub envelope_to: Vec<String>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub list_id: Option<String>,
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub mailing_list_headers: Vec<String>,
140    #[serde(default)]
141    pub authentication: MessageAuthentication,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub received_rfc3339: Option<String>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub sent_rfc3339: Option<String>,
146    #[serde(default)]
147    pub body_text: String,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub eml_path: Option<String>,
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub attachments: Vec<AttachmentRef>,
152    pub workspace: WorkspaceState,
153}
154
155/// Result of a single email authentication mechanism (SPF / DKIM / DMARC).
156///
157/// `Missing` means no `Authentication-Results` entry for the mechanism was
158/// present at all — which is a distinct, and arguably more suspicious, state
159/// than an explicit `none`.
160#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Default)]
161#[serde(rename_all = "snake_case")]
162pub enum AuthVerdict {
163    Pass,
164    Fail,
165    SoftFail,
166    Neutral,
167    None,
168    TempError,
169    PermError,
170    #[default]
171    Missing,
172}
173
174impl AuthVerdict {
175    pub fn as_str(self) -> &'static str {
176        match self {
177            AuthVerdict::Pass => "pass",
178            AuthVerdict::Fail => "fail",
179            AuthVerdict::SoftFail => "softfail",
180            AuthVerdict::Neutral => "neutral",
181            AuthVerdict::None => "none",
182            AuthVerdict::TempError => "temperror",
183            AuthVerdict::PermError => "permerror",
184            AuthVerdict::Missing => "missing",
185        }
186    }
187}
188
189/// Whether the DMARC-authenticated domain lines up with the visible `From`
190/// domain. `Unknown` when there is nothing authenticated to compare against.
191#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Default)]
192#[serde(rename_all = "snake_case")]
193pub enum AuthAlignment {
194    Aligned,
195    Mismatch,
196    #[default]
197    Unknown,
198}
199
200impl AuthAlignment {
201    pub fn as_str(self) -> &'static str {
202        match self {
203            AuthAlignment::Aligned => "aligned",
204            AuthAlignment::Mismatch => "mismatch",
205            AuthAlignment::Unknown => "unknown",
206        }
207    }
208}
209
210/// Structured view of a message's `Authentication-Results` headers.
211///
212/// afmail does not classify mail; this only reports what the receiving server
213/// asserted (which domain authenticated, and whether it aligns with `From`).
214/// Pass authenticates the *domain*, not the legitimacy of the contents.
215#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)]
216#[serde(deny_unknown_fields)]
217pub struct MessageAuthentication {
218    #[serde(default)]
219    pub spf: AuthVerdict,
220    #[serde(default)]
221    pub dkim: AuthVerdict,
222    #[serde(default)]
223    pub dmarc: AuthVerdict,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub dmarc_policy: Option<String>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub authenticated_domain: Option<String>,
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub from_domain: Option<String>,
230    #[serde(default)]
231    pub alignment: AuthAlignment,
232    #[serde(default, skip_serializing_if = "Vec::is_empty")]
233    pub raw: Vec<String>,
234}
235
236impl MessageAuthentication {
237    /// Whether any `Authentication-Results` header was present on the message.
238    pub fn has_results(&self) -> bool {
239        !self.raw.is_empty()
240    }
241
242    /// Whether the result should be surfaced in a warning tone: a hard failure,
243    /// a permanent error, or a domain that authenticated but does not align
244    /// with the visible `From`.
245    pub fn is_warning(&self) -> bool {
246        let hard = [self.spf, self.dkim, self.dmarc]
247            .into_iter()
248            .any(|v| matches!(v, AuthVerdict::Fail | AuthVerdict::PermError));
249        let aligned_failure = self.alignment == AuthAlignment::Mismatch
250            && [self.spf, self.dkim, self.dmarc]
251                .into_iter()
252                .any(|v| v == AuthVerdict::Pass);
253        hard || aligned_failure
254    }
255}
256
257#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
258#[serde(deny_unknown_fields)]
259pub struct ImapRef {
260    pub mailbox_name: String,
261    pub uid_validity: u64,
262    pub uid: u64,
263}
264
265#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
266#[serde(deny_unknown_fields)]
267pub struct RemoteState {
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    pub locations: Vec<RemoteLocation>,
270}
271
272#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
273#[serde(deny_unknown_fields)]
274pub struct RemoteLocation {
275    pub mailbox_name: String,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub mailbox_id: Option<String>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub uid_validity: Option<u64>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub uid: Option<u64>,
282    #[serde(default, skip_serializing_if = "Vec::is_empty")]
283    pub flags: Vec<String>,
284    pub observed_rfc3339: String,
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub missing_rfc3339: Option<String>,
287}
288
289#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
290#[serde(deny_unknown_fields)]
291pub struct AttachmentRef {
292    pub part_id: String,
293    pub filename: String,
294    pub content_type: String,
295    pub size_bytes: u64,
296    #[serde(default)]
297    pub fetched: bool,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub file_path: Option<String>,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub source_path: Option<String>,
302}
303
304#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
305#[serde(deny_unknown_fields)]
306pub struct WorkspaceState {
307    pub status: String,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub archive_uid: Option<String>,
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub archived_rfc3339: Option<String>,
312    /// Provenance tag for messages whose archived status comes from a remote
313    /// source rather than an explicit local disposition (e.g. "imap-archive").
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub origin: Option<String>,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub remote_sync: Option<RemoteSyncState>,
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub push: Option<WorkspacePushState>,
320}
321
322#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
323#[serde(deny_unknown_fields)]
324pub struct RemoteSyncState {
325    pub archive_eligible: bool,
326    pub checked_rfc3339: String,
327}
328
329#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
330#[serde(deny_unknown_fields)]
331pub struct WorkspacePushState {
332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
333    pub pending: Vec<WorkspacePendingPush>,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub last_completed_rfc3339: Option<String>,
336}
337
338#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
339#[serde(deny_unknown_fields)]
340pub struct WorkspacePendingPush {
341    pub push_id: String,
342    pub kind: String,
343    pub queued_rfc3339: String,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub last_error: Option<String>,
346}