feat(mail): add MimeParserAdapter using mail-parser and bytes::Bytes (GH-11) #40
No files matched your search
@@ -34,6 +34,9 @@ tempfile = "3.10"
|
||||
keyring = "2.1"
|
||||
age = "0.10"
|
||||
aes-gcm = "0.10"
|
||||
mail-parser = "0.9"
|
||||
bytes = "1.6"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,3 +13,4 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
bytes.workspace = true
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,5 @@ async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
mail-parser.workspace = true
|
||||
bytes.workspace = true
|
||||
@@ -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};
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user