Skip to main content

mailrs_jmap/
fixtures.rs

1//! In-memory [`MailStore`](crate::store::MailStore) implementation suitable
2//! for tests, examples, and downstream-consumer test harnesses.
3//!
4//! **Intended use is testing.** The store keeps every value in process memory
5//! and never persists across restarts; do not wire it into a real deployment.
6//!
7//! ## Quick start
8//!
9//! ```
10//! use mailrs_jmap::fixtures::{InMemoryStore, EXAMPLE_USER, make_message};
11//!
12//! let store = InMemoryStore::new()
13//!     .with_message(make_message(1, 10, EXAMPLE_USER));
14//! ```
15//!
16//! ## What it gives you
17//!
18//! - Stateful in-memory storage with a builder API — `with_mailbox`,
19//!   `with_message`, `with_message_raw`, `with_parsed_body`,
20//!   `with_mailbox_counts`.
21//! - Per-method error injection via `<method>_fails` setters so each error
22//!   path in your handler-driving code can be isolated in a single test.
23//! - Read-back helpers for assertions — `flags_for(mailbox_id, uid)`.
24//! - Convenience constructors — `make_message`, `make_request`,
25//!   `parsed_with_text`, `parsed_with_attachment`.
26//!
27//! Used internally by this crate's integration tests; the same module is
28//! exposed to downstream consumers so they can drive their own handler /
29//! dispatcher tests without re-implementing the store.
30
31use std::collections::HashMap;
32use std::sync::RwLock;
33
34use async_trait::async_trait;
35use serde_json::Value;
36
37use crate::dispatch::JmapRequest;
38use crate::store::{MailStore, StoreError};
39use crate::types::{
40    Attachment, FLAG_SEEN, Mailbox, MailboxCounts, Message, ParsedBody, SubmissionResult,
41};
42
43/// Convenience example user used by the constructors in this module. The
44/// store does not assume any particular value — callers can use their own.
45pub const EXAMPLE_USER: &str = "alice@example.com";
46
47/// In-memory [`MailStore`] backed by `Vec`s under an `RwLock`.
48///
49/// Build via [`Self::new`] + the chainable `with_*` setters. See the module
50/// docs for an example.
51pub struct InMemoryStore {
52    inner: RwLock<Inner>,
53}
54
55struct Inner {
56    mailboxes: Vec<Mailbox>,
57    messages: Vec<Message>,
58    raw_bytes: HashMap<i64, Vec<u8>>,
59    parsed_bodies: HashMap<Vec<u8>, ParsedBody>,
60    mailbox_counts: HashMap<i64, MailboxCounts>,
61
62    list_mailboxes_error: Option<String>,
63    mailbox_status_error: Option<String>,
64    list_messages_error: Option<String>,
65    get_message_error: Option<String>,
66    list_thread_messages_error: Option<String>,
67    update_flags_error: Option<String>,
68    add_flags_error: Option<String>,
69
70    submission_result: SubmissionResult,
71}
72
73impl InMemoryStore {
74    /// Construct an empty store.
75    pub fn new() -> Self {
76        Self {
77            inner: RwLock::new(Inner {
78                mailboxes: Vec::new(),
79                messages: Vec::new(),
80                raw_bytes: HashMap::new(),
81                parsed_bodies: HashMap::new(),
82                mailbox_counts: HashMap::new(),
83                list_mailboxes_error: None,
84                mailbox_status_error: None,
85                list_messages_error: None,
86                get_message_error: None,
87                list_thread_messages_error: None,
88                update_flags_error: None,
89                add_flags_error: None,
90                submission_result: SubmissionResult {
91                    success: true,
92                    message: None,
93                },
94            }),
95        }
96    }
97
98    /// Append a mailbox with the given id and name.
99    pub fn with_mailbox(self, id: i64, name: &str) -> Self {
100        self.inner.write().unwrap().mailboxes.push(Mailbox {
101            id,
102            name: name.to_string(),
103        });
104        self
105    }
106
107    /// Append a pre-built [`Message`]. Use [`make_message`] for a sane default
108    /// shape and mutate before passing in if you need overrides.
109    pub fn with_message(self, msg: Message) -> Self {
110        self.inner.write().unwrap().messages.push(msg);
111        self
112    }
113
114    /// Map a message id to raw RFC 5322 bytes. [`MailStore::read_message_raw`]
115    /// returns these bytes; without this setter that method returns `None`.
116    pub fn with_message_raw(self, msg_id: i64, raw: Vec<u8>) -> Self {
117        self.inner.write().unwrap().raw_bytes.insert(msg_id, raw);
118        self
119    }
120
121    /// Map a specific raw byte sequence to a parsed body. The store's
122    /// [`MailStore::parse_message`] returns the override when called with
123    /// matching bytes, and `ParsedBody::default()` otherwise.
124    pub fn with_parsed_body(self, raw: Vec<u8>, parsed: ParsedBody) -> Self {
125        self.inner.write().unwrap().parsed_bodies.insert(raw, parsed);
126        self
127    }
128
129    /// Override mailbox counts for a specific mailbox id. Without this the
130    /// store derives total/unread from the message list.
131    pub fn with_mailbox_counts(self, mb_id: i64, counts: MailboxCounts) -> Self {
132        self.inner
133            .write()
134            .unwrap()
135            .mailbox_counts
136            .insert(mb_id, counts);
137        self
138    }
139
140    /// Make [`MailStore::list_mailboxes`] return an error carrying `msg`.
141    pub fn list_mailboxes_fails(self, msg: &str) -> Self {
142        self.inner.write().unwrap().list_mailboxes_error = Some(msg.to_string());
143        self
144    }
145
146    /// Make [`MailStore::mailbox_status`] return an error carrying `msg`.
147    pub fn mailbox_status_fails(self, msg: &str) -> Self {
148        self.inner.write().unwrap().mailbox_status_error = Some(msg.to_string());
149        self
150    }
151
152    /// Make [`MailStore::list_messages`] return an error carrying `msg`.
153    pub fn list_messages_fails(self, msg: &str) -> Self {
154        self.inner.write().unwrap().list_messages_error = Some(msg.to_string());
155        self
156    }
157
158    /// Make [`MailStore::get_message_by_db_id`] return an error carrying `msg`.
159    pub fn get_message_fails(self, msg: &str) -> Self {
160        self.inner.write().unwrap().get_message_error = Some(msg.to_string());
161        self
162    }
163
164    /// Make [`MailStore::list_thread_messages`] return an error carrying `msg`.
165    pub fn list_thread_messages_fails(self, msg: &str) -> Self {
166        self.inner.write().unwrap().list_thread_messages_error = Some(msg.to_string());
167        self
168    }
169
170    /// Make [`MailStore::update_flags`] return an error carrying `msg`.
171    pub fn update_flags_fails(self, msg: &str) -> Self {
172        self.inner.write().unwrap().update_flags_error = Some(msg.to_string());
173        self
174    }
175
176    /// Make [`MailStore::add_flags`] return an error carrying `msg`.
177    pub fn add_flags_fails(self, msg: &str) -> Self {
178        self.inner.write().unwrap().add_flags_error = Some(msg.to_string());
179        self
180    }
181
182    /// Configure [`MailStore::submit_message`] to fail with a description.
183    pub fn submission_fails_with(self, msg: &str) -> Self {
184        self.inner.write().unwrap().submission_result = SubmissionResult {
185            success: false,
186            message: Some(msg.to_string()),
187        };
188        self
189    }
190
191    /// Configure [`MailStore::submit_message`] to fail without a description.
192    pub fn submission_fails_silently(self) -> Self {
193        self.inner.write().unwrap().submission_result = SubmissionResult {
194            success: false,
195            message: None,
196        };
197        self
198    }
199
200    /// Read back the current flag bitmask for `(mailbox_id, uid)`. `None`
201    /// when the row is missing. Tests use this to assert the effect of
202    /// `Email/set` updates and destroys.
203    pub fn flags_for(&self, mailbox_id: i64, uid: u32) -> Option<u32> {
204        self.inner
205            .read()
206            .unwrap()
207            .messages
208            .iter()
209            .find(|m| m.mailbox_id == mailbox_id && m.uid == uid)
210            .map(|m| m.flags)
211    }
212}
213
214impl Default for InMemoryStore {
215    fn default() -> Self {
216        Self::new()
217    }
218}
219
220#[async_trait]
221impl MailStore for InMemoryStore {
222    async fn list_mailboxes(&self, _user: &str) -> Result<Vec<Mailbox>, StoreError> {
223        let inner = self.inner.read().unwrap();
224        if let Some(ref msg) = inner.list_mailboxes_error {
225            return Err(msg.clone().into());
226        }
227        Ok(inner.mailboxes.clone())
228    }
229
230    async fn mailbox_status(&self, mailbox_id: i64) -> Result<MailboxCounts, StoreError> {
231        let inner = self.inner.read().unwrap();
232        if let Some(ref msg) = inner.mailbox_status_error {
233            return Err(msg.clone().into());
234        }
235        if let Some(counts) = inner.mailbox_counts.get(&mailbox_id) {
236            return Ok(*counts);
237        }
238        // Default: total = messages in mailbox, unread = those without FLAG_SEEN.
239        let total = inner
240            .messages
241            .iter()
242            .filter(|m| m.mailbox_id == mailbox_id)
243            .count() as u32;
244        let unread = inner
245            .messages
246            .iter()
247            .filter(|m| m.mailbox_id == mailbox_id && m.flags & FLAG_SEEN == 0)
248            .count() as u32;
249        Ok(MailboxCounts { total, unread })
250    }
251
252    async fn list_messages(
253        &self,
254        mailbox_id: i64,
255        offset: u32,
256        limit: u32,
257    ) -> Result<Vec<Message>, StoreError> {
258        let inner = self.inner.read().unwrap();
259        if let Some(ref msg) = inner.list_messages_error {
260            return Err(msg.clone().into());
261        }
262        Ok(inner
263            .messages
264            .iter()
265            .filter(|m| m.mailbox_id == mailbox_id)
266            .skip(offset as usize)
267            .take(limit as usize)
268            .cloned()
269            .collect())
270    }
271
272    async fn get_message_by_db_id(
273        &self,
274        user: &str,
275        id: i64,
276    ) -> Result<Option<Message>, StoreError> {
277        let inner = self.inner.read().unwrap();
278        if let Some(ref msg) = inner.get_message_error {
279            return Err(msg.clone().into());
280        }
281        Ok(inner
282            .messages
283            .iter()
284            .find(|m| m.id == id && m.user_address == user)
285            .cloned())
286    }
287
288    async fn list_thread_messages(
289        &self,
290        user: &str,
291        thread_id: &str,
292    ) -> Result<Vec<Message>, StoreError> {
293        let inner = self.inner.read().unwrap();
294        if let Some(ref msg) = inner.list_thread_messages_error {
295            return Err(msg.clone().into());
296        }
297        Ok(inner
298            .messages
299            .iter()
300            .filter(|m| m.thread_id == thread_id && m.user_address == user)
301            .cloned()
302            .collect())
303    }
304
305    async fn update_flags(
306        &self,
307        mailbox_id: i64,
308        uid: u32,
309        flags: u32,
310    ) -> Result<(), StoreError> {
311        let mut inner = self.inner.write().unwrap();
312        if let Some(ref msg) = inner.update_flags_error {
313            return Err(msg.clone().into());
314        }
315        if let Some(m) = inner
316            .messages
317            .iter_mut()
318            .find(|m| m.mailbox_id == mailbox_id && m.uid == uid)
319        {
320            m.flags = flags;
321        }
322        Ok(())
323    }
324
325    async fn add_flags(&self, mailbox_id: i64, uid: u32, flags: u32) -> Result<(), StoreError> {
326        let mut inner = self.inner.write().unwrap();
327        if let Some(ref msg) = inner.add_flags_error {
328            return Err(msg.clone().into());
329        }
330        if let Some(m) = inner
331            .messages
332            .iter_mut()
333            .find(|m| m.mailbox_id == mailbox_id && m.uid == uid)
334        {
335            m.flags |= flags;
336        }
337        Ok(())
338    }
339
340    async fn read_message_raw(&self, message: &Message) -> Option<Vec<u8>> {
341        self.inner.read().unwrap().raw_bytes.get(&message.id).cloned()
342    }
343
344    fn parse_message(&self, raw: &[u8]) -> ParsedBody {
345        self.inner
346            .read()
347            .unwrap()
348            .parsed_bodies
349            .get(raw)
350            .cloned()
351            .unwrap_or_default()
352    }
353
354    async fn submit_message(
355        &self,
356        _user: &str,
357        _message: &Message,
358        _raw: &[u8],
359    ) -> SubmissionResult {
360        self.inner.read().unwrap().submission_result.clone()
361    }
362}
363
364/// Build a [`Message`] with sane defaults. Tests override only the fields
365/// they care about by mutating the returned value before handing it to
366/// [`InMemoryStore::with_message`].
367pub fn make_message(id: i64, mailbox_id: i64, user: &str) -> Message {
368    Message {
369        id,
370        mailbox_id,
371        uid: id as u32,
372        sender: "Sender <sender@example.com>".to_string(),
373        recipients: user.to_string(),
374        subject: format!("message {id}"),
375        date: 1_700_000_000 + id,
376        size: 256,
377        flags: 0,
378        internal_date: 1_700_000_000 + id,
379        message_id: format!("msg-{id}@example.com"),
380        in_reply_to: String::new(),
381        thread_id: format!("thread-{id}"),
382        user_address: user.to_string(),
383        new_content: Some(format!("snippet {id}")),
384        blob_id: format!("blob-{id}"),
385    }
386}
387
388/// Assemble a [`JmapRequest`] from a slice of `(method, args, call_id)`
389/// tuples. Always declares the mail capability.
390pub fn make_request(calls: &[(&str, Value, &str)]) -> JmapRequest {
391    JmapRequest {
392        using: vec!["urn:ietf:params:jmap:mail".to_string()],
393        method_calls: calls
394            .iter()
395            .map(|(m, a, c)| (m.to_string(), a.clone(), c.to_string()))
396            .collect(),
397    }
398}
399
400/// Build a [`ParsedBody`] containing only a plain-text part.
401pub fn parsed_with_text(text: &str) -> ParsedBody {
402    ParsedBody {
403        text: Some(text.to_string()),
404        html: None,
405        attachments: vec![],
406    }
407}
408
409/// Build a [`ParsedBody`] containing one attachment of the given size and no
410/// body parts.
411pub fn parsed_with_attachment(filename: &str, content_type: &str, size: u32) -> ParsedBody {
412    ParsedBody {
413        text: None,
414        html: None,
415        attachments: vec![Attachment {
416            filename: filename.to_string(),
417            content_type: content_type.to_string(),
418            size,
419        }],
420    }
421}