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(outbound) => outbound.reply_to_message_id.as_deref(),
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"
221 | "draft_name"
222 | "draft_hash"
223 | "message_id"
224 | "reply_to_message_id"
225 | "eml_path"
226 | "envelope_from"
227 | "envelope_to"
228 | "drafts_mailbox_name"
229 | "sent_mailbox_name"
230 | "draft_uid_validity"
231 | "draft_uid"
232 | "draft_save_steps"
233 | "draft_send_steps"
234 ),
235 "message_action" => matches!(
236 key,
237 "action" | "message_ids" | "locations" | "steps" | "reply_to_message_id"
238 ),
239 _ => false,
240 }
241}
242
243#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
244#[serde(tag = "kind", rename_all = "snake_case")]
245pub enum PushPayload {
246 Outbound(Box<OutboundPush>),
247 MessageAction(MessageActionPush),
248}
249
250#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
251#[serde(deny_unknown_fields)]
252pub struct OutboundPush {
253 pub case_uid: String,
254 pub draft_name: String,
255 pub draft_hash: String,
256 pub message_id: String,
257 #[serde(skip_serializing_if = "Option::is_none")]
258 pub reply_to_message_id: Option<String>,
259 pub eml_path: String,
260 pub envelope_from: String,
261 #[serde(default, skip_serializing_if = "Vec::is_empty")]
262 pub envelope_to: Vec<String>,
263 #[serde(skip_serializing_if = "Option::is_none")]
264 pub drafts_mailbox_name: Option<String>,
265 #[serde(skip_serializing_if = "Option::is_none")]
266 pub sent_mailbox_name: Option<String>,
267 #[serde(skip_serializing_if = "Option::is_none")]
268 pub draft_uid_validity: Option<u64>,
269 #[serde(skip_serializing_if = "Option::is_none")]
270 pub draft_uid: Option<u64>,
271 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 pub draft_save_steps: Vec<ActionStep>,
273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
274 pub draft_send_steps: Vec<ActionStep>,
275}
276
277#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
278#[serde(deny_unknown_fields)]
279pub struct MessageActionPush {
280 pub action: MessagePushAction,
281 #[serde(default, skip_serializing_if = "Vec::is_empty")]
282 pub message_ids: Vec<String>,
283 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 pub locations: Vec<PushLocation>,
285 #[serde(default, skip_serializing_if = "Vec::is_empty")]
286 pub steps: Vec<ActionStep>,
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub reply_to_message_id: Option<String>,
289}
290
291#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
292#[serde(rename_all = "snake_case")]
293pub enum MessagePushAction {
294 CaseAdd,
295 Archive,
296 Spam,
297 Trash,
298}
299
300impl MessagePushAction {
301 pub fn from_kind(kind: &str) -> Option<Self> {
302 match kind {
303 "case.add" | "case_add" => Some(Self::CaseAdd),
304 "message.archive" | "archive" => Some(Self::Archive),
305 "message.spam" | "spam" => Some(Self::Spam),
306 "message.trash" | "trash" => Some(Self::Trash),
307 _ => None,
308 }
309 }
310
311 pub fn kind(self) -> &'static str {
312 match self {
313 Self::CaseAdd => "case.add",
314 Self::Archive => "message.archive",
315 Self::Spam => "message.spam",
316 Self::Trash => "message.trash",
317 }
318 }
319
320 pub fn mode_label(self) -> &'static str {
321 match self {
322 Self::CaseAdd => "case",
323 Self::Archive => "archive",
324 Self::Spam => "spam",
325 Self::Trash => "trash",
326 }
327 }
328}
329
330#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
331#[serde(deny_unknown_fields)]
332pub struct PushLocation {
333 pub message_id: String,
334 pub mailbox_name: String,
335 pub uid_validity: u64,
336 pub uid: u64,
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn push_item_requires_step_states() {
345 let data = r#"{
346 "schema_name": "push_item",
347 "schema_version": 1,
348 "push_id": "push_20260609T000000Z",
349 "kind": "message_action",
350 "action": "spam",
351 "message_ids": ["message_1"],
352 "locations": [],
353 "steps": [],
354 "created_rfc3339": "2026-06-09T00:00:00Z",
355 "updated_rfc3339": "2026-06-09T00:00:00Z",
356 "attempt_count": 1
357 }"#;
358 let err = PushItem::parse_json(data)
359 .err()
360 .unwrap_or_else(|| AppError::new("test_failure", "expected missing steps to fail"));
361 assert_eq!(err.error_code, "store_error");
362 }
363
364 #[test]
365 fn invalid_push_kind_is_rejected() {
366 let data = r#"{
367 "schema_name": "push_item",
368 "schema_version": 1,
369 "push_id": "push_bad",
370 "kind": "surprise",
371 "created_rfc3339": "2026-06-09T00:00:00Z",
372 "updated_rfc3339": "2026-06-09T00:00:00Z"
373 }"#;
374 let err = PushItem::parse_json(data)
375 .err()
376 .unwrap_or_else(|| AppError::new("test_failure", "expected invalid kind to fail"));
377 assert_eq!(err.error_code, "push_item_invalid");
378 }
379}