Skip to main content

rusmes_jmap/methods/submission/
store.rs

1//! Submission persistence layer — `SubmissionStore` trait and filesystem backend
2
3use 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// ── Internal state ────────────────────────────────────────────────────────────
11
12/// Account-scoped state file stored at `{base_dir}/submissions/{account_id}.json`.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub(super) struct SubmissionAccountState {
15    /// Map of submission id → StoredSubmission
16    pub submissions: HashMap<String, StoredSubmission>,
17    /// Monotonic version counter; incremented on every mutation
18    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/// Persisted form of an `EmailSubmission`.
31///
32/// We keep `created_at` alongside the public fields so we can enforce the
33/// undo-window check without relying on wall-clock drift in tests.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase")]
36pub struct StoredSubmission {
37    /// Public submission object
38    #[serde(flatten)]
39    pub submission: EmailSubmission,
40    /// UTC timestamp at which the submission was created (used for undo window)
41    pub created_at: DateTime<Utc>,
42}
43
44// ── SubmissionStore trait ─────────────────────────────────────────────────────
45
46/// Trait for submission persistence.
47#[async_trait]
48pub trait SubmissionStore: Send + Sync {
49    /// Return a submission by id, or `None` if not found.
50    async fn get_submission(
51        &self,
52        account_id: &str,
53        id: &str,
54    ) -> anyhow::Result<Option<StoredSubmission>>;
55
56    /// Persist a new or updated submission.
57    async fn put_submission(&self, account_id: &str, entry: StoredSubmission)
58        -> anyhow::Result<()>;
59
60    /// Delete a submission by id.  Returns `Ok(())` even if id is absent.
61    async fn delete_submission(&self, account_id: &str, id: &str) -> anyhow::Result<()>;
62
63    /// Return the current state token for an account.
64    async fn state_token(&self, account_id: &str) -> anyhow::Result<String>;
65}
66
67// ── FileSubmissionStore ───────────────────────────────────────────────────────
68
69/// Filesystem-backed submission store.
70///
71/// Each account's submissions are persisted to
72/// `{base_dir}/submissions/{account_id}.json`.
73pub struct FileSubmissionStore {
74    base_dir: PathBuf,
75}
76
77impl FileSubmissionStore {
78    /// Create a new store rooted at `base_dir`.
79    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
147/// Check whether a `StoredSubmission` can be canceled.
148///
149/// Returns `true` if the submission is `pending` and within the undo window.
150pub(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}