Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d0a11012 | ||
|
|
73518f849b |
No files matched your search
@@ -232,6 +232,21 @@ pub enum AccountSubcommand {
|
||||
/// Updated SMTP server port.
|
||||
#[arg(long, help = "Updated SMTP server port")]
|
||||
smtp_port: Option<u16>,
|
||||
/// Updated IMAP connection transport mode (implicit_tls, start_tls, plain).
|
||||
#[arg(long, help = "Updated IMAP transport mode")]
|
||||
imap_mode: Option<String>,
|
||||
/// Updated SMTP connection transport mode (implicit_tls, start_tls, plain).
|
||||
#[arg(long, help = "Updated SMTP transport mode")]
|
||||
smtp_mode: Option<String>,
|
||||
/// Prompt (without echo) for a new password and rotate the stored
|
||||
/// OS-keyring credential. Without this flag, the existing
|
||||
/// credential is left untouched.
|
||||
#[arg(long, help = "Rotate the stored password credential")]
|
||||
rotate_password: bool,
|
||||
/// Populated from an interactive `rpassword` prompt in `main` when
|
||||
/// `rotate_password` is set -- never a CLI flag. See [`PasswordArg`].
|
||||
#[arg(skip)]
|
||||
password: PasswordArg,
|
||||
},
|
||||
/// Remove a configured account profile.
|
||||
Delete {
|
||||
|
||||
@@ -20,6 +20,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
*password = PasswordArg(entered);
|
||||
}
|
||||
|
||||
// `account edit --rotate-password` follows the exact same pattern:
|
||||
// only prompt when the caller actually asked to rotate the credential,
|
||||
// so a plain settings edit (hostname/port/etc.) never touches the
|
||||
// terminal for a password it isn't changing.
|
||||
if let Commands::Account {
|
||||
action:
|
||||
AccountSubcommand::Edit {
|
||||
rotate_password: true,
|
||||
password,
|
||||
..
|
||||
},
|
||||
} = &mut cli.command
|
||||
{
|
||||
let entered = rpassword::prompt_password("New account password: ")
|
||||
.map_err(|e| format!("failed to read password: {e}"))?;
|
||||
*password = PasswordArg(entered);
|
||||
}
|
||||
|
||||
let runner = HeadlessRunner::ephemeral().await?;
|
||||
|
||||
let output_str = runner.execute_command(&cli.command, cli.json).await;
|
||||
|
||||
+652
-54
@@ -212,45 +212,35 @@ 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_show_account(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,
|
||||
rotate_password: _,
|
||||
password,
|
||||
} => {
|
||||
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_edit_account(
|
||||
id,
|
||||
email.as_deref(),
|
||||
imap_host.as_deref(),
|
||||
*imap_port,
|
||||
smtp_host.as_deref(),
|
||||
*smtp_port,
|
||||
imap_mode.as_deref(),
|
||||
smtp_mode.as_deref(),
|
||||
&password.0,
|
||||
json_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AccountSubcommand::Delete { id } => self.handle_delete_account(id, json_mode).await,
|
||||
AccountSubcommand::Test { id } => self.handle_test_account(id, json_mode).await,
|
||||
},
|
||||
Commands::Mail { action } => match action {
|
||||
MailSubcommand::Sync => self.handle_sync(json_mode).await,
|
||||
@@ -1546,6 +1536,240 @@ impl HeadlessRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// `account show`: a real thin gRPC client of the running `nunciod`
|
||||
/// daemon's `nuncio.v1.Accounts` API -- fetches every account via
|
||||
/// `ListAccounts` and filters client-side by `id`, exactly like `account
|
||||
/// list` does, rather than reading this process's own throwaway
|
||||
/// ephemeral local database (which never holds the daemon's real
|
||||
/// persisted accounts).
|
||||
async fn handle_show_account(&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),
|
||||
};
|
||||
|
||||
let accounts = match client
|
||||
.list_accounts(nuncio_proto::v1::ListAccountsRequest {})
|
||||
.await
|
||||
{
|
||||
Ok(response) => response.into_inner().accounts,
|
||||
Err(status) => {
|
||||
return Self::render_error(
|
||||
&format!("nunciod daemon rejected list_accounts: {status}"),
|
||||
json_mode,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
match accounts.into_iter().find(|a| a.id == id) {
|
||||
Some(a) if json_mode => format_json(&json!({
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
"email_address": a.email_address,
|
||||
"protocol": a.protocol().as_str_name(),
|
||||
"server_host": a.server_host,
|
||||
"server_port": a.server_port,
|
||||
"smtp_host": a.smtp_host,
|
||||
"smtp_port": a.smtp_port,
|
||||
"use_tls": a.use_tls,
|
||||
"imap_tls_mode": a.imap_tls_mode().as_str_name(),
|
||||
"smtp_tls_mode": a.smtp_tls_mode().as_str_name(),
|
||||
"sync_interval_secs": a.sync_interval_secs,
|
||||
})),
|
||||
Some(a) => format!(
|
||||
"Account {}: {} <{}> IMAP {}:{} ({}) SMTP {}:{} ({})",
|
||||
a.id,
|
||||
a.name,
|
||||
a.email_address,
|
||||
a.server_host,
|
||||
a.server_port,
|
||||
a.imap_tls_mode().as_str_name(),
|
||||
a.smtp_host,
|
||||
a.smtp_port,
|
||||
a.smtp_tls_mode().as_str_name(),
|
||||
),
|
||||
None if json_mode => format_json_error(&format!("account '{id}' not found")),
|
||||
None => format!("Account '{}' not found", id),
|
||||
}
|
||||
}
|
||||
|
||||
/// `account edit`: a real thin gRPC client of the running `nunciod`
|
||||
/// daemon's `nuncio.v1.Accounts` API. Fetches the account's current
|
||||
/// config via `ListAccounts`, applies only the flags the caller
|
||||
/// actually passed on top of it, then persists the merged result via
|
||||
/// `UpdateAccount` -- never a fabricated "updated successfully" without
|
||||
/// the daemon genuinely having anything to update.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_edit_account(
|
||||
&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>,
|
||||
new_password: &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),
|
||||
};
|
||||
|
||||
let accounts = match client
|
||||
.list_accounts(nuncio_proto::v1::ListAccountsRequest {})
|
||||
.await
|
||||
{
|
||||
Ok(response) => response.into_inner().accounts,
|
||||
Err(status) => {
|
||||
return Self::render_error(
|
||||
&format!("nunciod daemon rejected list_accounts: {status}"),
|
||||
json_mode,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let Some(mut config) = accounts.into_iter().find(|a| a.id == id) else {
|
||||
return Self::render_error(&format!("account '{id}' not found"), json_mode);
|
||||
};
|
||||
|
||||
if let Some(email) = email {
|
||||
config.email_address = email.to_string();
|
||||
}
|
||||
if let Some(imap_host) = imap_host {
|
||||
config.server_host = imap_host.to_string();
|
||||
}
|
||||
if let Some(imap_port) = imap_port {
|
||||
config.server_port = u32::from(imap_port);
|
||||
}
|
||||
if let Some(smtp_host) = smtp_host {
|
||||
config.smtp_host = smtp_host.to_string();
|
||||
}
|
||||
if let Some(smtp_port) = smtp_port {
|
||||
config.smtp_port = u32::from(smtp_port);
|
||||
}
|
||||
if let Some(imap_mode) = imap_mode {
|
||||
let mode = match parse_tls_mode(imap_mode) {
|
||||
Ok(mode) => mode,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
config.imap_tls_mode = map_tls_mode_to_proto(mode).into();
|
||||
}
|
||||
if let Some(smtp_mode) = smtp_mode {
|
||||
let mode = match parse_tls_mode(smtp_mode) {
|
||||
Ok(mode) => mode,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
config.smtp_tls_mode = map_tls_mode_to_proto(mode).into();
|
||||
}
|
||||
|
||||
match client
|
||||
.update_account(nuncio_proto::v1::UpdateAccountRequest {
|
||||
config: Some(config),
|
||||
password: new_password.to_string(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let id = response.into_inner().id;
|
||||
if json_mode {
|
||||
format_json(&json!({ "status": "updated", "id": id }))
|
||||
} else {
|
||||
format!("Account '{}' updated successfully.", id)
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected update_account: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `account delete`: a real thin gRPC client of the running `nunciod`
|
||||
/// daemon's `nuncio.v1.Accounts` API. Reports the daemon's real
|
||||
/// response -- including an honest "not found" for an unknown id --
|
||||
/// never a fabricated "removed" regardless of whether anything was
|
||||
/// actually deleted.
|
||||
async fn handle_delete_account(&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 '{}' removed.", id)
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected remove_account: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `account test`: a real thin gRPC client of the running `nunciod`
|
||||
/// daemon's `nuncio.v1.Accounts` API. Requests a genuine connect +
|
||||
/// authenticate against the account's configured transport(s) using
|
||||
/// its already-stored keyring credential (an empty `password` in the
|
||||
/// request tells the daemon to resolve it itself), and reports the
|
||||
/// real inbound/outbound results -- never a fabricated "OK" with an
|
||||
/// invented latency number.
|
||||
async fn handle_test_account(&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(),
|
||||
password: String::new(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let r = response.into_inner();
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"id": id,
|
||||
"inbound_ok": r.inbound_ok,
|
||||
"inbound_error": r.inbound_error,
|
||||
"outbound_ok": r.outbound_ok,
|
||||
"outbound_error": r.outbound_error,
|
||||
}))
|
||||
} else {
|
||||
let inbound = if r.inbound_ok {
|
||||
"OK".to_string()
|
||||
} else {
|
||||
format!("FAILED ({})", r.inbound_error)
|
||||
};
|
||||
let outbound = if r.outbound_ok {
|
||||
"OK".to_string()
|
||||
} else {
|
||||
format!("FAILED ({})", r.outbound_error)
|
||||
};
|
||||
format!(
|
||||
"Account '{}' connection test -- inbound: {}, outbound: {}",
|
||||
id, inbound, outbound
|
||||
)
|
||||
}
|
||||
}
|
||||
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`].
|
||||
@@ -1652,6 +1876,7 @@ impl HeadlessRunner {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[test]
|
||||
fn parse_tls_mode_accepts_all_valid_modes_and_rejects_garbage() {
|
||||
@@ -1745,26 +1970,13 @@ mod tests {
|
||||
.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"));
|
||||
// Account Noun Commands: `Add`/`List`/`Show`/`Edit`/`Delete`/`Test`
|
||||
// are all 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.
|
||||
|
||||
// Mail Noun Commands: `Sync`/`List`/`Read`/`Search`/`Mark`/`Send`
|
||||
// (and `Folder::List`) are all real gRPC clients of the `nunciod`
|
||||
@@ -2006,6 +2218,51 @@ mod tests {
|
||||
}],
|
||||
}))
|
||||
}
|
||||
|
||||
// Not exercised by this stub's tests (only `add_account`/
|
||||
// `list_accounts` are), but the trait requires an
|
||||
// implementation for every RPC on the service.
|
||||
async fn update_account(
|
||||
&self,
|
||||
request: tonic::Request<nuncio_proto::v1::UpdateAccountRequest>,
|
||||
) -> Result<tonic::Response<nuncio_proto::v1::UpdateAccountResponse>, tonic::Status>
|
||||
{
|
||||
let id = request
|
||||
.into_inner()
|
||||
.config
|
||||
.map(|c| c.id)
|
||||
.unwrap_or_default();
|
||||
Ok(tonic::Response::new(
|
||||
nuncio_proto::v1::UpdateAccountResponse { id },
|
||||
))
|
||||
}
|
||||
|
||||
async fn remove_account(
|
||||
&self,
|
||||
_request: tonic::Request<nuncio_proto::v1::RemoveAccountRequest>,
|
||||
) -> Result<tonic::Response<nuncio_proto::v1::RemoveAccountResponse>, tonic::Status>
|
||||
{
|
||||
Ok(tonic::Response::new(
|
||||
nuncio_proto::v1::RemoveAccountResponse {},
|
||||
))
|
||||
}
|
||||
|
||||
async fn test_account_connection(
|
||||
&self,
|
||||
_request: tonic::Request<nuncio_proto::v1::TestAccountConnectionRequest>,
|
||||
) -> Result<
|
||||
tonic::Response<nuncio_proto::v1::TestAccountConnectionResponse>,
|
||||
tonic::Status,
|
||||
> {
|
||||
Ok(tonic::Response::new(
|
||||
nuncio_proto::v1::TestAccountConnectionResponse {
|
||||
inbound_ok: true,
|
||||
inbound_error: String::new(),
|
||||
outbound_ok: true,
|
||||
outbound_error: String::new(),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let stub = StubAccounts::default();
|
||||
@@ -2091,6 +2348,347 @@ mod tests {
|
||||
assert!(list_out_text.contains("imap.nuncio.mx"));
|
||||
}
|
||||
|
||||
/// Minimal stub `nuncio.v1.Accounts` server for `account show`/`edit`/
|
||||
/// `delete`/`test` tests below: `ListAccounts` always returns one fixed
|
||||
/// account (`acct-stub-1`), and every other RPC records the exact
|
||||
/// request it received so each test can assert on what the CLI
|
||||
/// actually sent over the wire.
|
||||
#[derive(Default, Clone)]
|
||||
struct RecordingStubAccounts {
|
||||
last_update_request: Arc<Mutex<Option<nuncio_proto::v1::UpdateAccountRequest>>>,
|
||||
last_remove_request: Arc<Mutex<Option<nuncio_proto::v1::RemoveAccountRequest>>>,
|
||||
last_test_request: Arc<Mutex<Option<nuncio_proto::v1::TestAccountConnectionRequest>>>,
|
||||
test_connection_response: Arc<Mutex<nuncio_proto::v1::TestAccountConnectionResponse>>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl nuncio_proto::v1::accounts_server::Accounts for RecordingStubAccounts {
|
||||
async fn add_account(
|
||||
&self,
|
||||
_request: tonic::Request<nuncio_proto::v1::AddAccountRequest>,
|
||||
) -> Result<tonic::Response<nuncio_proto::v1::AddAccountResponse>, tonic::Status> {
|
||||
Ok(tonic::Response::new(nuncio_proto::v1::AddAccountResponse {
|
||||
id: "acct-stub-1".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
_request: tonic::Request<nuncio_proto::v1::ListAccountsRequest>,
|
||||
) -> Result<tonic::Response<nuncio_proto::v1::ListAccountsResponse>, tonic::Status>
|
||||
{
|
||||
Ok(tonic::Response::new(
|
||||
nuncio_proto::v1::ListAccountsResponse {
|
||||
accounts: vec![nuncio_proto::v1::AccountConfig {
|
||||
id: "acct-stub-1".to_string(),
|
||||
name: "Stub Account".to_string(),
|
||||
email_address: "stub@nuncio.mx".to_string(),
|
||||
protocol: nuncio_proto::v1::AccountProtocol::ImapSmtp.into(),
|
||||
server_host: "imap.nuncio.mx".to_string(),
|
||||
server_port: 993,
|
||||
use_tls: true,
|
||||
imap_tls_mode: nuncio_proto::v1::TlsMode::ImplicitTls.into(),
|
||||
smtp_tls_mode: nuncio_proto::v1::TlsMode::ImplicitTls.into(),
|
||||
keyring_secret_key: "nuncio/acct-stub-1".to_string(),
|
||||
sync_interval_secs: 300,
|
||||
smtp_host: "smtp.nuncio.mx".to_string(),
|
||||
smtp_port: 465,
|
||||
}],
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_account(
|
||||
&self,
|
||||
request: tonic::Request<nuncio_proto::v1::UpdateAccountRequest>,
|
||||
) -> Result<tonic::Response<nuncio_proto::v1::UpdateAccountResponse>, tonic::Status>
|
||||
{
|
||||
let req = request.into_inner();
|
||||
let id = req
|
||||
.config
|
||||
.as_ref()
|
||||
.map(|c| c.id.clone())
|
||||
.unwrap_or_default();
|
||||
*self
|
||||
.last_update_request
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(req);
|
||||
Ok(tonic::Response::new(
|
||||
nuncio_proto::v1::UpdateAccountResponse { id },
|
||||
))
|
||||
}
|
||||
|
||||
async fn remove_account(
|
||||
&self,
|
||||
request: tonic::Request<nuncio_proto::v1::RemoveAccountRequest>,
|
||||
) -> Result<tonic::Response<nuncio_proto::v1::RemoveAccountResponse>, tonic::Status>
|
||||
{
|
||||
let req = request.into_inner();
|
||||
if req.id == "acct-does-not-exist" {
|
||||
return Err(tonic::Status::not_found(format!(
|
||||
"no account with id '{}'",
|
||||
req.id
|
||||
)));
|
||||
}
|
||||
*self
|
||||
.last_remove_request
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(req);
|
||||
Ok(tonic::Response::new(
|
||||
nuncio_proto::v1::RemoveAccountResponse {},
|
||||
))
|
||||
}
|
||||
|
||||
async fn test_account_connection(
|
||||
&self,
|
||||
request: tonic::Request<nuncio_proto::v1::TestAccountConnectionRequest>,
|
||||
) -> Result<tonic::Response<nuncio_proto::v1::TestAccountConnectionResponse>, tonic::Status>
|
||||
{
|
||||
let req = request.into_inner();
|
||||
*self
|
||||
.last_test_request
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(req);
|
||||
let response = self
|
||||
.test_connection_response
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
Ok(tonic::Response::new(response))
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_recording_stub_accounts_server(
|
||||
stub: RecordingStubAccounts,
|
||||
) -> std::net::SocketAddr {
|
||||
use nuncio_proto::v1::accounts_server::AccountsServer;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
tokio::spawn(async move {
|
||||
let _ = tonic::transport::Server::builder()
|
||||
.add_service(AccountsServer::new(stub))
|
||||
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
|
||||
.await;
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_show_finds_existing_and_reports_not_found_for_missing_over_grpc() {
|
||||
let stub = RecordingStubAccounts::default();
|
||||
let addr = spawn_recording_stub_accounts_server(stub).await;
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
let found = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Show {
|
||||
id: "acct-stub-1".to_string(),
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(found.contains("stub@nuncio.mx"));
|
||||
|
||||
let missing = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Show {
|
||||
id: "acct-does-not-exist".to_string(),
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(missing.contains("Account 'acct-does-not-exist' not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_edit_merges_overrides_onto_the_existing_config_over_grpc() {
|
||||
let stub = RecordingStubAccounts::default();
|
||||
let probe = stub.last_update_request.clone();
|
||||
let addr = spawn_recording_stub_accounts_server(stub).await;
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
let out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Edit {
|
||||
id: "acct-stub-1".to_string(),
|
||||
email: None,
|
||||
imap_host: Some("imap.updated.example".to_string()),
|
||||
imap_port: None,
|
||||
smtp_host: None,
|
||||
smtp_port: None,
|
||||
imap_mode: Some("start_tls".to_string()),
|
||||
smtp_mode: None,
|
||||
rotate_password: false,
|
||||
password: crate::args::PasswordArg::default(),
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(out.contains("Account 'acct-stub-1' updated successfully."));
|
||||
|
||||
let sent = probe
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.expect("stub daemon received an update_account request");
|
||||
// Empty password means "leave the credential untouched".
|
||||
assert_eq!(sent.password, "");
|
||||
let sent_config = sent.config.expect("config present on the request");
|
||||
// Overridden fields changed...
|
||||
assert_eq!(sent_config.server_host, "imap.updated.example");
|
||||
assert_eq!(
|
||||
sent_config.imap_tls_mode(),
|
||||
nuncio_proto::v1::TlsMode::StartTls
|
||||
);
|
||||
// ...but fields the caller didn't pass keep their existing value
|
||||
// from `ListAccounts`, proving this is a genuine merge, not a
|
||||
// fresh/blank config.
|
||||
assert_eq!(sent_config.email_address, "stub@nuncio.mx");
|
||||
assert_eq!(sent_config.smtp_host, "smtp.nuncio.mx");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_edit_rejects_an_unknown_id_before_dialing_update() {
|
||||
let stub = RecordingStubAccounts::default();
|
||||
let probe = stub.last_update_request.clone();
|
||||
let addr = spawn_recording_stub_accounts_server(stub).await;
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
let out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Edit {
|
||||
id: "acct-does-not-exist".to_string(),
|
||||
email: None,
|
||||
imap_host: None,
|
||||
imap_port: None,
|
||||
smtp_host: None,
|
||||
smtp_port: None,
|
||||
imap_mode: None,
|
||||
smtp_mode: None,
|
||||
rotate_password: false,
|
||||
password: crate::args::PasswordArg::default(),
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(out.contains("Error"));
|
||||
assert!(out.contains("acct-does-not-exist"));
|
||||
assert!(
|
||||
probe.lock().unwrap_or_else(|e| e.into_inner()).is_none(),
|
||||
"an unknown id must never reach update_account at all"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_delete_round_trips_over_grpc_and_reports_honest_not_found() {
|
||||
let stub = RecordingStubAccounts::default();
|
||||
let probe = stub.last_remove_request.clone();
|
||||
let addr = spawn_recording_stub_accounts_server(stub).await;
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
let out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Delete {
|
||||
id: "acct-stub-1".to_string(),
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(out.contains("Account 'acct-stub-1' removed."));
|
||||
assert_eq!(
|
||||
probe
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.expect("stub daemon received a remove_account request")
|
||||
.id,
|
||||
"acct-stub-1"
|
||||
);
|
||||
|
||||
let not_found_out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Delete {
|
||||
id: "acct-does-not-exist".to_string(),
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(not_found_out.contains("Error"));
|
||||
assert!(not_found_out.contains("not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_test_reports_the_real_result_never_a_fabricated_latency() {
|
||||
let stub = RecordingStubAccounts::default();
|
||||
*stub
|
||||
.test_connection_response
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = nuncio_proto::v1::TestAccountConnectionResponse {
|
||||
inbound_ok: true,
|
||||
inbound_error: String::new(),
|
||||
outbound_ok: false,
|
||||
outbound_error: "auth failed".to_string(),
|
||||
};
|
||||
let probe = stub.last_test_request.clone();
|
||||
let addr = spawn_recording_stub_accounts_server(stub).await;
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
let out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Test {
|
||||
id: "acct-stub-1".to_string(),
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(out.contains("inbound: OK"));
|
||||
assert!(out.contains("outbound: FAILED (auth failed)"));
|
||||
// Never the old fabricated "24ms latency" text.
|
||||
assert!(!out.contains("latency"));
|
||||
|
||||
let sent = probe
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.expect("stub daemon received a test_account_connection request");
|
||||
// An empty password tells the daemon to resolve the credential
|
||||
// from its own keyring, rather than the CLI re-sending it.
|
||||
assert_eq!(sent.password, "");
|
||||
assert_eq!(sent.id, "acct-stub-1");
|
||||
}
|
||||
|
||||
/// Reference-client proof: boots a stub `nuncio.v1.Mail` gRPC server
|
||||
/// (mirroring the `Accounts` stub pattern above) and drives the real
|
||||
/// `HeadlessRunner`'s
|
||||
|
||||
@@ -21,6 +21,15 @@ pub trait MailBackend: Send + Sync {
|
||||
|
||||
/// Send an email message over the configured transport.
|
||||
async fn send_email(&self, email: &Email) -> Result<(), MailError>;
|
||||
|
||||
/// Validate that this backend's configured account settings genuinely
|
||||
/// work end to end: real connect + auth against every transport the
|
||||
/// backend uses, with no sync/send side effects. Implementations MUST
|
||||
/// return the real underlying error on failure -- never a fabricated
|
||||
/// success -- and MUST return an honest "not supported" error rather
|
||||
/// than a fabricated success for a protocol whose transport isn't
|
||||
/// genuinely implemented yet.
|
||||
async fn test_connection(&self) -> Result<(), MailError>;
|
||||
}
|
||||
|
||||
/// A composed outbound email message ready to send over SMTP. Deliberately
|
||||
|
||||
+516
-92
@@ -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, AsyncWrite, ReadBuf};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::client::TlsStream;
|
||||
use tokio_rustls::rustls::pki_types::ServerName;
|
||||
@@ -16,6 +20,27 @@ use tokio_stream::StreamExt;
|
||||
use crate::backend::MailBackend;
|
||||
use crate::parser::{MailError, MimeParserAdapter};
|
||||
|
||||
/// Upper bound on how long a single TCP connect + TLS handshake to an IMAP
|
||||
/// server may take before this client gives up and reports an honest
|
||||
/// timeout error, rather than hanging indefinitely. A misconfigured or
|
||||
/// unreachable host (wrong hostname, firewall silently dropping packets,
|
||||
/// wrong port) must fail fast and visibly, not stall the caller forever.
|
||||
const IMAP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Upper bound on how long a single `FETCH` response item may take to
|
||||
/// arrive once the `UID FETCH` command has been acknowledged. A real
|
||||
/// mailbox with many/large messages fetching a full body for everything in
|
||||
/// one unbatched command can legitimately take a while overall, but a
|
||||
/// single item that never arrives (dead socket, server-side hang) must
|
||||
/// still surface as an honest timeout instead of hanging the whole sync
|
||||
/// indefinitely.
|
||||
const IMAP_FETCH_ITEM_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How often (in fetched-message count) to log sync progress, so a
|
||||
/// genuinely slow-but-progressing sync against a large real mailbox is
|
||||
/// visibly distinguishable from a stalled one.
|
||||
const IMAP_FETCH_PROGRESS_LOG_INTERVAL: usize = 25;
|
||||
|
||||
/// State of the dedicated IMAP IDLE socket listener.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IdleSocketState {
|
||||
@@ -65,41 +90,189 @@ pub fn build_fetch_command_query() -> &'static str {
|
||||
"(FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODY.PEEK[])"
|
||||
}
|
||||
|
||||
/// Establish an encrypted TLS stream to an IMAP server.
|
||||
pub async fn connect_tls_stream(host: &str, port: u16) -> Result<TlsStream<TcpStream>, MailError> {
|
||||
/// A live IMAP socket that may or may not be TLS-wrapped, depending on the
|
||||
/// account's configured [`TlsMode`]. `async_imap::Session`/`Client` are
|
||||
/// generic over the underlying stream type, so this lets one connection
|
||||
/// path serve `ImplicitTls` (already-encrypted), `StartTls` (plaintext
|
||||
/// upgraded to TLS mid-handshake), and `Plain` (never encrypted -- local
|
||||
/// dev/test servers only) without duplicating the session/IDLE/fetch logic
|
||||
/// three times.
|
||||
pub enum MaybeTlsStream {
|
||||
/// Unencrypted TCP socket ([`TlsMode::Plain`]).
|
||||
Plain(TcpStream),
|
||||
/// TLS-wrapped socket, either from an immediate handshake
|
||||
/// ([`TlsMode::ImplicitTls`]) or a post-`STARTTLS` upgrade
|
||||
/// ([`TlsMode::StartTls`]). Boxed: `TlsStream` is far larger than
|
||||
/// `TcpStream`, and clippy's `large_enum_variant` flags the resulting
|
||||
/// size skew otherwise.
|
||||
Tls(Box<TlsStream<TcpStream>>),
|
||||
}
|
||||
|
||||
impl Debug for MaybeTlsStream {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MaybeTlsStream::Plain(_) => f.write_str("MaybeTlsStream::Plain"),
|
||||
MaybeTlsStream::Tls(_) => f.write_str("MaybeTlsStream::Tls"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for MaybeTlsStream {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
MaybeTlsStream::Plain(s) => Pin::new(s).poll_read(cx, buf),
|
||||
MaybeTlsStream::Tls(s) => Pin::new(s).poll_read(cx, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for MaybeTlsStream {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
match self.get_mut() {
|
||||
MaybeTlsStream::Plain(s) => Pin::new(s).poll_write(cx, buf),
|
||||
MaybeTlsStream::Tls(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() {
|
||||
MaybeTlsStream::Plain(s) => Pin::new(s).poll_flush(cx),
|
||||
MaybeTlsStream::Tls(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() {
|
||||
MaybeTlsStream::Plain(s) => Pin::new(s).poll_shutdown(cx),
|
||||
MaybeTlsStream::Tls(s) => Pin::new(s).poll_shutdown(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect the bare TCP socket underlying every [`TlsMode`], bounded by
|
||||
/// [`IMAP_CONNECT_TIMEOUT`] so an unreachable host (bad hostname silently
|
||||
/// dropped by a firewall, wrong port with no RST) fails fast with an honest
|
||||
/// timeout error instead of hanging indefinitely.
|
||||
async fn connect_tcp(host: &str, port: u16) -> Result<TcpStream, MailError> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
tokio::time::timeout(IMAP_CONNECT_TIMEOUT, TcpStream::connect(&addr))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MailError::NetworkError(format!(
|
||||
"TCP connection to {} timed out after {}s",
|
||||
addr,
|
||||
IMAP_CONNECT_TIMEOUT.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|e| MailError::NetworkError(format!("TCP connection to {} failed: {}", addr, e)))
|
||||
}
|
||||
|
||||
/// Upgrade a plaintext TCP socket to TLS, bounded by [`IMAP_CONNECT_TIMEOUT`].
|
||||
async fn upgrade_to_tls(
|
||||
host: &str,
|
||||
tcp_stream: TcpStream,
|
||||
) -> Result<TlsStream<TcpStream>, MailError> {
|
||||
let connector = build_tls_connector()?;
|
||||
let target_port = if port == 0 { 993 } else { port };
|
||||
let addr = format!("{}:{}", host, target_port);
|
||||
|
||||
let tcp_stream = TcpStream::connect(&addr).await.map_err(|e| {
|
||||
MailError::NetworkError(format!("TCP connection to {} failed: {}", addr, e))
|
||||
})?;
|
||||
|
||||
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_stream)
|
||||
.await
|
||||
.map_err(|e| MailError::TlsError(format!("TLS handshake with {} failed: {}", host, e)))?;
|
||||
tokio::time::timeout(
|
||||
IMAP_CONNECT_TIMEOUT,
|
||||
connector.connect(server_name, tcp_stream),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MailError::TlsError(format!(
|
||||
"TLS handshake with {} timed out after {}s",
|
||||
host,
|
||||
IMAP_CONNECT_TIMEOUT.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|e| MailError::TlsError(format!("TLS handshake with {} failed: {}", host, e)))
|
||||
}
|
||||
|
||||
Ok(tls_stream)
|
||||
/// Establish a socket to an IMAP server honoring `tls_mode`:
|
||||
/// - [`TlsMode::ImplicitTls`]: TCP connect, then an immediate TLS handshake
|
||||
/// (the traditional port-993 style).
|
||||
/// - [`TlsMode::StartTls`]: TCP connect in plaintext, issue the IMAP
|
||||
/// `STARTTLS` command and require the server to accept it, then perform
|
||||
/// the TLS handshake on the same socket. STARTTLS is required, never
|
||||
/// opportunistic -- a server that rejects or lacks it is an honest error,
|
||||
/// never a silent downgrade to plaintext for a mode the caller explicitly
|
||||
/// asked to be encrypted.
|
||||
/// - [`TlsMode::Plain`]: TCP connect only, no encryption. For trusted local
|
||||
/// dev/test servers only.
|
||||
pub async fn connect_stream(
|
||||
host: &str,
|
||||
port: u16,
|
||||
tls_mode: TlsMode,
|
||||
) -> Result<MaybeTlsStream, MailError> {
|
||||
let target_port = if port == 0 { 993 } else { port };
|
||||
|
||||
match tls_mode {
|
||||
TlsMode::ImplicitTls => {
|
||||
let tcp_stream = connect_tcp(host, target_port).await?;
|
||||
Ok(MaybeTlsStream::Tls(Box::new(
|
||||
upgrade_to_tls(host, tcp_stream).await?,
|
||||
)))
|
||||
}
|
||||
TlsMode::Plain => {
|
||||
let tcp_stream = connect_tcp(host, target_port).await?;
|
||||
Ok(MaybeTlsStream::Plain(tcp_stream))
|
||||
}
|
||||
TlsMode::StartTls => {
|
||||
let tcp_stream = connect_tcp(host, target_port).await?;
|
||||
let mut client = async_imap::Client::new(tcp_stream);
|
||||
tokio::time::timeout(
|
||||
IMAP_CONNECT_TIMEOUT,
|
||||
client.run_command_and_check_ok("STARTTLS", None),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MailError::TlsError(format!(
|
||||
"STARTTLS negotiation with {} timed out after {}s",
|
||||
host,
|
||||
IMAP_CONNECT_TIMEOUT.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|e| {
|
||||
MailError::TlsError(format!(
|
||||
"server {} rejected or does not support STARTTLS: {}",
|
||||
host, e
|
||||
))
|
||||
})?;
|
||||
let tcp_stream = client.into_inner();
|
||||
Ok(MaybeTlsStream::Tls(Box::new(
|
||||
upgrade_to_tls(host, tcp_stream).await?,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
pub fn new(server_host: &str, server_port: u16) -> Self {
|
||||
/// Create a new `ImapDualSocketManager` for the given [`TlsMode`].
|
||||
pub fn new(server_host: &str, server_port: u16, tls_mode: TlsMode) -> Self {
|
||||
let port = if server_port == 0 { 993 } else { server_port };
|
||||
Self {
|
||||
server_host: server_host.to_string(),
|
||||
server_port: port,
|
||||
tls_mode,
|
||||
idle_active: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
@@ -123,17 +296,18 @@ 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 socket connection to the target IMAP server,
|
||||
/// honoring this manager's configured [`TlsMode`].
|
||||
pub async fn connect_tls_socket(&self) -> Result<MaybeTlsStream, MailError> {
|
||||
connect_stream(&self.server_host, self.server_port, self.tls_mode).await
|
||||
}
|
||||
|
||||
/// Establish an authenticated IMAP session over a live TLS socket connection.
|
||||
/// Establish an authenticated IMAP session over a live socket connection.
|
||||
pub async fn connect_session(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<async_imap::Session<TlsStream<TcpStream>>, MailError> {
|
||||
) -> Result<async_imap::Session<MaybeTlsStream>, MailError> {
|
||||
let stream = self.connect_tls_socket().await?;
|
||||
let client = async_imap::Client::new(stream);
|
||||
let session = client
|
||||
@@ -148,6 +322,25 @@ impl ImapDualSocketManager {
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Validate that `username`/`password` can genuinely authenticate
|
||||
/// against this server: connects, logs in, then immediately logs out
|
||||
/// again -- no folders or messages are touched. Used by the daemon's
|
||||
/// `TestAccountConnection` RPC to let a caller check account settings
|
||||
/// are correct without running a real sync. Returns the real
|
||||
/// connect/TLS/auth error on failure, never a fabricated success.
|
||||
pub async fn test_login(&self, username: &str, password: &str) -> Result<(), MailError> {
|
||||
let mut session = self.connect_session(username, password).await?;
|
||||
// A logout failure after a successful login doesn't change the
|
||||
// verdict -- the credentials and server settings are already
|
||||
// proven correct at this point -- but it's still surfaced so a
|
||||
// caller can see the server misbehaved on teardown.
|
||||
session
|
||||
.logout()
|
||||
.await
|
||||
.map_err(|e| MailError::ImapError(format!("IMAP logout failed: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start the dedicated IDLE socket listener connection (Connection A).
|
||||
pub fn start_idle_listener(&self) -> Result<(), MailError> {
|
||||
self.idle_active.store(true, Ordering::SeqCst);
|
||||
@@ -169,26 +362,41 @@ pub struct ImapEngine {
|
||||
}
|
||||
|
||||
impl ImapEngine {
|
||||
/// Create a new `ImapEngine`.
|
||||
/// Create a new `ImapEngine` using [`TlsMode::ImplicitTls`] (the
|
||||
/// traditional port-993 style). Use [`Self::with_tls_mode`] to select a
|
||||
/// different connection type.
|
||||
pub fn new(account_id: &str, server_host: &str, server_port: u16) -> Self {
|
||||
Self::with_tls_mode(account_id, server_host, server_port, TlsMode::ImplicitTls)
|
||||
}
|
||||
|
||||
/// Create a new `ImapEngine` targeting a specific [`TlsMode`]
|
||||
/// (`ImplicitTls`, `StartTls`, or `Plain`).
|
||||
pub fn with_tls_mode(
|
||||
account_id: &str,
|
||||
server_host: &str,
|
||||
server_port: u16,
|
||||
tls_mode: TlsMode,
|
||||
) -> Self {
|
||||
Self {
|
||||
socket_manager: ImapDualSocketManager::new(server_host, server_port),
|
||||
socket_manager: ImapDualSocketManager::new(server_host, server_port, tls_mode),
|
||||
account_id: account_id.to_string(),
|
||||
username: None,
|
||||
password: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new `ImapEngine` with host, port, and authentication credentials.
|
||||
/// Create a new `ImapEngine` with host, port, TLS mode, and
|
||||
/// authentication credentials.
|
||||
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::new(server_host, server_port, tls_mode),
|
||||
account_id: account_id.to_string(),
|
||||
username: Some(username.to_string()),
|
||||
password: Some(password.to_string()),
|
||||
@@ -215,23 +423,76 @@ impl ImapEngine {
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin + Send + Debug + 'static,
|
||||
{
|
||||
session.select(folder_id).await.map_err(|e| {
|
||||
let select_started = std::time::Instant::now();
|
||||
let mailbox = session.select(folder_id).await.map_err(|e| {
|
||||
MailError::ImapError(format!("failed to select folder '{}': {}", folder_id, e))
|
||||
})?;
|
||||
tracing::info!(
|
||||
folder_id,
|
||||
exists = mailbox.exists,
|
||||
elapsed_ms = select_started.elapsed().as_millis() as u64,
|
||||
"IMAP SELECT completed"
|
||||
);
|
||||
|
||||
let query = build_fetch_command_query();
|
||||
let fetch_started = std::time::Instant::now();
|
||||
let mut fetch_stream = session.uid_fetch("1:*", query).await.map_err(|e| {
|
||||
MailError::ImapError(format!(
|
||||
"UID FETCH failed for folder '{}': {}",
|
||||
folder_id, e
|
||||
))
|
||||
})?;
|
||||
tracing::info!(
|
||||
folder_id,
|
||||
elapsed_ms = fetch_started.elapsed().as_millis() as u64,
|
||||
"IMAP UID FETCH command acknowledged; reading response stream"
|
||||
);
|
||||
|
||||
let mut emails = Vec::new();
|
||||
while let Some(fetch_res) = fetch_stream.next().await {
|
||||
let stream_started = std::time::Instant::now();
|
||||
loop {
|
||||
// Bound EVERY individual item read, not just the connect phase:
|
||||
// a real mailbox with many/large messages fetching
|
||||
// `BODY.PEEK[]` for everything in one unbatched command can be
|
||||
// legitimately slow, but a single stalled read (dead socket,
|
||||
// server-side hang) must still surface as an honest timeout
|
||||
// error rather than hanging the whole sync forever.
|
||||
let next_item =
|
||||
match tokio::time::timeout(IMAP_FETCH_ITEM_TIMEOUT, fetch_stream.next()).await {
|
||||
Ok(item) => item,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
folder_id,
|
||||
fetched_so_far = emails.len(),
|
||||
elapsed_ms = stream_started.elapsed().as_millis() as u64,
|
||||
"IMAP FETCH stream stalled: no response within {}s",
|
||||
IMAP_FETCH_ITEM_TIMEOUT.as_secs()
|
||||
);
|
||||
return Err(MailError::ImapError(format!(
|
||||
"UID FETCH for folder '{}' stalled after {} messages ({}s with no \
|
||||
server response)",
|
||||
folder_id,
|
||||
emails.len(),
|
||||
IMAP_FETCH_ITEM_TIMEOUT.as_secs()
|
||||
)));
|
||||
}
|
||||
};
|
||||
let Some(fetch_res) = next_item else {
|
||||
break;
|
||||
};
|
||||
|
||||
let fetch_data = fetch_res
|
||||
.map_err(|e| MailError::ImapError(format!("failed reading fetch item: {}", e)))?;
|
||||
|
||||
if emails.len() % IMAP_FETCH_PROGRESS_LOG_INTERVAL == 0 {
|
||||
tracing::info!(
|
||||
folder_id,
|
||||
fetched_so_far = emails.len(),
|
||||
elapsed_ms = stream_started.elapsed().as_millis() as u64,
|
||||
"IMAP FETCH progress"
|
||||
);
|
||||
}
|
||||
|
||||
let uid_num = fetch_data.uid.unwrap_or(0);
|
||||
let email_id = format!("imap-uid-{}", uid_num);
|
||||
|
||||
@@ -324,20 +585,9 @@ impl ImapEngine {
|
||||
let (username, password) = match (&self.username, &self.password) {
|
||||
(Some(u), Some(p)) => (u.as_str(), p.as_str()),
|
||||
_ => {
|
||||
let mock_emails = vec![Email {
|
||||
id: "imap-uid-100".to_string(),
|
||||
account_id: self.account_id.clone(),
|
||||
folder_id: folder_id.to_string(),
|
||||
subject: "IMAP Sync Message".to_string(),
|
||||
sender: "sender@nuncio.mx".to_string(),
|
||||
recipient: "me@nuncio.mx".to_string(),
|
||||
received_at: 1700000000,
|
||||
read: true,
|
||||
body_plain: Some("IMAP message body content".to_string()),
|
||||
body_html: None,
|
||||
attachments: Vec::new(),
|
||||
}];
|
||||
return Ok(mock_emails);
|
||||
return Err(MailError::AuthError(
|
||||
"no credentials configured for this IMAP engine instance".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -453,47 +703,41 @@ impl ImapEngine {
|
||||
#[async_trait]
|
||||
impl MailBackend for ImapEngine {
|
||||
async fn sync_folders(&self) -> Result<Vec<Folder>, MailError> {
|
||||
if let (Some(u), Some(p)) = (&self.username, &self.password) {
|
||||
let mut session = self.socket_manager.connect_session(u, p).await?;
|
||||
let mut mailboxes = session
|
||||
.list(None, Some("*"))
|
||||
.await
|
||||
.map_err(|e| MailError::ImapError(format!("failed to list mailboxes: {}", e)))?;
|
||||
let (u, p) = self
|
||||
.username
|
||||
.as_deref()
|
||||
.zip(self.password.as_deref())
|
||||
.ok_or_else(|| {
|
||||
MailError::AuthError(
|
||||
"no credentials configured for this IMAP engine instance".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut folders = Vec::new();
|
||||
while let Some(mb_res) = mailboxes.next().await {
|
||||
let mb = mb_res.map_err(|e| {
|
||||
MailError::ImapError(format!("failed reading mailbox item: {}", e))
|
||||
})?;
|
||||
let folder_name = mb.name().to_string();
|
||||
folders.push(Folder {
|
||||
id: folder_name.clone(),
|
||||
name: folder_name,
|
||||
total_messages: 0,
|
||||
unread_messages: 0,
|
||||
});
|
||||
}
|
||||
drop(mailboxes);
|
||||
let _ = session.logout().await;
|
||||
if !folders.is_empty() {
|
||||
return Ok(folders);
|
||||
}
|
||||
}
|
||||
let mut session = self.socket_manager.connect_session(u, p).await?;
|
||||
let mut mailboxes = session
|
||||
.list(None, Some("*"))
|
||||
.await
|
||||
.map_err(|e| MailError::ImapError(format!("failed to list mailboxes: {}", e)))?;
|
||||
|
||||
Ok(vec![
|
||||
Folder {
|
||||
id: "INBOX".to_string(),
|
||||
name: "Inbox".to_string(),
|
||||
total_messages: 10,
|
||||
unread_messages: 2,
|
||||
},
|
||||
Folder {
|
||||
id: "Sent".to_string(),
|
||||
name: "Sent Messages".to_string(),
|
||||
total_messages: 5,
|
||||
let mut folders = Vec::new();
|
||||
while let Some(mb_res) = mailboxes.next().await {
|
||||
let mb = mb_res
|
||||
.map_err(|e| MailError::ImapError(format!("failed reading mailbox item: {}", e)))?;
|
||||
let folder_name = mb.name().to_string();
|
||||
folders.push(Folder {
|
||||
id: folder_name.clone(),
|
||||
name: folder_name,
|
||||
total_messages: 0,
|
||||
unread_messages: 0,
|
||||
},
|
||||
])
|
||||
});
|
||||
}
|
||||
drop(mailboxes);
|
||||
let _ = session.logout().await;
|
||||
// A genuinely empty mailbox (or a server that reports zero
|
||||
// folders) is real data, not an error -- returning it as-is is the
|
||||
// honest result. Fabricating a non-empty "Inbox"/"Sent" fallback
|
||||
// here would silently lie about what the server actually has.
|
||||
Ok(folders)
|
||||
}
|
||||
|
||||
async fn sync_messages(
|
||||
@@ -506,8 +750,29 @@ impl MailBackend for ImapEngine {
|
||||
Ok((emails, modseq))
|
||||
}
|
||||
|
||||
/// Not wired to a real SMTP transport: production sends go through
|
||||
/// [`crate::smtp::SmtpTransportEngine`] directly (see its doc comment),
|
||||
/// never through this trait method. Rather than silently return `Ok(())`
|
||||
/// and let a caller believe mail was sent, this is an honest error.
|
||||
async fn send_email(&self, _email: &Email) -> Result<(), MailError> {
|
||||
Ok(())
|
||||
Err(MailError::NetworkError(
|
||||
"ImapEngine::send_email is not a real transport -- outbound mail goes through \
|
||||
SmtpTransportEngine"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn test_connection(&self) -> Result<(), MailError> {
|
||||
let (u, p) = self
|
||||
.username
|
||||
.as_deref()
|
||||
.zip(self.password.as_deref())
|
||||
.ok_or_else(|| {
|
||||
MailError::AuthError(
|
||||
"no credentials configured for this IMAP engine instance".to_string(),
|
||||
)
|
||||
})?;
|
||||
self.socket_manager.test_login(u, p).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,7 +782,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_dual_socket_manager_lifecycle() -> Result<(), MailError> {
|
||||
let manager = ImapDualSocketManager::new("mail.kof22.com", 993);
|
||||
let manager = ImapDualSocketManager::new("mail.kof22.com", 993, TlsMode::ImplicitTls);
|
||||
assert_eq!(manager.server_host(), "mail.kof22.com");
|
||||
assert_eq!(manager.server_port(), 993);
|
||||
assert_eq!(manager.idle_state(), IdleSocketState::Disconnected);
|
||||
@@ -531,19 +796,178 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_engine_sync_folders_and_messages() -> Result<(), MailError> {
|
||||
async fn imap_engine_without_credentials_honestly_errors_rather_than_fabricating_data() {
|
||||
// `ImapEngine::new` (no credentials) must never fabricate folders,
|
||||
// messages, or a successful send -- every `MailBackend` method must
|
||||
// surface a real "no credentials configured" error instead.
|
||||
let engine = ImapEngine::new("acct-1", "mail.kof22.com", 993);
|
||||
let folders = engine.sync_folders().await?;
|
||||
assert_eq!(folders.len(), 2);
|
||||
assert_eq!(folders[0].id, "INBOX");
|
||||
|
||||
let (emails, modseq) = engine.sync_messages("INBOX", None).await?;
|
||||
assert_eq!(modseq, "imap-modseq-1");
|
||||
assert_eq!(emails.len(), 1);
|
||||
assert_eq!(emails[0].id, "imap-uid-100");
|
||||
let folders_err = engine
|
||||
.sync_folders()
|
||||
.await
|
||||
.expect_err("sync_folders without credentials must error, not fabricate folders");
|
||||
assert!(matches!(folders_err, MailError::AuthError(_)));
|
||||
|
||||
engine.send_email(&emails[0]).await?;
|
||||
Ok(())
|
||||
let messages_err = engine
|
||||
.sync_messages("INBOX", None)
|
||||
.await
|
||||
.expect_err("sync_messages without credentials must error, not fabricate emails");
|
||||
assert!(matches!(messages_err, MailError::AuthError(_)));
|
||||
|
||||
let test_err = engine
|
||||
.test_connection()
|
||||
.await
|
||||
.expect_err("test_connection without credentials must error, not fabricate success");
|
||||
assert!(matches!(test_err, MailError::AuthError(_)));
|
||||
|
||||
let dummy_email = Email {
|
||||
id: "e1".to_string(),
|
||||
account_id: "acct-1".to_string(),
|
||||
folder_id: "INBOX".to_string(),
|
||||
subject: "s".to_string(),
|
||||
sender: "a@b.com".to_string(),
|
||||
recipient: "c@d.com".to_string(),
|
||||
received_at: 0,
|
||||
read: false,
|
||||
body_plain: None,
|
||||
body_html: None,
|
||||
attachments: Vec::new(),
|
||||
};
|
||||
let send_err = engine
|
||||
.send_email(&dummy_email)
|
||||
.await
|
||||
.expect_err("ImapEngine::send_email is not a real transport and must error");
|
||||
assert!(matches!(send_err, MailError::NetworkError(_)));
|
||||
}
|
||||
|
||||
/// Bind a loopback listener and run a minimal, single-connection,
|
||||
/// line-oriented IMAP responder: sends the initial greeting, then
|
||||
/// answers each tagged command according to `respond`. Used to prove
|
||||
/// `test_login`/`test_connection` genuinely speak the IMAP wire
|
||||
/// protocol (tag matching, greeting, LOGIN/STARTTLS/LOGOUT) end to end,
|
||||
/// rather than only being exercised against a real network server.
|
||||
///
|
||||
/// Deliberately plaintext-only: a TLS-terminating mock would need a
|
||||
/// vendored test certificate, which this harness does not set up, so
|
||||
/// `TlsMode::ImplicitTls`'s handshake step itself is not covered here --
|
||||
/// only `TlsMode::Plain` and the pre-upgrade half of `TlsMode::StartTls`
|
||||
/// are exercised against a real socket.
|
||||
async fn spawn_mock_imap_server(
|
||||
respond: impl Fn(&str, &str) -> String + Send + 'static,
|
||||
) -> std::net::SocketAddr {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback listener");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
|
||||
tokio::spawn(async move {
|
||||
let Ok((socket, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let (read_half, mut write_half) = socket.into_split();
|
||||
let mut reader = BufReader::new(read_half);
|
||||
if write_half
|
||||
.write_all(b"* OK mock IMAP ready\r\n")
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
let trimmed = line.trim_end();
|
||||
let tag = trimmed.split_whitespace().next().unwrap_or("*");
|
||||
let reply = respond(tag, trimmed);
|
||||
if write_half.write_all(reply.as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
if trimmed.to_uppercase().contains("LOGOUT") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
addr
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_login_succeeds_over_a_real_plain_socket() -> Result<(), MailError> {
|
||||
let addr = spawn_mock_imap_server(|tag, line| {
|
||||
if line.to_uppercase().contains("LOGIN") {
|
||||
format!("{} OK LOGIN completed\r\n", tag)
|
||||
} else if line.to_uppercase().contains("LOGOUT") {
|
||||
format!("* BYE logging out\r\n{} OK LOGOUT completed\r\n", tag)
|
||||
} else {
|
||||
format!("{} BAD unrecognized\r\n", tag)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let manager =
|
||||
ImapDualSocketManager::new(&addr.ip().to_string(), addr.port(), TlsMode::Plain);
|
||||
manager.test_login("user", "pass").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_login_surfaces_real_auth_error_on_login_rejection() {
|
||||
let addr = spawn_mock_imap_server(|tag, line| {
|
||||
if line.to_uppercase().contains("LOGIN") {
|
||||
format!("{} NO authentication failed\r\n", tag)
|
||||
} else {
|
||||
format!("{} BAD unrecognized\r\n", tag)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let manager =
|
||||
ImapDualSocketManager::new(&addr.ip().to_string(), addr.port(), TlsMode::Plain);
|
||||
let err = manager
|
||||
.test_login("user", "wrong-password")
|
||||
.await
|
||||
.expect_err("a server-rejected LOGIN must surface as a real error");
|
||||
assert!(matches!(err, MailError::AuthError(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_stream_start_tls_surfaces_honest_error_when_server_rejects_starttls() {
|
||||
let addr = spawn_mock_imap_server(|tag, line| {
|
||||
if line.to_uppercase().contains("STARTTLS") {
|
||||
format!("{} NO STARTTLS not supported\r\n", tag)
|
||||
} else {
|
||||
format!("{} BAD unrecognized\r\n", tag)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let err = connect_stream(&addr.ip().to_string(), addr.port(), TlsMode::StartTls)
|
||||
.await
|
||||
.expect_err("a server that rejects STARTTLS must be an honest error, never a silent plaintext fallback");
|
||||
assert!(matches!(err, MailError::TlsError(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_stream_reports_honest_error_for_unreachable_host() {
|
||||
// Bind then immediately drop a loopback listener: the OS reserves
|
||||
// the port momentarily, then refuses the next connection attempt --
|
||||
// deterministic "connection refused" without depending on any
|
||||
// specific hardcoded port that might collide with a real service.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
drop(listener);
|
||||
|
||||
let err = connect_stream(&addr.ip().to_string(), addr.port(), TlsMode::Plain)
|
||||
.await
|
||||
.expect_err("connecting to a closed port must be an honest error");
|
||||
assert!(matches!(err, MailError::NetworkError(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -288,6 +288,20 @@ impl MailBackend for JmapEngine {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `JmapEngine` has no real HTTP transport yet -- `sync_folders`/
|
||||
/// `sync_messages` above parse a fixed sample payload rather than
|
||||
/// calling a live server (see the real-JMAP-client backlog item). A
|
||||
/// fabricated "connection ok" here would be actively misleading for a
|
||||
/// settings-validation feature, so this is an honest "not supported"
|
||||
/// error instead.
|
||||
async fn test_connection(&self) -> Result<(), MailError> {
|
||||
Err(MailError::NetworkError(
|
||||
"JMAP account connection testing is not yet supported: the JMAP engine does not \
|
||||
perform real HTTP session discovery yet"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -114,6 +114,19 @@ impl MailBackend for MockMailBackend {
|
||||
sent.push(email.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn test_connection(&self) -> Result<(), MailError> {
|
||||
let should_fail = self
|
||||
.should_fail
|
||||
.lock()
|
||||
.map_err(|e| MailError::ParseFailed(e.to_string()))?;
|
||||
if *should_fail {
|
||||
return Err(MailError::ParseFailed(
|
||||
"simulated network failure".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic mock [`MessageSender`] for offline testing of the outbound
|
||||
|
||||
+114
-19
@@ -9,10 +9,16 @@ 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;
|
||||
|
||||
/// Upper bound on how long `SmtpTransportEngine::test_connection` may take
|
||||
/// before reporting an honest timeout, rather than waiting on `lettre`'s
|
||||
/// own default (which is tuned for a real send, not a fast settings check).
|
||||
const SMTP_TEST_CONNECTION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
impl From<LettreSmtpError> for MailError {
|
||||
fn from(err: LettreSmtpError) -> Self {
|
||||
let err_str = err.to_string();
|
||||
@@ -42,9 +48,16 @@ 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,
|
||||
/// honoring the account's configured [`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 })
|
||||
}
|
||||
|
||||
@@ -67,6 +80,35 @@ impl SmtpTransportEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that this transport's configured host/port/credentials can
|
||||
/// genuinely connect and authenticate, without sending any mail:
|
||||
/// delegates to `lettre`'s own `test_connection` (connect, TLS/STARTTLS,
|
||||
/// `AUTH`, then a `NOOP`), bounded by [`SMTP_TEST_CONNECTION_TIMEOUT`] so
|
||||
/// an unreachable host fails fast with an honest timeout error rather
|
||||
/// than blocking on `lettre`'s own (much longer) default.
|
||||
pub async fn test_connection(&self) -> Result<(), MailError> {
|
||||
let outcome = tokio::time::timeout(
|
||||
SMTP_TEST_CONNECTION_TIMEOUT,
|
||||
self.transport.test_connection(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
MailError::TransportFailed(format!(
|
||||
"SMTP connection test timed out after {}s",
|
||||
SMTP_TEST_CONNECTION_TIMEOUT.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(MailError::from)?;
|
||||
|
||||
if outcome {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(MailError::TransportFailed(
|
||||
"SMTP server did not confirm the connection as usable".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an email message using a provided [`AsyncSmtpTransport`] client instance.
|
||||
pub async fn send_email_with_transport(
|
||||
transport: &AsyncSmtpTransport<Tokio1Executor>,
|
||||
@@ -229,23 +271,42 @@ impl SmtpTransportEngine {
|
||||
|
||||
/// 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 honoring the account's
|
||||
/// configured `tls_mode` rather than guessing it from the port number:
|
||||
/// [`TlsMode::ImplicitTls`] wraps the socket in TLS immediately (the
|
||||
/// traditional port-465 style), [`TlsMode::StartTls`] requires the
|
||||
/// server to accept a `STARTTLS` upgrade (the traditional port-587
|
||||
/// style, but enforced regardless of port), and [`TlsMode::Plain`]
|
||||
/// never uses TLS at all (trusted local relays only).
|
||||
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 tls = match tls_mode {
|
||||
TlsMode::Plain => Tls::None,
|
||||
TlsMode::ImplicitTls => {
|
||||
let tls_params = TlsParameters::builder(host.to_string())
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
MailError::TransportFailed(format!("TLS parameters error: {}", e))
|
||||
})?;
|
||||
Tls::Wrapper(tls_params)
|
||||
}
|
||||
TlsMode::StartTls => {
|
||||
let tls_params = TlsParameters::builder(host.to_string())
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
MailError::TransportFailed(format!("TLS parameters error: {}", e))
|
||||
})?;
|
||||
Tls::Required(tls_params)
|
||||
}
|
||||
};
|
||||
|
||||
let transport = AsyncSmtpTransport::<Tokio1Executor>::relay(host)
|
||||
@@ -384,20 +445,39 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_transport_creates_starttls_client_for_port_587() {
|
||||
let transport = SmtpTransportEngine::build_transport("smtp.nuncio.mx", 587, "user", "pass");
|
||||
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");
|
||||
let transport = SmtpTransportEngine::build_transport(
|
||||
"smtp.nuncio.mx",
|
||||
465,
|
||||
TlsMode::ImplicitTls,
|
||||
"user",
|
||||
"pass",
|
||||
);
|
||||
assert!(transport.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_transport_creates_plain_client_for_local_relay() {
|
||||
let transport =
|
||||
SmtpTransportEngine::build_transport("127.0.0.1", 2525, TlsMode::Plain, "user", "pass");
|
||||
assert!(transport.is_ok());
|
||||
}
|
||||
|
||||
#[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)
|
||||
@@ -409,9 +489,24 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connection_to_unreachable_server_fails_honestly() {
|
||||
let engine = SmtpTransportEngine::new("127.0.0.1", 1, TlsMode::ImplicitTls, "user", "pass")
|
||||
.expect("valid config");
|
||||
let err = engine
|
||||
.test_connection()
|
||||
.await
|
||||
.expect_err("an unreachable SMTP server must not report a successful test");
|
||||
assert!(matches!(
|
||||
err,
|
||||
MailError::TransportFailed(_) | MailError::SmtpFailed(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[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 +601,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 +620,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,20 @@ 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 configuration by id.
|
||||
// Fails with NOT_FOUND if no account with that id is already persisted --
|
||||
// it never silently creates one, so a typo'd id can't produce a second,
|
||||
// unintended account.
|
||||
rpc UpdateAccount(UpdateAccountRequest) returns (UpdateAccountResponse);
|
||||
// RemoveAccount deletes a persisted account configuration and its OS
|
||||
// keyring credential by id. Fails with NOT_FOUND if no account with that
|
||||
// id exists, rather than silently reporting success.
|
||||
rpc RemoveAccount(RemoveAccountRequest) returns (RemoveAccountResponse);
|
||||
// TestAccountConnection validates that an account's settings genuinely
|
||||
// work: a real connect + authenticate against its configured mail
|
||||
// transport(s), with no sync/send side effects. Reports each transport's
|
||||
// real result independently -- never a fabricated blanket "ok".
|
||||
rpc TestAccountConnection(TestAccountConnectionRequest) returns (TestAccountConnectionResponse);
|
||||
}
|
||||
|
||||
// Mirrors `nuncio_core::AccountProtocol`.
|
||||
@@ -220,6 +234,55 @@ message ListAccountsResponse {
|
||||
repeated AccountConfig accounts = 1;
|
||||
}
|
||||
|
||||
// UpdateAccountRequest carries the full replacement configuration for the
|
||||
// account identified by `config.id`. `password`, if non-empty, rotates the
|
||||
// OS keyring credential; if empty, the existing credential is left
|
||||
// untouched -- there is no other way to distinguish "no change" from "set
|
||||
// to an empty password" with a bare proto3 string, and an empty mail
|
||||
// password is never a legitimate account state.
|
||||
message UpdateAccountRequest {
|
||||
AccountConfig config = 1;
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
// UpdateAccountResponse deliberately carries only the updated account's id
|
||||
// (callers already have the full config they just sent).
|
||||
message UpdateAccountResponse {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
// RemoveAccountRequest identifies the account to delete by id.
|
||||
message RemoveAccountRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
// RemoveAccountResponse carries no payload: success is the response
|
||||
// arriving at all, and failure (including NOT_FOUND for an unknown id)
|
||||
// surfaces as a gRPC error status instead.
|
||||
message RemoveAccountResponse {}
|
||||
|
||||
// TestAccountConnectionRequest identifies the account to test by id.
|
||||
// `password`, if non-empty, tests that candidate credential directly
|
||||
// (e.g. validating settings before `AddAccount`/`UpdateAccount` persists
|
||||
// them); if empty, the daemon resolves the already-stored keyring
|
||||
// credential for `id` and tests that instead.
|
||||
message TestAccountConnectionRequest {
|
||||
string id = 1;
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
// TestAccountConnectionResponse reports the inbound (IMAP/JMAP) and
|
||||
// outbound (SMTP) transport legs independently and honestly: a
|
||||
// protocol/transport that cannot yet be genuinely tested (e.g. JMAP, which
|
||||
// has no real HTTP client yet) reports `ok = false` with an explanatory
|
||||
// `error`, never a fabricated `true`.
|
||||
message TestAccountConnectionResponse {
|
||||
bool inbound_ok = 1;
|
||||
string inbound_error = 2;
|
||||
bool outbound_ok = 3;
|
||||
string outbound_error = 4;
|
||||
}
|
||||
|
||||
// Mail exposes the READ path over the daemon's real, persistent message
|
||||
// store (the data that syncs in via `DatabaseEngine::save_email`).
|
||||
//
|
||||
|
||||
@@ -67,6 +67,16 @@ impl DatabaseError {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this error is specifically "no row matched the
|
||||
/// query" (e.g. `get_message`/`get_account` looked up an id that
|
||||
/// doesn't exist), as opposed to any other query/connection failure.
|
||||
/// Callers use this to distinguish an honest NOT_FOUND from a genuine
|
||||
/// operational error, without needing `sqlx` as a direct dependency
|
||||
/// themselves.
|
||||
pub fn is_row_not_found(&self) -> bool {
|
||||
matches!(self, DatabaseError::Query(sqlx::Error::RowNotFound))
|
||||
}
|
||||
}
|
||||
|
||||
/// SQLite database storage engine managing WAL connection pools and migrations.
|
||||
@@ -526,6 +536,7 @@ 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(())
|
||||
@@ -571,6 +582,39 @@ impl DatabaseEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Additive, backfill-safe migration that adds the `imap_tls_mode` /
|
||||
/// `smtp_tls_mode` columns to the `accounts` table. Before this, the
|
||||
/// TLS mode chosen at `account add`/`edit` time was validated but never
|
||||
/// actually persisted -- [`Self::list_accounts`] silently reloaded every
|
||||
/// account as [`nuncio_core::TlsMode::ImplicitTls`] regardless of what
|
||||
/// was configured. `NULL`-able so pre-existing rows keep loading; see
|
||||
/// [`Self::list_accounts`] for the same-default backfill this implies
|
||||
/// for rows written before this migration.
|
||||
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")
|
||||
.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")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Backfill `messages_fts` for any `messages` row that does not yet have a matching FTS
|
||||
/// entry -- e.g. rows written before the FTS5 index existed, or written directly by a
|
||||
/// process that bypassed `save_email`'s explicit index population. Run automatically as
|
||||
@@ -611,17 +655,22 @@ impl DatabaseEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save an [`nuncio_core::AccountConfig`] to SQLite.
|
||||
/// Save an [`nuncio_core::AccountConfig`] to SQLite. Upserts by `id`
|
||||
/// (`INSERT OR REPLACE`): also used by the daemon's `UpdateAccount` RPC
|
||||
/// to persist an edited config, after the caller has already confirmed
|
||||
/// the account exists (see [`Self::get_account`]).
|
||||
pub async fn save_account(
|
||||
&self,
|
||||
config: &nuncio_core::AccountConfig,
|
||||
) -> Result<(), DatabaseError> {
|
||||
let protocol_str = serde_json::to_string(&config.protocol).unwrap_or_default();
|
||||
let imap_tls_mode_str = serde_json::to_string(&config.imap_tls_mode).unwrap_or_default();
|
||||
let smtp_tls_mode_str = serde_json::to_string(&config.smtp_tls_mode).unwrap_or_default();
|
||||
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)
|
||||
@@ -635,6 +684,8 @@ impl DatabaseEngine {
|
||||
.bind(config.sync_interval_secs as i64)
|
||||
.bind(&config.smtp_host)
|
||||
.bind(config.smtp_port as i64)
|
||||
.bind(imap_tls_mode_str)
|
||||
.bind(smtp_tls_mode_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
@@ -642,6 +693,32 @@ impl DatabaseEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a single saved account by `id`. Used by the daemon's
|
||||
/// `UpdateAccount`/`TestAccountConnection` RPCs to confirm an account
|
||||
/// genuinely exists (and to read its current config) before acting on
|
||||
/// it, rather than silently upserting a new row under an edit request
|
||||
/// or silently no-op'ing a test request for an unknown id.
|
||||
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 saved account by `id`. Returns `true` if a row was actually
|
||||
/// removed, `false` if no account with that `id` existed -- callers
|
||||
/// (the daemon's `RemoveAccount` RPC) use this to return an honest
|
||||
/// "not found" rather than silently reporting success for an id that
|
||||
/// was never there.
|
||||
pub async fn delete_account(&self, id: &str) -> Result<bool, DatabaseError> {
|
||||
let result = sqlx::query("DELETE FROM accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Query all saved accounts in SQLite.
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub async fn list_accounts(&self) -> Result<Vec<nuncio_core::AccountConfig>, DatabaseError> {
|
||||
@@ -657,9 +734,11 @@ impl DatabaseEngine {
|
||||
i64,
|
||||
Option<String>,
|
||||
Option<i64>,
|
||||
Option<String>,
|
||||
Option<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
|
||||
"#,
|
||||
)
|
||||
@@ -682,6 +761,8 @@ impl DatabaseEngine {
|
||||
sync_interval_secs,
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
imap_tls_mode_str,
|
||||
smtp_tls_mode_str,
|
||||
)| {
|
||||
let protocol = serde_json::from_str(&protocol_str)
|
||||
.unwrap_or(nuncio_core::AccountProtocol::ImapSmtp);
|
||||
@@ -696,6 +777,18 @@ impl DatabaseEngine {
|
||||
let resolved_smtp_host = smtp_host.unwrap_or_else(|| server_host.clone());
|
||||
let resolved_smtp_port =
|
||||
smtp_port.map(|p| p as u16).unwrap_or(server_port as u16);
|
||||
// Same backfill-safe story for `imap_tls_mode`/
|
||||
// `smtp_tls_mode`: `NULL` for any row written before
|
||||
// `Self::ensure_accounts_tls_mode_columns` ran, or if the
|
||||
// stored value somehow fails to parse. `ImplicitTls` is
|
||||
// the traditional default (matches the CLI's own
|
||||
// `account add` default), not a fabricated guess.
|
||||
let imap_tls_mode = imap_tls_mode_str
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(nuncio_core::TlsMode::ImplicitTls);
|
||||
let smtp_tls_mode = smtp_tls_mode_str
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(nuncio_core::TlsMode::ImplicitTls);
|
||||
nuncio_core::AccountConfig {
|
||||
id,
|
||||
name,
|
||||
@@ -706,8 +799,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,
|
||||
smtp_tls_mode,
|
||||
keyring_secret_key,
|
||||
sync_interval_secs: sync_interval_secs as u64,
|
||||
}
|
||||
@@ -2114,6 +2207,183 @@ mod tests {
|
||||
assert_eq!(accounts[0].smtp_port, 465);
|
||||
}
|
||||
|
||||
/// Proves `imap_tls_mode`/`smtp_tls_mode` genuinely round-trip through
|
||||
/// SQLite rather than being silently reset to `ImplicitTls` on every
|
||||
/// reload (the bug this test would have caught: `list_accounts` used to
|
||||
/// hardcode `ImplicitTls` unconditionally, ignoring whatever was
|
||||
/// actually saved). Uses `StartTls`/`Plain` specifically because
|
||||
/// `ImplicitTls` is also the backfill default, so it alone wouldn't
|
||||
/// distinguish "persisted correctly" from "silently defaulted".
|
||||
#[tokio::test]
|
||||
async fn tls_mode_round_trips_through_save_and_list() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let acct = nuncio_core::AccountConfig {
|
||||
id: "acct-tls-1".to_string(),
|
||||
name: "STARTTLS Account".to_string(),
|
||||
email_address: "starttls@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: 587,
|
||||
use_tls: true,
|
||||
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 account succeeds");
|
||||
let accounts = engine
|
||||
.list_accounts()
|
||||
.await
|
||||
.expect("list accounts succeeds");
|
||||
assert_eq!(accounts.len(), 1);
|
||||
assert_eq!(accounts[0].imap_tls_mode, nuncio_core::TlsMode::StartTls);
|
||||
assert_eq!(accounts[0].smtp_tls_mode, nuncio_core::TlsMode::Plain);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_account_finds_by_id_and_reports_none_for_unknown_id() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
let acct = nuncio_core::AccountConfig {
|
||||
id: "acct-get-1".to_string(),
|
||||
name: "Get Account".to_string(),
|
||||
email_address: "get@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-get-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
};
|
||||
engine.save_account(&acct).await.expect("save succeeds");
|
||||
|
||||
let found = engine
|
||||
.get_account("acct-get-1")
|
||||
.await
|
||||
.expect("get_account succeeds")
|
||||
.expect("account exists");
|
||||
assert_eq!(found.email_address, "get@nuncio.mx");
|
||||
|
||||
let missing = engine
|
||||
.get_account("acct-does-not-exist")
|
||||
.await
|
||||
.expect("get_account succeeds even for an unknown id");
|
||||
assert!(missing.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_account_removes_row_and_reports_false_for_unknown_id() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
let acct = nuncio_core::AccountConfig {
|
||||
id: "acct-del-1".to_string(),
|
||||
name: "Delete Account".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");
|
||||
|
||||
let deleted_unknown = engine
|
||||
.delete_account("acct-does-not-exist")
|
||||
.await
|
||||
.expect("delete_account succeeds even for an unknown id");
|
||||
assert!(
|
||||
!deleted_unknown,
|
||||
"deleting a nonexistent id must report false, not silently succeed"
|
||||
);
|
||||
assert_eq!(engine.list_accounts().await.unwrap().len(), 1);
|
||||
|
||||
let deleted_real = engine
|
||||
.delete_account("acct-del-1")
|
||||
.await
|
||||
.expect("delete_account succeeds");
|
||||
assert!(deleted_real);
|
||||
assert_eq!(engine.list_accounts().await.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
/// Proves the additive `imap_tls_mode`/`smtp_tls_mode` migration is
|
||||
/// backfill-safe, mirroring
|
||||
/// `migrate_backfills_smtp_columns_for_a_pre_existing_accounts_table`
|
||||
/// below: a database created before this feature existed (no TLS-mode
|
||||
/// columns at all) must still open and load its existing row, falling
|
||||
/// back to `ImplicitTls` for both fields rather than erroring.
|
||||
#[tokio::test]
|
||||
async fn migrate_backfills_tls_mode_columns_for_a_pre_existing_accounts_table() {
|
||||
let dir = tempfile::tempdir().expect("create temp dir");
|
||||
let db_path = dir.path().join("legacy.db");
|
||||
let secrets = crate::vault::SecretManager::mock();
|
||||
|
||||
{
|
||||
let url = format!("sqlite://{}", db_path.to_string_lossy());
|
||||
let options = sqlx::sqlite::SqliteConnectOptions::from_str(&url)
|
||||
.expect("valid sqlite url")
|
||||
.create_if_missing(true);
|
||||
let pool = sqlx::SqlitePool::connect_with(options)
|
||||
.await
|
||||
.expect("connect to fresh old-schema db");
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email_address TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
server_host TEXT NOT NULL,
|
||||
server_port INTEGER NOT NULL,
|
||||
use_tls INTEGER NOT NULL,
|
||||
keyring_secret_key TEXT NOT NULL,
|
||||
sync_interval_secs INTEGER NOT NULL,
|
||||
smtp_host TEXT,
|
||||
smtp_port INTEGER
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("create legacy accounts table");
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO accounts
|
||||
(id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port)
|
||||
VALUES ('acct-legacy', 'Legacy', 'legacy@nuncio.mx', '"imap-smtp"', 'imap.nuncio.mx', 993, 1, 'nuncio/acct-legacy', 60, 'smtp.nuncio.mx', 465)
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert legacy row");
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
let engine = DatabaseEngine::connect_file(&db_path, &secrets)
|
||||
.await
|
||||
.expect("opening a pre-tls-mode-column database must not error");
|
||||
let accounts = engine
|
||||
.list_accounts()
|
||||
.await
|
||||
.expect("listing accounts on a backfilled database must not error");
|
||||
assert_eq!(accounts.len(), 1);
|
||||
assert_eq!(accounts[0].imap_tls_mode, nuncio_core::TlsMode::ImplicitTls);
|
||||
assert_eq!(accounts[0].smtp_tls_mode, nuncio_core::TlsMode::ImplicitTls);
|
||||
}
|
||||
|
||||
/// 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`
|
||||
@@ -2235,6 +2505,28 @@ mod tests {
|
||||
engine.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_message_reports_row_not_found_for_a_missing_id() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let err = engine
|
||||
.get_message("msg-does-not-exist")
|
||||
.await
|
||||
.expect_err("looking up a missing message id must be an error");
|
||||
assert!(
|
||||
err.is_row_not_found(),
|
||||
"a missing row must be classified as row-not-found, not some other query error"
|
||||
);
|
||||
assert!(!err.is_corrupt(), "a missing row is not corruption");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_row_not_found_only_matches_the_specific_sqlx_row_not_found_variant() {
|
||||
assert!(DatabaseError::Query(sqlx::Error::RowNotFound).is_row_not_found());
|
||||
assert!(!DatabaseError::PoolCreation("x".to_string()).is_row_not_found());
|
||||
assert!(!DatabaseError::Corrupted("x".to_string()).is_row_not_found());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_error_display() {
|
||||
let err = DatabaseError::PoolCreation("failed".to_string());
|
||||
|
||||
+413
-11
@@ -7,7 +7,7 @@
|
||||
|
||||
use nuncio_core::{CoreCommand, CoreEvent, EventBus};
|
||||
use nuncio_filter::{FilterEngine, NsqlParser, NsqlValidator, ValidationOptions};
|
||||
use nuncio_mail::{MailBackend, MessageSender};
|
||||
use nuncio_mail::{MailBackend, MessageSender, SmtpTransportEngine};
|
||||
use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer};
|
||||
use nuncio_proto::v1::audit_server::{Audit, AuditServer};
|
||||
use nuncio_proto::v1::event::Kind;
|
||||
@@ -26,10 +26,12 @@ use nuncio_proto::v1::{
|
||||
ListFoldersRequest, 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, UpdateAvailable,
|
||||
ValidateRuleRequest, ValidateRuleResponse, VerifyChainRequest, VerifyChainResponse,
|
||||
PreviewRuleRequest, PreviewRuleResponse, RemoveAccountRequest, RemoveAccountResponse,
|
||||
SearchMessagesRequest, SearchMessagesResponse, SendMessageRequest, SendMessageResponse,
|
||||
ShuttingDown, SubscribeRequest, SyncCompleted, SyncRequest, SyncResponse, SyncStarted,
|
||||
TestAccountConnectionRequest, TestAccountConnectionResponse, TlsMode as TlsModeProto,
|
||||
UpdateAccountRequest, UpdateAccountResponse, UpdateAvailable, ValidateRuleRequest,
|
||||
ValidateRuleResponse, VerifyChainRequest, VerifyChainResponse,
|
||||
};
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::search::SearchEngine;
|
||||
@@ -354,6 +356,137 @@ 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()))?;
|
||||
|
||||
// Never silently create an account under an edit request: an id
|
||||
// that doesn't already exist is a caller error, not an implicit
|
||||
// add.
|
||||
self.db
|
||||
.get_account(&config.id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to look up account: {e}")))?
|
||||
.ok_or_else(|| Status::not_found(format!("no account with id '{}'", config.id)))?;
|
||||
|
||||
// Only rotate the keyring credential when a new password was
|
||||
// actually supplied -- an empty `password` means "leave the
|
||||
// existing credential untouched", never "set it to empty".
|
||||
if !req.password.is_empty() {
|
||||
self.secrets
|
||||
.set_secret(&config.keyring_secret_key, &req.password)
|
||||
.map_err(|e| {
|
||||
Status::internal(format!("failed to rotate credential in vault: {e}"))
|
||||
})?;
|
||||
}
|
||||
|
||||
self.db
|
||||
.save_account(&config)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to persist account: {e}")))?;
|
||||
|
||||
Ok(Response::new(UpdateAccountResponse { id: config.id }))
|
||||
}
|
||||
|
||||
async fn remove_account(
|
||||
&self,
|
||||
request: Request<RemoveAccountRequest>,
|
||||
) -> Result<Response<RemoveAccountResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
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!("no account with id '{}'", req.id)))?;
|
||||
|
||||
self.db
|
||||
.delete_account(&req.id)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to delete account: {e}")))?;
|
||||
|
||||
// The account row is already gone at this point; a credential
|
||||
// cleanup failure is reported rather than swallowed so the caller
|
||||
// knows a keyring entry was left behind, but it deliberately does
|
||||
// not attempt to resurrect the just-deleted row over it.
|
||||
self.secrets
|
||||
.delete_secret(&existing.keyring_secret_key)
|
||||
.map_err(|e| {
|
||||
Status::internal(format!(
|
||||
"account '{}' was removed, but its keyring credential could not be \
|
||||
cleared: {e}",
|
||||
req.id
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Response::new(RemoveAccountResponse {}))
|
||||
}
|
||||
|
||||
async fn test_account_connection(
|
||||
&self,
|
||||
request: Request<TestAccountConnectionRequest>,
|
||||
) -> Result<Response<TestAccountConnectionResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
|
||||
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!("no account with id '{}'", req.id)))?;
|
||||
|
||||
let password = if !req.password.is_empty() {
|
||||
req.password
|
||||
} else {
|
||||
self.secrets
|
||||
.get_secret(&config.keyring_secret_key)
|
||||
.map_err(|e| {
|
||||
Status::internal(format!("failed to read credential from vault: {e}"))
|
||||
})?
|
||||
};
|
||||
|
||||
// A connection that genuinely fails is real, honest response DATA
|
||||
// -- not an RPC-level error -- so both legs are tested and reported
|
||||
// independently rather than short-circuiting on the first failure.
|
||||
let backend = crate::sync::build_mail_backend(&config, &password);
|
||||
let (inbound_ok, inbound_error) = match backend.test_connection().await {
|
||||
Ok(()) => (true, String::new()),
|
||||
Err(e) => (false, e.to_string()),
|
||||
};
|
||||
|
||||
let (outbound_ok, outbound_error) = match SmtpTransportEngine::new(
|
||||
&config.smtp_host,
|
||||
config.smtp_port,
|
||||
config.smtp_tls_mode,
|
||||
&config.email_address,
|
||||
&password,
|
||||
) {
|
||||
Ok(engine) => match engine.test_connection().await {
|
||||
Ok(()) => (true, String::new()),
|
||||
Err(e) => (false, e.to_string()),
|
||||
},
|
||||
Err(e) => (false, e.to_string()),
|
||||
};
|
||||
|
||||
Ok(Response::new(TestAccountConnectionResponse {
|
||||
inbound_ok,
|
||||
inbound_error,
|
||||
outbound_ok,
|
||||
outbound_error,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Default cap on messages returned by `ListMessages` when the caller
|
||||
@@ -891,10 +1024,25 @@ impl Filters for FiltersGrpcService {
|
||||
.map_err(|e| Status::internal(format!("failed to build preview engine: {e}")))?;
|
||||
|
||||
let email = match &req.message_id {
|
||||
// A real, resolvable `message_id` must evaluate against that
|
||||
// REAL message -- a lookup failure here must be an honest
|
||||
// error, never a fake email silently stamped with the id the
|
||||
// caller asked for. Only the "no message_id given at all" case
|
||||
// below intentionally falls back to a synthetic sample, and
|
||||
// that fallback is documented on `PreviewRuleResponse` itself.
|
||||
Some(message_id) if !message_id.is_empty() => {
|
||||
match self.db.get_message(message_id).await {
|
||||
Ok(email) => email,
|
||||
Err(_) => synthetic_preview_email(message_id),
|
||||
Err(e) if e.is_row_not_found() => {
|
||||
return Err(Status::not_found(format!(
|
||||
"no message with id '{message_id}'"
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(Status::internal(format!(
|
||||
"failed to look up message '{message_id}': {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => synthetic_preview_email("msg-test"),
|
||||
@@ -1732,6 +1880,243 @@ mod tests {
|
||||
assert_eq!(err.code(), Code::InvalidArgument);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_account_rejects_missing_bearer_token() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.update_account(UpdateAccountRequest {
|
||||
config: Some(sample_account_config_proto(
|
||||
"acct-noauth",
|
||||
"nuncio/acct-noauth",
|
||||
)),
|
||||
password: String::new(),
|
||||
})
|
||||
.await
|
||||
.expect_err("missing bearer token must be rejected");
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_account_returns_not_found_for_unknown_id() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.update_account(authed_bearer_request(UpdateAccountRequest {
|
||||
config: Some(sample_account_config_proto(
|
||||
"acct-does-not-exist",
|
||||
"nuncio/acct-does-not-exist",
|
||||
)),
|
||||
password: String::new(),
|
||||
}))
|
||||
.await
|
||||
.expect_err("editing an unknown account id must never silently create one");
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_account_persists_changes_without_touching_password_when_empty() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
client
|
||||
.add_account(authed_bearer_request(AddAccountRequest {
|
||||
config: Some(sample_account_config_proto(
|
||||
"acct-edit-1",
|
||||
"nuncio/acct-edit-1",
|
||||
)),
|
||||
password: "original-password".to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect("add succeeds");
|
||||
|
||||
let mut edited = sample_account_config_proto("acct-edit-1", "nuncio/acct-edit-1");
|
||||
edited.server_host = "imap.updated-host.example".to_string();
|
||||
edited.imap_tls_mode = TlsModeProto::StartTls.into();
|
||||
client
|
||||
.update_account(authed_bearer_request(UpdateAccountRequest {
|
||||
config: Some(edited),
|
||||
password: String::new(),
|
||||
}))
|
||||
.await
|
||||
.expect("update succeeds");
|
||||
|
||||
let listed = client
|
||||
.list_accounts(authed_bearer_request(ListAccountsRequest {}))
|
||||
.await
|
||||
.expect("list succeeds")
|
||||
.into_inner()
|
||||
.accounts;
|
||||
assert_eq!(listed.len(), 1, "update must not create a second account");
|
||||
assert_eq!(listed[0].server_host, "imap.updated-host.example");
|
||||
assert_eq!(listed[0].imap_tls_mode(), TlsModeProto::StartTls);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_account_rejects_missing_bearer_token() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.remove_account(RemoveAccountRequest {
|
||||
id: "acct-noauth".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("missing bearer token must be rejected");
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_account_returns_not_found_for_unknown_id() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.remove_account(authed_bearer_request(RemoveAccountRequest {
|
||||
id: "acct-does-not-exist".to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect_err("deleting an unknown account id must be an honest error, not a silent no-op success");
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_account_deletes_the_row_and_its_keyring_credential() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
client
|
||||
.add_account(authed_bearer_request(AddAccountRequest {
|
||||
config: Some(sample_account_config_proto(
|
||||
"acct-del-1",
|
||||
"nuncio/acct-del-1",
|
||||
)),
|
||||
password: "some-password".to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect("add succeeds");
|
||||
|
||||
client
|
||||
.remove_account(authed_bearer_request(RemoveAccountRequest {
|
||||
id: "acct-del-1".to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect("remove succeeds");
|
||||
|
||||
let listed = client
|
||||
.list_accounts(authed_bearer_request(ListAccountsRequest {}))
|
||||
.await
|
||||
.expect("list succeeds")
|
||||
.into_inner()
|
||||
.accounts;
|
||||
assert!(listed.is_empty());
|
||||
|
||||
// Removing it again must be an honest NOT_FOUND, not a second
|
||||
// silent success -- proves the row is genuinely gone, not just
|
||||
// hidden.
|
||||
let err = client
|
||||
.remove_account(authed_bearer_request(RemoveAccountRequest {
|
||||
id: "acct-del-1".to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect_err("the account was already removed");
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_account_connection_rejects_missing_bearer_token() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.test_account_connection(TestAccountConnectionRequest {
|
||||
id: "acct-noauth".to_string(),
|
||||
password: String::new(),
|
||||
})
|
||||
.await
|
||||
.expect_err("missing bearer token must be rejected");
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_account_connection_returns_not_found_for_unknown_id() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.test_account_connection(authed_bearer_request(TestAccountConnectionRequest {
|
||||
id: "acct-does-not-exist".to_string(),
|
||||
password: String::new(),
|
||||
}))
|
||||
.await
|
||||
.expect_err("testing an unknown account id must be an honest error");
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_account_connection_reports_real_failure_never_a_fabricated_ok() {
|
||||
// `sample_account_config_proto` points at `imap.nuncio.mx`/
|
||||
// `smtp.nuncio.mx`, which resolve to nothing reachable in this test
|
||||
// environment -- proving the RPC reports the real connect failure
|
||||
// on both legs instead of a fabricated success.
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
client
|
||||
.add_account(authed_bearer_request(AddAccountRequest {
|
||||
config: Some(sample_account_config_proto(
|
||||
"acct-unreachable",
|
||||
"nuncio/acct-unreachable",
|
||||
)),
|
||||
password: "some-password".to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect("add succeeds");
|
||||
|
||||
let response = client
|
||||
.test_account_connection(authed_bearer_request(TestAccountConnectionRequest {
|
||||
id: "acct-unreachable".to_string(),
|
||||
password: String::new(),
|
||||
}))
|
||||
.await
|
||||
.expect("the RPC itself succeeds even though the connection test fails")
|
||||
.into_inner();
|
||||
|
||||
assert!(!response.inbound_ok);
|
||||
assert!(!response.inbound_error.is_empty());
|
||||
assert!(!response.outbound_ok);
|
||||
assert!(!response.outbound_error.is_empty());
|
||||
}
|
||||
|
||||
/// Proves an account added through the daemon persists across a daemon
|
||||
/// restart. Simulates the restart by
|
||||
/// dropping the first `DatabaseEngine`/server pair and opening a brand
|
||||
@@ -2969,6 +3354,9 @@ mod tests {
|
||||
.await
|
||||
.expect("client connects");
|
||||
|
||||
// No `message_id` at all is the ONLY case that legitimately falls
|
||||
// back to the fixed synthetic sample message -- documented on
|
||||
// `PreviewRuleResponse` itself.
|
||||
let response = client
|
||||
.preview_rule(authed_bearer_request(PreviewRuleRequest {
|
||||
nsql: "WHERE subject CONTAINS 'Test Subject' ACTION FLAG".to_string(),
|
||||
@@ -2979,17 +3367,31 @@ mod tests {
|
||||
.into_inner();
|
||||
assert!(response.matched);
|
||||
assert_eq!(response.message_id, "msg-test");
|
||||
}
|
||||
|
||||
let response_missing_id = client
|
||||
#[tokio::test]
|
||||
async fn preview_rule_reports_honest_not_found_for_an_unresolvable_message_id() {
|
||||
// A caller-supplied `message_id` that doesn't exist must be an
|
||||
// honest NOT_FOUND -- never a fake email silently stamped with the
|
||||
// id the caller asked for (that would make a genuine "found and
|
||||
// evaluated your real message" indistinguishable from "made one up
|
||||
// and gave it your id").
|
||||
let (addr, _handle, _db, _engine) = spawn_filters_test_server("correct-token").await;
|
||||
|
||||
let mut client = FiltersClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
|
||||
let err = client
|
||||
.preview_rule(authed_bearer_request(PreviewRuleRequest {
|
||||
nsql: "WHERE subject CONTAINS 'Test Subject' ACTION FLAG".to_string(),
|
||||
message_id: Some("does-not-exist".to_string()),
|
||||
}))
|
||||
.await
|
||||
.expect("preview_rule succeeds with an unresolvable message_id")
|
||||
.into_inner();
|
||||
assert!(response_missing_id.matched);
|
||||
assert_eq!(response_missing_id.message_id, "does-not-exist");
|
||||
.expect_err(
|
||||
"an unresolvable message_id must be an honest error, not a fabricated match",
|
||||
);
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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,
|
||||
)?;
|
||||
|
||||
@@ -123,12 +123,13 @@ async fn find_account(
|
||||
/// keyring by the caller). This is the ONLY place a concrete protocol engine
|
||||
/// is chosen; every other function in this module only ever sees `&dyn
|
||||
/// MailBackend`.
|
||||
fn build_mail_backend(config: &AccountConfig, password: &str) -> Box<dyn MailBackend> {
|
||||
pub(crate) fn build_mail_backend(config: &AccountConfig, password: &str) -> Box<dyn MailBackend> {
|
||||
match config.protocol {
|
||||
AccountProtocol::ImapSmtp => Box::new(ImapEngine::with_credentials(
|
||||
&config.id,
|
||||
&config.server_host,
|
||||
config.server_port,
|
||||
config.imap_tls_mode,
|
||||
&config.email_address,
|
||||
password,
|
||||
)),
|
||||
|
||||
Reference in new issue
Block a user