feat(mail): add MimeParserAdapter using mail-parser and bytes::Bytes (GH-11) #40

Merged
KofTwentyTwo merged 1 commits from feature/GH-11-zero-copy-mime-parser into main 2026-07-23 00:47:01 +00:00
7 changed files with 219 additions and 19 deletions

No files matched your search

+3
View File
@@ -34,6 +34,9 @@ tempfile = "3.10"
keyring = "2.1"
age = "0.10"
aes-gcm = "0.10"
mail-parser = "0.9"
bytes = "1.6"
+1
View File
@@ -13,3 +13,4 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
bytes.workspace = true
+3
View File
@@ -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;
+87
View File
@@ -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<String>,
/// HTML message body.
pub body_html: Option<String>,
/// List of attached files.
pub attachments: Vec<Attachment>,
}
/// 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("<p>Hello world</p>".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);
}
}
+2
View File
@@ -13,3 +13,5 @@ async-trait.workspace = true
serde.workspace = true
thiserror.workspace = true
tracing.workspace = true
mail-parser.workspace = true
bytes.workspace = true
+3 -19
View File
@@ -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};
+120
View File
@@ -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<Email, MailError> {
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 <alice@nuncio.mx>\r\n\
To: Bob <bob@nuncio.mx>\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");
}
}