Skip to main content

agent_first_mail/config/
actions.rs

1use super::validation::{validate_config_id, validate_steps};
2use super::MailConfig;
3use crate::error::{AppError, Result};
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6
7#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
8#[serde(rename_all = "snake_case")]
9pub enum PullImportAs {
10    #[default]
11    Triage,
12    Spam,
13    Trashed,
14}
15
16impl PullImportAs {
17    pub fn as_str(self) -> &'static str {
18        match self {
19            PullImportAs::Triage => "triage",
20            PullImportAs::Spam => "spam",
21            PullImportAs::Trashed => "trashed",
22        }
23    }
24
25    pub(super) fn parse(value: &str) -> Result<Self> {
26        match value {
27            "triage" => Ok(Self::Triage),
28            "spam" => Ok(Self::Spam),
29            "trashed" => Ok(Self::Trashed),
30            _ => Err(AppError::new(
31                "invalid_request",
32                "actions.pull.by_mailbox_id.<id>.import_as expects one of: triage, spam, trashed",
33            )),
34        }
35    }
36}
37
38#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum MailDirection {
41    #[default]
42    Inbound,
43    Outbound,
44}
45
46impl MailDirection {
47    pub fn as_str(self) -> &'static str {
48        match self {
49            MailDirection::Inbound => "inbound",
50            MailDirection::Outbound => "outbound",
51        }
52    }
53
54    pub(super) fn parse(value: &str) -> Result<Self> {
55        match value {
56            "inbound" => Ok(Self::Inbound),
57            "outbound" => Ok(Self::Outbound),
58            _ => Err(AppError::new(
59                "invalid_request",
60                "direction expects inbound or outbound",
61            )),
62        }
63    }
64}
65
66#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
67#[serde(deny_unknown_fields)]
68pub struct ActionsSection {
69    pub pull: PullActionSection,
70    #[serde(rename = "case.add")]
71    pub case_add: ActionRule,
72    #[serde(rename = "draft.save")]
73    pub draft_save: ActionRule,
74    #[serde(rename = "draft.send")]
75    pub draft_send: ActionRule,
76    #[serde(rename = "message.spam")]
77    pub message_spam: ActionRule,
78    #[serde(rename = "message.trash")]
79    pub message_trash: ActionRule,
80    #[serde(rename = "message.archive")]
81    pub message_archive: MessageArchiveAction,
82}
83
84impl Default for ActionsSection {
85    fn default() -> Self {
86        Self {
87            pull: PullActionSection::default(),
88            case_add: ActionRule { steps: Vec::new() },
89            draft_save: ActionRule {
90                steps: vec![ActionStep::append_to_mailbox_id("drafts")],
91            },
92            draft_send: ActionRule {
93                steps: vec![
94                    ActionStep::smtp_send(),
95                    ActionStep::append_to_mailbox_id("sent"),
96                    ActionStep::add_flags_on(
97                        vec!["\\Seen".to_string(), "\\Answered".to_string()],
98                        ActionStepOn::ReplyToMessage,
99                    ),
100                ],
101            },
102            message_spam: ActionRule {
103                steps: vec![
104                    ActionStep::add_flags(vec!["\\Seen".to_string(), "$Junk".to_string()]),
105                    ActionStep::move_to_mailbox_id("junk"),
106                ],
107            },
108            message_trash: ActionRule {
109                steps: vec![ActionStep::move_to_mailbox_id("trash")],
110            },
111            message_archive: MessageArchiveAction::default(),
112        }
113    }
114}
115
116impl ActionsSection {
117    pub(super) fn validate(&self, config: &MailConfig) -> Result<()> {
118        self.pull.validate(config)?;
119        validate_steps(config, "actions.case.add.steps", &self.case_add.steps)?;
120        validate_steps(config, "actions.draft.save.steps", &self.draft_save.steps)?;
121        validate_steps(config, "actions.draft.send.steps", &self.draft_send.steps)?;
122        validate_steps(
123            config,
124            "actions.message.spam.steps",
125            &self.message_spam.steps,
126        )?;
127        validate_steps(
128            config,
129            "actions.message.trash.steps",
130            &self.message_trash.steps,
131        )?;
132        for (id, rule) in &self.message_archive.by_source_mailbox_id {
133            validate_config_id("actions.message.archive source id", id)?;
134            if !config.mailboxes.contains_key(id) {
135                return Err(AppError::new(
136                    "config_invalid",
137                    format!("actions.message.archive.by_source_mailbox_id.{id} references unknown mailbox id"),
138                ));
139            }
140            validate_steps(
141                config,
142                &format!("actions.message.archive.by_source_mailbox_id.{id}.steps"),
143                &rule.steps,
144            )?;
145        }
146        Ok(())
147    }
148}
149
150#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
151#[serde(deny_unknown_fields)]
152pub struct PullActionSection {
153    pub default_mailbox_ids: Vec<String>,
154    pub by_mailbox_id: BTreeMap<String, PullMailboxAction>,
155}
156
157impl Default for PullActionSection {
158    fn default() -> Self {
159        let mut by_mailbox_id = BTreeMap::new();
160        by_mailbox_id.insert(
161            "inbox".to_string(),
162            PullMailboxAction {
163                import_as: PullImportAs::Triage,
164                direction: MailDirection::Inbound,
165            },
166        );
167        by_mailbox_id.insert(
168            "sent".to_string(),
169            PullMailboxAction {
170                import_as: PullImportAs::Triage,
171                direction: MailDirection::Outbound,
172            },
173        );
174        by_mailbox_id.insert(
175            "archive".to_string(),
176            PullMailboxAction {
177                import_as: PullImportAs::Triage,
178                direction: MailDirection::Inbound,
179            },
180        );
181        by_mailbox_id.insert(
182            "junk".to_string(),
183            PullMailboxAction {
184                import_as: PullImportAs::Spam,
185                direction: MailDirection::Inbound,
186            },
187        );
188        by_mailbox_id.insert(
189            "trash".to_string(),
190            PullMailboxAction {
191                import_as: PullImportAs::Trashed,
192                direction: MailDirection::Inbound,
193            },
194        );
195        by_mailbox_id.insert(
196            "drafts".to_string(),
197            PullMailboxAction {
198                import_as: PullImportAs::Triage,
199                direction: MailDirection::Outbound,
200            },
201        );
202        Self {
203            // Keep no-arg `afmail pull` broad enough to refresh active work surfaces.
204            // Narrowing this to only inbox silently stops syncing sent replies and archived mail.
205            default_mailbox_ids: vec![
206                "inbox".to_string(),
207                "sent".to_string(),
208                "archive".to_string(),
209                "junk".to_string(),
210                "trash".to_string(),
211            ],
212            by_mailbox_id,
213        }
214    }
215}
216
217impl PullActionSection {
218    pub(super) fn validate(&self, config: &MailConfig) -> Result<()> {
219        for id in &self.default_mailbox_ids {
220            validate_config_id("actions.pull.default_mailbox_ids id", id)?;
221            if !config.mailboxes.contains_key(id) {
222                return Err(AppError::new(
223                    "config_invalid",
224                    format!("actions.pull.default_mailbox_ids references unknown mailbox id: {id}"),
225                ));
226            }
227            if !self.by_mailbox_id.contains_key(id) {
228                return Err(AppError::new(
229                    "config_invalid",
230                    format!("actions.pull.by_mailbox_id.{id} is required by default_mailbox_ids"),
231                ));
232            }
233        }
234        for id in self.by_mailbox_id.keys() {
235            validate_config_id("actions.pull.by_mailbox_id id", id)?;
236            if !config.mailboxes.contains_key(id) {
237                return Err(AppError::new(
238                    "config_invalid",
239                    format!("actions.pull.by_mailbox_id.{id} references unknown mailbox id"),
240                ));
241            }
242        }
243        Ok(())
244    }
245}
246
247#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
248#[serde(deny_unknown_fields)]
249pub struct PullMailboxAction {
250    pub import_as: PullImportAs,
251    pub direction: MailDirection,
252}
253
254impl Default for PullMailboxAction {
255    fn default() -> Self {
256        Self {
257            import_as: PullImportAs::Triage,
258            direction: MailDirection::Inbound,
259        }
260    }
261}
262
263#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
264#[serde(deny_unknown_fields)]
265pub struct ActionRule {
266    #[serde(default)]
267    pub steps: Vec<ActionStep>,
268}
269
270#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
271#[serde(deny_unknown_fields)]
272pub struct MessageArchiveAction {
273    #[serde(default)]
274    pub by_source_mailbox_id: BTreeMap<String, ActionRule>,
275}
276
277impl Default for MessageArchiveAction {
278    fn default() -> Self {
279        let mut by_source_mailbox_id = BTreeMap::new();
280        by_source_mailbox_id.insert(
281            "inbox".to_string(),
282            ActionRule {
283                steps: vec![ActionStep::move_to_mailbox_id("archive")],
284            },
285        );
286        for id in ["sent", "archive", "junk", "trash", "drafts"] {
287            by_source_mailbox_id.insert(id.to_string(), ActionRule::default());
288        }
289        Self {
290            by_source_mailbox_id,
291        }
292    }
293}
294
295#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
296#[serde(deny_unknown_fields)]
297pub struct ActionStep {
298    #[serde(default, skip_serializing_if = "Vec::is_empty")]
299    pub add_flags: Vec<String>,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub move_to_mailbox_id: Option<String>,
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub append_to_mailbox_id: Option<String>,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub smtp_send: Option<BTreeMap<String, String>>,
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub on: Option<ActionStepOn>,
308}
309
310impl ActionStep {
311    pub fn add_flags(flags: Vec<String>) -> Self {
312        Self {
313            add_flags: flags,
314            ..Self::default()
315        }
316    }
317
318    pub fn add_flags_on(flags: Vec<String>, on: ActionStepOn) -> Self {
319        Self {
320            add_flags: flags,
321            on: Some(on),
322            ..Self::default()
323        }
324    }
325
326    pub fn move_to_mailbox_id(id: impl Into<String>) -> Self {
327        Self {
328            move_to_mailbox_id: Some(id.into()),
329            ..Self::default()
330        }
331    }
332
333    pub fn append_to_mailbox_id(id: impl Into<String>) -> Self {
334        Self {
335            append_to_mailbox_id: Some(id.into()),
336            ..Self::default()
337        }
338    }
339
340    pub fn smtp_send() -> Self {
341        Self {
342            smtp_send: Some(BTreeMap::new()),
343            ..Self::default()
344        }
345    }
346}
347
348#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
349#[serde(rename_all = "snake_case")]
350pub enum ActionStepOn {
351    ReplyToMessage,
352}