rusmes_jmap/methods/submission/
store.rs1use crate::methods::submission::types::{EmailSubmission, UndoStatus};
4use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
14pub(super) struct SubmissionAccountState {
15 pub submissions: HashMap<String, StoredSubmission>,
17 pub state_version: u64,
19}
20
21impl Default for SubmissionAccountState {
22 fn default() -> Self {
23 Self {
24 submissions: HashMap::new(),
25 state_version: 1,
26 }
27 }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct StoredSubmission {
37 #[serde(flatten)]
39 pub submission: EmailSubmission,
40 pub created_at: DateTime<Utc>,
42}
43
44#[async_trait]
48pub trait SubmissionStore: Send + Sync {
49 async fn get_submission(
51 &self,
52 account_id: &str,
53 id: &str,
54 ) -> anyhow::Result<Option<StoredSubmission>>;
55
56 async fn put_submission(&self, account_id: &str, entry: StoredSubmission)
58 -> anyhow::Result<()>;
59
60 async fn delete_submission(&self, account_id: &str, id: &str) -> anyhow::Result<()>;
62
63 async fn state_token(&self, account_id: &str) -> anyhow::Result<String>;
65}
66
67pub struct FileSubmissionStore {
74 base_dir: PathBuf,
75}
76
77impl FileSubmissionStore {
78 pub fn new(base_dir: impl Into<PathBuf>) -> Self {
80 Self {
81 base_dir: base_dir.into(),
82 }
83 }
84
85 fn account_path(&self, account_id: &str) -> PathBuf {
86 self.base_dir
87 .join("submissions")
88 .join(format!("{}.json", account_id))
89 }
90
91 async fn load(&self, account_id: &str) -> anyhow::Result<SubmissionAccountState> {
92 let path = self.account_path(account_id);
93 if !path.exists() {
94 return Ok(SubmissionAccountState::default());
95 }
96 let bytes = tokio::fs::read(&path).await?;
97 let state: SubmissionAccountState = serde_json::from_slice(&bytes)?;
98 Ok(state)
99 }
100
101 async fn save(&self, account_id: &str, state: &SubmissionAccountState) -> anyhow::Result<()> {
102 let path = self.account_path(account_id);
103 if let Some(parent) = path.parent() {
104 tokio::fs::create_dir_all(parent).await?;
105 }
106 let bytes = serde_json::to_vec_pretty(state)?;
107 tokio::fs::write(&path, bytes).await?;
108 Ok(())
109 }
110}
111
112#[async_trait]
113impl SubmissionStore for FileSubmissionStore {
114 async fn get_submission(
115 &self,
116 account_id: &str,
117 id: &str,
118 ) -> anyhow::Result<Option<StoredSubmission>> {
119 let state = self.load(account_id).await?;
120 Ok(state.submissions.get(id).cloned())
121 }
122
123 async fn put_submission(
124 &self,
125 account_id: &str,
126 entry: StoredSubmission,
127 ) -> anyhow::Result<()> {
128 let mut state = self.load(account_id).await?;
129 state.submissions.insert(entry.submission.id.clone(), entry);
130 state.state_version += 1;
131 self.save(account_id, &state).await
132 }
133
134 async fn delete_submission(&self, account_id: &str, id: &str) -> anyhow::Result<()> {
135 let mut state = self.load(account_id).await?;
136 state.submissions.remove(id);
137 state.state_version += 1;
138 self.save(account_id, &state).await
139 }
140
141 async fn state_token(&self, account_id: &str) -> anyhow::Result<String> {
142 let state = self.load(account_id).await?;
143 Ok(state.state_version.to_string())
144 }
145}
146
147pub(super) fn within_undo_window(stored: &StoredSubmission, window_secs: i64) -> bool {
151 stored.submission.undo_status == UndoStatus::Pending
152 && Utc::now() <= stored.created_at + chrono::Duration::seconds(window_secs)
153}