diff --git a/Cargo.toml b/Cargo.toml index 51f8856..19374cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,9 @@ tempfile = "3.10" keyring = "2.1" age = "0.10" aes-gcm = "0.10" +mail-parser = "0.9" +bytes = "1.6" + diff --git a/crates/nuncio-core/Cargo.toml b/crates/nuncio-core/Cargo.toml index 8245b6f..8cfeba6 100644 --- a/crates/nuncio-core/Cargo.toml +++ b/crates/nuncio-core/Cargo.toml @@ -13,3 +13,4 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true tracing.workspace = true +bytes.workspace = true diff --git a/crates/nuncio-core/src/lib.rs b/crates/nuncio-core/src/lib.rs index a237e37..1250b26 100644 --- a/crates/nuncio-core/src/lib.rs +++ b/crates/nuncio-core/src/lib.rs @@ -1,7 +1,10 @@ //! Core event bus, state management, and command orchestration for Nuncio. pub mod config; +pub mod model; + pub use config::{AccountConfig, AccountProtocol, ConfigError}; +pub use model::{Attachment, Email, Folder}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; diff --git a/crates/nuncio-core/src/model.rs b/crates/nuncio-core/src/model.rs new file mode 100644 index 0000000..cc2a0ab --- /dev/null +++ b/crates/nuncio-core/src/model.rs @@ -0,0 +1,87 @@ +//! Pure domain entities owned by Nuncio (Email, Attachment, Folder). + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; + +/// Email attachment metadata and payload buffer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Attachment { + /// Attachment filename. + pub filename: String, + /// MIME content type (e.g. "application/pdf"). + pub mime_type: String, + /// Raw attachment content buffer. + #[serde(skip)] + pub content: Bytes, +} + +/// Email message domain entity owned by Nuncio core. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Email { + /// Unique message identifier. + pub id: String, + /// Account identifier owning the message. + pub account_id: String, + /// Mailbox folder identifier (e.g. "inbox"). + pub folder_id: String, + /// Subject line. + pub subject: String, + /// Sender address (e.g. "alice@nuncio.mx"). + pub sender: String, + /// Recipient address (e.g. "bob@nuncio.mx"). + pub recipient: String, + /// Unix timestamp of message arrival. + pub received_at: i64, + /// Read/unread flag status. + pub read: bool, + /// Plaintext message body. + pub body_plain: Option, + /// HTML message body. + pub body_html: Option, + /// List of attached files. + pub attachments: Vec, +} + +/// Mailbox folder entity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Folder { + /// Folder identifier. + pub id: String, + /// Folder display name. + pub name: String, + /// Total message count. + pub total_messages: usize, + /// Unread message count. + pub unread_messages: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn email_domain_entity_creation_and_serde() { + let email = Email { + id: "msg-100".to_string(), + account_id: "acct-1".to_string(), + folder_id: "inbox".to_string(), + subject: "Architecture Review".to_string(), + sender: "alice@nuncio.mx".to_string(), + recipient: "bob@nuncio.mx".to_string(), + received_at: 1700000000, + read: false, + body_plain: Some("Hello world".to_string()), + body_html: Some("

Hello world

".to_string()), + attachments: vec![Attachment { + filename: "spec.pdf".to_string(), + mime_type: "application/pdf".to_string(), + content: Bytes::from_static(b"PDF DATA"), + }], + }; + + let json = serde_json::to_string(&email).unwrap(); + let parsed: Email = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.id, email.id); + assert_eq!(parsed.subject, email.subject); + } +} diff --git a/crates/nuncio-mail/Cargo.toml b/crates/nuncio-mail/Cargo.toml index ace3338..470e449 100644 --- a/crates/nuncio-mail/Cargo.toml +++ b/crates/nuncio-mail/Cargo.toml @@ -13,3 +13,5 @@ async-trait.workspace = true serde.workspace = true thiserror.workspace = true tracing.workspace = true +mail-parser.workspace = true +bytes.workspace = true diff --git a/crates/nuncio-mail/src/lib.rs b/crates/nuncio-mail/src/lib.rs index ea41a1d..d8856b8 100644 --- a/crates/nuncio-mail/src/lib.rs +++ b/crates/nuncio-mail/src/lib.rs @@ -1,21 +1,5 @@ -use thiserror::Error; +//! Protocol engines for IMAP4rev1, JMAP (RFC 8620/8621), and SMTP. -#[derive(Error, Debug)] -pub enum MailError { - #[error("Connection error: {0}")] - Connection(String), -} +pub mod parser; -pub struct MailEngine; - -impl MailEngine { - pub fn new() -> Self { - Self - } -} - -impl Default for MailEngine { - fn default() -> Self { - Self::new() - } -} +pub use parser::{MailError, MimeParserAdapter}; diff --git a/crates/nuncio-mail/src/parser.rs b/crates/nuncio-mail/src/parser.rs new file mode 100644 index 0000000..f38bc7c --- /dev/null +++ b/crates/nuncio-mail/src/parser.rs @@ -0,0 +1,120 @@ +//! Zero-copy MIME parser adapter wrapping Stalwart's `mail-parser` library. + +use bytes::Bytes; +use mail_parser::Message; +use nuncio_core::model::{Attachment, Email}; +use thiserror::Error; + +/// Errors returned by the MIME parsing engine. +#[derive(Error, Debug, PartialEq, Eq)] +pub enum MailError { + /// Failed to parse RFC 5322 byte slice. + #[error("failed to parse MIME email payload: {0}")] + ParseFailed(String), +} + +/// MIME parser adapter converting raw byte buffers into Nuncio [`Email`] domain entities. +pub struct MimeParserAdapter; + +impl MimeParserAdapter { + /// Parse a raw RFC 5322 byte slice into an [`Email`] domain entity. + pub fn parse_mime( + id: &str, + account_id: &str, + folder_id: &str, + raw_bytes: &[u8], + ) -> Result { + let msg = Message::parse(raw_bytes) + .ok_or_else(|| MailError::ParseFailed("invalid RFC 5322 MIME structure".to_string()))?; + + let subject = msg.subject().unwrap_or("No Subject").to_string(); + + let sender = msg + .from() + .and_then(|f| f.first()) + .and_then(|a| a.address()) + .unwrap_or("unknown@nuncio.mx") + .to_string(); + + let recipient = msg + .to() + .and_then(|t| t.first()) + .and_then(|a| a.address()) + .unwrap_or("me@nuncio.mx") + .to_string(); + + let received_at = msg.date().map_or(0, |d| d.to_timestamp()); + + let body_plain = msg.body_text(0).map(|b| b.to_string()); + let body_html = msg.body_html(0).map(|b| b.to_string()); + + let mut attachments = Vec::new(); + for attachment in msg.attachments() { + let filename = attachment + .attachment_name() + .unwrap_or("unnamed_attachment") + .to_string(); + + let mime_type = attachment + .content_type() + .map_or("application/octet-stream".to_string(), |c| { + format!("{}/{}", c.c_type, c.c_subtype.unwrap_or("octet-stream")) + }); + + let content = Bytes::copy_from_slice(attachment.contents()); + + attachments.push(Attachment { + filename, + mime_type, + content, + }); + } + + Ok(Email { + id: id.to_string(), + account_id: account_id.to_string(), + folder_id: folder_id.to_string(), + subject, + sender, + recipient, + received_at, + read: false, + body_plain, + body_html, + attachments, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_valid_mime_email_with_text_and_html() { + let raw = b"From: Alice \r\n\ + To: Bob \r\n\ + Subject: Test Subject\r\n\ + Content-Type: text/plain\r\n\ + \r\n\ + Hello Bob, this is a plain text email."; + + let email = MimeParserAdapter::parse_mime("msg-1", "acct-1", "inbox", raw).expect("parse succeeds"); + assert_eq!(email.id, "msg-1"); + assert_eq!(email.subject, "Test Subject"); + assert_eq!(email.sender, "alice@nuncio.mx"); + assert_eq!(email.recipient, "bob@nuncio.mx"); + assert_eq!(email.body_plain, Some("Hello Bob, this is a plain text email.".to_string())); + } + + #[test] + fn parse_invalid_mime_returns_error() { + let raw = b""; + let err = MimeParserAdapter::parse_mime("msg-2", "acct-1", "inbox", raw).expect_err("should fail"); + assert_eq!( + err, + MailError::ParseFailed("invalid RFC 5322 MIME structure".to_string()) + ); + assert_eq!(err.to_string(), "failed to parse MIME email payload: invalid RFC 5322 MIME structure"); + } +}