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}