Skip to main content

mailsis_utils/
metadata.rs

1//! SQLite-backed email metadata index.
2//!
3//! After an email is stored on disk, its envelope data (sender, recipient,
4//! subject, file path) is recorded in a local SQLite database so that the
5//! IMAP server can list and search messages without scanning the filesystem.
6
7use std::{
8    error::Error,
9    path::{Path, PathBuf},
10};
11
12use rusqlite::{params, Connection};
13use tokio::task::spawn_blocking;
14
15#[derive(Clone, Debug)]
16pub struct EmailMetadata {
17    pub message_id: String,
18    pub from: String,
19    pub rcpt: String,
20    pub subject: String,
21    pub path: PathBuf,
22}
23
24impl EmailMetadata {
25    pub fn new(
26        message_id: String,
27        from: String,
28        rcpt: String,
29        subject: String,
30        path: PathBuf,
31    ) -> Self {
32        Self {
33            message_id,
34            from,
35            rcpt,
36            subject,
37            path,
38        }
39    }
40
41    pub async fn store_sqlite(
42        &self,
43        db: impl AsRef<Path>,
44    ) -> Result<(), Box<dyn Error + Send + Sync>> {
45        let path = db.as_ref().to_path_buf();
46        let metadata = self.clone();
47
48        // spawns a blocking task to store the metadata in the database
49        // this is done to avoid blocking the main thread
50        spawn_blocking(move || -> Result<(), Box<dyn Error + Send + Sync>> {
51            let conn = Connection::open(path)?;
52            conn.execute(
53                "CREATE TABLE IF NOT EXISTS metadata (message_id TEXT PRIMARY KEY, _from TEXT, rcpt TEXT, subject TEXT, path TEXT)",
54                [],
55            )?;
56            conn.execute(
57                "INSERT INTO metadata (message_id, _from, rcpt, subject, path) VALUES (?1, ?2, ?3, ?4, ?5)",
58                params![metadata.message_id, metadata.from, metadata.rcpt, metadata.subject, metadata.path.to_string_lossy()],
59            )?;
60            Ok(())
61        })
62        .await??;
63        Ok(())
64    }
65
66    pub async fn retrieve_sqlite(
67        db: impl AsRef<Path>,
68        message_id: String,
69    ) -> Result<EmailMetadata, Box<dyn Error + Send + Sync>> {
70        let path = db.as_ref().to_path_buf();
71
72        // Spawn a blocking task to retrieve the metadata from the database
73        // this is done to avoid blocking the main thread
74        spawn_blocking(
75            move || -> Result<EmailMetadata, Box<dyn Error + Send + Sync>> {
76                let conn = Connection::open(path)?;
77                let mut stmt = conn.prepare(
78                    "SELECT message_id, _from, rcpt, subject, path FROM metadata WHERE message_id = ?",
79                )?;
80                let row = stmt.query_row(params![message_id], |row| {
81                    Ok(EmailMetadata {
82                        message_id: row.get(0)?,
83                        from: row.get(1)?,
84                        rcpt: row.get(2)?,
85                        subject: row.get(3)?,
86                        path: PathBuf::from(row.get::<_, String>(4)?),
87                    })
88                })?;
89                Ok(row)
90            },
91        )
92        .await?
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use tempfile::NamedTempFile;
99
100    use super::*;
101
102    #[test]
103    fn test_email_metadata_new() {
104        let message_id = "test-message-id".to_string();
105        let from = "sender@example.com".to_string();
106        let rcpt = "recipient@example.com".to_string();
107        let subject = "Test Subject".to_string();
108        let path = PathBuf::from("/path/to/email.eml");
109
110        let metadata = EmailMetadata::new(
111            message_id.clone(),
112            from.clone(),
113            rcpt.clone(),
114            subject.clone(),
115            path.clone(),
116        );
117
118        assert_eq!(metadata.message_id, message_id);
119        assert_eq!(metadata.from, from);
120        assert_eq!(metadata.rcpt, rcpt);
121        assert_eq!(metadata.subject, subject);
122        assert_eq!(metadata.path, path);
123    }
124
125    #[test]
126    fn test_email_metadata_clone() {
127        let original = EmailMetadata::new(
128            "test-id".to_string(),
129            "sender@example.com".to_string(),
130            "recipient@example.com".to_string(),
131            "Test Subject".to_string(),
132            PathBuf::from("/path/to/email.eml"),
133        );
134
135        let cloned = original.clone();
136
137        assert_eq!(original.message_id, cloned.message_id);
138        assert_eq!(original.from, cloned.from);
139        assert_eq!(original.rcpt, cloned.rcpt);
140        assert_eq!(original.subject, cloned.subject);
141        assert_eq!(original.path, cloned.path);
142    }
143
144    #[tokio::test]
145    async fn test_store_sqlite_success() {
146        let temp_file = NamedTempFile::new().unwrap();
147        let db_path = temp_file.path();
148
149        let metadata = EmailMetadata::new(
150            "test-message-id".to_string(),
151            "sender@example.com".to_string(),
152            "recipient@example.com".to_string(),
153            "Test Subject".to_string(),
154            PathBuf::from("/path/to/email.eml"),
155        );
156
157        let result = metadata.store_sqlite(db_path).await;
158        assert!(result.is_ok(), "Failed to store metadata: {result:?}");
159
160        let conn = Connection::open(db_path).unwrap();
161        let mut stmt = conn
162            .prepare(
163                "SELECT message_id, _from, rcpt, subject, path FROM metadata WHERE message_id = ?",
164            )
165            .unwrap();
166
167        let row = stmt
168            .query_row(params!["test-message-id"], |row| {
169                Ok((
170                    row.get::<_, String>(0)?,
171                    row.get::<_, String>(1)?,
172                    row.get::<_, String>(2)?,
173                    row.get::<_, String>(3)?,
174                    row.get::<_, String>(4)?,
175                ))
176            })
177            .unwrap();
178
179        assert_eq!(row.0, "test-message-id");
180        assert_eq!(row.1, "sender@example.com");
181        assert_eq!(row.2, "recipient@example.com");
182        assert_eq!(row.3, "Test Subject");
183        assert_eq!(row.4, "/path/to/email.eml");
184    }
185
186    #[tokio::test]
187    async fn test_store_sqlite_duplicate_id() {
188        let temp_file = NamedTempFile::new().unwrap();
189        let db_path = temp_file.path();
190
191        let metadata1 = EmailMetadata::new(
192            "duplicate-id".to_string(),
193            "sender1@example.com".to_string(),
194            "recipient1@example.com".to_string(),
195            "First Subject".to_string(),
196            PathBuf::from("/path/to/email1.eml"),
197        );
198
199        let metadata2 = EmailMetadata::new(
200            "duplicate-id".to_string(),
201            "sender2@example.com".to_string(),
202            "recipient2@example.com".to_string(),
203            "Second Subject".to_string(),
204            PathBuf::from("/path/to/email2.eml"),
205        );
206
207        let result1 = metadata1.store_sqlite(db_path).await;
208        assert!(result1.is_ok(), "First insert should succeed: {result1:?}");
209
210        let result2 = metadata2.store_sqlite(db_path).await;
211        assert!(
212            result2.is_err(),
213            "Second insert should fail due to duplicate ID"
214        );
215    }
216
217    #[tokio::test]
218    async fn test_store_sqlite_multiple_entries() {
219        let temp_file = NamedTempFile::new().unwrap();
220        let db_path = temp_file.path();
221
222        let metadata1 = EmailMetadata::new(
223            "id-1".to_string(),
224            "sender1@example.com".to_string(),
225            "recipient1@example.com".to_string(),
226            "Subject 1".to_string(),
227            PathBuf::from("/path/to/email1.eml"),
228        );
229
230        let metadata2 = EmailMetadata::new(
231            "id-2".to_string(),
232            "sender2@example.com".to_string(),
233            "recipient2@example.com".to_string(),
234            "Subject 2".to_string(),
235            PathBuf::from("/path/to/email2.eml"),
236        );
237
238        let result1 = metadata1.store_sqlite(db_path).await;
239        assert!(result1.is_ok(), "First insert failed: {result1:?}");
240
241        let result2 = metadata2.store_sqlite(db_path).await;
242        assert!(result2.is_ok(), "Second insert failed: {result2:?}");
243
244        let conn = Connection::open(db_path).unwrap();
245        let count: i64 = conn
246            .query_row("SELECT COUNT(*) FROM metadata", [], |row| row.get(0))
247            .unwrap();
248
249        assert_eq!(count, 2, "Should have exactly 2 entries in the database");
250    }
251
252    #[tokio::test]
253    async fn test_store_sqlite_with_special_characters() {
254        let temp_file = NamedTempFile::new().unwrap();
255        let db_path = temp_file.path();
256
257        let metadata = EmailMetadata::new(
258            "test-id-with-special-chars".to_string(),
259            "sender+tag@example.com".to_string(),
260            "recipient.name@domain.co.uk".to_string(),
261            "Subject with \"quotes\" and 'apostrophes'".to_string(),
262            PathBuf::from("/path/with spaces/email.eml"),
263        );
264
265        let result = metadata.store_sqlite(db_path).await;
266        assert!(
267            result.is_ok(),
268            "Failed to store metadata with special characters: {result:?}"
269        );
270
271        let conn = Connection::open(db_path).unwrap();
272        let mut stmt = conn
273            .prepare(
274                "SELECT message_id, _from, rcpt, subject, path FROM metadata WHERE message_id = ?",
275            )
276            .unwrap();
277        let row = stmt
278            .query_row(params!["test-id-with-special-chars"], |row| {
279                Ok((
280                    row.get::<_, String>(0)?,
281                    row.get::<_, String>(1)?,
282                    row.get::<_, String>(2)?,
283                    row.get::<_, String>(3)?,
284                    row.get::<_, String>(4)?,
285                ))
286            })
287            .unwrap();
288
289        assert_eq!(row.0, "test-id-with-special-chars");
290        assert_eq!(row.1, "sender+tag@example.com");
291        assert_eq!(row.2, "recipient.name@domain.co.uk");
292        assert_eq!(row.3, "Subject with \"quotes\" and 'apostrophes'");
293        assert_eq!(row.4, "/path/with spaces/email.eml");
294    }
295
296    #[tokio::test]
297    async fn test_store_sqlite_invalid_path() {
298        let metadata = EmailMetadata::new(
299            "test-id".to_string(),
300            "sender@example.com".to_string(),
301            "recipient@example.com".to_string(),
302            "Test Subject".to_string(),
303            PathBuf::from("/path/to/email.eml"),
304        );
305        let invalid_path = PathBuf::from("/nonexistent/directory/database.db");
306        let result = metadata.store_sqlite(&invalid_path).await;
307
308        assert!(
309            result.is_err(),
310            "Should fail when trying to create database in nonexistent directory"
311        );
312    }
313
314    #[tokio::test]
315    async fn test_retrieve_sqlite_success() {
316        let temp_file = NamedTempFile::new().unwrap();
317        let db_path = temp_file.path();
318
319        let original_metadata = EmailMetadata::new(
320            "retrieve-test-id".to_string(),
321            "sender@example.com".to_string(),
322            "recipient@example.com".to_string(),
323            "Test Subject for Retrieval".to_string(),
324            PathBuf::from("/path/to/email.eml"),
325        );
326
327        // First store the metadata
328        let store_result = original_metadata.store_sqlite(db_path).await;
329        assert!(
330            store_result.is_ok(),
331            "Failed to store metadata: {store_result:?}"
332        );
333
334        // Then retrieve it
335        let retrieved_metadata =
336            EmailMetadata::retrieve_sqlite(db_path, "retrieve-test-id".to_string()).await;
337        assert!(
338            retrieved_metadata.is_ok(),
339            "Failed to retrieve metadata: {retrieved_metadata:?}"
340        );
341
342        let retrieved = retrieved_metadata.unwrap();
343        assert_eq!(retrieved.message_id, original_metadata.message_id);
344        assert_eq!(retrieved.from, original_metadata.from);
345        assert_eq!(retrieved.rcpt, original_metadata.rcpt);
346        assert_eq!(retrieved.subject, original_metadata.subject);
347        assert_eq!(retrieved.path, original_metadata.path);
348    }
349
350    #[tokio::test]
351    async fn test_retrieve_sqlite_not_found() {
352        let temp_file = NamedTempFile::new().unwrap();
353        let db_path = temp_file.path();
354
355        // Try to retrieve non-existent metadata
356        let result = EmailMetadata::retrieve_sqlite(db_path, "non-existent-id".to_string()).await;
357        assert!(
358            result.is_err(),
359            "Should fail when trying to retrieve non-existent metadata"
360        );
361    }
362}