feat(grpc): real SMTP send + separate account SMTP endpoint

Add the outbound path: authenticated Mail.SendMessage builds an SMTP transport
from the account's SMTP endpoint + keyring password and sends via
SmtpTransportEngine -- honest success/failure, never fabricated. New
MessageSender trait (production SmtpTransportEngine; MockMessageSender for tests)
mirrors the sync MailBackend seam. #168: AccountConfig gains smtp_host/smtp_port
via a backfill-safe accounts migration (old rows fall back to the IMAP
host/port); proto + CLI now persist the SMTP endpoint instead of dropping it.
CLI 'mail send' is a real gRPC client; the fabricated 'Message sent' output is
gone. Tests prove the exact message reaches a mock transport and failures
surface honestly (no live network).

Refs #160 #168
This commit is contained in:
James Maes committed 2026-07-27 01:01:46 -05:00
1 parent aa1b6c0ce5
commit 03cbd68d49
15 files changed
+1307 -78

No files matched your search

+116 -37
View File
@@ -149,8 +149,8 @@ impl HeadlessRunner {
email,
imap_host,
imap_port,
smtp_host: _,
smtp_port: _,
smtp_host,
smtp_port,
imap_mode,
smtp_mode,
password,
@@ -159,6 +159,8 @@ impl HeadlessRunner {
email,
imap_host,
*imap_port,
smtp_host,
*smtp_port,
imap_mode,
smtp_mode,
&password.0,
@@ -773,6 +775,13 @@ impl HeadlessRunner {
}
}
/// `mail send`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Mail/SendMessage` API (backlog story 1.C.5, GH
/// #160). The daemon builds a real SMTP transport from the configured
/// account's SMTP endpoint (backlog story #168) and keyring password,
/// and only reports success when the transport genuinely accepted the
/// message -- this NEVER prints a fabricated "Message sent" without a
/// real send actually happening.
async fn handle_send_email(
&self,
to: &str,
@@ -780,15 +789,43 @@ impl HeadlessRunner {
body: &str,
json_mode: bool,
) -> String {
if json_mode {
format_json(&json!({
"sent": true,
"to": to,
"subject": subject,
"bytes": body.len()
}))
} else {
format!("Message sent to {} ('{}')", to, subject)
let mut client = match self.connect_mail_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.send_message(nuncio_proto::v1::SendMessageRequest {
to: to.to_string(),
cc: None,
subject: subject.to_string(),
body_text: body.to_string(),
body_html: None,
attachments: Vec::new(),
})
.await
{
Ok(response) => {
let message_id = response.into_inner().message_id;
if json_mode {
format_json(&json!({
"sent": true,
"message_id": message_id,
"to": to,
"subject": subject,
"bytes": body.len()
}))
} else {
format!(
"Message sent to {} ('{}') [id: {}]",
to, subject, message_id
)
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected send_message: {status}"),
json_mode,
),
}
}
@@ -981,6 +1018,8 @@ impl HeadlessRunner {
email: &str,
imap_host: &str,
imap_port: u16,
smtp_host: &str,
smtp_port: u16,
imap_mode: &str,
smtp_mode: &str,
password: &str,
@@ -1010,6 +1049,8 @@ impl HeadlessRunner {
smtp_tls_mode: map_tls_mode_to_proto(smtp_tls_mode).into(),
keyring_secret_key: keyring_key.clone(),
sync_interval_secs: 300,
smtp_host: smtp_host.to_string(),
smtp_port: u32::from(smtp_port),
};
let mut client = match self.connect_accounts_client().await {
@@ -1034,13 +1075,15 @@ impl HeadlessRunner {
"imap_host": imap_host,
"imap_port": imap_port,
"imap_mode": imap_mode,
"smtp_host": smtp_host,
"smtp_port": smtp_port,
"smtp_mode": smtp_mode,
"keyring_key": keyring_key
}))
} else {
format!(
"Account '{}' (ID: {}) added via nunciod daemon and configured for IMAP ({}:{}, mode: {}) and SMTP (mode: {})",
email, account_id, imap_host, imap_port, imap_mode, smtp_mode
"Account '{}' (ID: {}) added via nunciod daemon and configured for IMAP ({}:{}, mode: {}) and SMTP ({}:{}, mode: {})",
email, account_id, imap_host, imap_port, imap_mode, smtp_host, smtp_port, smtp_mode
)
}
}
@@ -1079,6 +1122,8 @@ impl HeadlessRunner {
"protocol": a.protocol().as_str_name(),
"server_host": a.server_host,
"server_port": a.server_port,
"smtp_host": a.smtp_host,
"smtp_port": a.smtp_port,
"use_tls": a.use_tls,
"imap_tls_mode": a.imap_tls_mode().as_str_name(),
"smtp_tls_mode": a.smtp_tls_mode().as_str_name(),
@@ -1323,16 +1368,16 @@ mod tests {
.await;
assert!(acct_show.contains("Account 'missing' not found"));
// Mail Noun Commands: `Sync` and `Send` do not talk to the daemon
// (yet), so they are safe to exercise against this ephemeral local
// runner. `List`/`Read`/`Search`/`Mark` (and `Folder::List`) are now
// Mail Noun Commands: `Sync` does not talk to the daemon (yet), so
// it is safe to exercise against this ephemeral local runner.
// `List`/`Read`/`Search`/`Mark`/`Send` (and `Folder::List`) are now
// real gRPC clients of the `nunciod` daemon's `Mail` API (backlog
// story 1.C.4, GH #159) -- exactly like `Account::Add`/`List` and
// `System::Status` above, they must never run against this
// `ephemeral()`-constructed runner's production `SecretManager` or
// its (unreachable in CI) default gRPC address. They are exercised
// separately below via `ephemeral_with` + `SecretManager::mock()`
// against a live stub `Mail` gRPC server
// stories 1.C.4 / 1.C.5, GH #159 / GH #160) -- exactly like
// `Account::Add`/`List` and `System::Status` above, they must never
// run against this `ephemeral()`-constructed runner's production
// `SecretManager` or its (unreachable in CI) default gRPC address.
// They are exercised separately below via `ephemeral_with` +
// `SecretManager::mock()` against a live stub `Mail` gRPC server
// (`mail_rpcs_round_trip_over_grpc_to_a_stub_daemon`).
let mail_sync = runner
.execute_command(
@@ -1344,20 +1389,6 @@ mod tests {
.await;
assert!(mail_sync.contains(r#""status":"sync_started""#));
let mail_send = runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Send {
to: "alice@nuncio.mx".to_string(),
subject: "Test".to_string(),
body: "Body".to_string(),
},
},
false,
)
.await;
assert!(mail_send.contains("alice@nuncio.mx"));
// Cal Noun Commands
let cal_list = runner
.execute_command(
@@ -1584,6 +1615,8 @@ mod tests {
smtp_tls_mode: TlsModeProto::ImplicitTls.into(),
keyring_secret_key: "nuncio/acct-stub-1".to_string(),
sync_interval_secs: 300,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
}],
}))
}
@@ -1689,7 +1722,7 @@ mod tests {
Folder as FolderProto, GetMessageRequest, GetMessageResponse, ListFoldersRequest,
ListFoldersResponse, ListMessagesRequest, ListMessagesResponse, MarkReadRequest,
MarkReadResponse, Message as MessageProto, MessageSearchHit, SearchMessagesRequest,
SearchMessagesResponse,
SearchMessagesResponse, SendMessageRequest, SendMessageResponse,
};
use std::sync::Mutex;
@@ -1716,6 +1749,7 @@ mod tests {
#[derive(Default)]
struct StubMail {
last_mark_read: Arc<Mutex<Option<MarkReadRequest>>>,
last_send_message: Arc<Mutex<Option<SendMessageRequest>>>,
}
#[tonic::async_trait]
@@ -1788,10 +1822,25 @@ mod tests {
}],
}))
}
async fn send_message(
&self,
request: tonic::Request<SendMessageRequest>,
) -> Result<tonic::Response<SendMessageResponse>, tonic::Status> {
let req = request.into_inner();
*self
.last_send_message
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(req);
Ok(tonic::Response::new(SendMessageResponse {
message_id: "sent-stub-1".to_string(),
}))
}
}
let stub = StubMail::default();
let probe = stub.last_mark_read.clone();
let send_probe = stub.last_send_message.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
@@ -1916,6 +1965,36 @@ mod tests {
)
.await;
assert!(mark_no_flag.contains("exactly one of --read or --unread"));
// `mail send`: proves the CLI is a real gRPC client of
// `Mail/SendMessage` (backlog story 1.C.5, GH #160) -- the exact
// recipient/subject/body the caller supplied reaches the daemon
// over the wire, and the daemon's returned message id (NOT a
// fabricated "Message sent") appears in the CLI's output.
let mail_send = runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Send {
to: "alice@nuncio.mx".to_string(),
subject: "Quarterly Roadmap".to_string(),
body: "Let's discuss the roadmap.".to_string(),
},
},
true,
)
.await;
assert!(mail_send.contains(r#""sent":true"#));
assert!(mail_send.contains("sent-stub-1"));
assert!(mail_send.contains("alice@nuncio.mx"));
let recorded_send = send_probe
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.expect("stub daemon received a send_message request");
assert_eq!(recorded_send.to, "alice@nuncio.mx");
assert_eq!(recorded_send.subject, "Quarterly Roadmap");
assert_eq!(recorded_send.body_text, "Let's discuss the roadmap.");
}
#[test]
+33 -2
View File
@@ -56,10 +56,16 @@ pub struct AccountConfig {
pub email_address: String,
/// Protocol engine type.
pub protocol: AccountProtocol,
/// Server hostname or IP address.
/// Server hostname or IP address (IMAP/JMAP).
pub server_host: String,
/// Server port number.
/// Server port number (IMAP/JMAP).
pub server_port: u16,
/// SMTP server hostname or IP address, used for outbound mail (backlog
/// story #168). Distinct from `server_host` because a mail provider's
/// inbound (IMAP/JMAP) and outbound (SMTP) endpoints commonly differ.
pub smtp_host: String,
/// SMTP server port number, used for outbound mail (backlog story #168).
pub smtp_port: u16,
/// Whether TLS connection encryption is enabled.
pub use_tls: bool,
/// IMAP connection security transport mode.
@@ -105,6 +111,12 @@ impl AccountConfig {
if self.server_port == 0 {
return Err(ConfigError::InvalidPort);
}
if self.smtp_host.trim().is_empty() {
return Err(ConfigError::EmptyField { field: "smtp_host" });
}
if self.smtp_port == 0 {
return Err(ConfigError::InvalidPort);
}
if self.keyring_secret_key.trim().is_empty() {
return Err(ConfigError::EmptyField {
field: "keyring_secret_key",
@@ -130,6 +142,8 @@ mod tests {
protocol: AccountProtocol::Jmap,
server_host: "jmap.nuncio.mx".to_string(),
server_port: 443,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: TlsMode::ImplicitTls,
smtp_tls_mode: TlsMode::ImplicitTls,
@@ -211,6 +225,23 @@ mod tests {
assert_eq!(config.validate().unwrap_err(), ConfigError::InvalidPort);
}
#[test]
fn empty_smtp_host_fails_validation() {
let mut config = valid_account();
config.smtp_host = " ".to_string();
assert_eq!(
config.validate().unwrap_err(),
ConfigError::EmptyField { field: "smtp_host" }
);
}
#[test]
fn zero_smtp_port_fails_validation() {
let mut config = valid_account();
config.smtp_port = 0;
assert_eq!(config.validate().unwrap_err(), ConfigError::InvalidPort);
}
#[test]
fn empty_keyring_key_fails_validation() {
let mut config = valid_account();
+41 -1
View File
@@ -1,7 +1,7 @@
//! Protocol-agnostic async mail backend trait definitions.
use async_trait::async_trait;
use nuncio_core::model::{Email, Folder};
use nuncio_core::model::{Attachment, Email, Folder};
use crate::parser::MailError;
@@ -22,3 +22,43 @@ pub trait MailBackend: Send + Sync {
/// Send an email message over the configured transport.
async fn send_email(&self, email: &Email) -> Result<(), MailError>;
}
/// A composed outbound email message ready to send over SMTP (backlog
/// story 1.C.5, GH #160). Deliberately independent of the persisted
/// [`Email`] model: an outbound compose has no `id`/`folder_id`/
/// `received_at`/`read` -- it is never itself a synced inbox message, only
/// something being handed to a transport for delivery.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutboundMessage {
/// Sender address (the configured account's own email address).
pub from: String,
/// Recipient email address.
pub to: String,
/// Optional CC recipient email address.
pub cc: Option<String>,
/// Message subject line.
pub subject: String,
/// Plaintext message body.
pub body_plain: Option<String>,
/// HTML message body.
pub body_html: Option<String>,
/// File attachments.
pub attachments: Vec<Attachment>,
}
/// Narrow, send-only transport seam used by the outbound send RPC (backlog
/// story 1.C.5, GH #160), distinct from the broader [`MailBackend`] trait:
/// an SMTP-only transport cannot meaningfully implement
/// `sync_folders`/`sync_messages`, so forcing it to implement all of
/// `MailBackend` would mean fabricating those methods. Production code
/// (`nunciod`) builds a real [`crate::smtp::SmtpTransportEngine`] from the
/// account's SMTP endpoint + keyring password; tests inject a mock that
/// records the exact [`OutboundMessage`] it received instead of touching
/// the network.
#[async_trait]
pub trait MessageSender: Send + Sync {
/// Send `message` over the configured transport. MUST return `Ok(())`
/// only when the transport genuinely accepted the message for
/// delivery -- implementations must never fabricate success.
async fn send(&self, message: &OutboundMessage) -> Result<(), MailError>;
}
+2 -2
View File
@@ -10,9 +10,9 @@ pub mod mock;
pub mod parser;
pub mod smtp;
pub use backend::MailBackend;
pub use backend::{MailBackend, MessageSender, OutboundMessage};
pub use imap::{IdleSocketState, ImapDualSocketManager, ImapEngine};
pub use jmap::JmapEngine;
pub use mock::MockMailBackend;
pub use mock::{MockMailBackend, MockMessageSender};
pub use parser::{MailError, MimeParserAdapter};
pub use smtp::SmtpTransportEngine;
+87 -1
View File
@@ -4,7 +4,7 @@ use async_trait::async_trait;
use nuncio_core::model::{Email, Folder};
use std::sync::{Arc, Mutex};
use crate::backend::MailBackend;
use crate::backend::{MailBackend, MessageSender, OutboundMessage};
use crate::parser::MailError;
/// Thread-safe mock mail backend for offline testing.
@@ -116,6 +116,60 @@ impl MailBackend for MockMailBackend {
}
}
/// Deterministic mock [`MessageSender`] for offline testing of the outbound
/// send RPC (backlog story 1.C.5, GH #160): records every [`OutboundMessage`]
/// it is given, rather than touching a real SMTP transport, and can be
/// configured to simulate a transport failure so callers can prove a failed
/// send surfaces as a genuine error rather than a fabricated success.
#[derive(Debug, Clone, Default)]
pub struct MockMessageSender {
sent: Arc<Mutex<Vec<OutboundMessage>>>,
should_fail: Arc<Mutex<bool>>,
}
impl MockMessageSender {
/// Create a new `MockMessageSender` with empty storage.
pub fn new() -> Self {
Self::default()
}
/// Configure the mock to simulate a transport-level send failure.
pub fn set_should_fail(&self, fail: bool) {
if let Ok(mut flag) = self.should_fail.lock() {
*flag = fail;
}
}
/// Retrieve every message recorded by the mock, in send order.
pub fn sent_messages(&self) -> Vec<OutboundMessage> {
self.sent
.lock()
.map(|guard| guard.clone())
.unwrap_or_default()
}
}
#[async_trait]
impl MessageSender for MockMessageSender {
async fn send(&self, message: &OutboundMessage) -> Result<(), MailError> {
let should_fail = self
.should_fail
.lock()
.map_err(|e| MailError::ParseFailed(e.to_string()))?;
if *should_fail {
return Err(MailError::TransportFailed(
"simulated SMTP transport failure".to_string(),
));
}
let mut sent = self
.sent
.lock()
.map_err(|e| MailError::ParseFailed(e.to_string()))?;
sent.push(message.clone());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -164,4 +218,36 @@ mod tests {
assert!(mock.sync_messages("inbox", None).await.is_err());
assert!(mock.send_email(&email).await.is_err());
}
fn sample_outbound_message() -> OutboundMessage {
OutboundMessage {
from: "alice@nuncio.mx".to_string(),
to: "bob@nuncio.mx".to_string(),
cc: None,
subject: "Mock Send".to_string(),
body_plain: Some("Mock body".to_string()),
body_html: None,
attachments: Vec::new(),
}
}
#[tokio::test]
async fn mock_message_sender_records_exact_message_and_supports_failure_simulation() {
let mock = MockMessageSender::new();
let message = sample_outbound_message();
mock.send(&message).await.expect("send succeeds");
let sent = mock.sent_messages();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0], message);
mock.set_should_fail(true);
let err = mock
.send(&message)
.await
.expect_err("simulated failure must surface as an error");
assert!(matches!(err, MailError::TransportFailed(_)));
// The failed attempt must never be recorded as "sent".
assert_eq!(mock.sent_messages().len(), 1);
}
}
+213 -14
View File
@@ -1,12 +1,16 @@
//! Async Tokio SMTP transport engine wrapping the `lettre` library.
use lettre::message::{header::ContentType, Mailbox, Message, MultiPart, SinglePart};
use async_trait::async_trait;
use lettre::message::{
header::ContentType, Mailbox, Message, MessageBuilder, MultiPart, SinglePart,
};
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::transport::smtp::Error as LettreSmtpError;
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use nuncio_core::model::Email;
use nuncio_core::model::{Attachment, Email};
use crate::backend::{MessageSender, OutboundMessage};
use crate::parser::MailError;
impl From<LettreSmtpError> for MailError {
@@ -54,6 +58,15 @@ impl SmtpTransportEngine {
Self::send_email_with_transport(&self.transport, email).await
}
/// Send a composed [`OutboundMessage`] using the inner transport client
/// (backlog story 1.C.5, GH #160). Returns `Ok(())` only when the
/// transport genuinely accepted the message.
pub async fn send_message(&self, message: &OutboundMessage) -> Result<(), MailError> {
let msg = Self::build_outbound_mime_message(message)?;
self.transport.send(msg).await.map_err(MailError::from)?;
Ok(())
}
/// Send an email message using a provided [`AsyncSmtpTransport`] client instance.
pub async fn send_email_with_transport(
transport: &AsyncSmtpTransport<Tokio1Executor>,
@@ -84,28 +97,80 @@ impl SmtpTransportEngine {
.to(to_mailbox)
.subject(&email.subject);
let has_attachments = !email.attachments.is_empty();
let has_plain = email.body_plain.as_ref().is_some_and(|b| !b.is_empty());
let has_html = email.body_html.as_ref().is_some_and(|b| !b.is_empty());
Self::finish_message(
builder,
email.body_plain.as_deref(),
email.body_html.as_deref(),
&email.attachments,
)
}
/// Build an RFC 5322 [`lettre::Message`] from a composed [`OutboundMessage`]
/// (backlog story 1.C.5, GH #160), supporting an optional `Cc:` recipient
/// in addition to everything [`Self::build_mime_message`] supports.
pub fn build_outbound_mime_message(message: &OutboundMessage) -> Result<Message, MailError> {
let from_mailbox = message
.from
.parse::<Mailbox>()
.map_err(|e| MailError::ParseFailed(format!("invalid sender address: {}", e)))?;
let to_mailbox = message
.to
.parse::<Mailbox>()
.map_err(|e| MailError::ParseFailed(format!("invalid recipient address: {}", e)))?;
let mut builder = Message::builder()
.from(from_mailbox)
.to(to_mailbox)
.subject(&message.subject);
if let Some(cc) = message.cc.as_ref().filter(|cc| !cc.trim().is_empty()) {
let cc_mailbox = cc
.parse::<Mailbox>()
.map_err(|e| MailError::ParseFailed(format!("invalid cc address: {}", e)))?;
builder = builder.cc(cc_mailbox);
}
Self::finish_message(
builder,
message.body_plain.as_deref(),
message.body_html.as_deref(),
&message.attachments,
)
}
/// Shared multipart/attachment assembly for [`Self::build_mime_message`]
/// and [`Self::build_outbound_mime_message`]: builds `multipart/mixed`
/// when attachments are present, `multipart/alternative` for
/// plain+HTML bodies, or a single part otherwise.
fn finish_message(
builder: MessageBuilder,
body_plain: Option<&str>,
body_html: Option<&str>,
attachments: &[Attachment],
) -> Result<Message, MailError> {
let has_attachments = !attachments.is_empty();
let has_plain = body_plain.is_some_and(|b| !b.is_empty());
let has_html = body_html.is_some_and(|b| !b.is_empty());
if has_attachments {
let initial_mixed = if has_plain && has_html {
let plain_part = SinglePart::plain(email.body_plain.clone().unwrap_or_default());
let html_part = SinglePart::html(email.body_html.clone().unwrap_or_default());
let plain_part = SinglePart::plain(body_plain.unwrap_or_default().to_string());
let html_part = SinglePart::html(body_html.unwrap_or_default().to_string());
let alt = MultiPart::alternative()
.singlepart(plain_part)
.singlepart(html_part);
MultiPart::mixed().multipart(alt)
} else if has_html {
let html_part = SinglePart::html(email.body_html.clone().unwrap_or_default());
let html_part = SinglePart::html(body_html.unwrap_or_default().to_string());
MultiPart::mixed().singlepart(html_part)
} else {
let plain_part = SinglePart::plain(email.body_plain.clone().unwrap_or_default());
let plain_part = SinglePart::plain(body_plain.unwrap_or_default().to_string());
MultiPart::mixed().singlepart(plain_part)
};
let mut mixed = initial_mixed;
for att in &email.attachments {
for att in attachments {
let content_type = ContentType::parse(&att.mime_type).map_err(|e| {
MailError::ParseFailed(format!(
"invalid attachment content-type '{}': {}",
@@ -121,8 +186,8 @@ impl SmtpTransportEngine {
.multipart(mixed)
.map_err(|e| MailError::ParseFailed(e.to_string()))
} else if has_plain && has_html {
let plain_part = SinglePart::plain(email.body_plain.clone().unwrap_or_default());
let html_part = SinglePart::html(email.body_html.clone().unwrap_or_default());
let plain_part = SinglePart::plain(body_plain.unwrap_or_default().to_string());
let html_part = SinglePart::html(body_html.unwrap_or_default().to_string());
let alt = MultiPart::alternative()
.singlepart(plain_part)
.singlepart(html_part);
@@ -130,12 +195,12 @@ impl SmtpTransportEngine {
.multipart(alt)
.map_err(|e| MailError::ParseFailed(e.to_string()))
} else if has_html {
let html_part = SinglePart::html(email.body_html.clone().unwrap_or_default());
let html_part = SinglePart::html(body_html.unwrap_or_default().to_string());
builder
.singlepart(html_part)
.map_err(|e| MailError::ParseFailed(e.to_string()))
} else {
let plain_part = SinglePart::plain(email.body_plain.clone().unwrap_or_default());
let plain_part = SinglePart::plain(body_plain.unwrap_or_default().to_string());
builder
.singlepart(plain_part)
.map_err(|e| MailError::ParseFailed(e.to_string()))
@@ -194,6 +259,16 @@ impl SmtpTransportEngine {
}
}
/// Production implementation of [`MessageSender`] for the outbound send RPC
/// (backlog story 1.C.5, GH #160): delegates to [`SmtpTransportEngine::send_message`],
/// so a real send genuinely reaches the configured SMTP server -- never fabricated.
#[async_trait]
impl MessageSender for SmtpTransportEngine {
async fn send(&self, message: &OutboundMessage) -> Result<(), MailError> {
self.send_message(message).await
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
@@ -339,4 +414,128 @@ mod tests {
let engine = SmtpTransportEngine::new("smtp.nuncio.mx", 465, "user", "pass");
assert!(engine.is_ok());
}
fn sample_outbound_message() -> OutboundMessage {
OutboundMessage {
from: "alice@nuncio.mx".to_string(),
to: "bob@nuncio.mx".to_string(),
cc: None,
subject: "Status Update".to_string(),
body_plain: Some("Plaintext status update".to_string()),
body_html: None,
attachments: Vec::new(),
}
}
#[test]
fn build_outbound_mime_message_plain_text_without_cc() {
let message = sample_outbound_message();
let msg =
SmtpTransportEngine::build_outbound_mime_message(&message).expect("build succeeds");
assert_eq!(msg.headers().get_raw("Subject").unwrap(), "Status Update");
assert!(msg.headers().get_raw("Cc").is_none());
let raw_bytes = msg.formatted();
let formatted = String::from_utf8_lossy(&raw_bytes);
assert!(formatted.contains("Plaintext status update"));
}
#[test]
fn build_outbound_mime_message_includes_cc_header_when_present() {
let mut message = sample_outbound_message();
message.cc = Some("carol@nuncio.mx".to_string());
let msg =
SmtpTransportEngine::build_outbound_mime_message(&message).expect("build succeeds");
let raw_bytes = msg.formatted();
let formatted = String::from_utf8_lossy(&raw_bytes);
assert!(formatted.contains("carol@nuncio.mx"));
}
#[test]
fn build_outbound_mime_message_ignores_blank_cc() {
let mut message = sample_outbound_message();
message.cc = Some(" ".to_string());
let msg =
SmtpTransportEngine::build_outbound_mime_message(&message).expect("build succeeds");
assert!(msg.headers().get_raw("Cc").is_none());
}
#[test]
fn build_outbound_mime_message_html_and_attachments() {
let mut message = sample_outbound_message();
message.body_html = Some("<p>HTML update</p>".to_string());
message.attachments.push(Attachment {
filename: "report.pdf".to_string(),
mime_type: "application/pdf".to_string(),
content: Bytes::from_static(b"%PDF-1.4 fake pdf data"),
});
let msg =
SmtpTransportEngine::build_outbound_mime_message(&message).expect("build succeeds");
let raw_bytes = msg.formatted();
let formatted = String::from_utf8_lossy(&raw_bytes);
assert!(formatted.contains("multipart/mixed"));
assert!(formatted.contains("report.pdf"));
assert!(formatted.contains("<p>HTML update</p>"));
}
#[test]
fn build_outbound_mime_message_invalid_sender_fails() {
let mut message = sample_outbound_message();
message.from = "invalid_sender".to_string();
let err =
SmtpTransportEngine::build_outbound_mime_message(&message).expect_err("should fail");
assert!(matches!(err, MailError::ParseFailed(_)));
}
#[test]
fn build_outbound_mime_message_invalid_recipient_fails() {
let mut message = sample_outbound_message();
message.to = "invalid_recipient".to_string();
let err =
SmtpTransportEngine::build_outbound_mime_message(&message).expect_err("should fail");
assert!(matches!(err, MailError::ParseFailed(_)));
}
#[test]
fn build_outbound_mime_message_invalid_cc_fails() {
let mut message = sample_outbound_message();
message.cc = Some("invalid_cc".to_string());
let err =
SmtpTransportEngine::build_outbound_mime_message(&message).expect_err("should fail");
assert!(matches!(err, MailError::ParseFailed(_)));
}
#[tokio::test]
async fn send_message_to_unreachable_server_fails_with_transport_error() {
let engine =
SmtpTransportEngine::new("127.0.0.1", 1, "user", "pass").expect("valid config");
let message = sample_outbound_message();
let err = engine
.send_message(&message)
.await
.expect_err("delivery to port 1 should fail");
assert!(matches!(
err,
MailError::TransportFailed(_) | MailError::SmtpFailed(_)
));
}
#[tokio::test]
async fn message_sender_trait_impl_delegates_to_send_message() {
// Proves `SmtpTransportEngine`'s `MessageSender` trait impl is wired
// through to the real `send_message` path (backlog story 1.C.5),
// not a separate/fabricated stub -- exercised via the trait object
// exactly like production code (`nunciod::send`) uses it.
let engine =
SmtpTransportEngine::new("127.0.0.1", 1, "user", "pass").expect("valid config");
let sender: &dyn MessageSender = &engine;
let message = sample_outbound_message();
let err = sender
.send(&message)
.await
.expect_err("delivery to port 1 should fail");
assert!(matches!(
err,
MailError::TransportFailed(_) | MailError::SmtpFailed(_)
));
}
}
@@ -175,9 +175,9 @@ message AccountConfig {
string email_address = 3;
// Protocol engine type.
AccountProtocol protocol = 4;
// Server hostname or IP address.
// Server hostname or IP address (IMAP/JMAP).
string server_host = 5;
// Server port number (1..=65535).
// Server port number (1..=65535) (IMAP/JMAP).
uint32 server_port = 6;
// Whether TLS connection encryption is enabled.
bool use_tls = 7;
@@ -189,6 +189,13 @@ message AccountConfig {
string keyring_secret_key = 10;
// Background sync polling interval in seconds (minimum 10s).
uint64 sync_interval_secs = 11;
// SMTP server hostname or IP address, used for outbound mail (backlog
// story #168). Distinct from `server_host` because a mail provider's
// inbound (IMAP/JMAP) and outbound (SMTP) endpoints commonly differ.
string smtp_host = 12;
// SMTP server port number (1..=65535), used for outbound mail (backlog
// story #168).
uint32 smtp_port = 13;
}
// AddAccountRequest carries the full account configuration plus the raw
@@ -239,6 +246,15 @@ service Mail {
// SearchMessages runs a full-text (FTS5) search over cached message
// subjects, senders, and bodies.
rpc SearchMessages(SearchMessagesRequest) returns (SearchMessagesResponse);
// SendMessage composes and sends a real outbound email over SMTP
// (backlog story 1.C.5, GH #160), built from the configured account's
// SMTP endpoint (`AccountConfig.smtp_host`/`smtp_port`, backlog story
// #168) and its keyring-stored password. Returns successfully ONLY when
// the SMTP transport genuinely accepted the message for delivery --
// never a fabricated success. A transport/authentication failure surfaces
// as a `Status::internal` (or `Status::invalid_argument` for a malformed
// request) error, never a fabricated `SendMessageResponse`.
rpc SendMessage(SendMessageRequest) returns (SendMessageResponse);
}
// Mirrors `nuncio_core::model::Attachment`.
@@ -356,3 +372,32 @@ message MessageSearchHit {
message SearchMessagesResponse {
repeated MessageSearchHit hits = 1;
}
// SendMessageRequest carries a composed outbound email (backlog story
// 1.C.5, GH #160). The sending account's own address is used as the
// `From:` header -- it is resolved server-side from the configured
// account, never supplied by the caller.
message SendMessageRequest {
// Recipient email address.
string to = 1;
// Optional CC recipient email address.
optional string cc = 2;
// Message subject line.
string subject = 3;
// Plaintext message body.
string body_text = 4;
// Optional HTML message body.
optional string body_html = 5;
// Optional file attachments.
repeated Attachment attachments = 6;
}
// SendMessageResponse. A successful response means the SMTP transport
// genuinely accepted the message for delivery -- never a fabricated
// success (see `Mail.SendMessage`'s doc comment).
message SendMessageResponse {
// Daemon-generated identifier for the sent message, for reference in
// logs/output. NOT a persisted message id -- outbound sends are not
// written back into the synced message store by this RPC.
string message_id = 1;
}
+194 -5
View File
@@ -1,7 +1,7 @@
//! SQLite database engine initialization, connection pooling, and migrations.
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::SqlitePool;
use sqlx::{Row, SqlitePool};
use std::path::Path;
use std::str::FromStr;
use tempfile::TempDir;
@@ -348,7 +348,9 @@ impl DatabaseEngine {
server_port INTEGER NOT NULL,
use_tls INTEGER NOT NULL,
keyring_secret_key TEXT NOT NULL,
sync_interval_secs INTEGER NOT NULL
sync_interval_secs INTEGER NOT NULL,
smtp_host TEXT,
smtp_port INTEGER
);
CREATE TABLE IF NOT EXISTS filter_rules (
@@ -491,11 +493,52 @@ impl DatabaseEngine {
.await
.map_err(DatabaseError::Query)?;
self.ensure_accounts_smtp_columns().await?;
self.backfill_message_fts().await?;
Ok(())
}
/// Additive, backfill-safe migration for backlog story #168: adds the
/// `smtp_host` / `smtp_port` columns to a pre-existing `accounts` table
/// that predates the SMTP account-endpoint feature.
///
/// A fresh database already gets these columns from `CREATE TABLE IF NOT
/// EXISTS accounts` above, so on a fresh database this is a no-op (the
/// `PRAGMA table_info` check below finds both columns already present).
/// For a pre-existing database file, `CREATE TABLE IF NOT EXISTS` is
/// itself a no-op (the table already exists), so this is what actually
/// adds the new columns -- as `NULL`-able columns, so every existing row
/// keeps loading successfully (see [`Self::list_accounts`], which falls
/// back to `server_host`/`server_port` for any row where `smtp_host` /
/// `smtp_port` is still `NULL`). SQLite has no `ADD COLUMN IF NOT
/// EXISTS`, so column presence is checked explicitly via `PRAGMA
/// table_info` first, making this safe to run on every daemon startup.
async fn ensure_accounts_smtp_columns(&self) -> Result<(), DatabaseError> {
let existing_columns: Vec<String> = sqlx::query("PRAGMA table_info(accounts)")
.fetch_all(&self.pool)
.await
.map_err(DatabaseError::Query)?
.iter()
.map(|row| row.get::<String, _>("name"))
.collect();
if !existing_columns.iter().any(|c| c == "smtp_host") {
sqlx::query("ALTER TABLE accounts ADD COLUMN smtp_host TEXT")
.execute(&self.pool)
.await
.map_err(DatabaseError::Query)?;
}
if !existing_columns.iter().any(|c| c == "smtp_port") {
sqlx::query("ALTER TABLE accounts ADD COLUMN smtp_port INTEGER")
.execute(&self.pool)
.await
.map_err(DatabaseError::Query)?;
}
Ok(())
}
/// Backfill `messages_fts` for any `messages` row that does not yet have a matching FTS
/// entry -- e.g. rows written before the FTS5 index existed, or written directly by a
/// process that bypassed `save_email`'s explicit index population. Run automatically as
@@ -545,8 +588,8 @@ impl DatabaseEngine {
sqlx::query(
r#"
INSERT OR REPLACE INTO accounts
(id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&config.id)
@@ -558,6 +601,8 @@ impl DatabaseEngine {
.bind(if config.use_tls { 1i64 } else { 0i64 })
.bind(&config.keyring_secret_key)
.bind(config.sync_interval_secs as i64)
.bind(&config.smtp_host)
.bind(config.smtp_port as i64)
.execute(&self.pool)
.await
.map_err(DatabaseError::Query)?;
@@ -578,9 +623,11 @@ impl DatabaseEngine {
i64,
String,
i64,
Option<String>,
Option<i64>,
)> = sqlx::query_as(
r#"
SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs
SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port
FROM accounts
"#,
)
@@ -601,9 +648,22 @@ impl DatabaseEngine {
use_tls,
keyring_secret_key,
sync_interval_secs,
smtp_host,
smtp_port,
)| {
let protocol = serde_json::from_str(&protocol_str)
.unwrap_or(nuncio_core::AccountProtocol::ImapSmtp);
// Backfill-safe fallback (backlog story #168): a row
// written before `smtp_host`/`smtp_port` existed has
// `NULL` in both columns (see
// `Self::ensure_accounts_smtp_columns`). Rather than
// surface an incomplete/invalid config, fall back to the
// same host/port already used for IMAP/JMAP -- the best
// available default for an account configured before
// outbound mail had its own endpoint.
let resolved_smtp_host = smtp_host.unwrap_or_else(|| server_host.clone());
let resolved_smtp_port =
smtp_port.map(|p| p as u16).unwrap_or(server_port as u16);
nuncio_core::AccountConfig {
id,
name,
@@ -611,6 +671,8 @@ impl DatabaseEngine {
protocol,
server_host,
server_port: server_port as u16,
smtp_host: resolved_smtp_host,
smtp_port: resolved_smtp_port,
use_tls: use_tls != 0,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
@@ -1427,6 +1489,8 @@ mod tests {
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
@@ -1637,6 +1701,8 @@ mod tests {
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
@@ -1655,6 +1721,129 @@ mod tests {
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].id, "acct-test-1");
assert_eq!(accounts[0].email_address, "work@nuncio.mx");
assert_eq!(accounts[0].smtp_host, "smtp.nuncio.mx");
assert_eq!(accounts[0].smtp_port, 465);
}
/// Backlog story #168: proves the additive `smtp_host`/`smtp_port`
/// migration is backfill-safe. Simulates a database created BEFORE this
/// feature existed (an `accounts` table with no `smtp_host`/`smtp_port`
/// columns at all, populated via a direct `INSERT` bypassing
/// `save_account`), then opens it through `DatabaseEngine::connect_file`
/// (which runs `migrate()`, including `ensure_accounts_smtp_columns`)
/// and confirms: (1) opening an old-schema database never errors, (2)
/// the pre-existing row still loads, falling back to `server_host`/
/// `server_port` for the missing SMTP endpoint, and (3) re-running
/// `migrate()` a second time (simulating a daemon restart) is a no-op
/// that does not error or duplicate columns.
#[tokio::test]
async fn migrate_backfills_smtp_columns_for_a_pre_existing_accounts_table() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("pre_smtp_migration.db");
let secrets = crate::vault::SecretManager::mock();
// Step 1: create an OLD-schema `accounts` table (no smtp_host/smtp_port
// columns) directly, bypassing `DatabaseEngine::migrate` entirely, and
// insert one pre-existing row -- exactly what a database created
// before backlog story #168 would look like on disk.
{
let url = format!("sqlite://{}", db_path.to_string_lossy());
let options = sqlx::sqlite::SqliteConnectOptions::from_str(&url)
.expect("valid sqlite url")
.create_if_missing(true);
let pool = sqlx::SqlitePool::connect_with(options)
.await
.expect("connect to fresh old-schema db");
sqlx::query(
r#"
CREATE TABLE accounts (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
email_address TEXT NOT NULL,
protocol TEXT NOT NULL,
server_host TEXT NOT NULL,
server_port INTEGER NOT NULL,
use_tls INTEGER NOT NULL,
keyring_secret_key TEXT NOT NULL,
sync_interval_secs INTEGER NOT NULL
)
"#,
)
.execute(&pool)
.await
.expect("create old-schema accounts table");
sqlx::query(
r#"
INSERT INTO accounts
(id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs)
VALUES ('acct-pre-migration', 'Pre-Migration Account', 'pre@nuncio.mx', '"imap-smtp"', 'imap.nuncio.mx', 993, 1, 'nuncio/acct-pre-migration', 60)
"#,
)
.execute(&pool)
.await
.expect("insert pre-existing row");
pool.close().await;
}
// Step 2: open through the real production path -- this is what
// actually runs `migrate()` / `ensure_accounts_smtp_columns`.
let engine = DatabaseEngine::connect_file(&db_path, &secrets)
.await
.expect("opening an old-schema database must never error");
let accounts = engine
.list_accounts()
.await
.expect("list accounts succeeds on a migrated old-schema table");
assert_eq!(accounts.len(), 1);
let acct = &accounts[0];
assert_eq!(acct.id, "acct-pre-migration");
// Falls back to the IMAP/JMAP endpoint since smtp_host/smtp_port
// were NULL for this pre-existing row.
assert_eq!(acct.smtp_host, "imap.nuncio.mx");
assert_eq!(acct.smtp_port, 993);
// Step 3: re-running migrate() (e.g. a second daemon startup against
// the same file) must be a no-op, not an error.
engine
.migrate()
.await
.expect("re-running migrate on an already-migrated table must not error");
// A newly saved account (going through the real `save_account` path)
// now gets its own genuine smtp_host/smtp_port persisted, proving
// the migrated columns are fully writable/readable going forward.
let new_acct = nuncio_core::AccountConfig {
id: "acct-post-migration".to_string(),
name: "Post Migration Account".to_string(),
email_address: "post@nuncio.mx".to_string(),
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 587,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::StartTls,
keyring_secret_key: "nuncio/acct-post-migration".to_string(),
sync_interval_secs: 60,
};
engine
.save_account(&new_acct)
.await
.expect("save account succeeds after migration");
let accounts = engine.list_accounts().await.expect("list accounts");
let post = accounts
.iter()
.find(|a| a.id == "acct-post-migration")
.expect("newly saved account present");
assert_eq!(post.smtp_host, "smtp.nuncio.mx");
assert_eq!(post.smtp_port, 587);
engine.close().await;
}
#[test]
+20 -1
View File
@@ -16,6 +16,8 @@ type SalvagedAccountRow = (
i64,
String,
i64,
Option<String>,
Option<i64>,
);
/// Summary report of database self-healing recovery output.
@@ -300,7 +302,7 @@ impl SqliteRecoveryEngine {
async fn salvage_accounts(pool: &sqlx::SqlitePool) -> Vec<nuncio_core::AccountConfig> {
let rows: Result<Vec<SalvagedAccountRow>, _> = sqlx::query_as(
"SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs FROM accounts"
"SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port FROM accounts"
)
.fetch_all(pool)
.await;
@@ -319,9 +321,20 @@ impl SqliteRecoveryEngine {
use_tls,
keyring_secret_key,
sync_interval_secs,
smtp_host,
smtp_port,
)| {
let protocol = serde_json::from_str(&protocol_str)
.unwrap_or(nuncio_core::AccountProtocol::ImapSmtp);
// Backfill-safe fallback (backlog story #168): see
// `DatabaseEngine::list_accounts` for the matching
// rationale -- a salvaged row written before
// `smtp_host`/`smtp_port` existed falls back to the
// IMAP/JMAP endpoint rather than salvaging an
// incomplete config.
let resolved_smtp_host = smtp_host.unwrap_or_else(|| server_host.clone());
let resolved_smtp_port =
smtp_port.map(|p| p as u16).unwrap_or(server_port as u16);
nuncio_core::AccountConfig {
id,
name,
@@ -329,6 +342,8 @@ impl SqliteRecoveryEngine {
protocol,
server_host,
server_port: server_port as u16,
smtp_host: resolved_smtp_host,
smtp_port: resolved_smtp_port,
use_tls: use_tls != 0,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
@@ -441,6 +456,8 @@ mod tests {
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
@@ -500,6 +517,8 @@ mod tests {
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
+1
View File
@@ -31,6 +31,7 @@ tonic.workspace = true
async-trait.workspace = true
subtle.workspace = true
hex.workspace = true
bytes.workspace = true
[dev-dependencies]
nuncio-cli = { path = "../nuncio-cli" }
+209 -13
View File
@@ -20,8 +20,8 @@ use nuncio_proto::v1::{
ListAccountsResponse, ListFoldersRequest, ListFoldersResponse, ListMessagesRequest,
ListMessagesResponse, MarkReadRequest, MarkReadResponse, Message as MessageProto,
MessageFlagsChanged, MessageSearchHit, SearchMessagesRequest, SearchMessagesResponse,
ShuttingDown, SubscribeRequest, SyncCompleted, SyncStarted, TlsMode as TlsModeProto,
UpdateAvailable,
SendMessageRequest, SendMessageResponse, ShuttingDown, SubscribeRequest, SyncCompleted,
SyncStarted, TlsMode as TlsModeProto, UpdateAvailable,
};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::search::SearchEngine;
@@ -246,6 +246,8 @@ fn map_account_config_to_proto(config: nuncio_core::AccountConfig) -> AccountCon
smtp_tls_mode: map_tls_mode_to_proto(config.smtp_tls_mode).into(),
keyring_secret_key: config.keyring_secret_key,
sync_interval_secs: config.sync_interval_secs,
smtp_host: config.smtp_host,
smtp_port: u32::from(config.smtp_port),
}
}
@@ -260,6 +262,8 @@ fn map_account_config_from_proto(
let smtp_tls_mode = map_tls_mode_from_proto(config.smtp_tls_mode())?;
let server_port = u16::try_from(config.server_port)
.map_err(|_| Status::invalid_argument("server_port must be in range 1..=65535"))?;
let smtp_port = u16::try_from(config.smtp_port)
.map_err(|_| Status::invalid_argument("smtp_port must be in range 1..=65535"))?;
Ok(nuncio_core::AccountConfig {
id: config.id,
@@ -268,6 +272,8 @@ fn map_account_config_from_proto(
protocol,
server_host: config.server_host,
server_port,
smtp_host: config.smtp_host,
smtp_port,
use_tls: config.use_tls,
imap_tls_mode,
smtp_tls_mode,
@@ -356,6 +362,17 @@ fn map_attachment_to_proto(attachment: nuncio_core::model::Attachment) -> Attach
}
}
/// Maps a wire-format `nuncio.v1.Attachment` request payload back onto
/// `nuncio_core::model::Attachment` (backlog story 1.C.5, GH #160, used by
/// `SendMessage`).
fn map_attachment_from_proto(attachment: AttachmentProto) -> nuncio_core::model::Attachment {
nuncio_core::model::Attachment {
filename: attachment.filename,
mime_type: attachment.mime_type,
content: bytes::Bytes::from(attachment.content),
}
}
/// Maps a `nuncio_core::model::Email` onto its wire-format `nuncio.v1.Message`
/// representation. The store (`DatabaseEngine::get_message` /
/// `list_messages`) already returns `body_plain`/`body_html` decrypted, so
@@ -396,16 +413,21 @@ fn map_folder_to_proto(folder: nuncio_core::model::Folder) -> FolderProto {
/// `nuncio.v1.Mail` gRPC service implementation backed by the daemon's live
/// [`DatabaseEngine`] read/mark methods and [`SearchEngine`] FTS index
/// (backlog story 1.C.4, GH #159).
/// (backlog story 1.C.4, GH #159), and (backlog story 1.C.5, GH #160) the
/// live [`SecretManager`] vault used to build a real outbound SMTP
/// transport for `SendMessage`.
///
/// This exposes the mail READ path (list folders, list messages, read a
/// message, mark read/unread, search) over the real, persistent store that
/// backlog story 1.C.3 (GH #158) syncs into -- as opposed to the CLI's
/// previous local ephemeral `HeadlessRunner` database, which was thrown away
/// when the CLI process exited.
/// backlog story 1.C.3 (GH #158) syncs into, AND the outbound SEND path
/// (`SendMessage`) -- as opposed to the CLI's previous local ephemeral
/// `HeadlessRunner` database, which was thrown away when the CLI process
/// exited, and its previous fabricated "Message sent" output, which never
/// actually dialed an SMTP server.
struct MailGrpcService {
db: Arc<DatabaseEngine>,
event_bus: Arc<EventBus>,
secrets: Arc<SecretManager>,
}
#[tonic::async_trait]
@@ -522,6 +544,46 @@ impl Mail for MailGrpcService {
Ok(Response::new(SearchMessagesResponse { hits }))
}
/// SendMessage (backlog story 1.C.5, GH #160): composes and sends a real
/// outbound email over SMTP via [`crate::send::send_message_for_account`],
/// which resolves the sending account, its keyring password, and builds
/// a real [`nuncio_mail::SmtpTransportEngine`] from the account's SMTP
/// endpoint (backlog story #168). Returns `Ok` ONLY when the transport
/// genuinely accepted the message -- a resolution or transport failure
/// surfaces as `Status::internal`/`Status::invalid_argument`, never a
/// fabricated `SendMessageResponse`.
async fn send_message(
&self,
request: Request<SendMessageRequest>,
) -> Result<Response<SendMessageResponse>, Status> {
let req = request.into_inner();
if req.to.trim().is_empty() {
return Err(Status::invalid_argument("to is required"));
}
if req.subject.trim().is_empty() {
return Err(Status::invalid_argument("subject is required"));
}
let compose = crate::send::ComposeRequest {
to: req.to,
cc: req.cc,
subject: req.subject,
body_text: Some(req.body_text),
body_html: req.body_html,
attachments: req
.attachments
.into_iter()
.map(map_attachment_from_proto)
.collect(),
};
let message_id = crate::send::send_message_for_account(&self.db, &self.secrets, compose)
.await
.map_err(|e| Status::internal(format!("failed to send message: {e}")))?;
Ok(Response::new(SendMessageResponse { message_id }))
}
}
/// Bearer-token authentication interceptor for the loopback `nuncio.v1` gRPC
@@ -628,16 +690,20 @@ pub async fn serve_on_listener(
let accounts_service = AccountsGrpcService {
db: db.clone(),
secrets,
secrets: secrets.clone(),
};
let accounts_interceptor = BearerAuthInterceptor::new(token.clone());
let accounts_svc = AccountsServer::with_interceptor(accounts_service, accounts_interceptor);
// Mail (backlog story 1.C.4, GH #159): mounted behind its own
// `BearerAuthInterceptor`, exactly like `System` and `Accounts` above --
// see the hard invariant documented on this function's doc comment
// (GH #165).
let mail_service = MailGrpcService { db, event_bus };
// Mail (backlog story 1.C.4, GH #159; SendMessage: 1.C.5, GH #160):
// mounted behind its own `BearerAuthInterceptor`, exactly like `System`
// and `Accounts` above -- see the hard invariant documented on this
// function's doc comment (GH #165).
let mail_service = MailGrpcService {
db,
event_bus,
secrets,
};
let mail_interceptor = BearerAuthInterceptor::new(token);
let mail_svc = MailServer::with_interceptor(mail_service, mail_interceptor);
@@ -1008,6 +1074,8 @@ mod tests {
smtp_tls_mode: TlsModeProto::ImplicitTls.into(),
keyring_secret_key: keyring_secret_key.to_string(),
sync_interval_secs: 60,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
}
}
@@ -1266,7 +1334,7 @@ mod tests {
use nuncio_proto::v1::mail_client::MailClient;
use nuncio_proto::v1::{
GetMessageRequest, ListFoldersRequest, ListMessagesRequest, MarkReadRequest,
SearchMessagesRequest,
SearchMessagesRequest, SendMessageRequest,
};
fn sample_email(
@@ -1341,6 +1409,19 @@ mod tests {
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
let err = client
.send_message(SendMessageRequest {
to: "bob@nuncio.mx".to_string(),
cc: None,
subject: "Hi".to_string(),
body_text: "Body".to_string(),
body_html: None,
attachments: Vec::new(),
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
}
#[tokio::test]
@@ -1525,4 +1606,119 @@ mod tests {
}
);
}
// ---- SendMessage (backlog story 1.C.5, GH #160) ----
fn valid_send_message_request() -> SendMessageRequest {
SendMessageRequest {
to: "bob@nuncio.mx".to_string(),
cc: None,
subject: "Quarterly Roadmap".to_string(),
body_text: "Let's discuss the roadmap.".to_string(),
body_html: None,
attachments: Vec::new(),
}
}
#[tokio::test]
async fn send_message_rejects_empty_to() {
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let mut req = valid_send_message_request();
req.to = String::new();
let err = client
.send_message(authed_bearer_request(req))
.await
.expect_err("empty to must be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
}
#[tokio::test]
async fn send_message_rejects_empty_subject() {
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let mut req = valid_send_message_request();
req.subject = String::new();
let err = client
.send_message(authed_bearer_request(req))
.await
.expect_err("empty subject must be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
}
/// Backlog story 1.C.5 (GH #160): proves `SendMessage` never fabricates
/// success -- with no account configured at all, the daemon has nothing
/// to send from, and this surfaces as `Status::internal` rather than a
/// fabricated `SendMessageResponse`.
#[tokio::test]
async fn send_message_reports_honest_error_when_no_account_configured() {
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.send_message(authed_bearer_request(valid_send_message_request()))
.await
.expect_err("no configured account must be rejected");
assert_eq!(err.code(), Code::Internal);
}
/// Proves `SendMessage` genuinely builds a real SMTP transport from the
/// account's `smtp_host`/`smtp_port` (backlog story #168) and its
/// keyring password, and surfaces a real transport failure honestly --
/// WITHOUT any live network dependency: `smtp_host`/`smtp_port` point at
/// a reserved loopback port nothing is listening on, so the connection
/// attempt fails fast and deterministically (mirroring
/// `nuncio_mail::smtp`'s own unreachable-server test).
#[tokio::test]
async fn send_message_builds_real_smtp_transport_and_fails_honestly_when_unreachable() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let db = Arc::new(db);
let secrets = Arc::new(SecretManager::mock());
let config = nuncio_core::AccountConfig {
id: "acct-send-1".to_string(),
name: "Send Test Account".to_string(),
email_address: "sender@nuncio.mx".to_string(),
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "127.0.0.1".to_string(),
smtp_port: 1,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
keyring_secret_key: "nuncio/acct-send-1".to_string(),
sync_interval_secs: 60,
};
db.save_account(&config).await.expect("save account");
secrets
.set_secret(&config.keyring_secret_key, "irrelevant-password")
.expect("store credential in mock vault");
let (addr, _handle) =
spawn_test_server_with(Arc::new(EventBus::new()), db, secrets, "correct-token").await;
let mut client = MailClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.send_message(authed_bearer_request(valid_send_message_request()))
.await
.expect_err("delivery to an unreachable SMTP host must fail");
assert_eq!(err.code(), Code::Internal);
assert!(err.message().contains("failed to send message"));
}
}
+1
View File
@@ -4,6 +4,7 @@
pub mod grpc;
pub mod orchestrator;
pub mod send;
pub mod sync;
pub use orchestrator::SelfHealingSyncOrchestrator;
+2
View File
@@ -155,6 +155,8 @@ mod tests {
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
+337
View File
@@ -0,0 +1,337 @@
//! Real outbound mail send routine (backlog story 1.C.5, GH #160), folding
//! in backlog story #168 (per-account SMTP endpoint).
//!
//! Prior to this module, `nuncio-cli`'s `mail send` fabricated success --
//! it never dialed an SMTP server at all (see `HeadlessRunner::handle_send_email`
//! before this story). This module closes that gap: the daemon resolves the
//! account to send from, reads its keyring-stored password, builds a real
//! [`SmtpTransportEngine`] from the account's SMTP endpoint
//! (`AccountConfig::smtp_host`/`smtp_port`), and hands the composed message
//! to it. A genuine SMTP transport failure surfaces as [`SendError`]; success
//! is only ever reported when the transport actually accepted the message.
//!
//! # Account selection
//!
//! The `nuncio.v1.Mail` service (like every other RPC on it --
//! `ListMessages`, `GetMessage`, `SearchMessages`, etc.) does not yet
//! disambiguate between multiple configured accounts; it operates over a
//! single, global store. Consistent with that existing scope, `SendMessage`
//! sends from the first account returned by [`DatabaseEngine::list_accounts`].
//! Supporting an explicit multi-account choice is future work, tracked
//! alongside the rest of the `Mail` service's single-account scope.
//!
//! # Testability
//!
//! [`send_with_sender`] takes `&dyn MessageSender` rather than a concrete
//! transport, so it is exercised directly in tests with [`MockMessageSender`]
//! -- no real network or OS keyring access ever happens inside it, and tests
//! can assert on the EXACT [`OutboundMessage`] the transport received.
//! Production entry points ([`send_message_for_account`]) are the only place
//! that resolves a real account's credentials and constructs a real
//! [`SmtpTransportEngine`]; they then delegate the actual send to
//! [`send_with_sender`]. This mirrors `nunciod::sync`'s `&dyn MailBackend`
//! seam for inbound sync.
//!
//! # Outbox (reliability) design decision
//!
//! The existing transactional outbox (`nuncio_filter::OutboxManager` /
//! `pending_remote_mutations`) is purpose-built for retrying remote
//! mutations against an EXISTING synced message (`rule_id` + `message_id` +
//! an action type like `MOVE`/`FLAG`/`DELETE`) -- it has no notion of a
//! freshly-composed outbound message (recipients, subject, body,
//! attachments) that was never itself a persisted/synced message. Reshaping
//! that schema to carry a full compose payload would be a much larger
//! change than this story's scope. Per this story's own escape hatch, this
//! send is therefore a DIRECT send inside the `SendMessage` RPC handler:
//! synchronous, and honest about success/failure -- never fabricated --
//! but a transient SMTP failure is NOT automatically retried in the
//! background the way a filter mutation is. A future story can route
//! outbound sends through a dedicated outbox table if automatic retry
//! becomes a requirement.
use nuncio_core::model::Attachment;
use nuncio_core::AccountConfig;
use nuncio_mail::{MailError, MessageSender, OutboundMessage, SmtpTransportEngine};
use nuncio_store::db::{DatabaseEngine, DatabaseError};
use nuncio_store::vault::{SecretManager, VaultError};
use thiserror::Error;
/// Errors that can occur while sending an outbound message.
#[derive(Debug, Error)]
pub enum SendError {
/// No account is configured to send mail from.
#[error("no account is configured to send mail from")]
NoAccountConfigured,
/// Failed to read the account's credential from the secret vault.
#[error("failed to read account credential from the secret vault: {0}")]
Vault(#[from] VaultError),
/// The SMTP transport reported an error. Never fabricated -- this is
/// always a genuine failure surfaced from the transport.
#[error("mail transport error: {0}")]
Mail(#[from] MailError),
/// A database error occurred while resolving the sending account.
#[error("database error: {0}")]
Store(#[from] DatabaseError),
}
/// A caller-supplied compose request: everything the `SendMessage` RPC
/// receives from the client, MINUS the `From:` address (always resolved
/// server-side from the configured account, never supplied by the caller).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComposeRequest {
/// Recipient email address.
pub to: String,
/// Optional CC recipient email address.
pub cc: Option<String>,
/// Message subject line.
pub subject: String,
/// Plaintext message body.
pub body_text: Option<String>,
/// Optional HTML message body.
pub body_html: Option<String>,
/// File attachments.
pub attachments: Vec<Attachment>,
}
/// Send `message` through `sender`. Pure seam shared by production and
/// tests -- see the module-level "Testability" doc above. Never fabricates
/// success: `sender.send` reporting an error propagates as [`SendError::Mail`].
pub async fn send_with_sender(
sender: &dyn MessageSender,
message: &OutboundMessage,
) -> Result<(), SendError> {
sender.send(message).await.map_err(SendError::from)
}
/// Construct the real [`SmtpTransportEngine`] for `config`, authenticated
/// with `password` (read from the OS keyring by the caller). This is the
/// ONLY place a concrete transport is chosen in production; every other
/// function in this module only ever sees `&dyn MessageSender`.
fn build_smtp_sender(
config: &AccountConfig,
password: &str,
) -> Result<SmtpTransportEngine, SendError> {
let engine = SmtpTransportEngine::new(
&config.smtp_host,
config.smtp_port,
&config.email_address,
password,
)?;
Ok(engine)
}
/// Resolves the account to send mail from. See the module-level "Account
/// selection" doc above for why this is currently the first configured
/// account rather than an explicit per-request choice.
async fn resolve_sending_account(db: &DatabaseEngine) -> Result<AccountConfig, SendError> {
let accounts = db.list_accounts().await?;
accounts
.into_iter()
.next()
.ok_or(SendError::NoAccountConfigured)
}
/// Generates a daemon-local identifier for a sent message, for reference in
/// logs/output only -- NOT a persisted message id (outbound sends are not
/// written back into the synced message store by this RPC).
fn generate_message_id() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("sent-{nanos:x}")
}
/// Production entry point for the `SendMessage` RPC (backlog story 1.C.5,
/// GH #160): resolves the sending account and its keyring password, builds
/// a real [`SmtpTransportEngine`] from the account's SMTP endpoint (backlog
/// story #168), and sends `request` through it. Returns the generated
/// message id ONLY on genuine transport acceptance -- a resolution failure
/// (no account, missing credential) or transport failure both surface as
/// `Err`, never a fabricated success.
pub async fn send_message_for_account(
db: &DatabaseEngine,
secrets: &SecretManager,
request: ComposeRequest,
) -> Result<String, SendError> {
let config = resolve_sending_account(db).await?;
let password = secrets.get_secret(&config.keyring_secret_key)?;
let sender = build_smtp_sender(&config, &password)?;
let outbound = OutboundMessage {
from: config.email_address,
to: request.to,
cc: request.cc,
subject: request.subject,
body_plain: request.body_text,
body_html: request.body_html,
attachments: request.attachments,
};
send_with_sender(&sender, &outbound).await?;
Ok(generate_message_id())
}
#[cfg(test)]
mod tests {
use super::*;
use nuncio_core::TlsMode;
use nuncio_mail::MockMessageSender;
fn sample_account(id: &str) -> AccountConfig {
AccountConfig {
id: id.to_string(),
name: "Send Test Account".to_string(),
email_address: format!("{id}@nuncio.mx"),
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "127.0.0.1".to_string(),
smtp_port: 1,
use_tls: true,
imap_tls_mode: TlsMode::ImplicitTls,
smtp_tls_mode: TlsMode::ImplicitTls,
keyring_secret_key: format!("nuncio/{id}"),
sync_interval_secs: 60,
}
}
fn sample_outbound_message() -> OutboundMessage {
OutboundMessage {
from: "alice@nuncio.mx".to_string(),
to: "bob@nuncio.mx".to_string(),
cc: Some("carol@nuncio.mx".to_string()),
subject: "Quarterly Roadmap".to_string(),
body_plain: Some("Let's discuss the roadmap.".to_string()),
body_html: None,
attachments: Vec::new(),
}
}
#[tokio::test]
async fn send_with_sender_records_the_exact_message_and_reports_genuine_success() {
let mock = MockMessageSender::new();
let message = sample_outbound_message();
send_with_sender(&mock, &message)
.await
.expect("send succeeds");
let sent = mock.sent_messages();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0], message);
assert_eq!(sent[0].to, "bob@nuncio.mx");
assert_eq!(sent[0].cc.as_deref(), Some("carol@nuncio.mx"));
assert_eq!(sent[0].subject, "Quarterly Roadmap");
}
#[tokio::test]
async fn send_with_sender_surfaces_transport_failure_as_error_never_fabricating_success() {
let mock = MockMessageSender::new();
mock.set_should_fail(true);
let message = sample_outbound_message();
let err = send_with_sender(&mock, &message)
.await
.expect_err("simulated transport failure must surface as an error");
assert!(matches!(err, SendError::Mail(_)));
// The failed attempt must never be recorded as "sent".
assert!(mock.sent_messages().is_empty());
}
#[tokio::test]
async fn send_message_for_account_reports_honest_error_when_no_account_configured() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let secrets = SecretManager::mock();
let err = send_message_for_account(
&db,
&secrets,
ComposeRequest {
to: "bob@nuncio.mx".to_string(),
cc: None,
subject: "Hi".to_string(),
body_text: Some("Body".to_string()),
body_html: None,
attachments: Vec::new(),
},
)
.await
.expect_err("no configured account must fail");
assert!(matches!(err, SendError::NoAccountConfigured));
}
#[tokio::test]
async fn send_message_for_account_reports_honest_error_when_credential_missing() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let secrets = SecretManager::mock();
let config = sample_account("acct-no-cred");
db.save_account(&config).await.expect("save account");
// Deliberately never calls `secrets.set_secret(...)`.
let err = send_message_for_account(
&db,
&secrets,
ComposeRequest {
to: "bob@nuncio.mx".to_string(),
cc: None,
subject: "Hi".to_string(),
body_text: Some("Body".to_string()),
body_html: None,
attachments: Vec::new(),
},
)
.await
.expect_err("missing credential must fail");
assert!(matches!(err, SendError::Vault(_)));
}
/// Proves the full production wiring (account resolution -> keyring
/// password -> real `SmtpTransportEngine` construction -> send) reports
/// a genuine transport error rather than fabricating success, WITHOUT
/// any live network dependency: `smtp_host`/`smtp_port` point at a
/// reserved loopback port nothing is listening on (mirroring
/// `nuncio_mail::smtp`'s own `send_email_to_unreachable_server_fails_with_transport_error`
/// test), so the connection attempt fails fast and deterministically.
#[tokio::test]
async fn send_message_for_account_builds_real_smtp_transport_and_fails_honestly_when_unreachable(
) {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let secrets = SecretManager::mock();
let config = sample_account("acct-unreachable");
db.save_account(&config).await.expect("save account");
secrets
.set_secret(&config.keyring_secret_key, "irrelevant-password")
.expect("store credential in mock vault");
let err = send_message_for_account(
&db,
&secrets,
ComposeRequest {
to: "bob@nuncio.mx".to_string(),
cc: None,
subject: "Hi".to_string(),
body_text: Some("Body".to_string()),
body_html: None,
attachments: Vec::new(),
},
)
.await
.expect_err("delivery to an unreachable SMTP host must fail");
assert!(matches!(err, SendError::Mail(_)));
}
#[test]
fn generate_message_id_produces_a_non_empty_prefixed_identifier() {
let id = generate_message_id();
assert!(id.starts_with("sent-"));
assert!(id.len() > "sent-".len());
}
}
+4
View File
@@ -247,6 +247,8 @@ mod tests {
protocol: AccountProtocol::Jmap,
server_host: "jmap.nuncio.mx".to_string(),
server_port: 443,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: TlsMode::ImplicitTls,
smtp_tls_mode: TlsMode::ImplicitTls,
@@ -263,6 +265,8 @@ mod tests {
protocol: AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: TlsMode::ImplicitTls,
smtp_tls_mode: TlsMode::ImplicitTls,