Compare commits

...
Author SHA1 Message Date
James Maes 16d0a11012 feat(mail): instrument IMAP sync + bound per-item FETCH stalls
Investigated a real "mail sync hangs" report: it wasn't a hang. Live
instrumentation against the real account showed sustained, correct
progress (~625 msgs/sec, zero stalls) through a 59,771-message folder
that a single unbatched `UID FETCH 1:* BODY.PEEK[]` re-downloads in
full every sync. The CLI's gRPC client has no request timeout and
there was no progress feedback at all, so a multi-minute sync of a
mailbox this size looked identical to an infinite hang.

- Log SELECT/UID FETCH acknowledgement (folder, message count,
  elapsed) and progress every 25 fetched messages, so a genuinely
  slow-but-progressing sync is now visibly distinguishable from a
  stalled one.
- Bound each individual FETCH response item by 60s: if the server
  genuinely stops responding mid-stream, that's now an honest timeout
  error instead of hanging indefinitely (the original hypothesis,
  just not what was actually happening here).

Real incremental sync (since_state is currently accepted and ignored
-- every sync re-fetches full history) is a separate, larger fix;
filed as #184.

Refs #183
2026-07-27 14:33:39 -05:00
James Maes 73518f849b feat(accounts): real edit/delete/test-connection, fix TLS-mode gaps
account edit/delete/test were pure fabrication: Edit/Delete returned
canned success strings without touching the daemon, and Test always
reported {"status":"ok","latency_ms":24} without attempting a
connection. account show read from the CLI's own always-empty
ephemeral local DB instead of the daemon.

- proto: add Accounts.UpdateAccount/RemoveAccount/TestAccountConnection
  RPCs (additive within nuncio.v1; golden descriptor regenerated).
- store: add get_account/delete_account; fix imap_tls_mode/smtp_tls_mode
  being validated at add time but never persisted (list_accounts
  silently reloaded every account as ImplicitTls regardless of what was
  configured); add is_row_not_found to distinguish a missing row from
  other query errors.
- mail: add MailBackend::test_connection (real for IMAP, honest
  "not supported" for JMAP, controllable for the mock); wire a proper
  TlsMode-aware connection layer (MaybeTlsStream) so ImplicitTls/
  StartTls/Plain are all real end to end, not just accepted and
  ignored; add a bounded connect/TLS timeout to the IMAP transport
  (previously unbounded -- could hang indefinitely against an
  unreachable host); fix ImapEngine::sync_folders/sync_folder_messages
  silently fabricating fake folder/message data whenever the real IMAP
  result was empty or credentials were unset, instead of the real
  (possibly empty) result or an honest error.
- daemon: implement the three new RPC handlers; fix Filters.PreviewRule
  silently substituting a fake email (stamped with the caller's real
  requested id) when a message lookup failed, instead of NOT_FOUND.
- cli: account edit/delete/test/show now real gRPC clients; edit merges
  only the flags actually passed onto the existing config and gains
  --imap-mode/--smtp-mode/--rotate-password.

Verified live against a real IMAP/SMTP account (both legs authenticate
successfully). Full workspace test/clippy/fmt green.

Refs #182
2026-07-27 14:00:53 -05:00
14 changed files with 2128 additions and 183 deletions

No files matched your search

+15
View File
@@ -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 {
+18
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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]
+14
View File
@@ -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)]
+13
View File
@@ -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
View File
@@ -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`).
//
+298 -6
View File
@@ -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
View File
@@ -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]
+1
View File
@@ -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,
)?;
+2 -1
View File
@@ -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,
)),