Skip to main content

agent_first_mail/types/
push.rs

1use crate::config::ActionStep;
2use crate::error::{AppError, Result};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
7pub enum PushKind {
8    Outbound,
9    MessageAction,
10}
11
12impl PushKind {
13    pub fn parse(value: &str) -> Result<Self> {
14        match value {
15            "outbound" => Ok(Self::Outbound),
16            "message_action" => Ok(Self::MessageAction),
17            other => Err(AppError::new(
18                "push_item_invalid",
19                format!("unsupported push item kind: {other}"),
20            )),
21        }
22    }
23
24    pub fn as_str(self) -> &'static str {
25        match self {
26            Self::Outbound => "outbound",
27            Self::MessageAction => "message_action",
28        }
29    }
30}
31
32#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
33pub struct PushItem {
34    pub schema_name: String,
35    pub schema_version: u64,
36    pub push_id: String,
37    #[serde(flatten)]
38    pub payload: PushPayload,
39    pub created_rfc3339: String,
40    pub updated_rfc3339: String,
41    #[serde(default)]
42    pub attempt_count: u64,
43    pub step_states: Vec<PushStepState>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub last_error: Option<String>,
46}
47
48#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
49#[serde(deny_unknown_fields)]
50pub struct PushStepState {
51    pub index: usize,
52    pub label: String,
53    pub status: PushStepStatus,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub started_rfc3339: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub completed_rfc3339: Option<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub result_summary: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub error_code: Option<String>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub error: Option<String>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub retryable: Option<bool>,
66}
67
68#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum PushStepStatus {
71    Pending,
72    Succeeded,
73    Failed,
74}
75
76impl PushItem {
77    pub fn parse_json(data: &str) -> Result<Self> {
78        let value: Value =
79            serde_json::from_str(data).map_err(|e| AppError::json("parse push item", &e))?;
80        validate_push_item_keys(&value)?;
81        serde_json::from_value(value).map_err(|e| AppError::json("parse push item", &e))
82    }
83
84    pub fn push_kind(&self) -> PushKind {
85        match &self.payload {
86            PushPayload::Outbound(_) => PushKind::Outbound,
87            PushPayload::MessageAction(_) => PushKind::MessageAction,
88        }
89    }
90
91    pub fn kind(&self) -> &'static str {
92        self.push_kind().as_str()
93    }
94
95    pub fn display_kind(&self) -> String {
96        match &self.payload {
97            PushPayload::Outbound(_) => "outbound".to_string(),
98            PushPayload::MessageAction(action) => action.action.kind().to_string(),
99        }
100    }
101
102    pub fn outbound(&self) -> Option<&OutboundPush> {
103        match &self.payload {
104            PushPayload::Outbound(outbound) => Some(outbound.as_ref()),
105            PushPayload::MessageAction(_) => None,
106        }
107    }
108
109    pub fn outbound_mut(&mut self) -> Option<&mut OutboundPush> {
110        match &mut self.payload {
111            PushPayload::Outbound(outbound) => Some(outbound.as_mut()),
112            PushPayload::MessageAction(_) => None,
113        }
114    }
115
116    pub fn message_action(&self) -> Option<&MessageActionPush> {
117        match &self.payload {
118            PushPayload::MessageAction(action) => Some(action),
119            PushPayload::Outbound(_) => None,
120        }
121    }
122
123    pub fn message_action_mut(&mut self) -> Option<&mut MessageActionPush> {
124        match &mut self.payload {
125            PushPayload::MessageAction(action) => Some(action),
126            PushPayload::Outbound(_) => None,
127        }
128    }
129
130    pub fn message_ids(&self) -> &[String] {
131        self.message_action()
132            .map(|action| action.message_ids.as_slice())
133            .unwrap_or(&[])
134    }
135
136    pub fn locations(&self) -> &[PushLocation] {
137        self.message_action()
138            .map(|action| action.locations.as_slice())
139            .unwrap_or(&[])
140    }
141
142    pub fn steps(&self) -> &[ActionStep] {
143        self.message_action()
144            .map(|action| action.steps.as_slice())
145            .unwrap_or(&[])
146    }
147
148    pub fn reply_to_message_id(&self) -> Option<&str> {
149        match &self.payload {
150            PushPayload::Outbound(_) => None,
151            PushPayload::MessageAction(action) => action.reply_to_message_id.as_deref(),
152        }
153    }
154
155    pub fn succeeded_step_count(&self) -> usize {
156        let mut completed = 0usize;
157        for state in &self.step_states {
158            if state.index == completed && state.status == PushStepStatus::Succeeded {
159                completed += 1;
160            }
161        }
162        completed
163    }
164
165    pub fn has_started_steps(&self) -> bool {
166        self.step_states
167            .iter()
168            .any(|state| state.status != PushStepStatus::Pending)
169    }
170}
171
172fn validate_push_item_keys(value: &Value) -> Result<()> {
173    let Some(obj) = value.as_object() else {
174        return Err(AppError::new(
175            "push_item_invalid",
176            "push item must be an object",
177        ));
178    };
179    let Some(raw_kind) = obj.get("kind").and_then(Value::as_str) else {
180        return Err(AppError::new(
181            "push_item_invalid",
182            "push item requires kind",
183        ));
184    };
185    if obj.get("schema_name").and_then(Value::as_str) != Some("push_item")
186        || obj.get("schema_version").and_then(Value::as_u64) != Some(1)
187    {
188        return Err(AppError::new(
189            "push_item_invalid",
190            "push item requires schema_name push_item and schema_version 1",
191        ));
192    }
193    let kind = PushKind::parse(raw_kind)?.as_str();
194    let common = [
195        "schema_name",
196        "schema_version",
197        "push_id",
198        "kind",
199        "created_rfc3339",
200        "updated_rfc3339",
201        "attempt_count",
202        "step_states",
203        "last_error",
204    ];
205    for key in obj.keys() {
206        if !common.contains(&key.as_str()) && !push_payload_key_allowed(kind, key) {
207            return Err(AppError::new(
208                "push_item_invalid",
209                format!("unsupported push item field: {key}"),
210            ));
211        }
212    }
213    Ok(())
214}
215
216fn push_payload_key_allowed(kind: &str, key: &str) -> bool {
217    match kind {
218        "outbound" => matches!(
219            key,
220            "case_uid" | "action" | "draft_name" | "draft_uid_validity" | "draft_uid"
221        ),
222        "message_action" => matches!(
223            key,
224            "action" | "message_ids" | "locations" | "steps" | "reply_to_message_id"
225        ),
226        _ => false,
227    }
228}
229
230#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
231#[serde(tag = "kind", rename_all = "snake_case")]
232pub enum PushPayload {
233    Outbound(Box<OutboundPush>),
234    MessageAction(MessageActionPush),
235}
236
237#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
238#[serde(rename_all = "snake_case")]
239pub enum OutboundAction {
240    SaveDraft,
241    Send,
242}
243
244impl OutboundAction {
245    pub fn as_str(self) -> &'static str {
246        match self {
247            Self::SaveDraft => "save_draft",
248            Self::Send => "send",
249        }
250    }
251}
252
253#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
254#[serde(deny_unknown_fields)]
255pub struct OutboundPush {
256    pub action: OutboundAction,
257    pub case_uid: String,
258    pub draft_name: String,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub draft_uid_validity: Option<u64>,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub draft_uid: Option<u64>,
263}
264
265#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
266#[serde(deny_unknown_fields)]
267pub struct MessageActionPush {
268    pub action: MessagePushAction,
269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
270    pub message_ids: Vec<String>,
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    pub locations: Vec<PushLocation>,
273    #[serde(default, skip_serializing_if = "Vec::is_empty")]
274    pub steps: Vec<ActionStep>,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub reply_to_message_id: Option<String>,
277}
278
279#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
280#[serde(rename_all = "snake_case")]
281pub enum MessagePushAction {
282    CaseAdd,
283    Archive,
284    Spam,
285    Trash,
286}
287
288impl MessagePushAction {
289    pub fn from_kind(kind: &str) -> Option<Self> {
290        match kind {
291            "case.add" | "case_add" => Some(Self::CaseAdd),
292            "message.archive" | "archive" => Some(Self::Archive),
293            "message.spam" | "spam" => Some(Self::Spam),
294            "message.trash" | "trash" => Some(Self::Trash),
295            _ => None,
296        }
297    }
298
299    pub fn kind(self) -> &'static str {
300        match self {
301            Self::CaseAdd => "case.add",
302            Self::Archive => "message.archive",
303            Self::Spam => "message.spam",
304            Self::Trash => "message.trash",
305        }
306    }
307
308    pub fn mode_label(self) -> &'static str {
309        match self {
310            Self::CaseAdd => "case",
311            Self::Archive => "archive",
312            Self::Spam => "spam",
313            Self::Trash => "trash",
314        }
315    }
316}
317
318#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
319#[serde(deny_unknown_fields)]
320pub struct PushLocation {
321    pub message_id: String,
322    pub mailbox_name: String,
323    pub uid_validity: u64,
324    pub uid: u64,
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn push_item_requires_step_states() {
333        let data = r#"{
334          "schema_name": "push_item",
335          "schema_version": 1,
336          "push_id": "push_20260609T000000Z",
337          "kind": "message_action",
338          "action": "spam",
339          "message_ids": ["message_1"],
340          "locations": [],
341          "steps": [],
342          "created_rfc3339": "2026-06-09T00:00:00Z",
343          "updated_rfc3339": "2026-06-09T00:00:00Z",
344          "attempt_count": 1
345        }"#;
346        let err = PushItem::parse_json(data)
347            .err()
348            .unwrap_or_else(|| AppError::new("test_failure", "expected missing steps to fail"));
349        assert_eq!(err.error_code, "store_error");
350    }
351
352    #[test]
353    fn invalid_push_kind_is_rejected() {
354        let data = r#"{
355          "schema_name": "push_item",
356          "schema_version": 1,
357          "push_id": "push_bad",
358          "kind": "surprise",
359          "created_rfc3339": "2026-06-09T00:00:00Z",
360          "updated_rfc3339": "2026-06-09T00:00:00Z"
361        }"#;
362        let err = PushItem::parse_json(data)
363            .err()
364            .unwrap_or_else(|| AppError::new("test_failure", "expected invalid kind to fail"));
365        assert_eq!(err.error_code, "push_item_invalid");
366    }
367}