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:
15 files changed
+1307
-78
No files matched your search
+116
-37
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
pub mod grpc;
|
||||
pub mod orchestrator;
|
||||
pub mod send;
|
||||
pub mod sync;
|
||||
pub use orchestrator::SelfHealingSyncOrchestrator;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user