M5: Account lifecycle over gRPC (edit/delete/test) + persist TLS mode #230
No files matched your search
@@ -258,6 +258,12 @@ pub enum AccountSubcommand {
|
||||
/// Updated SMTP server port.
|
||||
#[arg(long, help = "Updated SMTP server port")]
|
||||
smtp_port: Option<u16>,
|
||||
/// Updated IMAP transport mode (implicit_tls, start_tls, plain).
|
||||
#[arg(long, help = "Updated IMAP transport mode")]
|
||||
imap_mode: Option<String>,
|
||||
/// Updated SMTP transport mode (implicit_tls, start_tls, plain).
|
||||
#[arg(long, help = "Updated SMTP transport mode")]
|
||||
smtp_mode: Option<String>,
|
||||
},
|
||||
/// Remove a configured account profile.
|
||||
Delete {
|
||||
|
||||
+357
-89
@@ -291,45 +291,32 @@ impl HeadlessRunner {
|
||||
)
|
||||
.await
|
||||
}
|
||||
AccountSubcommand::Show { id } => {
|
||||
let accounts = self.db.list_accounts().await.unwrap_or_default();
|
||||
let acct = accounts.into_iter().find(|a| a.id == *id);
|
||||
if json_mode {
|
||||
format_json(&json!({ "account": acct }))
|
||||
} else if let Some(a) = acct {
|
||||
format!("Account {}: Email: {}", a.id, a.email_address)
|
||||
} else {
|
||||
format!("Account '{}' not found", id)
|
||||
}
|
||||
}
|
||||
AccountSubcommand::Show { id } => self.handle_account_show(id, json_mode).await,
|
||||
AccountSubcommand::Edit {
|
||||
id,
|
||||
email,
|
||||
imap_host: _,
|
||||
imap_port: _,
|
||||
smtp_host: _,
|
||||
smtp_port: _,
|
||||
imap_host,
|
||||
imap_port,
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
imap_mode,
|
||||
smtp_mode,
|
||||
} => {
|
||||
if json_mode {
|
||||
format_json(&json!({ "status": "updated", "id": id, "email": email }))
|
||||
} else {
|
||||
format!("Account '{}' updated successfully.", id)
|
||||
}
|
||||
}
|
||||
AccountSubcommand::Delete { id } => {
|
||||
if json_mode {
|
||||
format_json(&json!({ "status": "deleted", "id": id }))
|
||||
} else {
|
||||
format!("Account '{}' removed.", id)
|
||||
}
|
||||
}
|
||||
AccountSubcommand::Test { id } => {
|
||||
if json_mode {
|
||||
format_json(&json!({ "status": "ok", "id": id, "latency_ms": 24 }))
|
||||
} else {
|
||||
format!("✓ Account '{}' connection test OK (24ms latency).", id)
|
||||
}
|
||||
self.handle_account_edit(
|
||||
id,
|
||||
email.as_deref(),
|
||||
imap_host.as_deref(),
|
||||
*imap_port,
|
||||
smtp_host.as_deref(),
|
||||
*smtp_port,
|
||||
imap_mode.as_deref(),
|
||||
smtp_mode.as_deref(),
|
||||
json_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AccountSubcommand::Delete { id } => self.handle_account_delete(id, json_mode).await,
|
||||
AccountSubcommand::Test { id } => self.handle_account_test(id, json_mode).await,
|
||||
},
|
||||
Commands::Mail { action } => match action {
|
||||
MailSubcommand::Sync => self.handle_sync(json_mode).await,
|
||||
@@ -1753,6 +1740,229 @@ impl HeadlessRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches every account from the daemon and returns the one matching
|
||||
/// `id`, or an error string. There is deliberately no single-account
|
||||
/// `GetAccount` RPC in the contract, so `account show`/`edit` filter the
|
||||
/// `ListAccounts` result client-side -- always over the real daemon API,
|
||||
/// never this runner's own ephemeral local `db`.
|
||||
async fn fetch_account_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<nuncio_proto::v1::AccountConfig, String> {
|
||||
let mut client = self.connect_accounts_client().await?;
|
||||
let accounts = client
|
||||
.list_accounts(nuncio_proto::v1::ListAccountsRequest {})
|
||||
.await
|
||||
.map_err(|status| format!("nunciod daemon rejected list_accounts: {status}"))?
|
||||
.into_inner()
|
||||
.accounts;
|
||||
accounts
|
||||
.into_iter()
|
||||
.find(|a| a.id == id)
|
||||
.ok_or_else(|| format!("account '{id}' not found"))
|
||||
}
|
||||
|
||||
/// `account show`: a real thin gRPC client of the daemon's
|
||||
/// `nuncio.v1.Accounts/ListAccounts` API, filtered client-side to a single
|
||||
/// account. Reads ONLY the daemon's persistent store -- never this
|
||||
/// runner's own ephemeral local `db`.
|
||||
async fn handle_account_show(&self, id: &str, json_mode: bool) -> String {
|
||||
let account = match self.fetch_account_by_id(id).await {
|
||||
Ok(account) => account,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"id": account.id,
|
||||
"name": account.name,
|
||||
"email_address": account.email_address,
|
||||
"protocol": account.protocol().as_str_name(),
|
||||
"server_host": account.server_host,
|
||||
"server_port": account.server_port,
|
||||
"smtp_host": account.smtp_host,
|
||||
"smtp_port": account.smtp_port,
|
||||
"use_tls": account.use_tls,
|
||||
"imap_tls_mode": account.imap_tls_mode().as_str_name(),
|
||||
"smtp_tls_mode": account.smtp_tls_mode().as_str_name(),
|
||||
"keyring_secret_key": account.keyring_secret_key,
|
||||
"sync_interval_secs": account.sync_interval_secs,
|
||||
}))
|
||||
} else {
|
||||
format!(
|
||||
"Account [{}] {} <{}> IMAP {}:{} ({}) SMTP {}:{} ({})",
|
||||
account.id,
|
||||
account.name,
|
||||
account.email_address,
|
||||
account.server_host,
|
||||
account.server_port,
|
||||
account.imap_tls_mode().as_str_name(),
|
||||
account.smtp_host,
|
||||
account.smtp_port,
|
||||
account.smtp_tls_mode().as_str_name(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `account edit`: a real thin gRPC client of the daemon's
|
||||
/// `nuncio.v1.Accounts/UpdateAccount` API. Fetches the existing account,
|
||||
/// applies ONLY the flags actually supplied onto it, and persists the
|
||||
/// merged config through the daemon. No password is sent, so the existing
|
||||
/// keyring credential is preserved (see `UpdateAccountRequest`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_account_edit(
|
||||
&self,
|
||||
id: &str,
|
||||
email: Option<&str>,
|
||||
imap_host: Option<&str>,
|
||||
imap_port: Option<u16>,
|
||||
smtp_host: Option<&str>,
|
||||
smtp_port: Option<u16>,
|
||||
imap_mode: Option<&str>,
|
||||
smtp_mode: Option<&str>,
|
||||
json_mode: bool,
|
||||
) -> String {
|
||||
let mut config = match self.fetch_account_by_id(id).await {
|
||||
Ok(config) => config,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
if let Some(email) = email {
|
||||
config.email_address = email.to_string();
|
||||
}
|
||||
if let Some(host) = imap_host {
|
||||
config.server_host = host.to_string();
|
||||
}
|
||||
if let Some(port) = imap_port {
|
||||
config.server_port = u32::from(port);
|
||||
}
|
||||
if let Some(host) = smtp_host {
|
||||
config.smtp_host = host.to_string();
|
||||
}
|
||||
if let Some(port) = smtp_port {
|
||||
config.smtp_port = u32::from(port);
|
||||
}
|
||||
if let Some(mode) = imap_mode {
|
||||
match parse_tls_mode(mode) {
|
||||
Ok(mode) => config.imap_tls_mode = map_tls_mode_to_proto(mode).into(),
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
}
|
||||
}
|
||||
if let Some(mode) = smtp_mode {
|
||||
match parse_tls_mode(mode) {
|
||||
Ok(mode) => config.smtp_tls_mode = map_tls_mode_to_proto(mode).into(),
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
}
|
||||
}
|
||||
|
||||
let mut client = match self.connect_accounts_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
match client
|
||||
.update_account(nuncio_proto::v1::UpdateAccountRequest {
|
||||
config: Some(config),
|
||||
password: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
if json_mode {
|
||||
format_json(&json!({ "status": "updated", "id": id }))
|
||||
} else {
|
||||
format!("Account '{id}' updated via nunciod daemon.")
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected update_account: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `account delete`: a real thin gRPC client of the daemon's
|
||||
/// `nuncio.v1.Accounts/RemoveAccount` API -- genuinely removes the
|
||||
/// persisted account and its keyring credential, never a fabricated
|
||||
/// "removed." string.
|
||||
async fn handle_account_delete(&self, id: &str, json_mode: bool) -> String {
|
||||
let mut client = match self.connect_accounts_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
match client
|
||||
.remove_account(nuncio_proto::v1::RemoveAccountRequest { id: id.to_string() })
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
if json_mode {
|
||||
format_json(&json!({ "status": "deleted", "id": id }))
|
||||
} else {
|
||||
format!("Account '{id}' removed via nunciod daemon.")
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected remove_account: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `account test`: a real thin gRPC client of the daemon's
|
||||
/// `nuncio.v1.Accounts/TestAccountConnection` API. Reports the GENUINE
|
||||
/// per-protocol outcome the daemon measured -- never a fabricated
|
||||
/// "connection test OK (24ms latency)". The two legs verify different
|
||||
/// depths (see the RPC's proto doc): IMAP is a full login, SMTP is
|
||||
/// transport reachability only (no AUTH), so the output labels them
|
||||
/// distinctly and never implies SMTP authentication was verified.
|
||||
async fn handle_account_test(&self, id: &str, json_mode: bool) -> String {
|
||||
let mut client = match self.connect_accounts_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
match client
|
||||
.test_account_connection(nuncio_proto::v1::TestAccountConnectionRequest {
|
||||
id: id.to_string(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let report = response.into_inner();
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"id": id,
|
||||
"imap_ok": report.imap_ok,
|
||||
"smtp_ok": report.smtp_ok,
|
||||
"imap_error": report.imap_error,
|
||||
"smtp_error": report.smtp_error,
|
||||
}))
|
||||
} else {
|
||||
// Label each leg by what it actually verifies: IMAP is a
|
||||
// full login, SMTP is transport reachability only (no
|
||||
// AUTH). The wording must never imply SMTP authentication
|
||||
// was checked when it was not.
|
||||
let imap = match &report.imap_error {
|
||||
Some(e) => format!("IMAP (login): FAIL ({e})"),
|
||||
None if report.imap_ok => "IMAP (login): OK".to_string(),
|
||||
None => "IMAP (login): FAIL".to_string(),
|
||||
};
|
||||
let smtp = match &report.smtp_error {
|
||||
Some(e) => format!("SMTP (reachability, no auth): FAIL ({e})"),
|
||||
None if report.smtp_ok => "SMTP (reachability, no auth): OK".to_string(),
|
||||
None => "SMTP (reachability, no auth): FAIL".to_string(),
|
||||
};
|
||||
format!("Connection test for '{id}'\n {imap}\n {smtp}")
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected test_account_connection: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// `nuncio.v1.Accounts` service at `self.grpc_addr`, shared by
|
||||
/// [`Self::handle_add_account`] and [`Self::handle_accounts_list`].
|
||||
@@ -2246,60 +2456,6 @@ mod tests {
|
||||
assert!(bad_smtp_mode.starts_with("Error: invalid tls mode"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_runner_executes_all_pure_noun_verb_commands() {
|
||||
let runner = HeadlessRunner::ephemeral()
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
// Account Noun Commands: `Add`/`List` are exercised separately
|
||||
// below via `ephemeral_with` + `SecretManager::mock()` against a
|
||||
// live test gRPC server, for the exact same reason `system status`
|
||||
// is: they are real gRPC clients of the `nunciod` daemon's
|
||||
// `Accounts` API, so they must never run against this
|
||||
// `ephemeral()`-constructed runner's production `SecretManager` or
|
||||
// its (unreachable in CI) default gRPC address. `Show` still reads
|
||||
// this runner's own ephemeral local `db`, so it is safe to
|
||||
// exercise here.
|
||||
let acct_show = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Show {
|
||||
id: "missing".to_string(),
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(acct_show.contains("Account 'missing' not found"));
|
||||
|
||||
// Mail Noun Commands: `Sync`/`List`/`Read`/`Search`/`Mark`/`Send`
|
||||
// (and `Folder::List`) are all real gRPC clients of the `nunciod`
|
||||
// daemon's `Mail` API -- 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`).
|
||||
|
||||
// Cal Noun Commands: `List`/`Sync` are real gRPC clients of the
|
||||
// `nunciod` daemon's `Calendar` API -- exactly like the Mail Noun
|
||||
// Commands 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 `Calendar` gRPC server
|
||||
// (`calendar_rpcs_round_trip_over_grpc_to_a_stub_daemon`).
|
||||
|
||||
// System Noun Commands are exercised separately below via
|
||||
// `ephemeral_with` + `SecretManager::mock()`: `system status` is a
|
||||
// real gRPC client of the `nunciod` daemon, so it must never run
|
||||
// against the production
|
||||
// `SecretManager` this `ephemeral()`-constructed runner holds (that
|
||||
// would touch the real OS keyring during a test run).
|
||||
}
|
||||
|
||||
/// Binds an ephemeral loopback TCP listener, reads back its OS-assigned
|
||||
/// address, then immediately drops the listener so the port is free
|
||||
/// again. Nothing is listening on the returned address, so connecting
|
||||
@@ -2447,7 +2603,9 @@ mod tests {
|
||||
use nuncio_proto::v1::{
|
||||
AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
|
||||
AddAccountRequest, AddAccountResponse, ListAccountsRequest, ListAccountsResponse,
|
||||
TlsMode as TlsModeProto,
|
||||
RemoveAccountRequest, RemoveAccountResponse, TestAccountConnectionRequest,
|
||||
TestAccountConnectionResponse, TlsMode as TlsModeProto, UpdateAccountRequest,
|
||||
UpdateAccountResponse,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -2479,6 +2637,32 @@ mod tests {
|
||||
Ok(tonic::Response::new(AddAccountResponse { id }))
|
||||
}
|
||||
|
||||
async fn update_account(
|
||||
&self,
|
||||
_request: tonic::Request<UpdateAccountRequest>,
|
||||
) -> Result<tonic::Response<UpdateAccountResponse>, tonic::Status> {
|
||||
Ok(tonic::Response::new(UpdateAccountResponse {}))
|
||||
}
|
||||
|
||||
async fn remove_account(
|
||||
&self,
|
||||
_request: tonic::Request<RemoveAccountRequest>,
|
||||
) -> Result<tonic::Response<RemoveAccountResponse>, tonic::Status> {
|
||||
Ok(tonic::Response::new(RemoveAccountResponse {}))
|
||||
}
|
||||
|
||||
async fn test_account_connection(
|
||||
&self,
|
||||
_request: tonic::Request<TestAccountConnectionRequest>,
|
||||
) -> Result<tonic::Response<TestAccountConnectionResponse>, tonic::Status> {
|
||||
Ok(tonic::Response::new(TestAccountConnectionResponse {
|
||||
imap_ok: true,
|
||||
smtp_ok: true,
|
||||
imap_error: None,
|
||||
smtp_error: None,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
_request: tonic::Request<ListAccountsRequest>,
|
||||
@@ -2584,6 +2768,90 @@ mod tests {
|
||||
// Plain output must show each account's details, not just the count.
|
||||
assert!(list_out_text.contains("stub@nuncio.mx"));
|
||||
assert!(list_out_text.contains("imap.nuncio.mx"));
|
||||
|
||||
// `account show` reads the daemon's ListAccounts result (filtered
|
||||
// client-side), not the runner's own ephemeral local db.
|
||||
let show_out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Show {
|
||||
id: "acct-stub-1".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(show_out.contains("stub@nuncio.mx"));
|
||||
assert!(show_out.contains("acct-stub-1"));
|
||||
|
||||
// `account edit` drives the real UpdateAccount RPC (stub accepts it).
|
||||
let edit_out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Edit {
|
||||
id: "acct-stub-1".to_string(),
|
||||
email: None,
|
||||
imap_host: None,
|
||||
imap_port: None,
|
||||
smtp_host: None,
|
||||
smtp_port: None,
|
||||
imap_mode: Some("start_tls".to_string()),
|
||||
smtp_mode: None,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(edit_out.contains(r#""status":"updated""#));
|
||||
|
||||
// A garbage transport mode is rejected client-side before any RPC.
|
||||
let bad_edit = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Edit {
|
||||
id: "acct-stub-1".to_string(),
|
||||
email: None,
|
||||
imap_host: None,
|
||||
imap_port: None,
|
||||
smtp_host: None,
|
||||
smtp_port: None,
|
||||
imap_mode: Some("nonsense".to_string()),
|
||||
smtp_mode: None,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(bad_edit.contains("invalid tls mode"));
|
||||
|
||||
// `account test` reports the daemon's genuine per-protocol result --
|
||||
// never a fabricated "OK (24ms latency)".
|
||||
let test_out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Test {
|
||||
id: "acct-stub-1".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(test_out.contains(r#""imap_ok":true"#));
|
||||
assert!(test_out.contains(r#""smtp_ok":true"#));
|
||||
assert!(!test_out.contains("latency"));
|
||||
|
||||
// `account delete` drives the real RemoveAccount RPC.
|
||||
let delete_out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Delete {
|
||||
id: "acct-stub-1".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(delete_out.contains(r#""status":"deleted""#));
|
||||
}
|
||||
|
||||
/// Reference-client proof: boots a stub `nuncio.v1.Mail` gRPC server
|
||||
|
||||
+326
-12
@@ -2,10 +2,14 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nuncio_core::model::{Email, Folder};
|
||||
use nuncio_core::TlsMode;
|
||||
use std::fmt::Debug;
|
||||
use std::pin::Pin;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::client::TlsStream;
|
||||
use tokio_rustls::rustls::pki_types::ServerName;
|
||||
@@ -13,6 +17,12 @@ use tokio_rustls::rustls::{ClientConfig, RootCertStore};
|
||||
use tokio_rustls::TlsConnector;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
/// Upper bound on how long a single IMAP transport connect (TCP dial, plus
|
||||
/// any STARTTLS negotiation and TLS handshake) may take before it is treated
|
||||
/// as a failure. Without this an unreachable or silently-dropping host could
|
||||
/// hang a connect -- and therefore a `TestAccountConnection` RPC -- forever.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
use crate::backend::MailBackend;
|
||||
use crate::parser::{MailError, MimeParserAdapter};
|
||||
|
||||
@@ -86,20 +96,191 @@ pub async fn connect_tls_stream(host: &str, port: u16) -> Result<TlsStream<TcpSt
|
||||
Ok(tls_stream)
|
||||
}
|
||||
|
||||
/// A connected IMAP transport stream, unified across the three transport
|
||||
/// security modes so a single `async_imap` session type works for all of
|
||||
/// them: implicit TLS (wrapped immediately on connect), STARTTLS (plain TCP
|
||||
/// upgraded to TLS after negotiation), or plain unencrypted TCP. The TLS
|
||||
/// variant is boxed because a `TlsStream` is substantially larger than a bare
|
||||
/// `TcpStream`, so boxing keeps the enum small and uniform.
|
||||
#[derive(Debug)]
|
||||
pub enum MailStream {
|
||||
/// TLS-encrypted stream (implicit TLS, or a STARTTLS-upgraded socket).
|
||||
Tls(Box<TlsStream<TcpStream>>),
|
||||
/// Plain, unencrypted TCP stream.
|
||||
Plain(TcpStream),
|
||||
}
|
||||
|
||||
impl AsyncRead for MailStream {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
MailStream::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf),
|
||||
MailStream::Plain(s) => Pin::new(s).poll_read(cx, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for MailStream {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
match self.get_mut() {
|
||||
MailStream::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf),
|
||||
MailStream::Plain(s) => Pin::new(s).poll_write(cx, buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
MailStream::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx),
|
||||
MailStream::Plain(s) => Pin::new(s).poll_flush(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
MailStream::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx),
|
||||
MailStream::Plain(s) => Pin::new(s).poll_shutdown(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish a plain, unencrypted TCP stream to an IMAP server. Used for
|
||||
/// [`TlsMode::Plain`] (e.g. a local test/dev port) -- no TLS is negotiated,
|
||||
/// so credentials travel in the clear; the caller opted into that explicitly.
|
||||
pub async fn connect_plain_stream(host: &str, port: u16) -> Result<TcpStream, MailError> {
|
||||
let target_port = if port == 0 { 143 } else { port };
|
||||
let addr = format!("{}:{}", host, target_port);
|
||||
TcpStream::connect(&addr)
|
||||
.await
|
||||
.map_err(|e| MailError::NetworkError(format!("TCP connection to {} failed: {}", addr, e)))
|
||||
}
|
||||
|
||||
/// Read a single CRLF-terminated line from an IMAP transport stream, byte by
|
||||
/// byte. Used only during STARTTLS negotiation, where exactly one greeting
|
||||
/// line and then a small number of response lines are read before the socket
|
||||
/// is upgraded -- not on the hot fetch path, so the simple unbuffered read is
|
||||
/// adequate and avoids buffering bytes that would be lost across the upgrade.
|
||||
async fn read_imap_line<S>(stream: &mut S) -> Result<String, MailError>
|
||||
where
|
||||
S: AsyncRead + Unpin,
|
||||
{
|
||||
let mut line = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
loop {
|
||||
let n = stream
|
||||
.read(&mut byte)
|
||||
.await
|
||||
.map_err(|e| MailError::NetworkError(format!("reading IMAP line failed: {}", e)))?;
|
||||
if n == 0 {
|
||||
return Err(MailError::ImapError(
|
||||
"connection closed before a complete IMAP line was read".to_string(),
|
||||
));
|
||||
}
|
||||
if byte[0] == b'\n' {
|
||||
break;
|
||||
}
|
||||
if byte[0] != b'\r' {
|
||||
line.push(byte[0]);
|
||||
}
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&line).into_owned())
|
||||
}
|
||||
|
||||
/// Establish a STARTTLS IMAP stream: connect plain TCP, read the server
|
||||
/// greeting, issue an explicit `STARTTLS` command, confirm the tagged `OK`,
|
||||
/// then upgrade the SAME socket to TLS via the shared rustls connector. This
|
||||
/// is the RFC 3501 / RFC 2595 explicit-TLS flow used on the submission port
|
||||
/// (143). The upgrade reuses [`build_tls_connector`], so certificate
|
||||
/// validation is identical to implicit TLS.
|
||||
pub async fn connect_starttls_stream(
|
||||
host: &str,
|
||||
port: u16,
|
||||
) -> Result<TlsStream<TcpStream>, MailError> {
|
||||
let target_port = if port == 0 { 143 } else { port };
|
||||
let addr = format!("{}:{}", host, target_port);
|
||||
let mut tcp = TcpStream::connect(&addr).await.map_err(|e| {
|
||||
MailError::NetworkError(format!("TCP connection to {} failed: {}", addr, e))
|
||||
})?;
|
||||
|
||||
// Server greeting (untagged `* OK ...`); consume it before commanding.
|
||||
let greeting = read_imap_line(&mut tcp).await?;
|
||||
if !greeting.starts_with("* OK") && !greeting.starts_with("* PREAUTH") {
|
||||
return Err(MailError::ImapError(format!(
|
||||
"unexpected IMAP greeting during STARTTLS negotiation: {}",
|
||||
greeting
|
||||
)));
|
||||
}
|
||||
|
||||
const TAG: &str = "N1";
|
||||
tcp.write_all(format!("{} STARTTLS\r\n", TAG).as_bytes())
|
||||
.await
|
||||
.map_err(|e| MailError::NetworkError(format!("sending STARTTLS failed: {}", e)))?;
|
||||
|
||||
// Read responses until the tagged completion line for our command,
|
||||
// tolerating any untagged lines the server may interleave first.
|
||||
let tag_prefix = format!("{} ", TAG);
|
||||
loop {
|
||||
let line = read_imap_line(&mut tcp).await?;
|
||||
if let Some(rest) = line.strip_prefix(&tag_prefix) {
|
||||
if rest.starts_with("OK") {
|
||||
break;
|
||||
}
|
||||
return Err(MailError::ImapError(format!(
|
||||
"server refused STARTTLS: {}",
|
||||
line
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let connector = build_tls_connector()?;
|
||||
let server_name = ServerName::try_from(host.to_string())
|
||||
.map_err(|e| MailError::TlsError(format!("invalid TLS server name '{}': {}", host, e)))?;
|
||||
let tls_stream = connector.connect(server_name, tcp).await.map_err(|e| {
|
||||
MailError::TlsError(format!(
|
||||
"TLS handshake with {} after STARTTLS failed: {}",
|
||||
host, e
|
||||
))
|
||||
})?;
|
||||
Ok(tls_stream)
|
||||
}
|
||||
|
||||
/// IMAP dual-socket manager maintaining isolated connections for IDLE push and FETCH/STORE queries.
|
||||
pub struct ImapDualSocketManager {
|
||||
server_host: String,
|
||||
server_port: u16,
|
||||
tls_mode: TlsMode,
|
||||
idle_active: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ImapDualSocketManager {
|
||||
/// Create a new `ImapDualSocketManager`.
|
||||
/// Create a new `ImapDualSocketManager` defaulting to implicit TLS. The
|
||||
/// default port for a `0` port is the implicit-TLS IMAPS port (993).
|
||||
pub fn new(server_host: &str, server_port: u16) -> Self {
|
||||
let port = if server_port == 0 { 993 } else { server_port };
|
||||
Self::with_tls_mode(server_host, server_port, TlsMode::ImplicitTls)
|
||||
}
|
||||
|
||||
/// Create a new `ImapDualSocketManager` for a specific transport security
|
||||
/// [`TlsMode`]. A `0` port is resolved to the conventional default for
|
||||
/// the chosen mode: 993 for implicit TLS, 143 for STARTTLS/plain.
|
||||
pub fn with_tls_mode(server_host: &str, server_port: u16, tls_mode: TlsMode) -> Self {
|
||||
let port = if server_port == 0 {
|
||||
match tls_mode {
|
||||
TlsMode::ImplicitTls => 993,
|
||||
TlsMode::StartTls | TlsMode::Plain => 143,
|
||||
}
|
||||
} else {
|
||||
server_port
|
||||
};
|
||||
Self {
|
||||
server_host: server_host.to_string(),
|
||||
server_port: port,
|
||||
tls_mode,
|
||||
idle_active: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
@@ -114,6 +295,11 @@ impl ImapDualSocketManager {
|
||||
self.server_port
|
||||
}
|
||||
|
||||
/// Retrieve the configured transport security mode.
|
||||
pub fn tls_mode(&self) -> TlsMode {
|
||||
self.tls_mode
|
||||
}
|
||||
|
||||
/// Retrieve the current IDLE socket state.
|
||||
pub fn idle_state(&self) -> IdleSocketState {
|
||||
if self.idle_active.load(Ordering::SeqCst) {
|
||||
@@ -123,18 +309,44 @@ impl ImapDualSocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish a live TLS socket connection to the target IMAP server.
|
||||
pub async fn connect_tls_socket(&self) -> Result<TlsStream<TcpStream>, MailError> {
|
||||
connect_tls_stream(&self.server_host, self.server_port).await
|
||||
/// Establish a live transport socket to the target IMAP server, selecting
|
||||
/// implicit TLS, STARTTLS, or plain TCP per the configured [`TlsMode`].
|
||||
/// Bounded by [`CONNECT_TIMEOUT`] so an unreachable host cannot hang the
|
||||
/// caller indefinitely.
|
||||
pub async fn connect_stream(&self) -> Result<MailStream, MailError> {
|
||||
let connect = async {
|
||||
match self.tls_mode {
|
||||
TlsMode::ImplicitTls => Ok(MailStream::Tls(Box::new(
|
||||
connect_tls_stream(&self.server_host, self.server_port).await?,
|
||||
))),
|
||||
TlsMode::StartTls => Ok(MailStream::Tls(Box::new(
|
||||
connect_starttls_stream(&self.server_host, self.server_port).await?,
|
||||
))),
|
||||
TlsMode::Plain => Ok(MailStream::Plain(
|
||||
connect_plain_stream(&self.server_host, self.server_port).await?,
|
||||
)),
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(CONNECT_TIMEOUT, connect)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MailError::NetworkError(format!(
|
||||
"IMAP connect to {}:{} timed out after {}s",
|
||||
self.server_host,
|
||||
self.server_port,
|
||||
CONNECT_TIMEOUT.as_secs()
|
||||
))
|
||||
})?
|
||||
}
|
||||
|
||||
/// Establish an authenticated IMAP session over a live TLS socket connection.
|
||||
/// Establish an authenticated IMAP session over a live transport socket
|
||||
/// selected by the configured [`TlsMode`].
|
||||
pub async fn connect_session(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<async_imap::Session<TlsStream<TcpStream>>, MailError> {
|
||||
let stream = self.connect_tls_socket().await?;
|
||||
) -> Result<async_imap::Session<MailStream>, MailError> {
|
||||
let stream = self.connect_stream().await?;
|
||||
let client = async_imap::Client::new(stream);
|
||||
let session = client
|
||||
.login(username, password)
|
||||
@@ -169,7 +381,7 @@ pub struct ImapEngine {
|
||||
}
|
||||
|
||||
impl ImapEngine {
|
||||
/// Create a new `ImapEngine`.
|
||||
/// Create a new `ImapEngine` defaulting to implicit TLS transport.
|
||||
pub fn new(account_id: &str, server_host: &str, server_port: u16) -> Self {
|
||||
Self {
|
||||
socket_manager: ImapDualSocketManager::new(server_host, server_port),
|
||||
@@ -179,22 +391,53 @@ impl ImapEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new `ImapEngine` with host, port, and authentication credentials.
|
||||
/// Create a new `ImapEngine` with host, port, transport security
|
||||
/// [`TlsMode`], and authentication credentials. The `tls_mode` genuinely
|
||||
/// selects the transport (implicit TLS / STARTTLS / plain) used for every
|
||||
/// connection this engine opens -- it is not merely recorded.
|
||||
pub fn with_credentials(
|
||||
account_id: &str,
|
||||
server_host: &str,
|
||||
server_port: u16,
|
||||
tls_mode: TlsMode,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Self {
|
||||
Self {
|
||||
socket_manager: ImapDualSocketManager::new(server_host, server_port),
|
||||
socket_manager: ImapDualSocketManager::with_tls_mode(
|
||||
server_host,
|
||||
server_port,
|
||||
tls_mode,
|
||||
),
|
||||
account_id: account_id.to_string(),
|
||||
username: Some(username.to_string()),
|
||||
password: Some(password.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the IMAP endpoint end to end: open a transport connection using
|
||||
/// the configured [`TlsMode`], authenticate with the engine's
|
||||
/// credentials, and cleanly log out. Returns `Ok(())` only on a genuine
|
||||
/// successful login -- any dial, TLS-handshake, or authentication failure
|
||||
/// surfaces as the real [`MailError`], never a fabricated success. This
|
||||
/// backs the daemon's `TestAccountConnection` IMAP leg.
|
||||
pub async fn probe_connection(&self) -> Result<(), MailError> {
|
||||
let (username, password) = match (&self.username, &self.password) {
|
||||
(Some(u), Some(p)) => (u.as_str(), p.as_str()),
|
||||
_ => {
|
||||
return Err(MailError::AuthError(
|
||||
"IMAP connection probe requires credentials".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let mut session = self
|
||||
.socket_manager
|
||||
.connect_session(username, password)
|
||||
.await?;
|
||||
let _ = session.logout().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set authentication credentials for the IMAP engine.
|
||||
pub fn set_credentials(&mut self, username: &str, password: &str) {
|
||||
self.username = Some(username.to_string());
|
||||
@@ -520,6 +763,7 @@ mod tests {
|
||||
let manager = ImapDualSocketManager::new("mail.kof22.com", 993);
|
||||
assert_eq!(manager.server_host(), "mail.kof22.com");
|
||||
assert_eq!(manager.server_port(), 993);
|
||||
assert_eq!(manager.tls_mode(), TlsMode::ImplicitTls);
|
||||
assert_eq!(manager.idle_state(), IdleSocketState::Disconnected);
|
||||
|
||||
manager.start_idle_listener()?;
|
||||
@@ -546,6 +790,76 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_tls_mode_records_mode_and_resolves_default_ports() {
|
||||
// A `0` port resolves to the conventional default for each mode.
|
||||
let implicit = ImapDualSocketManager::with_tls_mode("host", 0, TlsMode::ImplicitTls);
|
||||
assert_eq!(implicit.tls_mode(), TlsMode::ImplicitTls);
|
||||
assert_eq!(implicit.server_port(), 993);
|
||||
|
||||
let starttls = ImapDualSocketManager::with_tls_mode("host", 0, TlsMode::StartTls);
|
||||
assert_eq!(starttls.tls_mode(), TlsMode::StartTls);
|
||||
assert_eq!(starttls.server_port(), 143);
|
||||
|
||||
let plain = ImapDualSocketManager::with_tls_mode("host", 0, TlsMode::Plain);
|
||||
assert_eq!(plain.tls_mode(), TlsMode::Plain);
|
||||
assert_eq!(plain.server_port(), 143);
|
||||
|
||||
// An explicit non-zero port is always honored verbatim.
|
||||
let explicit = ImapDualSocketManager::with_tls_mode("host", 2143, TlsMode::StartTls);
|
||||
assert_eq!(explicit.server_port(), 2143);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_stream_plain_connects_to_a_loopback_listener() {
|
||||
// Plain mode performs a genuine TCP connect with no TLS: against a
|
||||
// loopback listener the connect succeeds and yields the plain
|
||||
// transport variant.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind loopback listener");
|
||||
let addr = listener.local_addr().expect("listener addr");
|
||||
let accept = tokio::spawn(async move {
|
||||
let _ = listener.accept().await;
|
||||
});
|
||||
|
||||
let manager =
|
||||
ImapDualSocketManager::with_tls_mode("127.0.0.1", addr.port(), TlsMode::Plain);
|
||||
let stream = manager
|
||||
.connect_stream()
|
||||
.await
|
||||
.expect("plain connect to loopback listener succeeds");
|
||||
assert!(matches!(stream, MailStream::Plain(_)));
|
||||
let _ = accept.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_stream_implicit_tls_fails_fast_against_a_closed_port() {
|
||||
// Implicit TLS against a closed port fails at the TCP dial (connection
|
||||
// refused) -- a genuine, honest failure, not a fabricated success.
|
||||
let manager = ImapDualSocketManager::with_tls_mode("127.0.0.1", 1, TlsMode::ImplicitTls);
|
||||
let err = manager
|
||||
.connect_stream()
|
||||
.await
|
||||
.expect_err("connect to a closed port must fail");
|
||||
assert!(matches!(
|
||||
err,
|
||||
MailError::NetworkError(_) | MailError::TlsError(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_connection_requires_credentials() {
|
||||
// Without credentials the probe reports an honest error rather than
|
||||
// dialing or fabricating a result.
|
||||
let engine = ImapEngine::new("acct-1", "127.0.0.1", 993);
|
||||
let err = engine
|
||||
.probe_connection()
|
||||
.await
|
||||
.expect_err("probe without credentials must fail");
|
||||
assert!(matches!(err, MailError::AuthError(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_config_building() -> Result<(), MailError> {
|
||||
let config = build_tls_config()?;
|
||||
|
||||
+116
-29
@@ -9,6 +9,7 @@ use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::transport::smtp::Error as LettreSmtpError;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||
use nuncio_core::model::{Attachment, Email};
|
||||
use nuncio_core::TlsMode;
|
||||
|
||||
use crate::backend::{MessageSender, OutboundMessage};
|
||||
use crate::parser::MailError;
|
||||
@@ -42,12 +43,39 @@ pub struct SmtpTransportEngine {
|
||||
}
|
||||
|
||||
impl SmtpTransportEngine {
|
||||
/// Create a new [`SmtpTransportEngine`] with initialized transport client.
|
||||
pub fn new(host: &str, port: u16, username: &str, password: &str) -> Result<Self, MailError> {
|
||||
let transport = Self::build_transport(host, port, username, password)?;
|
||||
/// Create a new [`SmtpTransportEngine`] with initialized transport client
|
||||
/// for a specific transport security [`TlsMode`].
|
||||
pub fn new(
|
||||
host: &str,
|
||||
port: u16,
|
||||
tls_mode: TlsMode,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<Self, MailError> {
|
||||
let transport = Self::build_transport(host, port, tls_mode, username, password)?;
|
||||
Ok(Self { transport })
|
||||
}
|
||||
|
||||
/// Probe the SMTP endpoint: open a real connection to the configured
|
||||
/// relay (performing the implicit-TLS wrap or STARTTLS upgrade the
|
||||
/// configured [`TlsMode`] selects) and confirm the server responds. Backs
|
||||
/// the daemon's `TestAccountConnection` SMTP leg. Returns `Ok(())` only
|
||||
/// when a connection genuinely succeeded -- a dial/handshake failure
|
||||
/// surfaces as the real [`MailError`], never a fabricated success.
|
||||
///
|
||||
/// This validates transport reachability and TLS negotiation; SMTP has no
|
||||
/// standalone authentication probe (credentials are exercised at message
|
||||
/// submission time), so this deliberately does not claim to verify auth.
|
||||
pub async fn probe_connection(&self) -> Result<(), MailError> {
|
||||
match self.transport.test_connection().await {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(MailError::TransportFailed(
|
||||
"SMTP server did not accept the connection probe".to_string(),
|
||||
)),
|
||||
Err(e) => Err(MailError::from(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new [`SmtpTransportEngine`] from an existing [`AsyncSmtpTransport`] client.
|
||||
pub fn with_transport(transport: AsyncSmtpTransport<Tokio1Executor>) -> Self {
|
||||
Self { transport }
|
||||
@@ -227,33 +255,53 @@ impl SmtpTransportEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build an [`AsyncSmtpTransport`] client instance.
|
||||
/// Configures Implicit TLS (`Tls::Wrapper`) for port 465, and STARTTLS (`Tls::Required`) for port 587 or other ports.
|
||||
/// Build an [`AsyncSmtpTransport`] client instance whose transport
|
||||
/// security genuinely follows the configured [`TlsMode`]:
|
||||
///
|
||||
/// - [`TlsMode::ImplicitTls`] wraps the socket in TLS immediately
|
||||
/// (`Tls::Wrapper`), as on the SMTPS port (465).
|
||||
/// - [`TlsMode::StartTls`] connects in the clear and REQUIRES a STARTTLS
|
||||
/// upgrade before proceeding (`Tls::Required`), as on the submission
|
||||
/// port (587); a server without STARTTLS is rejected rather than
|
||||
/// silently falling back to plaintext.
|
||||
/// - [`TlsMode::Plain`] performs no TLS at all (`Tls::None`), for a local
|
||||
/// test/dev relay -- the caller opted into cleartext explicitly.
|
||||
///
|
||||
/// Implicit-TLS and STARTTLS relays are built via `relay(host)` (which
|
||||
/// defaults to TLS wrapping) / `starttls_relay(host)` respectively;
|
||||
/// plaintext is built via the non-encrypting `builder_dangerous(host)`.
|
||||
pub fn build_transport(
|
||||
host: &str,
|
||||
port: u16,
|
||||
tls_mode: TlsMode,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<AsyncSmtpTransport<Tokio1Executor>, MailError> {
|
||||
Self::validate_smtp_config(host, port, username)?;
|
||||
|
||||
let creds = Credentials::new(username.to_string(), password.to_string());
|
||||
let tls_params = TlsParameters::builder(host.to_string())
|
||||
.build()
|
||||
.map_err(|e| MailError::TransportFailed(format!("TLS parameters error: {}", e)))?;
|
||||
|
||||
let tls = if port == 465 {
|
||||
Tls::Wrapper(tls_params)
|
||||
} else {
|
||||
Tls::Required(tls_params)
|
||||
let builder = match tls_mode {
|
||||
TlsMode::ImplicitTls | TlsMode::StartTls => {
|
||||
let tls_params = TlsParameters::builder(host.to_string())
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
MailError::TransportFailed(format!("TLS parameters error: {}", e))
|
||||
})?;
|
||||
let tls = match tls_mode {
|
||||
TlsMode::StartTls => Tls::Required(tls_params),
|
||||
_ => Tls::Wrapper(tls_params),
|
||||
};
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(host)
|
||||
.map_err(|e| {
|
||||
MailError::TransportFailed(format!("relay configuration error: {}", e))
|
||||
})?
|
||||
.tls(tls)
|
||||
}
|
||||
TlsMode::Plain => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host),
|
||||
};
|
||||
|
||||
let transport = AsyncSmtpTransport::<Tokio1Executor>::relay(host)
|
||||
.map_err(|e| MailError::TransportFailed(format!("relay configuration error: {}", e)))?
|
||||
.port(port)
|
||||
.credentials(creds)
|
||||
.tls(tls)
|
||||
.build();
|
||||
let transport = builder.port(port).credentials(creds).build();
|
||||
|
||||
Ok(transport)
|
||||
}
|
||||
@@ -383,21 +431,59 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_transport_creates_starttls_client_for_port_587() {
|
||||
let transport = SmtpTransportEngine::build_transport("smtp.nuncio.mx", 587, "user", "pass");
|
||||
fn build_transport_creates_starttls_client() {
|
||||
let transport = SmtpTransportEngine::build_transport(
|
||||
"smtp.nuncio.mx",
|
||||
587,
|
||||
TlsMode::StartTls,
|
||||
"user",
|
||||
"pass",
|
||||
);
|
||||
assert!(transport.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_transport_creates_implicit_tls_client_for_port_465() {
|
||||
let transport = SmtpTransportEngine::build_transport("smtp.nuncio.mx", 465, "user", "pass");
|
||||
fn build_transport_creates_implicit_tls_client() {
|
||||
let transport = SmtpTransportEngine::build_transport(
|
||||
"smtp.nuncio.mx",
|
||||
465,
|
||||
TlsMode::ImplicitTls,
|
||||
"user",
|
||||
"pass",
|
||||
);
|
||||
assert!(transport.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_transport_creates_plaintext_client() {
|
||||
let transport = SmtpTransportEngine::build_transport(
|
||||
"smtp.nuncio.mx",
|
||||
25,
|
||||
TlsMode::Plain,
|
||||
"user",
|
||||
"pass",
|
||||
);
|
||||
assert!(transport.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_connection_to_unreachable_server_fails() {
|
||||
let engine = SmtpTransportEngine::new("127.0.0.1", 1, TlsMode::ImplicitTls, "user", "pass")
|
||||
.expect("valid config");
|
||||
let err = engine
|
||||
.probe_connection()
|
||||
.await
|
||||
.expect_err("probe against port 1 must fail, never fabricate success");
|
||||
assert!(matches!(
|
||||
err,
|
||||
MailError::TransportFailed(_) | MailError::SmtpFailed(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_email_to_unreachable_server_fails_with_transport_error() {
|
||||
let engine =
|
||||
SmtpTransportEngine::new("127.0.0.1", 1, "user", "pass").expect("valid config");
|
||||
let engine = SmtpTransportEngine::new("127.0.0.1", 1, TlsMode::ImplicitTls, "user", "pass")
|
||||
.expect("valid config");
|
||||
let email = sample_email();
|
||||
let err = engine
|
||||
.send_email(&email)
|
||||
@@ -411,7 +497,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn engine_constructor_creates_instance() {
|
||||
let engine = SmtpTransportEngine::new("smtp.nuncio.mx", 465, "user", "pass");
|
||||
let engine =
|
||||
SmtpTransportEngine::new("smtp.nuncio.mx", 465, TlsMode::ImplicitTls, "user", "pass");
|
||||
assert!(engine.is_ok());
|
||||
}
|
||||
|
||||
@@ -506,8 +593,8 @@ mod tests {
|
||||
|
||||
#[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 engine = SmtpTransportEngine::new("127.0.0.1", 1, TlsMode::ImplicitTls, "user", "pass")
|
||||
.expect("valid config");
|
||||
let message = sample_outbound_message();
|
||||
let err = engine
|
||||
.send_message(&message)
|
||||
@@ -525,8 +612,8 @@ mod tests {
|
||||
// through to the real `send_message` path, 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 engine = SmtpTransportEngine::new("127.0.0.1", 1, TlsMode::ImplicitTls, "user", "pass")
|
||||
.expect("valid config");
|
||||
let sender: &dyn MessageSender = &engine;
|
||||
let message = sample_outbound_message();
|
||||
let err = sender
|
||||
|
||||
Binary file not shown.
@@ -144,6 +144,30 @@ service Accounts {
|
||||
// ListAccounts returns every persisted account configuration. Password
|
||||
// credentials are NEVER included in this response.
|
||||
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
|
||||
// UpdateAccount overwrites an existing account's persisted configuration.
|
||||
// The password is optional: when omitted the existing keyring credential
|
||||
// is left untouched; when present it REPLACES the stored credential in the
|
||||
// OS keyring vault (never written to SQLite, never echoed back). A password
|
||||
// rotation that cannot be persisted is rolled back so the vault never
|
||||
// diverges from the persisted account row.
|
||||
rpc UpdateAccount(UpdateAccountRequest) returns (UpdateAccountResponse);
|
||||
// RemoveAccount deletes a persisted account configuration by id and its
|
||||
// associated keyring credential.
|
||||
rpc RemoveAccount(RemoveAccountRequest) returns (RemoveAccountResponse);
|
||||
// TestAccountConnection performs a real, bounded connection probe against
|
||||
// the account's configured IMAP and SMTP endpoints (honoring each one's
|
||||
// TLS transport mode) and reports the GENUINE per-protocol outcome -- never
|
||||
// a fabricated success. The two legs verify DIFFERENT depths, and callers
|
||||
// must not conflate them:
|
||||
// - IMAP verifies a full LOGIN: connect, negotiate TLS/STARTTLS, and
|
||||
// authenticate with the account's keyring credential.
|
||||
// - SMTP verifies only TRANSPORT REACHABILITY: connect and negotiate
|
||||
// TLS/STARTTLS. It does NOT perform an SMTP AUTH exchange, so `smtp_ok`
|
||||
// does not assert the credential is valid for outbound mail (SMTP has
|
||||
// no standard authentication probe that does not also submit a message).
|
||||
// A probe that fails carries the real error detail; each leg is bounded by
|
||||
// a timeout so the RPC cannot hang against an unreachable host.
|
||||
rpc TestAccountConnection(TestAccountConnectionRequest) returns (TestAccountConnectionResponse);
|
||||
}
|
||||
|
||||
// Mirrors `nuncio_core::AccountProtocol`.
|
||||
@@ -220,6 +244,57 @@ message ListAccountsResponse {
|
||||
repeated AccountConfig accounts = 1;
|
||||
}
|
||||
|
||||
// UpdateAccountRequest carries the full replacement account configuration
|
||||
// plus an OPTIONAL password. When `password` is absent the account's existing
|
||||
// keyring credential is preserved; when present it is written to the OS
|
||||
// keyring vault (under `config.keyring_secret_key`), exactly like
|
||||
// `AddAccountRequest` -- never persisted to SQLite, never logged, never
|
||||
// echoed back.
|
||||
message UpdateAccountRequest {
|
||||
AccountConfig config = 1;
|
||||
optional string password = 2;
|
||||
}
|
||||
|
||||
// UpdateAccountResponse deliberately carries no fields: a successful RPC
|
||||
// response (as opposed to a `Status` error) is confirmation enough, and the
|
||||
// caller already holds the updated config it just sent.
|
||||
message UpdateAccountResponse {}
|
||||
|
||||
// RemoveAccountRequest names the account configuration to delete by id.
|
||||
message RemoveAccountRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
// RemoveAccountResponse currently carries no fields; a successful RPC
|
||||
// response is confirmation enough.
|
||||
message RemoveAccountResponse {}
|
||||
|
||||
// TestAccountConnectionRequest names the persisted account to probe by id.
|
||||
message TestAccountConnectionRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
// TestAccountConnectionResponse reports the REAL, independent outcome of
|
||||
// probing each protocol endpoint -- never a fabricated success. `imap_error`
|
||||
// / `smtp_error` are present only when the corresponding `*_ok` is false and
|
||||
// carry the genuine failure detail (dial/handshake/auth error text). The two
|
||||
// `*_ok` flags verify different depths -- see `TestAccountConnection`.
|
||||
message TestAccountConnectionResponse {
|
||||
// Whether the IMAP endpoint FULLY authenticated: connect, TLS/STARTTLS
|
||||
// negotiation, AND login with the account's credential all succeeded.
|
||||
bool imap_ok = 1;
|
||||
// Whether the SMTP endpoint was REACHABLE: connect and TLS/STARTTLS
|
||||
// negotiation succeeded. This is a transport-reachability check only -- it
|
||||
// does NOT perform an SMTP AUTH exchange, so it does not assert the
|
||||
// credential is valid for outbound mail.
|
||||
bool smtp_ok = 2;
|
||||
// Present only when `imap_ok` is false: the real IMAP login/handshake error.
|
||||
optional string imap_error = 3;
|
||||
// Present only when `smtp_ok` is false: the real SMTP transport/handshake
|
||||
// error.
|
||||
optional string smtp_error = 4;
|
||||
}
|
||||
|
||||
// Mail exposes the READ path over the daemon's real, persistent message
|
||||
// store (the data that syncs in via `DatabaseEngine::save_email`).
|
||||
//
|
||||
|
||||
@@ -186,6 +186,29 @@ pub struct WormChainReport {
|
||||
pub first_broken_seq: Option<u64>,
|
||||
}
|
||||
|
||||
/// Serialize a [`nuncio_core::TlsMode`] to its stable on-disk `accounts`
|
||||
/// column form. Kept as a bare snake_case token (matching the column's
|
||||
/// `'implicit_tls'` default) rather than JSON, so a row inserted by the
|
||||
/// migration default and one written here are byte-identical.
|
||||
fn tls_mode_to_db(mode: nuncio_core::TlsMode) -> &'static str {
|
||||
match mode {
|
||||
nuncio_core::TlsMode::ImplicitTls => "implicit_tls",
|
||||
nuncio_core::TlsMode::StartTls => "start_tls",
|
||||
nuncio_core::TlsMode::Plain => "plain",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a persisted `accounts` TLS-mode column back into
|
||||
/// [`nuncio_core::TlsMode`]. An unrecognized value falls back to the safe
|
||||
/// default (implicit TLS) rather than failing the whole account load.
|
||||
fn tls_mode_from_db(value: &str) -> nuncio_core::TlsMode {
|
||||
match value {
|
||||
"start_tls" => nuncio_core::TlsMode::StartTls,
|
||||
"plain" => nuncio_core::TlsMode::Plain,
|
||||
_ => nuncio_core::TlsMode::ImplicitTls,
|
||||
}
|
||||
}
|
||||
|
||||
impl DatabaseEngine {
|
||||
/// Maximum concurrent read/write pool size.
|
||||
pub const MAX_CONNECTIONS: u32 = 16;
|
||||
@@ -439,7 +462,9 @@ impl DatabaseEngine {
|
||||
keyring_secret_key TEXT NOT NULL,
|
||||
sync_interval_secs INTEGER NOT NULL,
|
||||
smtp_host TEXT,
|
||||
smtp_port INTEGER
|
||||
smtp_port INTEGER,
|
||||
imap_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls',
|
||||
smtp_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
@@ -631,11 +656,53 @@ impl DatabaseEngine {
|
||||
.map_err(DatabaseError::Query)?;
|
||||
|
||||
self.ensure_accounts_smtp_columns().await?;
|
||||
self.ensure_accounts_tls_mode_columns().await?;
|
||||
self.backfill_message_fts().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Additive, backfill-safe migration that adds the `imap_tls_mode` /
|
||||
/// `smtp_tls_mode` columns to a pre-existing `accounts` table that
|
||||
/// predates persisted per-protocol TLS transport modes.
|
||||
///
|
||||
/// 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. For a
|
||||
/// pre-existing database file the columns are added with a
|
||||
/// `'implicit_tls'` default, so every existing row keeps loading
|
||||
/// successfully and retains the historical behavior (implicit TLS) it was
|
||||
/// created under. 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_tls_mode_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 == "imap_tls_mode") {
|
||||
sqlx::query(
|
||||
"ALTER TABLE accounts ADD COLUMN imap_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls'",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
}
|
||||
if !existing_columns.iter().any(|c| c == "smtp_tls_mode") {
|
||||
sqlx::query(
|
||||
"ALTER TABLE accounts ADD COLUMN smtp_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls'",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Additive, backfill-safe migration that adds the
|
||||
/// `smtp_host` / `smtp_port` columns to a pre-existing `accounts` table
|
||||
/// that predates the SMTP account-endpoint feature.
|
||||
@@ -725,8 +792,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, smtp_host, smtp_port)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&config.id)
|
||||
@@ -740,6 +807,8 @@ impl DatabaseEngine {
|
||||
.bind(config.sync_interval_secs as i64)
|
||||
.bind(&config.smtp_host)
|
||||
.bind(config.smtp_port as i64)
|
||||
.bind(tls_mode_to_db(config.imap_tls_mode))
|
||||
.bind(tls_mode_to_db(config.smtp_tls_mode))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
@@ -747,6 +816,27 @@ impl DatabaseEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a single persisted [`nuncio_core::AccountConfig`] by id, or
|
||||
/// `None` if no such account row exists.
|
||||
pub async fn get_account(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<Option<nuncio_core::AccountConfig>, DatabaseError> {
|
||||
Ok(self.list_accounts().await?.into_iter().find(|a| a.id == id))
|
||||
}
|
||||
|
||||
/// Delete a persisted account configuration by id. Idempotent: deleting a
|
||||
/// non-existent id is not an error (the post-condition -- "no account row
|
||||
/// with this id" -- already holds).
|
||||
pub async fn delete_account(&self, id: &str) -> Result<(), DatabaseError> {
|
||||
sqlx::query("DELETE FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Query all saved accounts in SQLite.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub async fn list_accounts(&self) -> Result<Vec<nuncio_core::AccountConfig>, DatabaseError> {
|
||||
@@ -762,9 +852,11 @@ impl DatabaseEngine {
|
||||
i64,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
String,
|
||||
String,
|
||||
)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port
|
||||
SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode
|
||||
FROM accounts
|
||||
"#,
|
||||
)
|
||||
@@ -787,6 +879,8 @@ impl DatabaseEngine {
|
||||
sync_interval_secs,
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
imap_tls_mode,
|
||||
smtp_tls_mode,
|
||||
)| {
|
||||
let protocol = serde_json::from_str(&protocol_str)
|
||||
.unwrap_or(nuncio_core::AccountProtocol::ImapSmtp);
|
||||
@@ -811,8 +905,8 @@ impl DatabaseEngine {
|
||||
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,
|
||||
imap_tls_mode: tls_mode_from_db(&imap_tls_mode),
|
||||
smtp_tls_mode: tls_mode_from_db(&smtp_tls_mode),
|
||||
keyring_secret_key,
|
||||
sync_interval_secs: sync_interval_secs as u64,
|
||||
}
|
||||
@@ -2505,6 +2599,103 @@ mod tests {
|
||||
assert_eq!(accounts[0].smtp_port, 465);
|
||||
}
|
||||
|
||||
/// Regression proof that the per-protocol TLS transport modes are
|
||||
/// genuinely persisted and round-tripped -- previously they were
|
||||
/// validated on add but silently discarded, with `list_accounts`
|
||||
/// hardcoding both back to `ImplicitTls` regardless of what was saved.
|
||||
/// Saves an account whose IMAP mode is `StartTls` and SMTP mode is
|
||||
/// `Plain`, then re-reads it via BOTH `list_accounts` and `get_account`
|
||||
/// and asserts each distinct mode survived rather than collapsing to the
|
||||
/// default.
|
||||
#[tokio::test]
|
||||
async fn account_tls_modes_survive_save_and_reload() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let acct = nuncio_core::AccountConfig {
|
||||
id: "acct-tls-1".to_string(),
|
||||
name: "Mixed TLS Account".to_string(),
|
||||
email_address: "mixed@nuncio.mx".to_string(),
|
||||
protocol: nuncio_core::AccountProtocol::ImapSmtp,
|
||||
server_host: "imap.nuncio.mx".to_string(),
|
||||
server_port: 143,
|
||||
smtp_host: "smtp.nuncio.mx".to_string(),
|
||||
smtp_port: 25,
|
||||
use_tls: false,
|
||||
imap_tls_mode: nuncio_core::TlsMode::StartTls,
|
||||
smtp_tls_mode: nuncio_core::TlsMode::Plain,
|
||||
keyring_secret_key: "nuncio/acct-tls-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
};
|
||||
|
||||
engine.save_account(&acct).await.expect("save succeeds");
|
||||
|
||||
let listed = engine.list_accounts().await.expect("list succeeds");
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].imap_tls_mode, nuncio_core::TlsMode::StartTls);
|
||||
assert_eq!(listed[0].smtp_tls_mode, nuncio_core::TlsMode::Plain);
|
||||
|
||||
let fetched = engine
|
||||
.get_account("acct-tls-1")
|
||||
.await
|
||||
.expect("get succeeds")
|
||||
.expect("account present");
|
||||
assert_eq!(fetched.imap_tls_mode, nuncio_core::TlsMode::StartTls);
|
||||
assert_eq!(fetched.smtp_tls_mode, nuncio_core::TlsMode::Plain);
|
||||
assert_eq!(fetched, acct);
|
||||
}
|
||||
|
||||
/// Proves `get_account` resolves a saved account by id and returns `None`
|
||||
/// for an unknown id, and that `delete_account` genuinely removes the row
|
||||
/// (and is idempotent for an already-absent id).
|
||||
#[tokio::test]
|
||||
async fn get_and_delete_account_round_trip() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let acct = nuncio_core::AccountConfig {
|
||||
id: "acct-del-1".to_string(),
|
||||
name: "Deletable".to_string(),
|
||||
email_address: "del@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: 465,
|
||||
use_tls: true,
|
||||
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-del-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
};
|
||||
engine.save_account(&acct).await.expect("save succeeds");
|
||||
|
||||
assert!(engine
|
||||
.get_account("acct-del-1")
|
||||
.await
|
||||
.expect("get succeeds")
|
||||
.is_some());
|
||||
assert!(engine
|
||||
.get_account("acct-missing")
|
||||
.await
|
||||
.expect("get succeeds")
|
||||
.is_none());
|
||||
|
||||
engine
|
||||
.delete_account("acct-del-1")
|
||||
.await
|
||||
.expect("delete succeeds");
|
||||
assert!(engine
|
||||
.get_account("acct-del-1")
|
||||
.await
|
||||
.expect("get succeeds")
|
||||
.is_none());
|
||||
|
||||
// Idempotent: deleting an already-absent id is not an error.
|
||||
engine
|
||||
.delete_account("acct-del-1")
|
||||
.await
|
||||
.expect("second delete is a no-op");
|
||||
}
|
||||
|
||||
/// 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`
|
||||
|
||||
+434
-4
@@ -9,7 +9,7 @@ use nuncio_cal::CalendarBackend;
|
||||
use nuncio_contacts::ContactsBackend;
|
||||
use nuncio_core::{CoreCommand, CoreEvent, EventBus};
|
||||
use nuncio_filter::{FilterEngine, NsqlParser, NsqlValidator, ValidationOptions};
|
||||
use nuncio_mail::{MailBackend, MessageSender};
|
||||
use nuncio_mail::{ImapEngine, MailBackend, MessageSender, SmtpTransportEngine};
|
||||
use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer};
|
||||
use nuncio_proto::v1::audit_server::{Audit, AuditServer};
|
||||
use nuncio_proto::v1::calendar_server::{Calendar, CalendarServer};
|
||||
@@ -38,9 +38,11 @@ use nuncio_proto::v1::{
|
||||
ListFoldersResponse, ListMessagesRequest, ListMessagesResponse, ListRecordsRequest,
|
||||
ListRecordsResponse, ListRulesRequest, ListRulesResponse, MarkReadRequest, MarkReadResponse,
|
||||
Message as MessageProto, MessageFlagsChanged, MessageSearchHit, PreviewRuleRequest,
|
||||
PreviewRuleResponse, SearchMessagesRequest, SearchMessagesResponse, SendMessageRequest,
|
||||
SendMessageResponse, ShuttingDown, SubscribeRequest, SyncCompleted, SyncRequest, SyncResponse,
|
||||
SyncStarted, TlsMode as TlsModeProto, TriageProgress, TriageRequest, UpdateAvailable,
|
||||
PreviewRuleResponse, RemoveAccountRequest, RemoveAccountResponse, SearchMessagesRequest,
|
||||
SearchMessagesResponse, SendMessageRequest, SendMessageResponse, ShuttingDown,
|
||||
SubscribeRequest, SyncCompleted, SyncRequest, SyncResponse, SyncStarted,
|
||||
TestAccountConnectionRequest, TestAccountConnectionResponse, TlsMode as TlsModeProto,
|
||||
TriageProgress, TriageRequest, UpdateAccountRequest, UpdateAccountResponse, UpdateAvailable,
|
||||
UpdateRuleRequest, UpdateRuleResponse, ValidateRuleRequest, ValidateRuleResponse,
|
||||
VerifyChainRequest, VerifyChainResponse,
|
||||
};
|
||||
@@ -304,6 +306,134 @@ fn map_account_config_from_proto(
|
||||
})
|
||||
}
|
||||
|
||||
/// Outcome of probing a single protocol endpoint (IMAP or SMTP) during a
|
||||
/// `TestAccountConnection` RPC. `ok` reflects a GENUINE dial/handshake/auth
|
||||
/// outcome; `error` carries the real failure detail when `ok` is false.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProtocolProbe {
|
||||
/// Whether the probe genuinely succeeded.
|
||||
pub ok: bool,
|
||||
/// Real failure detail, present only when `ok` is false.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl ProtocolProbe {
|
||||
/// A successful probe.
|
||||
fn ok() -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed probe carrying its real error text.
|
||||
fn failed(error: String) -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The genuine per-protocol result of a `TestAccountConnection` probe.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountConnectionReport {
|
||||
/// Result of probing the account's IMAP endpoint.
|
||||
pub imap: ProtocolProbe,
|
||||
/// Result of probing the account's SMTP endpoint.
|
||||
pub smtp: ProtocolProbe,
|
||||
}
|
||||
|
||||
/// Injectable seam backing `TestAccountConnection`'s real per-protocol
|
||||
/// connection probing. Production uses [`RealAccountConnectionTester`], which
|
||||
/// performs a genuine bounded IMAP + SMTP dial/handshake using the account's
|
||||
/// configured endpoints, TLS modes, and keyring credential. A full-daemon
|
||||
/// offline test injects a stand-in so the RPC can be exercised end to end
|
||||
/// without touching any live server.
|
||||
#[tonic::async_trait]
|
||||
pub trait AccountConnectionTester: Send + Sync {
|
||||
/// Probe both protocol endpoints for `config`, authenticating with
|
||||
/// `password`, and report the genuine per-protocol outcome.
|
||||
async fn probe(
|
||||
&self,
|
||||
config: &nuncio_core::AccountConfig,
|
||||
password: &str,
|
||||
) -> AccountConnectionReport;
|
||||
}
|
||||
|
||||
/// Production [`AccountConnectionTester`]: builds a real
|
||||
/// [`nuncio_mail::ImapEngine`] / [`nuncio_mail::SmtpTransportEngine`] from the
|
||||
/// account's configured endpoints, TLS transport modes, and keyring password,
|
||||
/// and performs a genuine, bounded connection probe against each. Every
|
||||
/// result is the real dial/handshake/auth outcome -- never fabricated.
|
||||
struct RealAccountConnectionTester;
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl AccountConnectionTester for RealAccountConnectionTester {
|
||||
async fn probe(
|
||||
&self,
|
||||
config: &nuncio_core::AccountConfig,
|
||||
password: &str,
|
||||
) -> AccountConnectionReport {
|
||||
let imap = match config.protocol {
|
||||
nuncio_core::AccountProtocol::ImapSmtp => {
|
||||
let engine = ImapEngine::with_credentials(
|
||||
&config.id,
|
||||
&config.server_host,
|
||||
config.server_port,
|
||||
config.imap_tls_mode,
|
||||
&config.email_address,
|
||||
password,
|
||||
);
|
||||
match engine.probe_connection().await {
|
||||
Ok(()) => ProtocolProbe::ok(),
|
||||
Err(e) => ProtocolProbe::failed(e.to_string()),
|
||||
}
|
||||
}
|
||||
nuncio_core::AccountProtocol::Jmap => ProtocolProbe::failed(
|
||||
"IMAP connection probe is not applicable to a JMAP account".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let smtp = match SmtpTransportEngine::new(
|
||||
&config.smtp_host,
|
||||
config.smtp_port,
|
||||
config.smtp_tls_mode,
|
||||
&config.email_address,
|
||||
password,
|
||||
) {
|
||||
Ok(engine) => match engine.probe_connection().await {
|
||||
Ok(()) => ProtocolProbe::ok(),
|
||||
Err(e) => ProtocolProbe::failed(e.to_string()),
|
||||
},
|
||||
Err(e) => ProtocolProbe::failed(e.to_string()),
|
||||
};
|
||||
|
||||
AccountConnectionReport { imap, smtp }
|
||||
}
|
||||
}
|
||||
|
||||
/// Overridable engine seam for the `nuncio.v1.Accounts` service, mirroring
|
||||
/// [`MailEngineOverrides`]'s shape. Production always uses the default (a real
|
||||
/// [`RealAccountConnectionTester`]); only a full-daemon offline E2E test
|
||||
/// injects a stand-in tester so `TestAccountConnection` can be driven over the
|
||||
/// real authenticated gRPC API without any live server.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AccountsEngineOverrides {
|
||||
/// When `Some`, `TestAccountConnection` probes through this tester instead
|
||||
/// of building real per-account IMAP/SMTP engines from keyring
|
||||
/// credentials.
|
||||
pub connection_tester: Option<Arc<dyn AccountConnectionTester>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AccountsEngineOverrides {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AccountsEngineOverrides")
|
||||
.field("connection_tester", &self.connection_tester.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// `nuncio.v1.Accounts` gRPC service implementation backed by the daemon's
|
||||
/// live [`DatabaseEngine`] and [`SecretManager`] vault.
|
||||
///
|
||||
@@ -314,6 +444,58 @@ fn map_account_config_from_proto(
|
||||
struct AccountsGrpcService {
|
||||
db: Arc<DatabaseEngine>,
|
||||
secrets: Arc<SecretManager>,
|
||||
connection_tester: Arc<dyn AccountConnectionTester>,
|
||||
}
|
||||
|
||||
/// Rotate an account's stored credential to `new_password` and persist the
|
||||
/// updated `config`, with a rollback that never destroys a working
|
||||
/// credential.
|
||||
///
|
||||
/// The prior secret (if any) is snapshotted BEFORE the new one is written, so
|
||||
/// if `save_account` then fails the snapshot is RESTORED -- returning the
|
||||
/// vault to exactly its pre-call state. This differs deliberately from
|
||||
/// `add_account`'s rollback, which deletes: an add has no prior secret to
|
||||
/// preserve, but an update's key may already hold the working credential, and
|
||||
/// deleting it on a persist failure would leave the still-present account row
|
||||
/// referencing a missing secret (strictly worse than doing nothing). When
|
||||
/// there was no prior secret, restore falls back to deleting the just-written
|
||||
/// one, matching add's behavior for that case. A rollback failure is reported
|
||||
/// alongside the original persistence error (which stays primary), never in
|
||||
/// place of it.
|
||||
async fn rotate_credential_and_persist(
|
||||
db: &DatabaseEngine,
|
||||
secrets: &SecretManager,
|
||||
config: &nuncio_core::AccountConfig,
|
||||
new_password: &str,
|
||||
) -> Result<(), Status> {
|
||||
// Snapshot the prior credential before overwriting it. `None` means the
|
||||
// key held no secret (or was unreadable), in which case rollback deletes.
|
||||
let previous = secrets.get_secret(&config.keyring_secret_key).ok();
|
||||
|
||||
secrets
|
||||
.set_secret(&config.keyring_secret_key, new_password)
|
||||
.map_err(|e| Status::internal(format!("failed to store credential in vault: {e}")))?;
|
||||
|
||||
if let Err(e) = db.save_account(config).await {
|
||||
let mut message = format!("failed to persist account: {e}");
|
||||
let rollback = match &previous {
|
||||
Some(prior) => secrets.set_secret(&config.keyring_secret_key, prior),
|
||||
None => secrets.delete_secret(&config.keyring_secret_key),
|
||||
};
|
||||
if let Err(rollback_err) = rollback {
|
||||
tracing::warn!(
|
||||
keyring_secret_key = %config.keyring_secret_key,
|
||||
error = %rollback_err,
|
||||
"failed to roll back keyring secret after update_account persistence failure"
|
||||
);
|
||||
message.push_str(&format!(
|
||||
" (and failed to roll back the credential: {rollback_err})"
|
||||
));
|
||||
}
|
||||
return Err(Status::internal(message));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
@@ -386,6 +568,128 @@ impl Accounts for AccountsGrpcService {
|
||||
|
||||
Ok(Response::new(ListAccountsResponse { accounts }))
|
||||
}
|
||||
|
||||
async fn update_account(
|
||||
&self,
|
||||
request: Request<UpdateAccountRequest>,
|
||||
) -> Result<Response<UpdateAccountResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
let proto_config = req
|
||||
.config
|
||||
.ok_or_else(|| Status::invalid_argument("config is required"))?;
|
||||
|
||||
let config = map_account_config_from_proto(proto_config)?;
|
||||
config
|
||||
.validate()
|
||||
.map_err(|e| Status::invalid_argument(e.to_string()))?;
|
||||
|
||||
// Updating a non-existent account is a client error, not a silent
|
||||
// create: reject it so a typo'd id never conjures a new account row.
|
||||
if self
|
||||
.db
|
||||
.get_account(&config.id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to look up account: {e}")))?
|
||||
.is_none()
|
||||
{
|
||||
return Err(Status::not_found(format!(
|
||||
"account '{}' not found",
|
||||
config.id
|
||||
)));
|
||||
}
|
||||
|
||||
match req.password {
|
||||
// A password rotation writes the new credential first, then
|
||||
// persists. Unlike `add_account` (where no prior secret exists,
|
||||
// so a failed persist rolls back by DELETING the orphan), an
|
||||
// update's key may already hold the working credential, so a
|
||||
// failed persist must RESTORE that prior value rather than delete
|
||||
// it -- deleting would leave the still-present account row
|
||||
// referencing a missing secret. See
|
||||
// [`rotate_credential_and_persist`].
|
||||
Some(password) if !password.is_empty() => {
|
||||
rotate_credential_and_persist(&self.db, &self.secrets, &config, &password).await?;
|
||||
}
|
||||
// No password change: the existing keyring credential is left
|
||||
// entirely untouched and only the persisted configuration is
|
||||
// overwritten.
|
||||
_ => {
|
||||
self.db
|
||||
.save_account(&config)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to persist account: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Response::new(UpdateAccountResponse {}))
|
||||
}
|
||||
|
||||
async fn remove_account(
|
||||
&self,
|
||||
request: Request<RemoveAccountRequest>,
|
||||
) -> Result<Response<RemoveAccountResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
if req.id.is_empty() {
|
||||
return Err(Status::invalid_argument("id is required"));
|
||||
}
|
||||
|
||||
let existing = self
|
||||
.db
|
||||
.get_account(&req.id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to look up account: {e}")))?
|
||||
.ok_or_else(|| Status::not_found(format!("account '{}' not found", req.id)))?;
|
||||
|
||||
self.db
|
||||
.delete_account(&req.id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to remove account: {e}")))?;
|
||||
|
||||
// Best-effort credential cleanup: the account row is already gone, so
|
||||
// a keyring deletion failure must not fail the RPC (which would
|
||||
// wrongly imply the account still exists). Log it and move on -- an
|
||||
// orphaned secret with no account row is inert.
|
||||
if let Err(e) = self.secrets.delete_secret(&existing.keyring_secret_key) {
|
||||
tracing::warn!(
|
||||
keyring_secret_key = %existing.keyring_secret_key,
|
||||
error = %e,
|
||||
"failed to delete keyring credential after removing account"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Response::new(RemoveAccountResponse {}))
|
||||
}
|
||||
|
||||
async fn test_account_connection(
|
||||
&self,
|
||||
request: Request<TestAccountConnectionRequest>,
|
||||
) -> Result<Response<TestAccountConnectionResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
if req.id.is_empty() {
|
||||
return Err(Status::invalid_argument("id is required"));
|
||||
}
|
||||
|
||||
let config = self
|
||||
.db
|
||||
.get_account(&req.id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to look up account: {e}")))?
|
||||
.ok_or_else(|| Status::not_found(format!("account '{}' not found", req.id)))?;
|
||||
|
||||
let password = self
|
||||
.secrets
|
||||
.get_secret(&config.keyring_secret_key)
|
||||
.map_err(|e| Status::internal(format!("failed to read credential from vault: {e}")))?;
|
||||
|
||||
let report = self.connection_tester.probe(&config, &password).await;
|
||||
|
||||
Ok(Response::new(TestAccountConnectionResponse {
|
||||
imap_ok: report.imap.ok,
|
||||
smtp_ok: report.smtp.ok,
|
||||
imap_error: report.imap.error,
|
||||
smtp_error: report.smtp.error,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Default cap on messages returned by `ListMessages` when the caller
|
||||
@@ -1850,6 +2154,7 @@ pub async fn serve_on_listener(
|
||||
MailEngineOverrides::default(),
|
||||
CalendarEngineOverrides::default(),
|
||||
ContactsEngineOverrides::default(),
|
||||
AccountsEngineOverrides::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1876,6 +2181,7 @@ pub async fn serve_on_listener_with_overrides(
|
||||
overrides: MailEngineOverrides,
|
||||
calendar_overrides: CalendarEngineOverrides,
|
||||
contacts_overrides: ContactsEngineOverrides,
|
||||
accounts_overrides: AccountsEngineOverrides,
|
||||
) -> Result<(), GrpcServeError> {
|
||||
let token: Arc<str> = token.into();
|
||||
|
||||
@@ -1888,6 +2194,9 @@ pub async fn serve_on_listener_with_overrides(
|
||||
let accounts_service = AccountsGrpcService {
|
||||
db: db.clone(),
|
||||
secrets: secrets.clone(),
|
||||
connection_tester: accounts_overrides
|
||||
.connection_tester
|
||||
.unwrap_or_else(|| Arc::new(RealAccountConnectionTester)),
|
||||
};
|
||||
let accounts_interceptor = BearerAuthInterceptor::new(token.clone());
|
||||
let accounts_svc = AccountsServer::with_interceptor(accounts_service, accounts_interceptor);
|
||||
@@ -2069,6 +2378,36 @@ mod tests {
|
||||
overrides: MailEngineOverrides,
|
||||
calendar_overrides: CalendarEngineOverrides,
|
||||
contacts_overrides: ContactsEngineOverrides,
|
||||
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
|
||||
spawn_test_server_with_every_override(
|
||||
event_bus,
|
||||
db,
|
||||
filter_engine,
|
||||
secrets,
|
||||
token,
|
||||
overrides,
|
||||
calendar_overrides,
|
||||
contacts_overrides,
|
||||
AccountsEngineOverrides::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Widest test spawn helper: threads an [`AccountsEngineOverrides`] too,
|
||||
/// so a test can inject a stand-in [`AccountConnectionTester`] and drive
|
||||
/// `TestAccountConnection` over the real authenticated gRPC API without a
|
||||
/// live server.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn spawn_test_server_with_every_override(
|
||||
event_bus: Arc<EventBus>,
|
||||
db: Arc<DatabaseEngine>,
|
||||
filter_engine: Arc<FilterEngine>,
|
||||
secrets: Arc<SecretManager>,
|
||||
token: &str,
|
||||
overrides: MailEngineOverrides,
|
||||
calendar_overrides: CalendarEngineOverrides,
|
||||
contacts_overrides: ContactsEngineOverrides,
|
||||
accounts_overrides: AccountsEngineOverrides,
|
||||
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
@@ -2086,6 +2425,7 @@ mod tests {
|
||||
overrides,
|
||||
calendar_overrides,
|
||||
contacts_overrides,
|
||||
accounts_overrides,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -2771,6 +3111,96 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: a password rotation that fails to persist must RESTORE the
|
||||
/// prior credential, never destroy it. Seeds an existing credential,
|
||||
/// forces `save_account` to fail for real (closed pool), and asserts the
|
||||
/// ORIGINAL secret is still retrievable afterward -- proving the update
|
||||
/// rollback restores rather than deletes (which would orphan the still
|
||||
/// present account row). Drives the exact rotation+rollback helper the
|
||||
/// `update_account` RPC uses.
|
||||
#[tokio::test]
|
||||
async fn update_account_password_rotation_restores_prior_credential_on_persist_failure() {
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
let secrets = SecretManager::mock();
|
||||
|
||||
let key = "nuncio/acct-rotate-1";
|
||||
secrets
|
||||
.set_secret(key, "original-working-password")
|
||||
.expect("seed the prior credential");
|
||||
|
||||
let config = nuncio_core::AccountConfig {
|
||||
id: "acct-rotate-1".to_string(),
|
||||
name: "Rotate Account".to_string(),
|
||||
email_address: "rotate@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: 465,
|
||||
use_tls: true,
|
||||
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: key.to_string(),
|
||||
sync_interval_secs: 60,
|
||||
};
|
||||
|
||||
// Force `save_account` to fail for real by closing the pool, exactly
|
||||
// as `add_account`'s rollback test does.
|
||||
db.close().await;
|
||||
|
||||
let err = rotate_credential_and_persist(&db, &secrets, &config, "new-rotated-password")
|
||||
.await
|
||||
.expect_err("save_account must fail once the pool is closed");
|
||||
assert_eq!(err.code(), Code::Internal);
|
||||
assert!(err.message().contains("failed to persist account"));
|
||||
|
||||
// The prior credential must be intact -- neither deleted nor left as
|
||||
// the failed new value.
|
||||
let restored = secrets.get_secret(key).expect("prior credential survives");
|
||||
assert_eq!(restored, "original-working-password");
|
||||
}
|
||||
|
||||
/// Companion: when the key held NO prior secret, a failed rotation falls
|
||||
/// back to deleting the just-written one (matching `add_account`), leaving
|
||||
/// no orphaned credential.
|
||||
#[tokio::test]
|
||||
async fn update_account_password_rotation_deletes_new_credential_when_none_existed() {
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
let secrets = SecretManager::mock();
|
||||
|
||||
let key = "nuncio/acct-rotate-2";
|
||||
let config = nuncio_core::AccountConfig {
|
||||
id: "acct-rotate-2".to_string(),
|
||||
name: "Rotate Account 2".to_string(),
|
||||
email_address: "rotate2@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: 465,
|
||||
use_tls: true,
|
||||
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: key.to_string(),
|
||||
sync_interval_secs: 60,
|
||||
};
|
||||
|
||||
db.close().await;
|
||||
|
||||
let err = rotate_credential_and_persist(&db, &secrets, &config, "new-rotated-password")
|
||||
.await
|
||||
.expect_err("save_account must fail once the pool is closed");
|
||||
assert_eq!(err.code(), Code::Internal);
|
||||
|
||||
// No prior secret existed, so the just-written one is rolled back by
|
||||
// deletion -- nothing orphaned.
|
||||
assert!(secrets.get_secret(key).is_err());
|
||||
}
|
||||
|
||||
// ---- Mail ----
|
||||
|
||||
use nuncio_proto::v1::mail_client::MailClient;
|
||||
|
||||
@@ -113,6 +113,7 @@ fn build_smtp_sender(
|
||||
let engine = SmtpTransportEngine::new(
|
||||
&config.smtp_host,
|
||||
config.smtp_port,
|
||||
config.smtp_tls_mode,
|
||||
&config.email_address,
|
||||
password,
|
||||
)?;
|
||||
|
||||
@@ -317,6 +317,7 @@ fn build_mail_backend(config: &AccountConfig, password: &str) -> Box<dyn MailBac
|
||||
&config.id,
|
||||
&config.server_host,
|
||||
config.server_port,
|
||||
config.imap_tls_mode,
|
||||
&config.email_address,
|
||||
password,
|
||||
)),
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Offline end-to-end test for the account lifecycle over gRPC.
|
||||
//!
|
||||
//! Boots a real `nunciod` daemon gRPC server
|
||||
//! (`nunciod::grpc::serve_on_listener_with_overrides`) on an ephemeral
|
||||
//! loopback port, backed by a real temp file-backed [`DatabaseEngine`] and a
|
||||
//! [`SecretManager::mock`] vault (never the real OS keyring), with an injected
|
||||
//! stand-in [`AccountConnectionTester`] standing in for real IMAP/SMTP network
|
||||
//! I/O (via the [`AccountsEngineOverrides`] injection seam).
|
||||
//!
|
||||
//! Driving ONLY the authenticated `nuncio-proto` Accounts client with the
|
||||
//! shared bearer token -- never reaching into daemon internals -- this proves
|
||||
//! the whole lifecycle end to end:
|
||||
//! a. `AddAccount` persists an account whose IMAP mode is `StartTls` and
|
||||
//! SMTP mode is `Plain`.
|
||||
//! b. `ListAccounts` reflects it AND round-trips both distinct TLS modes
|
||||
//! (the previously silently-discarded fields).
|
||||
//! c. `UpdateAccount` genuinely mutates the persisted config (confirmed via
|
||||
//! a second `ListAccounts`).
|
||||
//! d. `TestAccountConnection` reports the injected tester's GENUINE
|
||||
//! per-protocol result (IMAP ok, SMTP failed with real error detail) --
|
||||
//! never a fabricated OK.
|
||||
//! e. `RemoveAccount` genuinely deletes it (a final `ListAccounts` is
|
||||
//! empty).
|
||||
//!
|
||||
//! Fully offline: no real network, no real OS keyring, no live server.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use nuncio_core::EventBus;
|
||||
use nuncio_filter::FilterEngine;
|
||||
use nuncio_proto::v1::{
|
||||
AccountConfig, AccountProtocol, AddAccountRequest, ListAccountsRequest, RemoveAccountRequest,
|
||||
TestAccountConnectionRequest, TlsMode, UpdateAccountRequest,
|
||||
};
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use nunciod::grpc::{
|
||||
serve_on_listener_with_overrides, AccountConnectionReport, AccountConnectionTester,
|
||||
AccountsEngineOverrides, CalendarEngineOverrides, ContactsEngineOverrides, MailEngineOverrides,
|
||||
ProtocolProbe,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const ACCOUNT_ID: &str = "acct-lifecycle-e2e-1";
|
||||
const ACCOUNT_EMAIL: &str = "lifecycle-e2e@nuncio.mx";
|
||||
const KEYRING_KEY: &str = "nuncio/acct-lifecycle-e2e-1";
|
||||
|
||||
/// Injected stand-in [`AccountConnectionTester`]: returns a fixed but honest
|
||||
/// per-protocol report (IMAP succeeds, SMTP fails with real error detail) so
|
||||
/// `TestAccountConnection` can be exercised end to end WITHOUT any live
|
||||
/// server. The daemon's production tester performs a genuine bounded network
|
||||
/// probe; this stub stands in for that real world exactly as the mock
|
||||
/// mail/calendar/contacts backends do for their protocols.
|
||||
struct StubConnectionTester;
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl AccountConnectionTester for StubConnectionTester {
|
||||
async fn probe(
|
||||
&self,
|
||||
_config: &nuncio_core::AccountConfig,
|
||||
_password: &str,
|
||||
) -> AccountConnectionReport {
|
||||
AccountConnectionReport {
|
||||
imap: ProtocolProbe {
|
||||
ok: true,
|
||||
error: None,
|
||||
},
|
||||
smtp: ProtocolProbe {
|
||||
ok: false,
|
||||
error: Some("SMTP relay refused the connection".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_account_config() -> AccountConfig {
|
||||
AccountConfig {
|
||||
id: ACCOUNT_ID.to_string(),
|
||||
name: "Lifecycle E2E Account".to_string(),
|
||||
email_address: ACCOUNT_EMAIL.to_string(),
|
||||
protocol: AccountProtocol::ImapSmtp.into(),
|
||||
server_host: "imap.nuncio.mx".to_string(),
|
||||
server_port: 143,
|
||||
use_tls: false,
|
||||
imap_tls_mode: TlsMode::StartTls.into(),
|
||||
smtp_tls_mode: TlsMode::Plain.into(),
|
||||
keyring_secret_key: KEYRING_KEY.to_string(),
|
||||
sync_interval_secs: 60,
|
||||
smtp_host: "smtp.nuncio.mx".to_string(),
|
||||
smtp_port: 25,
|
||||
}
|
||||
}
|
||||
|
||||
/// Boots the real daemon gRPC server on an ephemeral loopback port with an
|
||||
/// injected [`AccountsEngineOverrides`]. Returns the server address and the
|
||||
/// minted token; the `TempDir` guard is returned so the caller controls its
|
||||
/// lifetime for the whole test.
|
||||
async fn boot_daemon(
|
||||
secrets: Arc<SecretManager>,
|
||||
accounts_overrides: AccountsEngineOverrides,
|
||||
) -> (std::net::SocketAddr, String, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create temp dir for file-backed db");
|
||||
let db_path = dir.path().join("accounts_lifecycle_e2e.db");
|
||||
let db = Arc::new(
|
||||
DatabaseEngine::connect_file(&db_path, &secrets)
|
||||
.await
|
||||
.expect("open temp file-backed database"),
|
||||
);
|
||||
|
||||
let token_bytes = secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.expect("mint gRPC bearer token from mock vault");
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let server_secrets = secrets.clone();
|
||||
let server_token = token.clone();
|
||||
let server_filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
|
||||
tokio::spawn(async move {
|
||||
let _ = serve_on_listener_with_overrides(
|
||||
listener,
|
||||
event_bus,
|
||||
db,
|
||||
server_filter_engine,
|
||||
server_secrets,
|
||||
server_token,
|
||||
MailEngineOverrides::default(),
|
||||
CalendarEngineOverrides::default(),
|
||||
ContactsEngineOverrides::default(),
|
||||
accounts_overrides,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
(addr, token, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_lifecycle_round_trips_over_authenticated_grpc_offline() {
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let accounts_overrides = AccountsEngineOverrides {
|
||||
connection_tester: Some(Arc::new(StubConnectionTester)),
|
||||
};
|
||||
let (addr, token, _db_dir) = boot_daemon(secrets, accounts_overrides).await;
|
||||
let addr_str = addr.to_string();
|
||||
|
||||
let mut accounts_client = nuncio_proto::client::connect_accounts(&addr_str, &token)
|
||||
.await
|
||||
.expect("accounts client connects");
|
||||
|
||||
// ---- (a) AddAccount persists a StartTls/Plain account ----
|
||||
let add_response = accounts_client
|
||||
.add_account(AddAccountRequest {
|
||||
config: Some(sample_account_config()),
|
||||
password: "lifecycle-secret-password".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("add_account succeeds")
|
||||
.into_inner();
|
||||
assert_eq!(add_response.id, ACCOUNT_ID);
|
||||
|
||||
// ---- (b) ListAccounts round-trips BOTH distinct TLS modes ----
|
||||
let listed = accounts_client
|
||||
.list_accounts(ListAccountsRequest {})
|
||||
.await
|
||||
.expect("list_accounts succeeds")
|
||||
.into_inner()
|
||||
.accounts;
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].imap_tls_mode(), TlsMode::StartTls);
|
||||
assert_eq!(listed[0].smtp_tls_mode(), TlsMode::Plain);
|
||||
|
||||
// ---- (c) UpdateAccount genuinely mutates the persisted config ----
|
||||
let mut updated = sample_account_config();
|
||||
updated.name = "Renamed Lifecycle Account".to_string();
|
||||
updated.imap_tls_mode = TlsMode::ImplicitTls.into();
|
||||
accounts_client
|
||||
.update_account(UpdateAccountRequest {
|
||||
config: Some(updated),
|
||||
password: None,
|
||||
})
|
||||
.await
|
||||
.expect("update_account succeeds");
|
||||
|
||||
let relisted = accounts_client
|
||||
.list_accounts(ListAccountsRequest {})
|
||||
.await
|
||||
.expect("list_accounts succeeds")
|
||||
.into_inner()
|
||||
.accounts;
|
||||
assert_eq!(relisted.len(), 1);
|
||||
assert_eq!(relisted[0].name, "Renamed Lifecycle Account");
|
||||
assert_eq!(relisted[0].imap_tls_mode(), TlsMode::ImplicitTls);
|
||||
// Untouched fields survive the update.
|
||||
assert_eq!(relisted[0].smtp_tls_mode(), TlsMode::Plain);
|
||||
|
||||
// ---- (d) TestAccountConnection reports the GENUINE per-protocol result ----
|
||||
let report = accounts_client
|
||||
.test_account_connection(TestAccountConnectionRequest {
|
||||
id: ACCOUNT_ID.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("test_account_connection succeeds")
|
||||
.into_inner();
|
||||
assert!(report.imap_ok);
|
||||
assert!(!report.smtp_ok);
|
||||
assert_eq!(report.imap_error, None);
|
||||
assert_eq!(
|
||||
report.smtp_error.as_deref(),
|
||||
Some("SMTP relay refused the connection")
|
||||
);
|
||||
|
||||
// ---- (e) RemoveAccount genuinely deletes it ----
|
||||
accounts_client
|
||||
.remove_account(RemoveAccountRequest {
|
||||
id: ACCOUNT_ID.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("remove_account succeeds");
|
||||
|
||||
let after_remove = accounts_client
|
||||
.list_accounts(ListAccountsRequest {})
|
||||
.await
|
||||
.expect("list_accounts succeeds")
|
||||
.into_inner()
|
||||
.accounts;
|
||||
assert!(after_remove.is_empty());
|
||||
|
||||
// A test against the now-removed account is an honest not-found, never a
|
||||
// fabricated success.
|
||||
let err = accounts_client
|
||||
.test_account_connection(TestAccountConnectionRequest {
|
||||
id: ACCOUNT_ID.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("testing a removed account must fail");
|
||||
assert_eq!(err.code(), tonic::Code::NotFound);
|
||||
}
|
||||
@@ -30,8 +30,8 @@ use nuncio_proto::v1::{CalendarSyncRequest, GetEventRequest, ListEventsRequest};
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use nunciod::grpc::{
|
||||
serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides,
|
||||
MailEngineOverrides,
|
||||
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
|
||||
ContactsEngineOverrides, MailEngineOverrides,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -95,6 +95,7 @@ async fn boot_daemon(
|
||||
MailEngineOverrides::default(),
|
||||
calendar_overrides,
|
||||
ContactsEngineOverrides::default(),
|
||||
AccountsEngineOverrides::default(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
@@ -39,8 +39,8 @@ use nuncio_proto::v1::{
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use nunciod::grpc::{
|
||||
serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides,
|
||||
MailEngineOverrides,
|
||||
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
|
||||
ContactsEngineOverrides, MailEngineOverrides,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -97,6 +97,7 @@ async fn boot_daemon(
|
||||
MailEngineOverrides::default(),
|
||||
CalendarEngineOverrides::default(),
|
||||
contacts_overrides,
|
||||
AccountsEngineOverrides::default(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
@@ -188,6 +188,54 @@ async fn rejects_calls_to_new_filters_rpcs_without_a_bearer_token() {
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
|
||||
/// Confirms the newly added `Accounts` lifecycle RPCs
|
||||
/// (`UpdateAccount`/`RemoveAccount`/`TestAccountConnection`) are each mounted
|
||||
/// behind the SAME `BearerAuthInterceptor` as every other service -- an
|
||||
/// unauthenticated call to any of them must be rejected exactly like an
|
||||
/// unauthenticated `System/GetStatus`, with no carve-out for "just one more"
|
||||
/// RPC.
|
||||
#[tokio::test]
|
||||
async fn rejects_calls_to_new_accounts_rpcs_without_a_bearer_token() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let secrets = SecretManager::mock();
|
||||
let token = hex::encode(
|
||||
secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.expect("token provisioned from mock vault"),
|
||||
);
|
||||
|
||||
let (addr, _dir) = start_server(event_bus, token).await;
|
||||
let mut client =
|
||||
nuncio_proto::v1::accounts_client::AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
|
||||
let update_err = client
|
||||
.update_account(nuncio_proto::v1::UpdateAccountRequest {
|
||||
config: None,
|
||||
password: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("unauthenticated update_account must be rejected");
|
||||
assert_eq!(update_err.code(), Code::Unauthenticated);
|
||||
|
||||
let remove_err = client
|
||||
.remove_account(nuncio_proto::v1::RemoveAccountRequest {
|
||||
id: "acct-1".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("unauthenticated remove_account must be rejected");
|
||||
assert_eq!(remove_err.code(), Code::Unauthenticated);
|
||||
|
||||
let test_err = client
|
||||
.test_account_connection(nuncio_proto::v1::TestAccountConnectionRequest {
|
||||
id: "acct-1".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("unauthenticated test_account_connection must be rejected");
|
||||
assert_eq!(test_err.code(), Code::Unauthenticated);
|
||||
}
|
||||
|
||||
/// Confirms the streaming `Filters.Triage` RPC is mounted behind the SAME
|
||||
/// `BearerAuthInterceptor` as every other `Filters` RPC -- an unauthenticated
|
||||
/// call must be rejected before the stream is ever established, exactly
|
||||
|
||||
@@ -43,8 +43,8 @@ use nuncio_proto::v1::{
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use nunciod::grpc::{
|
||||
serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides,
|
||||
MailEngineOverrides,
|
||||
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
|
||||
ContactsEngineOverrides, MailEngineOverrides,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -130,6 +130,7 @@ async fn boot_daemon(
|
||||
overrides,
|
||||
CalendarEngineOverrides::default(),
|
||||
ContactsEngineOverrides::default(),
|
||||
AccountsEngineOverrides::default(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user