Skip to main content

rusmes_storage/backends/
postgres_complete.rs

1//! Complete PostgreSQL storage backend implementation
2
3use crate::traits::{MailboxStore, MessageStore, MetadataStore, StorageBackend};
4use crate::types::{
5    Mailbox, MailboxCounters, MailboxId, MailboxPath, MessageFlags, MessageMetadata, Quota,
6    SearchCriteria,
7};
8use async_trait::async_trait;
9use rusmes_proto::{Mail, MessageId, Username};
10use sqlx::postgres::{PgPool, PgPoolOptions};
11use sqlx::{Executor, Row};
12use std::sync::Arc;
13
14/// Complete PostgreSQL storage backend with connection pooling
15pub struct PostgresCompleteBackend {
16    pool: PgPool,
17}
18
19impl PostgresCompleteBackend {
20    /// Create a new PostgreSQL backend with connection pooling
21    pub async fn new(database_url: &str) -> anyhow::Result<Self> {
22        let pool = PgPoolOptions::new()
23            .max_connections(20)
24            .connect(database_url)
25            .await?;
26
27        Ok(Self { pool })
28    }
29
30    /// Initialize database schema and migrations
31    pub async fn init_schema(&self) -> anyhow::Result<()> {
32        // Mailboxes table
33        self.pool
34            .execute(
35                r#"
36            CREATE TABLE IF NOT EXISTS mailboxes (
37                id UUID PRIMARY KEY,
38                username TEXT NOT NULL,
39                path TEXT NOT NULL,
40                uid_validity INTEGER NOT NULL,
41                uid_next INTEGER NOT NULL,
42                special_use TEXT,
43                created_at TIMESTAMP NOT NULL DEFAULT NOW(),
44                updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
45                UNIQUE(username, path)
46            )
47            "#,
48            )
49            .await?;
50
51        self.pool
52            .execute("CREATE INDEX IF NOT EXISTS idx_mailboxes_username ON mailboxes(username)")
53            .await?;
54        self.pool
55            .execute("CREATE INDEX IF NOT EXISTS idx_mailboxes_path ON mailboxes(path)")
56            .await?;
57
58        // Messages table with BLOB storage
59        self.pool
60            .execute(
61                r#"
62            CREATE TABLE IF NOT EXISTS messages (
63                id UUID PRIMARY KEY,
64                mailbox_id UUID NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
65                uid INTEGER NOT NULL,
66                sender TEXT,
67                recipients TEXT[] NOT NULL,
68                headers JSONB NOT NULL,
69                body BYTEA NOT NULL,
70                size INTEGER NOT NULL,
71                search_vector TSVECTOR,
72                created_at TIMESTAMP NOT NULL DEFAULT NOW(),
73                UNIQUE(mailbox_id, uid)
74            )
75            "#,
76            )
77            .await?;
78
79        self.pool
80            .execute("CREATE INDEX IF NOT EXISTS idx_messages_mailbox ON messages(mailbox_id)")
81            .await?;
82        self.pool
83            .execute("CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender)")
84            .await?;
85        self.pool.execute("CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector)").await?;
86
87        // Message flags table
88        self.pool
89            .execute(
90                r#"
91            CREATE TABLE IF NOT EXISTS message_flags (
92                message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
93                flag_seen BOOLEAN NOT NULL DEFAULT FALSE,
94                flag_answered BOOLEAN NOT NULL DEFAULT FALSE,
95                flag_flagged BOOLEAN NOT NULL DEFAULT FALSE,
96                flag_deleted BOOLEAN NOT NULL DEFAULT FALSE,
97                flag_draft BOOLEAN NOT NULL DEFAULT FALSE,
98                flag_recent BOOLEAN NOT NULL DEFAULT FALSE,
99                custom_flags TEXT[] NOT NULL DEFAULT '{}',
100                PRIMARY KEY(message_id)
101            )
102            "#,
103            )
104            .await?;
105
106        // Subscriptions table
107        self.pool
108            .execute(
109                r#"
110            CREATE TABLE IF NOT EXISTS subscriptions (
111                username TEXT NOT NULL,
112                mailbox_name TEXT NOT NULL,
113                created_at TIMESTAMP NOT NULL DEFAULT NOW(),
114                PRIMARY KEY(username, mailbox_name)
115            )
116            "#,
117            )
118            .await?;
119
120        // User quotas table
121        self.pool
122            .execute(
123                r#"
124            CREATE TABLE IF NOT EXISTS user_quotas (
125                username TEXT PRIMARY KEY,
126                used BIGINT NOT NULL DEFAULT 0,
127                quota_limit BIGINT NOT NULL,
128                updated_at TIMESTAMP NOT NULL DEFAULT NOW()
129            )
130            "#,
131            )
132            .await?;
133
134        // Create trigger for updating search_vector
135        self.pool.execute(
136            r#"
137            CREATE OR REPLACE FUNCTION messages_search_vector_update() RETURNS trigger AS $$
138            BEGIN
139                NEW.search_vector :=
140                    setweight(to_tsvector('english', COALESCE(NEW.sender, '')), 'A') ||
141                    setweight(to_tsvector('english', COALESCE(array_to_string(NEW.recipients, ' '), '')), 'B') ||
142                    setweight(to_tsvector('english', COALESCE(NEW.headers::text, '')), 'C');
143                RETURN NEW;
144            END
145            $$ LANGUAGE plpgsql
146            "#,
147        ).await?;
148
149        self.pool
150            .execute(
151                r#"
152            DROP TRIGGER IF EXISTS messages_search_vector_trigger ON messages;
153            CREATE TRIGGER messages_search_vector_trigger
154            BEFORE INSERT OR UPDATE ON messages
155            FOR EACH ROW EXECUTE FUNCTION messages_search_vector_update()
156            "#,
157            )
158            .await?;
159
160        Ok(())
161    }
162
163    /// Get pool reference
164    pub fn pool(&self) -> &PgPool {
165        &self.pool
166    }
167}
168
169impl StorageBackend for PostgresCompleteBackend {
170    fn mailbox_store(&self) -> Arc<dyn MailboxStore> {
171        Arc::new(PostgresCompleteMailboxStore {
172            pool: self.pool.clone(),
173        })
174    }
175
176    fn message_store(&self) -> Arc<dyn MessageStore> {
177        Arc::new(PostgresCompleteMessageStore {
178            pool: self.pool.clone(),
179        })
180    }
181
182    fn metadata_store(&self) -> Arc<dyn MetadataStore> {
183        Arc::new(PostgresCompleteMetadataStore {
184            pool: self.pool.clone(),
185        })
186    }
187}
188
189/// Complete PostgreSQL mailbox store
190struct PostgresCompleteMailboxStore {
191    pool: PgPool,
192}
193
194#[async_trait]
195impl MailboxStore for PostgresCompleteMailboxStore {
196    async fn create_mailbox(&self, path: &MailboxPath) -> anyhow::Result<MailboxId> {
197        let mailbox = Mailbox::new(path.clone());
198        let id = *mailbox.id();
199
200        sqlx::query(
201            r#"
202            INSERT INTO mailboxes (id, username, path, uid_validity, uid_next, special_use)
203            VALUES ($1, $2, $3, $4, $5, $6)
204            "#,
205        )
206        .bind(*id.as_uuid())
207        .bind(path.user().to_string())
208        .bind(path.path().join("/"))
209        .bind(mailbox.uid_validity() as i32)
210        .bind(mailbox.uid_next() as i32)
211        .bind(mailbox.special_use())
212        .execute(&self.pool)
213        .await?;
214
215        Ok(id)
216    }
217
218    async fn delete_mailbox(&self, id: &MailboxId) -> anyhow::Result<()> {
219        sqlx::query("DELETE FROM mailboxes WHERE id = $1")
220            .bind(*id.as_uuid())
221            .execute(&self.pool)
222            .await?;
223
224        Ok(())
225    }
226
227    async fn rename_mailbox(&self, id: &MailboxId, new_path: &MailboxPath) -> anyhow::Result<()> {
228        sqlx::query("UPDATE mailboxes SET path = $1, updated_at = NOW() WHERE id = $2")
229            .bind(new_path.path().join("/"))
230            .bind(*id.as_uuid())
231            .execute(&self.pool)
232            .await?;
233
234        Ok(())
235    }
236
237    async fn get_mailbox(&self, id: &MailboxId) -> anyhow::Result<Option<Mailbox>> {
238        let row = sqlx::query(
239            "SELECT id, username, path, uid_validity, uid_next, special_use FROM mailboxes WHERE id = $1"
240        )
241        .bind(*id.as_uuid())
242        .fetch_optional(&self.pool)
243        .await?;
244
245        let row = match row {
246            Some(r) => r,
247            None => return Ok(None),
248        };
249
250        let username: String = row.try_get("username")?;
251        let path_str: String = row.try_get("path")?;
252        let path_parts: Vec<String> = path_str.split('/').map(|s| s.to_string()).collect();
253        let username_obj =
254            Username::new(username).map_err(|e| anyhow::anyhow!("Invalid username: {}", e))?;
255        let path = MailboxPath::new(username_obj, path_parts);
256
257        let mut mailbox = Mailbox::new(path);
258        let special_use: Option<String> = row.try_get("special_use")?;
259        mailbox.set_special_use(special_use);
260
261        Ok(Some(mailbox))
262    }
263
264    async fn list_mailboxes(&self, user: &Username) -> anyhow::Result<Vec<Mailbox>> {
265        let rows = sqlx::query(
266            "SELECT id, username, path, uid_validity, uid_next, special_use FROM mailboxes WHERE username = $1 ORDER BY path"
267        )
268        .bind(user.to_string())
269        .fetch_all(&self.pool)
270        .await?;
271
272        let mailboxes = rows
273            .into_iter()
274            .filter_map(|row| {
275                let username: String = row.try_get("username").ok()?;
276                let path_str: String = row.try_get("path").ok()?;
277                let path_parts: Vec<String> = path_str.split('/').map(|s| s.to_string()).collect();
278                let username_obj = Username::new(username).ok()?;
279                let path = MailboxPath::new(username_obj, path_parts);
280
281                let mut mailbox = Mailbox::new(path);
282                if let Ok(Some(special_use)) = row.try_get("special_use") {
283                    mailbox.set_special_use(Some(special_use));
284                }
285                Some(mailbox)
286            })
287            .collect();
288
289        Ok(mailboxes)
290    }
291
292    async fn get_user_inbox(&self, user: &Username) -> anyhow::Result<Option<MailboxId>> {
293        let row =
294            sqlx::query("SELECT id FROM mailboxes WHERE username = $1 AND path = 'INBOX' LIMIT 1")
295                .bind(user.to_string())
296                .fetch_optional(&self.pool)
297                .await?;
298
299        if let Some(row) = row {
300            let uuid: uuid::Uuid = row.get(0);
301            Ok(Some(MailboxId::from_uuid(uuid)))
302        } else {
303            Ok(None)
304        }
305    }
306
307    async fn subscribe_mailbox(&self, user: &Username, mailbox_name: String) -> anyhow::Result<()> {
308        sqlx::query(
309            "INSERT INTO subscriptions (username, mailbox_name) VALUES ($1, $2) ON CONFLICT DO NOTHING"
310        )
311        .bind(user.to_string())
312        .bind(mailbox_name)
313        .execute(&self.pool)
314        .await?;
315
316        Ok(())
317    }
318
319    async fn unsubscribe_mailbox(&self, user: &Username, mailbox_name: &str) -> anyhow::Result<()> {
320        sqlx::query("DELETE FROM subscriptions WHERE username = $1 AND mailbox_name = $2")
321            .bind(user.to_string())
322            .bind(mailbox_name)
323            .execute(&self.pool)
324            .await?;
325
326        Ok(())
327    }
328
329    async fn list_subscriptions(&self, user: &Username) -> anyhow::Result<Vec<String>> {
330        let rows = sqlx::query("SELECT mailbox_name FROM subscriptions WHERE username = $1")
331            .bind(user.to_string())
332            .fetch_all(&self.pool)
333            .await?;
334
335        let subscriptions = rows
336            .into_iter()
337            .filter_map(|row| row.try_get("mailbox_name").ok())
338            .collect();
339
340        Ok(subscriptions)
341    }
342}
343
344/// Complete PostgreSQL message store
345struct PostgresCompleteMessageStore {
346    pool: PgPool,
347}
348
349#[async_trait]
350impl MessageStore for PostgresCompleteMessageStore {
351    async fn append_message(
352        &self,
353        mailbox_id: &MailboxId,
354        message: Mail,
355    ) -> anyhow::Result<MessageMetadata> {
356        // Get next UID for mailbox
357        let uid_row = sqlx::query("SELECT uid_next FROM mailboxes WHERE id = $1 FOR UPDATE")
358            .bind(*mailbox_id.as_uuid())
359            .fetch_one(&self.pool)
360            .await?;
361        let uid: i32 = uid_row.try_get("uid_next")?;
362
363        // Serialize message
364        // In production: serialize headers and body properly
365        let headers_json = serde_json::json!({});
366        let body_bytes: &[u8] = b"";
367
368        // Insert message
369        sqlx::query(
370            r#"
371            INSERT INTO messages (id, mailbox_id, uid, sender, recipients, headers, body, size)
372            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
373            "#,
374        )
375        .bind(*message.message_id().as_uuid())
376        .bind(*mailbox_id.as_uuid())
377        .bind(uid)
378        .bind(message.sender().map(|s| s.to_string()))
379        .bind(
380            message
381                .recipients()
382                .iter()
383                .map(|r| r.to_string())
384                .collect::<Vec<_>>(),
385        )
386        .bind(&headers_json)
387        .bind(body_bytes)
388        .bind(message.size() as i32)
389        .execute(&self.pool)
390        .await?;
391
392        // Insert initial flags
393        sqlx::query("INSERT INTO message_flags (message_id, flag_recent) VALUES ($1, TRUE)")
394            .bind(*message.message_id().as_uuid())
395            .execute(&self.pool)
396            .await?;
397
398        // Update mailbox uid_next
399        sqlx::query("UPDATE mailboxes SET uid_next = $1 WHERE id = $2")
400            .bind(uid + 1)
401            .bind(*mailbox_id.as_uuid())
402            .execute(&self.pool)
403            .await?;
404
405        let metadata = MessageMetadata::new(
406            *message.message_id(),
407            *mailbox_id,
408            uid as u32,
409            MessageFlags::new(),
410            message.size(),
411        );
412
413        Ok(metadata)
414    }
415
416    async fn get_message(&self, _message_id: &MessageId) -> anyhow::Result<Option<Mail>> {
417        // In production: reconstruct Mail from stored data
418        // This requires parsing the body as MimeMessage
419        Ok(None)
420    }
421
422    async fn delete_messages(&self, message_ids: &[MessageId]) -> anyhow::Result<()> {
423        let uuids: Vec<uuid::Uuid> = message_ids.iter().map(|id| *id.as_uuid()).collect();
424
425        sqlx::query("DELETE FROM messages WHERE id = ANY($1)")
426            .bind(&uuids)
427            .execute(&self.pool)
428            .await?;
429
430        Ok(())
431    }
432
433    async fn set_flags(
434        &self,
435        message_ids: &[MessageId],
436        flags: MessageFlags,
437    ) -> anyhow::Result<()> {
438        let uuids: Vec<uuid::Uuid> = message_ids.iter().map(|id| *id.as_uuid()).collect();
439
440        sqlx::query(
441            r#"
442            UPDATE message_flags SET
443                flag_seen = $1,
444                flag_answered = $2,
445                flag_flagged = $3,
446                flag_deleted = $4,
447                flag_draft = $5
448            WHERE message_id = ANY($6)
449            "#,
450        )
451        .bind(flags.is_seen())
452        .bind(flags.is_answered())
453        .bind(flags.is_flagged())
454        .bind(flags.is_deleted())
455        .bind(flags.is_draft())
456        .bind(&uuids)
457        .execute(&self.pool)
458        .await?;
459
460        Ok(())
461    }
462
463    async fn search(
464        &self,
465        mailbox_id: &MailboxId,
466        criteria: SearchCriteria,
467    ) -> anyhow::Result<Vec<MessageId>> {
468        let query = match criteria {
469            SearchCriteria::All => {
470                sqlx::query("SELECT id FROM messages WHERE mailbox_id = $1")
471                    .bind(*mailbox_id.as_uuid())
472                    .fetch_all(&self.pool)
473                    .await?
474            }
475            SearchCriteria::Unseen => {
476                sqlx::query(
477                    r#"
478                    SELECT m.id FROM messages m
479                    JOIN message_flags f ON m.id = f.message_id
480                    WHERE m.mailbox_id = $1 AND f.flag_seen = FALSE
481                    "#,
482                )
483                .bind(*mailbox_id.as_uuid())
484                .fetch_all(&self.pool)
485                .await?
486            }
487            SearchCriteria::Seen => {
488                sqlx::query(
489                    r#"
490                    SELECT m.id FROM messages m
491                    JOIN message_flags f ON m.id = f.message_id
492                    WHERE m.mailbox_id = $1 AND f.flag_seen = TRUE
493                    "#,
494                )
495                .bind(*mailbox_id.as_uuid())
496                .fetch_all(&self.pool)
497                .await?
498            }
499            SearchCriteria::Subject(text) => {
500                sqlx::query(
501                    r#"
502                    SELECT id FROM messages
503                    WHERE mailbox_id = $1 AND search_vector @@ plainto_tsquery('english', $2)
504                    "#,
505                )
506                .bind(*mailbox_id.as_uuid())
507                .bind(text)
508                .fetch_all(&self.pool)
509                .await?
510            }
511            SearchCriteria::From(email) => {
512                sqlx::query("SELECT id FROM messages WHERE mailbox_id = $1 AND sender ILIKE $2")
513                    .bind(*mailbox_id.as_uuid())
514                    .bind(format!("%{}%", email))
515                    .fetch_all(&self.pool)
516                    .await?
517            }
518            _ => Vec::new(),
519        };
520
521        let _message_ids: Vec<MessageId> = query
522            .into_iter()
523            .filter_map(|row| {
524                let _uuid: uuid::Uuid = row.try_get("id").ok()?;
525                Some(MessageId::new())
526            })
527            .collect();
528
529        Ok(vec![])
530    }
531
532    async fn copy_messages(
533        &self,
534        message_ids: &[MessageId],
535        dest_mailbox_id: &MailboxId,
536    ) -> anyhow::Result<Vec<MessageMetadata>> {
537        let mut metadata_list = Vec::new();
538
539        for message_id in message_ids {
540            let message = self.get_message(message_id).await?;
541            if let Some(msg) = message {
542                let metadata = self.append_message(dest_mailbox_id, msg).await?;
543                metadata_list.push(metadata);
544            }
545        }
546
547        Ok(metadata_list)
548    }
549
550    async fn get_mailbox_messages(
551        &self,
552        mailbox_id: &MailboxId,
553    ) -> anyhow::Result<Vec<MessageMetadata>> {
554        let rows = sqlx::query(
555            r#"
556            SELECT m.id, m.mailbox_id, m.uid, m.size,
557                   f.flag_seen, f.flag_answered, f.flag_flagged,
558                   f.flag_deleted, f.flag_draft, f.flag_recent
559            FROM messages m
560            LEFT JOIN message_flags f ON m.id = f.message_id
561            WHERE m.mailbox_id = $1
562            ORDER BY m.uid
563            "#,
564        )
565        .bind(*mailbox_id.as_uuid())
566        .fetch_all(&self.pool)
567        .await?;
568
569        let metadata_list = rows
570            .into_iter()
571            .filter_map(|row| {
572                let _msg_id: uuid::Uuid = row.try_get("id").ok()?;
573                let uid: i32 = row.try_get("uid").ok()?;
574                let size: i32 = row.try_get("size").ok()?;
575
576                let mut flags = MessageFlags::new();
577                if let Ok(seen) = row.try_get("flag_seen") {
578                    flags.set_seen(seen);
579                }
580                if let Ok(answered) = row.try_get("flag_answered") {
581                    flags.set_answered(answered);
582                }
583                if let Ok(flagged) = row.try_get("flag_flagged") {
584                    flags.set_flagged(flagged);
585                }
586                if let Ok(deleted) = row.try_get("flag_deleted") {
587                    flags.set_deleted(deleted);
588                }
589                if let Ok(draft) = row.try_get("flag_draft") {
590                    flags.set_draft(draft);
591                }
592                if let Ok(recent) = row.try_get("flag_recent") {
593                    flags.set_recent(recent);
594                }
595
596                Some(MessageMetadata::new(
597                    MessageId::new(),
598                    *mailbox_id,
599                    uid as u32,
600                    flags,
601                    size as usize,
602                ))
603            })
604            .collect();
605
606        Ok(metadata_list)
607    }
608}
609
610/// Complete PostgreSQL metadata store
611struct PostgresCompleteMetadataStore {
612    pool: PgPool,
613}
614
615#[async_trait]
616impl MetadataStore for PostgresCompleteMetadataStore {
617    async fn get_user_quota(&self, user: &Username) -> anyhow::Result<Quota> {
618        let row = sqlx::query("SELECT used, quota_limit FROM user_quotas WHERE username = $1")
619            .bind(user.to_string())
620            .fetch_optional(&self.pool)
621            .await?;
622
623        match row {
624            Some(r) => {
625                let used: i64 = r.try_get("used")?;
626                let limit: i64 = r.try_get("quota_limit")?;
627                Ok(Quota::new(used as u64, limit as u64))
628            }
629            None => Ok(Quota::new(0, 1024 * 1024 * 1024)), // Default 1GB
630        }
631    }
632
633    async fn set_user_quota(&self, user: &Username, quota: Quota) -> anyhow::Result<()> {
634        sqlx::query(
635            r#"
636            INSERT INTO user_quotas (username, used, quota_limit)
637            VALUES ($1, $2, $3)
638            ON CONFLICT (username) DO UPDATE
639            SET used = $2, quota_limit = $3, updated_at = NOW()
640            "#,
641        )
642        .bind(user.to_string())
643        .bind(quota.used as i64)
644        .bind(quota.limit as i64)
645        .execute(&self.pool)
646        .await?;
647
648        Ok(())
649    }
650
651    async fn get_mailbox_counters(
652        &self,
653        mailbox_id: &MailboxId,
654    ) -> anyhow::Result<MailboxCounters> {
655        let row = sqlx::query(
656            r#"
657            SELECT
658                COUNT(*) as total,
659                COUNT(*) FILTER (WHERE f.flag_recent = TRUE) as recent,
660                COUNT(*) FILTER (WHERE f.flag_seen = FALSE) as unseen
661            FROM messages m
662            LEFT JOIN message_flags f ON m.id = f.message_id
663            WHERE m.mailbox_id = $1
664            "#,
665        )
666        .bind(*mailbox_id.as_uuid())
667        .fetch_one(&self.pool)
668        .await?;
669
670        let total: i64 = row.try_get("total")?;
671        let recent: i64 = row.try_get("recent")?;
672        let unseen: i64 = row.try_get("unseen")?;
673
674        Ok(MailboxCounters {
675            exists: total as u32,
676            recent: recent as u32,
677            unseen: unseen as u32,
678        })
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    #[test]
687    fn test_postgres_complete_backend_struct() {
688        // Test that the struct is properly defined
689        let _ = std::mem::size_of::<PostgresCompleteBackend>();
690    }
691
692    #[tokio::test]
693    async fn test_init_schema_creates_tables() {
694        // This would require a test database connection
695        // For now, we just verify the schema SQL is valid
696        let schema = r#"
697            CREATE TABLE IF NOT EXISTS mailboxes (
698                id UUID PRIMARY KEY,
699                username TEXT NOT NULL,
700                path TEXT NOT NULL,
701                uid_validity INTEGER NOT NULL,
702                uid_next INTEGER NOT NULL,
703                special_use TEXT,
704                created_at TIMESTAMP NOT NULL DEFAULT NOW(),
705                updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
706                UNIQUE(username, path)
707            )
708        "#;
709        assert!(schema.contains("CREATE TABLE"));
710    }
711
712    #[test]
713    fn test_search_criteria_all() {
714        let criteria = SearchCriteria::All;
715        assert!(matches!(criteria, SearchCriteria::All));
716    }
717
718    #[test]
719    fn test_search_criteria_unseen() {
720        let criteria = SearchCriteria::Unseen;
721        assert!(matches!(criteria, SearchCriteria::Unseen));
722    }
723
724    #[test]
725    fn test_search_criteria_from() {
726        let criteria = SearchCriteria::From("test@example.com".to_string());
727        assert!(matches!(criteria, SearchCriteria::From(_)));
728    }
729
730    #[test]
731    fn test_search_criteria_subject() {
732        let criteria = SearchCriteria::Subject("test subject".to_string());
733        assert!(matches!(criteria, SearchCriteria::Subject(_)));
734    }
735
736    #[test]
737    fn test_message_flags_default() {
738        let flags = MessageFlags::new();
739        assert!(!flags.is_seen());
740        assert!(!flags.is_answered());
741        assert!(!flags.is_flagged());
742        assert!(!flags.is_deleted());
743        assert!(!flags.is_draft());
744    }
745
746    #[test]
747    fn test_message_flags_setters() {
748        let mut flags = MessageFlags::new();
749        flags.set_seen(true);
750        flags.set_answered(true);
751        flags.set_flagged(true);
752
753        assert!(flags.is_seen());
754        assert!(flags.is_answered());
755        assert!(flags.is_flagged());
756    }
757
758    #[test]
759    fn test_quota_new() {
760        let quota = Quota::new(1024, 2048);
761        assert_eq!(quota.used, 1024);
762        assert_eq!(quota.limit, 2048);
763    }
764
765    #[test]
766    fn test_quota_exceeded() {
767        let quota = Quota::new(2048, 1024);
768        assert!(quota.is_exceeded());
769
770        let quota_ok = Quota::new(512, 1024);
771        assert!(!quota_ok.is_exceeded());
772    }
773
774    #[test]
775    fn test_quota_remaining() {
776        let quota = Quota::new(256, 1024);
777        assert_eq!(quota.remaining(), 768);
778    }
779
780    #[test]
781    fn test_mailbox_counters_default() {
782        let counters = MailboxCounters::default();
783        assert_eq!(counters.exists, 0);
784        assert_eq!(counters.recent, 0);
785        assert_eq!(counters.unseen, 0);
786    }
787
788    #[test]
789    fn test_mailbox_id_new() {
790        let id1 = MailboxId::new();
791        let id2 = MailboxId::new();
792        assert_ne!(id1, id2);
793    }
794
795    #[test]
796    fn test_mailbox_id_display() {
797        let id = MailboxId::new();
798        let display = format!("{}", id);
799        assert!(!display.is_empty());
800    }
801
802    #[test]
803    fn test_mailbox_path_creation() {
804        let user = Username::new("test@example.com".to_string()).unwrap();
805        let path = MailboxPath::new(user.clone(), vec!["INBOX".to_string()]);
806        assert_eq!(path.user(), &user);
807        assert_eq!(path.path().len(), 1);
808    }
809
810    #[test]
811    fn test_mailbox_path_name() {
812        let user = Username::new("test@example.com".to_string()).unwrap();
813        let path = MailboxPath::new(user, vec!["INBOX".to_string(), "Sent".to_string()]);
814        assert_eq!(path.name(), Some("Sent"));
815    }
816
817    #[test]
818    fn test_mailbox_new() {
819        let user = Username::new("test@example.com".to_string()).unwrap();
820        let path = MailboxPath::new(user, vec!["INBOX".to_string()]);
821        let mailbox = Mailbox::new(path);
822
823        assert_eq!(mailbox.uid_validity(), 1);
824        assert_eq!(mailbox.uid_next(), 1);
825        assert!(mailbox.special_use().is_none());
826    }
827
828    #[test]
829    fn test_mailbox_special_use() {
830        let user = Username::new("test@example.com".to_string()).unwrap();
831        let path = MailboxPath::new(user, vec!["Sent".to_string()]);
832        let mut mailbox = Mailbox::new(path);
833
834        mailbox.set_special_use(Some("\\Sent".to_string()));
835        assert_eq!(mailbox.special_use(), Some("\\Sent"));
836    }
837
838    #[test]
839    fn test_message_metadata_new() {
840        let msg_id = MessageId::new();
841        let mailbox_id = MailboxId::new();
842        let flags = MessageFlags::new();
843
844        let metadata = MessageMetadata::new(msg_id, mailbox_id, 1, flags, 1024);
845
846        assert_eq!(metadata.message_id(), &msg_id);
847        assert_eq!(metadata.mailbox_id(), &mailbox_id);
848        assert_eq!(metadata.uid(), 1);
849        assert_eq!(metadata.size(), 1024);
850    }
851
852    #[test]
853    fn test_message_metadata_getters() {
854        let msg_id = MessageId::new();
855        let mailbox_id = MailboxId::new();
856        let metadata = MessageMetadata::new(msg_id, mailbox_id, 42, MessageFlags::new(), 2048);
857
858        assert_eq!(*metadata.message_id(), msg_id);
859        assert_eq!(*metadata.mailbox_id(), mailbox_id);
860        assert_eq!(metadata.uid(), 42);
861        assert_eq!(metadata.size(), 2048);
862    }
863
864    #[test]
865    fn test_search_criteria_and() {
866        let criteria = SearchCriteria::And(vec![
867            SearchCriteria::Unseen,
868            SearchCriteria::From("test@example.com".to_string()),
869        ]);
870        assert!(matches!(criteria, SearchCriteria::And(_)));
871    }
872
873    #[test]
874    fn test_search_criteria_or() {
875        let criteria = SearchCriteria::Or(vec![SearchCriteria::Flagged, SearchCriteria::Deleted]);
876        assert!(matches!(criteria, SearchCriteria::Or(_)));
877    }
878
879    #[test]
880    fn test_search_criteria_not() {
881        let criteria = SearchCriteria::Not(Box::new(SearchCriteria::Seen));
882        assert!(matches!(criteria, SearchCriteria::Not(_)));
883    }
884
885    #[test]
886    fn test_mailbox_counters_struct() {
887        let counters = MailboxCounters {
888            exists: 10,
889            recent: 3,
890            unseen: 5,
891        };
892        assert_eq!(counters.exists, 10);
893        assert_eq!(counters.recent, 3);
894        assert_eq!(counters.unseen, 5);
895    }
896}