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    /// Contact association materialized from the sender address at
153    /// render/materialize time. Read-only on `message show`; refreshed when a
154    /// contact's emails or name change, or by `afmail render refresh`.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub contact: Option<MessageContact>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub identity: Option<String>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub identity_email: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub identity_match: Option<String>,
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub identity_candidates: Vec<String>,
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub observed_recipient_emails: Vec<String>,
167    pub workspace: WorkspaceState,
168}
169
170/// Snapshot of the contact whose email set includes this message's sender.
171#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
172#[serde(deny_unknown_fields)]
173pub struct MessageContact {
174    pub contact_uid: String,
175    pub display_name: String,
176}
177
178/// Result of a single email authentication mechanism (SPF / DKIM / DMARC).
179///
180/// `Missing` means no `Authentication-Results` entry for the mechanism was
181/// present at all — which is a distinct, and arguably more suspicious, state
182/// than an explicit `none`.
183#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Default)]
184#[serde(rename_all = "snake_case")]
185pub enum AuthVerdict {
186    Pass,
187    Fail,
188    SoftFail,
189    Neutral,
190    None,
191    TempError,
192    PermError,
193    #[default]
194    Missing,
195}
196
197impl AuthVerdict {
198    pub fn as_str(self) -> &'static str {
199        match self {
200            AuthVerdict::Pass => "pass",
201            AuthVerdict::Fail => "fail",
202            AuthVerdict::SoftFail => "softfail",
203            AuthVerdict::Neutral => "neutral",
204            AuthVerdict::None => "none",
205            AuthVerdict::TempError => "temperror",
206            AuthVerdict::PermError => "permerror",
207            AuthVerdict::Missing => "missing",
208        }
209    }
210}
211
212/// Whether the DMARC-authenticated domain lines up with the visible `From`
213/// domain. `Unknown` when there is nothing authenticated to compare against.
214#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Default)]
215#[serde(rename_all = "snake_case")]
216pub enum AuthAlignment {
217    Aligned,
218    Mismatch,
219    #[default]
220    Unknown,
221}
222
223impl AuthAlignment {
224    pub fn as_str(self) -> &'static str {
225        match self {
226            AuthAlignment::Aligned => "aligned",
227            AuthAlignment::Mismatch => "mismatch",
228            AuthAlignment::Unknown => "unknown",
229        }
230    }
231}
232
233/// Structured view of a message's `Authentication-Results` headers.
234///
235/// afmail does not classify mail; this only reports what the receiving server
236/// asserted (which domain authenticated, and whether it aligns with `From`).
237/// Pass authenticates the *domain*, not the legitimacy of the contents.
238#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Default)]
239#[serde(deny_unknown_fields)]
240pub struct MessageAuthentication {
241    #[serde(default)]
242    pub spf: AuthVerdict,
243    #[serde(default)]
244    pub dkim: AuthVerdict,
245    #[serde(default)]
246    pub dmarc: AuthVerdict,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub dmarc_policy: Option<String>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub authenticated_domain: Option<String>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub from_domain: Option<String>,
253    #[serde(default)]
254    pub alignment: AuthAlignment,
255    #[serde(default, skip_serializing_if = "Vec::is_empty")]
256    pub raw: Vec<String>,
257}
258
259impl MessageAuthentication {
260    /// Whether any `Authentication-Results` header was present on the message.
261    pub fn has_results(&self) -> bool {
262        !self.raw.is_empty()
263    }
264
265    /// Whether the result should be surfaced in a warning tone: a hard failure,
266    /// a permanent error, or a domain that authenticated but does not align
267    /// with the visible `From`.
268    pub fn is_warning(&self) -> bool {
269        let hard = [self.spf, self.dkim, self.dmarc]
270            .into_iter()
271            .any(|v| matches!(v, AuthVerdict::Fail | AuthVerdict::PermError));
272        let aligned_failure = self.alignment == AuthAlignment::Mismatch
273            && [self.spf, self.dkim, self.dmarc]
274                .into_iter()
275                .any(|v| v == AuthVerdict::Pass);
276        hard || aligned_failure
277    }
278}
279
280#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
281#[serde(deny_unknown_fields)]
282pub struct ImapRef {
283    pub mailbox_name: String,
284    pub uid_validity: u64,
285    pub uid: u64,
286}
287
288#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
289#[serde(deny_unknown_fields)]
290pub struct RemoteState {
291    #[serde(default, skip_serializing_if = "Vec::is_empty")]
292    pub locations: Vec<RemoteLocation>,
293}
294
295#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
296#[serde(deny_unknown_fields)]
297pub struct RemoteLocation {
298    pub mailbox_name: String,
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub mailbox_id: Option<String>,
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub uid_validity: Option<u64>,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub uid: Option<u64>,
305    #[serde(default, skip_serializing_if = "Vec::is_empty")]
306    pub flags: Vec<String>,
307    pub observed_rfc3339: String,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub missing_rfc3339: Option<String>,
310}
311
312#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
313#[serde(deny_unknown_fields)]
314pub struct AttachmentRef {
315    pub part_id: String,
316    pub filename: String,
317    pub content_type: String,
318    pub size_bytes: u64,
319    #[serde(default)]
320    pub fetched: bool,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub file_path: Option<String>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub source_path: Option<String>,
325}
326
327#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
328#[serde(deny_unknown_fields)]
329pub struct WorkspaceState {
330    pub status: String,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub archive_uid: Option<String>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub archived_rfc3339: Option<String>,
335    /// Provenance tag for messages whose archived status comes from a remote
336    /// source rather than an explicit local disposition (e.g. "imap-archive").
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub origin: Option<String>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub remote_sync: Option<RemoteSyncState>,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub push: Option<WorkspacePushState>,
343}
344
345#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
346#[serde(deny_unknown_fields)]
347pub struct RemoteSyncState {
348    pub archive_eligible: bool,
349    pub checked_rfc3339: String,
350}
351
352#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
353#[serde(deny_unknown_fields)]
354pub struct WorkspacePushState {
355    #[serde(default, skip_serializing_if = "Vec::is_empty")]
356    pub pending: Vec<WorkspacePendingPush>,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub last_completed_rfc3339: Option<String>,
359}
360
361#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
362#[serde(deny_unknown_fields)]
363pub struct WorkspacePendingPush {
364    pub push_id: String,
365    pub kind: String,
366    pub queued_rfc3339: String,
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub last_error: Option<String>,
369}