Promote to main: M5 (Mail lifecycle + incremental sync) #235

Merged
KofTwentyTwo merged 5 commits from dev into main 2026-07-30 03:00:46 +00:00
22 changed files with 3041 additions and 243 deletions

No files matched your search

+10
View File
@@ -258,6 +258,12 @@ pub enum AccountSubcommand {
/// Updated SMTP server port.
#[arg(long, help = "Updated SMTP server port")]
smtp_port: Option<u16>,
/// Updated IMAP transport mode (implicit_tls, start_tls, plain).
#[arg(long, help = "Updated IMAP transport mode")]
imap_mode: Option<String>,
/// Updated SMTP transport mode (implicit_tls, start_tls, plain).
#[arg(long, help = "Updated SMTP transport mode")]
smtp_mode: Option<String>,
},
/// Remove a configured account profile.
Delete {
@@ -302,6 +308,10 @@ pub enum MailSubcommand {
/// Message body text content.
#[arg(short, long, help = "Message body text content")]
body: String,
/// Identifier of the account to send from (defaults to the first
/// configured account when omitted).
#[arg(short, long, help = "Identifier of the account to send from")]
account: Option<String>,
},
/// Full-text search across all cached email messages.
Search {
+397 -91
View File
@@ -291,52 +291,45 @@ impl HeadlessRunner {
)
.await
}
AccountSubcommand::Show { id } => {
let accounts = self.db.list_accounts().await.unwrap_or_default();
let acct = accounts.into_iter().find(|a| a.id == *id);
if json_mode {
format_json(&json!({ "account": acct }))
} else if let Some(a) = acct {
format!("Account {}: Email: {}", a.id, a.email_address)
} else {
format!("Account '{}' not found", id)
}
}
AccountSubcommand::Show { id } => self.handle_account_show(id, json_mode).await,
AccountSubcommand::Edit {
id,
email,
imap_host: _,
imap_port: _,
smtp_host: _,
smtp_port: _,
imap_host,
imap_port,
smtp_host,
smtp_port,
imap_mode,
smtp_mode,
} => {
if json_mode {
format_json(&json!({ "status": "updated", "id": id, "email": email }))
} else {
format!("Account '{}' updated successfully.", id)
}
}
AccountSubcommand::Delete { id } => {
if json_mode {
format_json(&json!({ "status": "deleted", "id": id }))
} else {
format!("Account '{}' removed.", id)
}
}
AccountSubcommand::Test { id } => {
if json_mode {
format_json(&json!({ "status": "ok", "id": id, "latency_ms": 24 }))
} else {
format!("✓ Account '{}' connection test OK (24ms latency).", id)
}
self.handle_account_edit(
id,
email.as_deref(),
imap_host.as_deref(),
*imap_port,
smtp_host.as_deref(),
*smtp_port,
imap_mode.as_deref(),
smtp_mode.as_deref(),
json_mode,
)
.await
}
AccountSubcommand::Delete { id } => self.handle_account_delete(id, json_mode).await,
AccountSubcommand::Test { id } => self.handle_account_test(id, json_mode).await,
},
Commands::Mail { action } => match action {
MailSubcommand::Sync => self.handle_sync(json_mode).await,
MailSubcommand::List { folder } => self.handle_list_folder(folder, json_mode).await,
MailSubcommand::Read { id } => self.handle_read_message(id, json_mode).await,
MailSubcommand::Send { to, subject, body } => {
self.handle_send_email(to, subject, body, json_mode).await
MailSubcommand::Send {
to,
subject,
body,
account,
} => {
self.handle_send_email(to, subject, body, account.as_deref(), json_mode)
.await
}
MailSubcommand::Search { query } => self.handle_search(query, json_mode).await,
MailSubcommand::Mark { id, read, unread } => {
@@ -708,6 +701,7 @@ impl HeadlessRunner {
to: &str,
subject: &str,
body: &str,
account: Option<&str>,
json_mode: bool,
) -> String {
let mut client = match self.connect_mail_client().await {
@@ -723,6 +717,7 @@ impl HeadlessRunner {
body_text: body.to_string(),
body_html: None,
attachments: Vec::new(),
account_id: account.map(str::to_string),
})
.await
{
@@ -1753,6 +1748,229 @@ impl HeadlessRunner {
}
}
/// Fetches every account from the daemon and returns the one matching
/// `id`, or an error string. There is deliberately no single-account
/// `GetAccount` RPC in the contract, so `account show`/`edit` filter the
/// `ListAccounts` result client-side -- always over the real daemon API,
/// never this runner's own ephemeral local `db`.
async fn fetch_account_by_id(
&self,
id: &str,
) -> Result<nuncio_proto::v1::AccountConfig, String> {
let mut client = self.connect_accounts_client().await?;
let accounts = client
.list_accounts(nuncio_proto::v1::ListAccountsRequest {})
.await
.map_err(|status| format!("nunciod daemon rejected list_accounts: {status}"))?
.into_inner()
.accounts;
accounts
.into_iter()
.find(|a| a.id == id)
.ok_or_else(|| format!("account '{id}' not found"))
}
/// `account show`: a real thin gRPC client of the daemon's
/// `nuncio.v1.Accounts/ListAccounts` API, filtered client-side to a single
/// account. Reads ONLY the daemon's persistent store -- never this
/// runner's own ephemeral local `db`.
async fn handle_account_show(&self, id: &str, json_mode: bool) -> String {
let account = match self.fetch_account_by_id(id).await {
Ok(account) => account,
Err(e) => return Self::render_error(&e, json_mode),
};
if json_mode {
format_json(&json!({
"id": account.id,
"name": account.name,
"email_address": account.email_address,
"protocol": account.protocol().as_str_name(),
"server_host": account.server_host,
"server_port": account.server_port,
"smtp_host": account.smtp_host,
"smtp_port": account.smtp_port,
"use_tls": account.use_tls,
"imap_tls_mode": account.imap_tls_mode().as_str_name(),
"smtp_tls_mode": account.smtp_tls_mode().as_str_name(),
"keyring_secret_key": account.keyring_secret_key,
"sync_interval_secs": account.sync_interval_secs,
}))
} else {
format!(
"Account [{}] {} <{}> IMAP {}:{} ({}) SMTP {}:{} ({})",
account.id,
account.name,
account.email_address,
account.server_host,
account.server_port,
account.imap_tls_mode().as_str_name(),
account.smtp_host,
account.smtp_port,
account.smtp_tls_mode().as_str_name(),
)
}
}
/// `account edit`: a real thin gRPC client of the daemon's
/// `nuncio.v1.Accounts/UpdateAccount` API. Fetches the existing account,
/// applies ONLY the flags actually supplied onto it, and persists the
/// merged config through the daemon. No password is sent, so the existing
/// keyring credential is preserved (see `UpdateAccountRequest`).
#[allow(clippy::too_many_arguments)]
async fn handle_account_edit(
&self,
id: &str,
email: Option<&str>,
imap_host: Option<&str>,
imap_port: Option<u16>,
smtp_host: Option<&str>,
smtp_port: Option<u16>,
imap_mode: Option<&str>,
smtp_mode: Option<&str>,
json_mode: bool,
) -> String {
let mut config = match self.fetch_account_by_id(id).await {
Ok(config) => config,
Err(e) => return Self::render_error(&e, json_mode),
};
if let Some(email) = email {
config.email_address = email.to_string();
}
if let Some(host) = imap_host {
config.server_host = host.to_string();
}
if let Some(port) = imap_port {
config.server_port = u32::from(port);
}
if let Some(host) = smtp_host {
config.smtp_host = host.to_string();
}
if let Some(port) = smtp_port {
config.smtp_port = u32::from(port);
}
if let Some(mode) = imap_mode {
match parse_tls_mode(mode) {
Ok(mode) => config.imap_tls_mode = map_tls_mode_to_proto(mode).into(),
Err(e) => return Self::render_error(&e, json_mode),
}
}
if let Some(mode) = smtp_mode {
match parse_tls_mode(mode) {
Ok(mode) => config.smtp_tls_mode = map_tls_mode_to_proto(mode).into(),
Err(e) => return Self::render_error(&e, json_mode),
}
}
let mut client = match self.connect_accounts_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.update_account(nuncio_proto::v1::UpdateAccountRequest {
config: Some(config),
password: None,
})
.await
{
Ok(_) => {
if json_mode {
format_json(&json!({ "status": "updated", "id": id }))
} else {
format!("Account '{id}' updated via nunciod daemon.")
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected update_account: {status}"),
json_mode,
),
}
}
/// `account delete`: a real thin gRPC client of the daemon's
/// `nuncio.v1.Accounts/RemoveAccount` API -- genuinely removes the
/// persisted account and its keyring credential, never a fabricated
/// "removed." string.
async fn handle_account_delete(&self, id: &str, json_mode: bool) -> String {
let mut client = match self.connect_accounts_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.remove_account(nuncio_proto::v1::RemoveAccountRequest { id: id.to_string() })
.await
{
Ok(_) => {
if json_mode {
format_json(&json!({ "status": "deleted", "id": id }))
} else {
format!("Account '{id}' removed via nunciod daemon.")
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected remove_account: {status}"),
json_mode,
),
}
}
/// `account test`: a real thin gRPC client of the daemon's
/// `nuncio.v1.Accounts/TestAccountConnection` API. Reports the GENUINE
/// per-protocol outcome the daemon measured -- never a fabricated
/// "connection test OK (24ms latency)". The two legs verify different
/// depths (see the RPC's proto doc): IMAP is a full login, SMTP is
/// transport reachability only (no AUTH), so the output labels them
/// distinctly and never implies SMTP authentication was verified.
async fn handle_account_test(&self, id: &str, json_mode: bool) -> String {
let mut client = match self.connect_accounts_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.test_account_connection(nuncio_proto::v1::TestAccountConnectionRequest {
id: id.to_string(),
})
.await
{
Ok(response) => {
let report = response.into_inner();
if json_mode {
format_json(&json!({
"id": id,
"imap_ok": report.imap_ok,
"smtp_ok": report.smtp_ok,
"imap_error": report.imap_error,
"smtp_error": report.smtp_error,
}))
} else {
// Label each leg by what it actually verifies: IMAP is a
// full login, SMTP is transport reachability only (no
// AUTH). The wording must never imply SMTP authentication
// was checked when it was not.
let imap = match &report.imap_error {
Some(e) => format!("IMAP (login): FAIL ({e})"),
None if report.imap_ok => "IMAP (login): OK".to_string(),
None => "IMAP (login): FAIL".to_string(),
};
let smtp = match &report.smtp_error {
Some(e) => format!("SMTP (reachability, no auth): FAIL ({e})"),
None if report.smtp_ok => "SMTP (reachability, no auth): OK".to_string(),
None => "SMTP (reachability, no auth): FAIL".to_string(),
};
format!("Connection test for '{id}'\n {imap}\n {smtp}")
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected test_account_connection: {status}"),
json_mode,
),
}
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// `nuncio.v1.Accounts` service at `self.grpc_addr`, shared by
/// [`Self::handle_add_account`] and [`Self::handle_accounts_list`].
@@ -2246,60 +2464,6 @@ mod tests {
assert!(bad_smtp_mode.starts_with("Error: invalid tls mode"));
}
#[tokio::test]
async fn headless_runner_executes_all_pure_noun_verb_commands() {
let runner = HeadlessRunner::ephemeral()
.await
.expect("ephemeral runner initializes");
// Account Noun Commands: `Add`/`List` are exercised separately
// below via `ephemeral_with` + `SecretManager::mock()` against a
// live test gRPC server, for the exact same reason `system status`
// is: they are real gRPC clients of the `nunciod` daemon's
// `Accounts` API, so they must never run against this
// `ephemeral()`-constructed runner's production `SecretManager` or
// its (unreachable in CI) default gRPC address. `Show` still reads
// this runner's own ephemeral local `db`, so it is safe to
// exercise here.
let acct_show = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Show {
id: "missing".to_string(),
},
},
false,
)
.await;
assert!(acct_show.contains("Account 'missing' not found"));
// Mail Noun Commands: `Sync`/`List`/`Read`/`Search`/`Mark`/`Send`
// (and `Folder::List`) are all real gRPC clients of the `nunciod`
// daemon's `Mail` API -- exactly like `Account::Add`/`List` and
// `System::Status` above, they must never
// run against this `ephemeral()`-constructed runner's production
// `SecretManager` or its (unreachable in CI) default gRPC address.
// They are exercised separately below via `ephemeral_with` +
// `SecretManager::mock()` against a live stub `Mail` gRPC server
// (`mail_rpcs_round_trip_over_grpc_to_a_stub_daemon`).
// Cal Noun Commands: `List`/`Sync` are real gRPC clients of the
// `nunciod` daemon's `Calendar` API -- exactly like the Mail Noun
// Commands above, they must never run against this
// `ephemeral()`-constructed runner's production `SecretManager` or
// its (unreachable in CI) default gRPC address. They are exercised
// separately below via `ephemeral_with` + `SecretManager::mock()`
// against a live stub `Calendar` gRPC server
// (`calendar_rpcs_round_trip_over_grpc_to_a_stub_daemon`).
// System Noun Commands are exercised separately below via
// `ephemeral_with` + `SecretManager::mock()`: `system status` is a
// real gRPC client of the `nunciod` daemon, so it must never run
// against the production
// `SecretManager` this `ephemeral()`-constructed runner holds (that
// would touch the real OS keyring during a test run).
}
/// Binds an ephemeral loopback TCP listener, reads back its OS-assigned
/// address, then immediately drops the listener so the port is free
/// again. Nothing is listening on the returned address, so connecting
@@ -2447,7 +2611,9 @@ mod tests {
use nuncio_proto::v1::{
AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
AddAccountRequest, AddAccountResponse, ListAccountsRequest, ListAccountsResponse,
TlsMode as TlsModeProto,
RemoveAccountRequest, RemoveAccountResponse, TestAccountConnectionRequest,
TestAccountConnectionResponse, TlsMode as TlsModeProto, UpdateAccountRequest,
UpdateAccountResponse,
};
use std::sync::Mutex;
@@ -2479,6 +2645,32 @@ mod tests {
Ok(tonic::Response::new(AddAccountResponse { id }))
}
async fn update_account(
&self,
_request: tonic::Request<UpdateAccountRequest>,
) -> Result<tonic::Response<UpdateAccountResponse>, tonic::Status> {
Ok(tonic::Response::new(UpdateAccountResponse {}))
}
async fn remove_account(
&self,
_request: tonic::Request<RemoveAccountRequest>,
) -> Result<tonic::Response<RemoveAccountResponse>, tonic::Status> {
Ok(tonic::Response::new(RemoveAccountResponse {}))
}
async fn test_account_connection(
&self,
_request: tonic::Request<TestAccountConnectionRequest>,
) -> Result<tonic::Response<TestAccountConnectionResponse>, tonic::Status> {
Ok(tonic::Response::new(TestAccountConnectionResponse {
imap_ok: true,
smtp_ok: true,
imap_error: None,
smtp_error: None,
}))
}
async fn list_accounts(
&self,
_request: tonic::Request<ListAccountsRequest>,
@@ -2584,6 +2776,90 @@ mod tests {
// Plain output must show each account's details, not just the count.
assert!(list_out_text.contains("stub@nuncio.mx"));
assert!(list_out_text.contains("imap.nuncio.mx"));
// `account show` reads the daemon's ListAccounts result (filtered
// client-side), not the runner's own ephemeral local db.
let show_out = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Show {
id: "acct-stub-1".to_string(),
},
},
true,
)
.await;
assert!(show_out.contains("stub@nuncio.mx"));
assert!(show_out.contains("acct-stub-1"));
// `account edit` drives the real UpdateAccount RPC (stub accepts it).
let edit_out = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Edit {
id: "acct-stub-1".to_string(),
email: None,
imap_host: None,
imap_port: None,
smtp_host: None,
smtp_port: None,
imap_mode: Some("start_tls".to_string()),
smtp_mode: None,
},
},
true,
)
.await;
assert!(edit_out.contains(r#""status":"updated""#));
// A garbage transport mode is rejected client-side before any RPC.
let bad_edit = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Edit {
id: "acct-stub-1".to_string(),
email: None,
imap_host: None,
imap_port: None,
smtp_host: None,
smtp_port: None,
imap_mode: Some("nonsense".to_string()),
smtp_mode: None,
},
},
true,
)
.await;
assert!(bad_edit.contains("invalid tls mode"));
// `account test` reports the daemon's genuine per-protocol result --
// never a fabricated "OK (24ms latency)".
let test_out = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Test {
id: "acct-stub-1".to_string(),
},
},
true,
)
.await;
assert!(test_out.contains(r#""imap_ok":true"#));
assert!(test_out.contains(r#""smtp_ok":true"#));
assert!(!test_out.contains("latency"));
// `account delete` drives the real RemoveAccount RPC.
let delete_out = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Delete {
id: "acct-stub-1".to_string(),
},
},
true,
)
.await;
assert!(delete_out.contains(r#""status":"deleted""#));
}
/// Reference-client proof: boots a stub `nuncio.v1.Mail` gRPC server
@@ -2897,6 +3173,7 @@ mod tests {
to: "alice@nuncio.mx".to_string(),
subject: "Quarterly Roadmap".to_string(),
body: "Let's discuss the roadmap.".to_string(),
account: None,
},
},
true,
@@ -2914,6 +3191,35 @@ mod tests {
assert_eq!(recorded_send.to, "alice@nuncio.mx");
assert_eq!(recorded_send.subject, "Quarterly Roadmap");
assert_eq!(recorded_send.body_text, "Let's discuss the roadmap.");
assert_eq!(recorded_send.account_id, None);
// `mail send --account <id>` threads the explicit selector through
// to the daemon over the wire, proving the CLI flag is not merely
// parsed but actually reaches `SendMessageRequest.account_id`.
let mail_send_with_account = runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Send {
to: "alice@nuncio.mx".to_string(),
subject: "Quarterly Roadmap".to_string(),
body: "Let's discuss the roadmap.".to_string(),
account: Some("acct-work".to_string()),
},
},
true,
)
.await;
assert!(mail_send_with_account.contains(r#""sent":true"#));
let recorded_send_with_account = send_probe
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.expect("stub daemon received a second send_message request");
assert_eq!(
recorded_send_with_account.account_id,
Some("acct-work".to_string())
);
}
/// Reference-client proof: boots a stub `nuncio.v1.Calendar` gRPC server
+13
View File
@@ -93,6 +93,19 @@ pub enum CoreEvent {
/// Action description.
action_taken: String,
},
/// Progress notification emitted during an inbound mail sync, so a
/// long-running sync of a large mailbox is observably progressing rather
/// than indistinguishable from a hang. Emitted per folder as it completes.
SyncProgress {
/// Account being synced, if the sync is scoped to one.
account_id: Option<String>,
/// Folder whose messages were just fetched and persisted.
folder_id: String,
/// Messages fetched from this folder on this pass.
fetched: usize,
/// Cumulative messages fetched across the sync so far.
total: usize,
},
/// Progress notification during bulk filter execution over a batch of emails.
BatchFilterProgress {
/// Messages processed so far in the batch.
+3
View File
@@ -31,3 +31,6 @@ reqwest.workspace = true
[dev-dependencies]
tempfile.workspace = true
wiremock.workspace = true
# `test-util` (not in tokio's `full`) enables paused-clock tests so the
# bounded per-item FETCH timeout can be asserted without a real-time wait.
tokio = { workspace = true, features = ["test-util"] }
File diff suppressed because it is too large. Load diff
+51 -3
View File
@@ -14,12 +14,41 @@ pub struct MockMailBackend {
messages: Arc<Mutex<Vec<Email>>>,
sent_messages: Arc<Mutex<Vec<Email>>>,
should_fail: Arc<Mutex<bool>>,
/// Every `since_state` argument this mock's `sync_messages` was called
/// with, in call order, so tests can prove a caller threads the returned
/// checkpoint back into the next sync (real incrementality).
since_state_calls: Arc<Mutex<Vec<Option<String>>>>,
/// The checkpoint token this mock returns from `sync_messages`.
returned_state: Arc<Mutex<String>>,
}
impl MockMailBackend {
/// Create a new `MockMailBackend` with empty storage.
pub fn new() -> Self {
Self::default()
let backend = Self::default();
if let Ok(mut state) = backend.returned_state.lock() {
*state = "mock-state-token-100".to_string();
}
backend
}
/// Override the checkpoint token this mock returns from `sync_messages`,
/// so a test can control the exact state a caller must persist and thread
/// back on the next sync.
pub fn set_returned_state(&self, state: &str) {
if let Ok(mut guard) = self.returned_state.lock() {
*guard = state.to_string();
}
}
/// Every `since_state` argument passed to `sync_messages`, in call order.
/// A caller that genuinely resumes from a checkpoint will show `None` on
/// the first call and the previously-returned state on the next.
pub fn since_state_calls(&self) -> Vec<Option<String>> {
self.since_state_calls
.lock()
.map(|guard| guard.clone())
.unwrap_or_default()
}
/// Configure the mock to simulate network failure errors.
@@ -74,8 +103,14 @@ impl MailBackend for MockMailBackend {
async fn sync_messages(
&self,
folder_id: &str,
_since_state: Option<&str>,
since_state: Option<&str>,
) -> Result<(Vec<Email>, String), MailError> {
// Record the checkpoint arg before any early return, so even a failing
// call is visible to a test asserting on how the caller resumes.
if let Ok(mut calls) = self.since_state_calls.lock() {
calls.push(since_state.map(str::to_string));
}
let should_fail = self
.should_fail
.lock()
@@ -85,6 +120,19 @@ impl MailBackend for MockMailBackend {
"simulated network failure".to_string(),
));
}
let returned_state = self
.returned_state
.lock()
.map_err(|e| MailError::ParseFailed(e.to_string()))?
.clone();
// Simulate a since_state-aware backend: with a prior checkpoint there
// is nothing new to hand back, so the incremental fetch is genuinely
// narrower (empty) rather than a full re-report of every message.
if since_state.is_some() {
return Ok((Vec::new(), returned_state));
}
let messages = self
.messages
.lock()
@@ -94,7 +142,7 @@ impl MailBackend for MockMailBackend {
.filter(|m| m.folder_id == folder_id)
.cloned()
.collect();
Ok((matches, "mock-state-token-100".to_string()))
Ok((matches, returned_state))
}
async fn send_email(&self, email: &Email) -> Result<(), MailError> {
+8
View File
@@ -32,6 +32,14 @@ pub enum MailError {
#[error("IMAP error: {0}")]
ImapError(String),
/// A single FETCH response item exceeded its bounded read timeout without
/// the server responding. Distinct from a generic [`MailError::ImapError`]
/// so callers can tell an honest mid-stream stall (dead socket, server-side
/// hang) apart from a protocol/parse failure instead of the sync hanging
/// forever.
#[error("IMAP FETCH stalled: {0}")]
FetchStalled(String),
/// Network or IO error.
#[error("network I/O error: {0}")]
NetworkError(String),
+116 -29
View File
@@ -9,6 +9,7 @@ use lettre::transport::smtp::client::{Tls, TlsParameters};
use lettre::transport::smtp::Error as LettreSmtpError;
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
use nuncio_core::model::{Attachment, Email};
use nuncio_core::TlsMode;
use crate::backend::{MessageSender, OutboundMessage};
use crate::parser::MailError;
@@ -42,12 +43,39 @@ pub struct SmtpTransportEngine {
}
impl SmtpTransportEngine {
/// Create a new [`SmtpTransportEngine`] with initialized transport client.
pub fn new(host: &str, port: u16, username: &str, password: &str) -> Result<Self, MailError> {
let transport = Self::build_transport(host, port, username, password)?;
/// Create a new [`SmtpTransportEngine`] with initialized transport client
/// for a specific transport security [`TlsMode`].
pub fn new(
host: &str,
port: u16,
tls_mode: TlsMode,
username: &str,
password: &str,
) -> Result<Self, MailError> {
let transport = Self::build_transport(host, port, tls_mode, username, password)?;
Ok(Self { transport })
}
/// Probe the SMTP endpoint: open a real connection to the configured
/// relay (performing the implicit-TLS wrap or STARTTLS upgrade the
/// configured [`TlsMode`] selects) and confirm the server responds. Backs
/// the daemon's `TestAccountConnection` SMTP leg. Returns `Ok(())` only
/// when a connection genuinely succeeded -- a dial/handshake failure
/// surfaces as the real [`MailError`], never a fabricated success.
///
/// This validates transport reachability and TLS negotiation; SMTP has no
/// standalone authentication probe (credentials are exercised at message
/// submission time), so this deliberately does not claim to verify auth.
pub async fn probe_connection(&self) -> Result<(), MailError> {
match self.transport.test_connection().await {
Ok(true) => Ok(()),
Ok(false) => Err(MailError::TransportFailed(
"SMTP server did not accept the connection probe".to_string(),
)),
Err(e) => Err(MailError::from(e)),
}
}
/// Create a new [`SmtpTransportEngine`] from an existing [`AsyncSmtpTransport`] client.
pub fn with_transport(transport: AsyncSmtpTransport<Tokio1Executor>) -> Self {
Self { transport }
@@ -227,33 +255,53 @@ impl SmtpTransportEngine {
Ok(())
}
/// Build an [`AsyncSmtpTransport`] client instance.
/// Configures Implicit TLS (`Tls::Wrapper`) for port 465, and STARTTLS (`Tls::Required`) for port 587 or other ports.
/// Build an [`AsyncSmtpTransport`] client instance whose transport
/// security genuinely follows the configured [`TlsMode`]:
///
/// - [`TlsMode::ImplicitTls`] wraps the socket in TLS immediately
/// (`Tls::Wrapper`), as on the SMTPS port (465).
/// - [`TlsMode::StartTls`] connects in the clear and REQUIRES a STARTTLS
/// upgrade before proceeding (`Tls::Required`), as on the submission
/// port (587); a server without STARTTLS is rejected rather than
/// silently falling back to plaintext.
/// - [`TlsMode::Plain`] performs no TLS at all (`Tls::None`), for a local
/// test/dev relay -- the caller opted into cleartext explicitly.
///
/// Implicit-TLS and STARTTLS relays are built via `relay(host)` (which
/// defaults to TLS wrapping) / `starttls_relay(host)` respectively;
/// plaintext is built via the non-encrypting `builder_dangerous(host)`.
pub fn build_transport(
host: &str,
port: u16,
tls_mode: TlsMode,
username: &str,
password: &str,
) -> Result<AsyncSmtpTransport<Tokio1Executor>, MailError> {
Self::validate_smtp_config(host, port, username)?;
let creds = Credentials::new(username.to_string(), password.to_string());
let tls_params = TlsParameters::builder(host.to_string())
.build()
.map_err(|e| MailError::TransportFailed(format!("TLS parameters error: {}", e)))?;
let tls = if port == 465 {
Tls::Wrapper(tls_params)
} else {
Tls::Required(tls_params)
let builder = match tls_mode {
TlsMode::ImplicitTls | TlsMode::StartTls => {
let tls_params = TlsParameters::builder(host.to_string())
.build()
.map_err(|e| {
MailError::TransportFailed(format!("TLS parameters error: {}", e))
})?;
let tls = match tls_mode {
TlsMode::StartTls => Tls::Required(tls_params),
_ => Tls::Wrapper(tls_params),
};
AsyncSmtpTransport::<Tokio1Executor>::relay(host)
.map_err(|e| {
MailError::TransportFailed(format!("relay configuration error: {}", e))
})?
.tls(tls)
}
TlsMode::Plain => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host),
};
let transport = AsyncSmtpTransport::<Tokio1Executor>::relay(host)
.map_err(|e| MailError::TransportFailed(format!("relay configuration error: {}", e)))?
.port(port)
.credentials(creds)
.tls(tls)
.build();
let transport = builder.port(port).credentials(creds).build();
Ok(transport)
}
@@ -383,21 +431,59 @@ mod tests {
}
#[test]
fn build_transport_creates_starttls_client_for_port_587() {
let transport = SmtpTransportEngine::build_transport("smtp.nuncio.mx", 587, "user", "pass");
fn build_transport_creates_starttls_client() {
let transport = SmtpTransportEngine::build_transport(
"smtp.nuncio.mx",
587,
TlsMode::StartTls,
"user",
"pass",
);
assert!(transport.is_ok());
}
#[test]
fn build_transport_creates_implicit_tls_client_for_port_465() {
let transport = SmtpTransportEngine::build_transport("smtp.nuncio.mx", 465, "user", "pass");
fn build_transport_creates_implicit_tls_client() {
let transport = SmtpTransportEngine::build_transport(
"smtp.nuncio.mx",
465,
TlsMode::ImplicitTls,
"user",
"pass",
);
assert!(transport.is_ok());
}
#[test]
fn build_transport_creates_plaintext_client() {
let transport = SmtpTransportEngine::build_transport(
"smtp.nuncio.mx",
25,
TlsMode::Plain,
"user",
"pass",
);
assert!(transport.is_ok());
}
#[tokio::test]
async fn probe_connection_to_unreachable_server_fails() {
let engine = SmtpTransportEngine::new("127.0.0.1", 1, TlsMode::ImplicitTls, "user", "pass")
.expect("valid config");
let err = engine
.probe_connection()
.await
.expect_err("probe against port 1 must fail, never fabricate success");
assert!(matches!(
err,
MailError::TransportFailed(_) | MailError::SmtpFailed(_)
));
}
#[tokio::test]
async fn send_email_to_unreachable_server_fails_with_transport_error() {
let engine =
SmtpTransportEngine::new("127.0.0.1", 1, "user", "pass").expect("valid config");
let engine = SmtpTransportEngine::new("127.0.0.1", 1, TlsMode::ImplicitTls, "user", "pass")
.expect("valid config");
let email = sample_email();
let err = engine
.send_email(&email)
@@ -411,7 +497,8 @@ mod tests {
#[test]
fn engine_constructor_creates_instance() {
let engine = SmtpTransportEngine::new("smtp.nuncio.mx", 465, "user", "pass");
let engine =
SmtpTransportEngine::new("smtp.nuncio.mx", 465, TlsMode::ImplicitTls, "user", "pass");
assert!(engine.is_ok());
}
@@ -506,8 +593,8 @@ mod tests {
#[tokio::test]
async fn send_message_to_unreachable_server_fails_with_transport_error() {
let engine =
SmtpTransportEngine::new("127.0.0.1", 1, "user", "pass").expect("valid config");
let engine = SmtpTransportEngine::new("127.0.0.1", 1, TlsMode::ImplicitTls, "user", "pass")
.expect("valid config");
let message = sample_outbound_message();
let err = engine
.send_message(&message)
@@ -525,8 +612,8 @@ mod tests {
// through to the real `send_message` path, not a separate/fabricated
// stub -- exercised via the trait object
// exactly like production code (`nunciod::send`) uses it.
let engine =
SmtpTransportEngine::new("127.0.0.1", 1, "user", "pass").expect("valid config");
let engine = SmtpTransportEngine::new("127.0.0.1", 1, TlsMode::ImplicitTls, "user", "pass")
.expect("valid config");
let sender: &dyn MessageSender = &engine;
let message = sample_outbound_message();
let err = sender
Binary file not shown.
@@ -144,6 +144,30 @@ service Accounts {
// ListAccounts returns every persisted account configuration. Password
// credentials are NEVER included in this response.
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
// UpdateAccount overwrites an existing account's persisted configuration.
// The password is optional: when omitted the existing keyring credential
// is left untouched; when present it REPLACES the stored credential in the
// OS keyring vault (never written to SQLite, never echoed back). A password
// rotation that cannot be persisted is rolled back so the vault never
// diverges from the persisted account row.
rpc UpdateAccount(UpdateAccountRequest) returns (UpdateAccountResponse);
// RemoveAccount deletes a persisted account configuration by id and its
// associated keyring credential.
rpc RemoveAccount(RemoveAccountRequest) returns (RemoveAccountResponse);
// TestAccountConnection performs a real, bounded connection probe against
// the account's configured IMAP and SMTP endpoints (honoring each one's
// TLS transport mode) and reports the GENUINE per-protocol outcome -- never
// a fabricated success. The two legs verify DIFFERENT depths, and callers
// must not conflate them:
// - IMAP verifies a full LOGIN: connect, negotiate TLS/STARTTLS, and
// authenticate with the account's keyring credential.
// - SMTP verifies only TRANSPORT REACHABILITY: connect and negotiate
// TLS/STARTTLS. It does NOT perform an SMTP AUTH exchange, so `smtp_ok`
// does not assert the credential is valid for outbound mail (SMTP has
// no standard authentication probe that does not also submit a message).
// A probe that fails carries the real error detail; each leg is bounded by
// a timeout so the RPC cannot hang against an unreachable host.
rpc TestAccountConnection(TestAccountConnectionRequest) returns (TestAccountConnectionResponse);
}
// Mirrors `nuncio_core::AccountProtocol`.
@@ -220,6 +244,57 @@ message ListAccountsResponse {
repeated AccountConfig accounts = 1;
}
// UpdateAccountRequest carries the full replacement account configuration
// plus an OPTIONAL password. When `password` is absent the account's existing
// keyring credential is preserved; when present it is written to the OS
// keyring vault (under `config.keyring_secret_key`), exactly like
// `AddAccountRequest` -- never persisted to SQLite, never logged, never
// echoed back.
message UpdateAccountRequest {
AccountConfig config = 1;
optional string password = 2;
}
// UpdateAccountResponse deliberately carries no fields: a successful RPC
// response (as opposed to a `Status` error) is confirmation enough, and the
// caller already holds the updated config it just sent.
message UpdateAccountResponse {}
// RemoveAccountRequest names the account configuration to delete by id.
message RemoveAccountRequest {
string id = 1;
}
// RemoveAccountResponse currently carries no fields; a successful RPC
// response is confirmation enough.
message RemoveAccountResponse {}
// TestAccountConnectionRequest names the persisted account to probe by id.
message TestAccountConnectionRequest {
string id = 1;
}
// TestAccountConnectionResponse reports the REAL, independent outcome of
// probing each protocol endpoint -- never a fabricated success. `imap_error`
// / `smtp_error` are present only when the corresponding `*_ok` is false and
// carry the genuine failure detail (dial/handshake/auth error text). The two
// `*_ok` flags verify different depths -- see `TestAccountConnection`.
message TestAccountConnectionResponse {
// Whether the IMAP endpoint FULLY authenticated: connect, TLS/STARTTLS
// negotiation, AND login with the account's credential all succeeded.
bool imap_ok = 1;
// Whether the SMTP endpoint was REACHABLE: connect and TLS/STARTTLS
// negotiation succeeded. This is a transport-reachability check only -- it
// does NOT perform an SMTP AUTH exchange, so it does not assert the
// credential is valid for outbound mail.
bool smtp_ok = 2;
// Present only when `imap_ok` is false: the real IMAP login/handshake error.
optional string imap_error = 3;
// Present only when `smtp_ok` is false: the real SMTP transport/handshake
// error.
optional string smtp_error = 4;
}
// Mail exposes the READ path over the daemon's real, persistent message
// store (the data that syncs in via `DatabaseEngine::save_email`).
//
@@ -246,7 +321,11 @@ service Mail {
// SendMessage composes and sends a real outbound email over SMTP, built
// from the configured account's SMTP endpoint
// (`AccountConfig.smtp_host`/`smtp_port`) and its keyring-stored
// password. Returns successfully ONLY when
// password. When `account_id` is set, that specific account is used as
// the sender, and a request naming an account that does not exist fails
// rather than silently falling back to a different account. When
// `account_id` is omitted, the first configured account is used. Returns
// successfully ONLY when
// the SMTP transport genuinely accepted the message for delivery --
// never a fabricated success. A transport/authentication failure surfaces
// as a `Status::internal` (or `Status::invalid_argument` for a malformed
@@ -394,6 +473,10 @@ message SendMessageRequest {
optional string body_html = 5;
// Optional file attachments.
repeated Attachment attachments = 6;
// Optional identifier of the account to send from. When omitted, the
// first configured account is used (preserving prior behavior); when
// set, the named account must exist or the request fails.
optional string account_id = 7;
}
// SendMessageResponse. A successful response means the SMTP transport
+310 -6
View File
@@ -186,6 +186,29 @@ pub struct WormChainReport {
pub first_broken_seq: Option<u64>,
}
/// Serialize a [`nuncio_core::TlsMode`] to its stable on-disk `accounts`
/// column form. Kept as a bare snake_case token (matching the column's
/// `'implicit_tls'` default) rather than JSON, so a row inserted by the
/// migration default and one written here are byte-identical.
fn tls_mode_to_db(mode: nuncio_core::TlsMode) -> &'static str {
match mode {
nuncio_core::TlsMode::ImplicitTls => "implicit_tls",
nuncio_core::TlsMode::StartTls => "start_tls",
nuncio_core::TlsMode::Plain => "plain",
}
}
/// Parse a persisted `accounts` TLS-mode column back into
/// [`nuncio_core::TlsMode`]. An unrecognized value falls back to the safe
/// default (implicit TLS) rather than failing the whole account load.
fn tls_mode_from_db(value: &str) -> nuncio_core::TlsMode {
match value {
"start_tls" => nuncio_core::TlsMode::StartTls,
"plain" => nuncio_core::TlsMode::Plain,
_ => nuncio_core::TlsMode::ImplicitTls,
}
}
impl DatabaseEngine {
/// Maximum concurrent read/write pool size.
pub const MAX_CONNECTIONS: u32 = 16;
@@ -439,7 +462,9 @@ impl DatabaseEngine {
keyring_secret_key TEXT NOT NULL,
sync_interval_secs INTEGER NOT NULL,
smtp_host TEXT,
smtp_port INTEGER
smtp_port INTEGER,
imap_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls',
smtp_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls'
);
CREATE TABLE IF NOT EXISTS filter_rules (
@@ -624,6 +649,19 @@ impl DatabaseEngine {
INSERT INTO contacts_fts(id, display_name, organization, emails_json)
VALUES (new.id, new.display_name, COALESCE(new.organization, ''), new.emails_json);
END;
-- Per-folder inbound-sync checkpoint. `state` is an opaque,
-- protocol-defined resumption token (an IMAP UID boundary such as
-- UIDNEXT, a JMAP state string) that a backend hands back after a
-- sync and expects to receive on the next sync so it can fetch only
-- what changed since. Keyed by (account, folder) because the token
-- is meaningful only within a single mailbox on a single account.
CREATE TABLE IF NOT EXISTS folder_sync_state (
account_id TEXT NOT NULL,
folder_id TEXT NOT NULL,
state TEXT NOT NULL,
PRIMARY KEY (account_id, folder_id)
);
"#,
)
.execute(&self.pool)
@@ -631,11 +669,53 @@ impl DatabaseEngine {
.map_err(DatabaseError::Query)?;
self.ensure_accounts_smtp_columns().await?;
self.ensure_accounts_tls_mode_columns().await?;
self.backfill_message_fts().await?;
Ok(())
}
/// Additive, backfill-safe migration that adds the `imap_tls_mode` /
/// `smtp_tls_mode` columns to a pre-existing `accounts` table that
/// predates persisted per-protocol TLS transport modes.
///
/// A fresh database already gets these columns from `CREATE TABLE IF NOT
/// EXISTS accounts` above, so on a fresh database this is a no-op. For a
/// pre-existing database file the columns are added with a
/// `'implicit_tls'` default, so every existing row keeps loading
/// successfully and retains the historical behavior (implicit TLS) it was
/// created under. SQLite has no `ADD COLUMN IF NOT EXISTS`, so column
/// presence is checked explicitly via `PRAGMA table_info` first, making
/// this safe to run on every daemon startup.
async fn ensure_accounts_tls_mode_columns(&self) -> Result<(), DatabaseError> {
let existing_columns: Vec<String> = sqlx::query("PRAGMA table_info(accounts)")
.fetch_all(&self.pool)
.await
.map_err(DatabaseError::Query)?
.iter()
.map(|row| row.get::<String, _>("name"))
.collect();
if !existing_columns.iter().any(|c| c == "imap_tls_mode") {
sqlx::query(
"ALTER TABLE accounts ADD COLUMN imap_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls'",
)
.execute(&self.pool)
.await
.map_err(DatabaseError::Query)?;
}
if !existing_columns.iter().any(|c| c == "smtp_tls_mode") {
sqlx::query(
"ALTER TABLE accounts ADD COLUMN smtp_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls'",
)
.execute(&self.pool)
.await
.map_err(DatabaseError::Query)?;
}
Ok(())
}
/// Additive, backfill-safe migration that adds the
/// `smtp_host` / `smtp_port` columns to a pre-existing `accounts` table
/// that predates the SMTP account-endpoint feature.
@@ -725,8 +805,8 @@ impl DatabaseEngine {
sqlx::query(
r#"
INSERT OR REPLACE INTO accounts
(id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&config.id)
@@ -740,6 +820,8 @@ impl DatabaseEngine {
.bind(config.sync_interval_secs as i64)
.bind(&config.smtp_host)
.bind(config.smtp_port as i64)
.bind(tls_mode_to_db(config.imap_tls_mode))
.bind(tls_mode_to_db(config.smtp_tls_mode))
.execute(&self.pool)
.await
.map_err(DatabaseError::Query)?;
@@ -747,6 +829,27 @@ impl DatabaseEngine {
Ok(())
}
/// Fetch a single persisted [`nuncio_core::AccountConfig`] by id, or
/// `None` if no such account row exists.
pub async fn get_account(
&self,
id: &str,
) -> Result<Option<nuncio_core::AccountConfig>, DatabaseError> {
Ok(self.list_accounts().await?.into_iter().find(|a| a.id == id))
}
/// Delete a persisted account configuration by id. Idempotent: deleting a
/// non-existent id is not an error (the post-condition -- "no account row
/// with this id" -- already holds).
pub async fn delete_account(&self, id: &str) -> Result<(), DatabaseError> {
sqlx::query("DELETE FROM accounts WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await
.map_err(DatabaseError::Query)?;
Ok(())
}
/// Query all saved accounts in SQLite.
#[allow(clippy::type_complexity)]
pub async fn list_accounts(&self) -> Result<Vec<nuncio_core::AccountConfig>, DatabaseError> {
@@ -762,9 +865,11 @@ impl DatabaseEngine {
i64,
Option<String>,
Option<i64>,
String,
String,
)> = sqlx::query_as(
r#"
SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port
SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode
FROM accounts
"#,
)
@@ -787,6 +892,8 @@ impl DatabaseEngine {
sync_interval_secs,
smtp_host,
smtp_port,
imap_tls_mode,
smtp_tls_mode,
)| {
let protocol = serde_json::from_str(&protocol_str)
.unwrap_or(nuncio_core::AccountProtocol::ImapSmtp);
@@ -811,8 +918,8 @@ impl DatabaseEngine {
smtp_host: resolved_smtp_host,
smtp_port: resolved_smtp_port,
use_tls: use_tls != 0,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
imap_tls_mode: tls_mode_from_db(&imap_tls_mode),
smtp_tls_mode: tls_mode_from_db(&smtp_tls_mode),
keyring_secret_key,
sync_interval_secs: sync_interval_secs as u64,
}
@@ -821,6 +928,51 @@ impl DatabaseEngine {
.collect())
}
/// Fetch the persisted inbound-sync checkpoint for a folder, or `None` if
/// this account/folder pair has never completed a sync. A `None` result is
/// the signal to the backend that it must perform a full first-time fetch.
pub async fn get_folder_sync_state(
&self,
account_id: &str,
folder_id: &str,
) -> Result<Option<String>, DatabaseError> {
let row: Option<(String,)> = sqlx::query_as(
"SELECT state FROM folder_sync_state WHERE account_id = ? AND folder_id = ?",
)
.bind(account_id)
.bind(folder_id)
.fetch_optional(&self.pool)
.await
.map_err(DatabaseError::Query)?;
Ok(row.map(|(state,)| state))
}
/// Persist the inbound-sync checkpoint a backend returned for a folder,
/// overwriting any prior value (a checkpoint is a running high-water mark,
/// not history). Written only after a folder's messages have been fetched
/// and persisted, so the stored checkpoint can never advance past work that
/// actually landed in the store.
pub async fn save_folder_sync_state(
&self,
account_id: &str,
folder_id: &str,
state: &str,
) -> Result<(), DatabaseError> {
sqlx::query(
r#"
INSERT OR REPLACE INTO folder_sync_state (account_id, folder_id, state)
VALUES (?, ?, ?)
"#,
)
.bind(account_id)
.bind(folder_id)
.bind(state)
.execute(&self.pool)
.await
.map_err(DatabaseError::Query)?;
Ok(())
}
/// Save an [`nuncio_core::model::Email`] to SQLite (INSERT OR REPLACE).
///
/// The message body is encrypted (AES-256-GCM) before being written to the `messages`
@@ -2505,6 +2657,158 @@ mod tests {
assert_eq!(accounts[0].smtp_port, 465);
}
/// Regression proof that the per-protocol TLS transport modes are
/// genuinely persisted and round-tripped -- previously they were
/// validated on add but silently discarded, with `list_accounts`
/// hardcoding both back to `ImplicitTls` regardless of what was saved.
/// Saves an account whose IMAP mode is `StartTls` and SMTP mode is
/// `Plain`, then re-reads it via BOTH `list_accounts` and `get_account`
/// and asserts each distinct mode survived rather than collapsing to the
/// default.
#[tokio::test]
async fn account_tls_modes_survive_save_and_reload() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
let acct = nuncio_core::AccountConfig {
id: "acct-tls-1".to_string(),
name: "Mixed TLS Account".to_string(),
email_address: "mixed@nuncio.mx".to_string(),
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 143,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 25,
use_tls: false,
imap_tls_mode: nuncio_core::TlsMode::StartTls,
smtp_tls_mode: nuncio_core::TlsMode::Plain,
keyring_secret_key: "nuncio/acct-tls-1".to_string(),
sync_interval_secs: 60,
};
engine.save_account(&acct).await.expect("save succeeds");
let listed = engine.list_accounts().await.expect("list succeeds");
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].imap_tls_mode, nuncio_core::TlsMode::StartTls);
assert_eq!(listed[0].smtp_tls_mode, nuncio_core::TlsMode::Plain);
let fetched = engine
.get_account("acct-tls-1")
.await
.expect("get succeeds")
.expect("account present");
assert_eq!(fetched.imap_tls_mode, nuncio_core::TlsMode::StartTls);
assert_eq!(fetched.smtp_tls_mode, nuncio_core::TlsMode::Plain);
assert_eq!(fetched, acct);
}
/// Proves `get_account` resolves a saved account by id and returns `None`
/// for an unknown id, and that `delete_account` genuinely removes the row
/// (and is idempotent for an already-absent id).
#[tokio::test]
async fn get_and_delete_account_round_trip() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
let acct = nuncio_core::AccountConfig {
id: "acct-del-1".to_string(),
name: "Deletable".to_string(),
email_address: "del@nuncio.mx".to_string(),
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
keyring_secret_key: "nuncio/acct-del-1".to_string(),
sync_interval_secs: 60,
};
engine.save_account(&acct).await.expect("save succeeds");
assert!(engine
.get_account("acct-del-1")
.await
.expect("get succeeds")
.is_some());
assert!(engine
.get_account("acct-missing")
.await
.expect("get succeeds")
.is_none());
engine
.delete_account("acct-del-1")
.await
.expect("delete succeeds");
assert!(engine
.get_account("acct-del-1")
.await
.expect("get succeeds")
.is_none());
// Idempotent: deleting an already-absent id is not an error.
engine
.delete_account("acct-del-1")
.await
.expect("second delete is a no-op");
}
#[tokio::test]
async fn folder_sync_state_round_trip_is_scoped_and_overwrites() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
// Absent before any sync completes.
assert_eq!(
engine
.get_folder_sync_state("acct-1", "INBOX")
.await
.expect("get succeeds"),
None
);
engine
.save_folder_sync_state("acct-1", "INBOX", "105")
.await
.expect("save succeeds");
assert_eq!(
engine
.get_folder_sync_state("acct-1", "INBOX")
.await
.expect("get succeeds"),
Some("105".to_string())
);
// A later checkpoint overwrites (high-water mark, not history).
engine
.save_folder_sync_state("acct-1", "INBOX", "220")
.await
.expect("save succeeds");
assert_eq!(
engine
.get_folder_sync_state("acct-1", "INBOX")
.await
.expect("get succeeds"),
Some("220".to_string())
);
// Keyed by (account, folder): a different folder/account is unaffected.
assert_eq!(
engine
.get_folder_sync_state("acct-1", "Sent")
.await
.expect("get succeeds"),
None
);
assert_eq!(
engine
.get_folder_sync_state("acct-2", "INBOX")
.await
.expect("get succeeds"),
None
);
}
/// 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`
+570 -13
View File
@@ -9,7 +9,7 @@ use nuncio_cal::CalendarBackend;
use nuncio_contacts::ContactsBackend;
use nuncio_core::{CoreCommand, CoreEvent, EventBus};
use nuncio_filter::{FilterEngine, NsqlParser, NsqlValidator, ValidationOptions};
use nuncio_mail::{MailBackend, MessageSender};
use nuncio_mail::{ImapEngine, MailBackend, MessageSender, SmtpTransportEngine};
use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer};
use nuncio_proto::v1::audit_server::{Audit, AuditServer};
use nuncio_proto::v1::calendar_server::{Calendar, CalendarServer};
@@ -38,9 +38,11 @@ use nuncio_proto::v1::{
ListFoldersResponse, ListMessagesRequest, ListMessagesResponse, ListRecordsRequest,
ListRecordsResponse, ListRulesRequest, ListRulesResponse, MarkReadRequest, MarkReadResponse,
Message as MessageProto, MessageFlagsChanged, MessageSearchHit, PreviewRuleRequest,
PreviewRuleResponse, SearchMessagesRequest, SearchMessagesResponse, SendMessageRequest,
SendMessageResponse, ShuttingDown, SubscribeRequest, SyncCompleted, SyncRequest, SyncResponse,
SyncStarted, TlsMode as TlsModeProto, TriageProgress, TriageRequest, UpdateAvailable,
PreviewRuleResponse, RemoveAccountRequest, RemoveAccountResponse, SearchMessagesRequest,
SearchMessagesResponse, SendMessageRequest, SendMessageResponse, ShuttingDown,
SubscribeRequest, SyncCompleted, SyncRequest, SyncResponse, SyncStarted,
TestAccountConnectionRequest, TestAccountConnectionResponse, TlsMode as TlsModeProto,
TriageProgress, TriageRequest, UpdateAccountRequest, UpdateAccountResponse, UpdateAvailable,
UpdateRuleRequest, UpdateRuleResponse, ValidateRuleRequest, ValidateRuleResponse,
VerifyChainRequest, VerifyChainResponse,
};
@@ -85,15 +87,17 @@ pub enum GrpcServeError {
}
/// Maps a `nuncio_core::CoreEvent` domain event onto its wire-format
/// `nuncio.v1.Event` representation, faithfully carrying every variant's
/// `nuncio.v1.Event` representation, carrying every wire-visible variant's
/// fields into the matching `oneof` case (see `proto/nuncio/v1/nuncio.proto`
/// for the wire contract).
/// for the wire contract). Returns `None` for events that are internal-only
/// and have no case in the versioned contract, so they are dropped from the
/// gRPC subscription stream rather than forced onto the wire.
///
/// `usize` fields (`processed`, `total`, `matched`, `salvaged_rules_count`)
/// are narrowed to `u64` for the wire; these are in-process counters that
/// cannot realistically approach `u64::MAX`, and protobuf has no native
/// `usize` type.
fn map_core_event(event: CoreEvent) -> Event {
fn map_core_event(event: CoreEvent) -> Option<Event> {
let kind = match event {
CoreEvent::SyncStarted { account_id } => Kind::SyncStarted(SyncStarted { account_id }),
CoreEvent::SyncCompleted { account_id } => {
@@ -138,8 +142,12 @@ fn map_core_event(event: CoreEvent) -> Event {
}),
CoreEvent::Error { message } => Kind::Error(EventError { message }),
CoreEvent::ShuttingDown => Kind::ShuttingDown(ShuttingDown {}),
// Internal sync-progress signal: intentionally not forwarded to gRPC
// subscribers, as it has no case in the versioned wire contract. It is
// observed in-process (e.g. by the daemon's own subscribers) only.
CoreEvent::SyncProgress { .. } => return None,
};
Event { kind: Some(kind) }
Some(Event { kind: Some(kind) })
}
/// `nuncio.v1.System` gRPC service implementation backed by the daemon's live
@@ -189,7 +197,7 @@ impl System for SystemGrpcService {
) -> Result<Response<Self::SubscribeStream>, Status> {
let receiver = self.event_bus.subscribe_events();
let mapped = BroadcastStream::new(receiver).filter_map(|item| match item {
Ok(core_event) => Some(Ok(map_core_event(core_event))),
Ok(core_event) => map_core_event(core_event).map(Ok),
Err(BroadcastStreamRecvError::Lagged(skipped)) => {
tracing::warn!(
"gRPC Subscribe stream lagged behind the daemon event bus; skipped {} \
@@ -304,6 +312,134 @@ fn map_account_config_from_proto(
})
}
/// Outcome of probing a single protocol endpoint (IMAP or SMTP) during a
/// `TestAccountConnection` RPC. `ok` reflects a GENUINE dial/handshake/auth
/// outcome; `error` carries the real failure detail when `ok` is false.
#[derive(Debug, Clone)]
pub struct ProtocolProbe {
/// Whether the probe genuinely succeeded.
pub ok: bool,
/// Real failure detail, present only when `ok` is false.
pub error: Option<String>,
}
impl ProtocolProbe {
/// A successful probe.
fn ok() -> Self {
Self {
ok: true,
error: None,
}
}
/// A failed probe carrying its real error text.
fn failed(error: String) -> Self {
Self {
ok: false,
error: Some(error),
}
}
}
/// The genuine per-protocol result of a `TestAccountConnection` probe.
#[derive(Debug, Clone)]
pub struct AccountConnectionReport {
/// Result of probing the account's IMAP endpoint.
pub imap: ProtocolProbe,
/// Result of probing the account's SMTP endpoint.
pub smtp: ProtocolProbe,
}
/// Injectable seam backing `TestAccountConnection`'s real per-protocol
/// connection probing. Production uses [`RealAccountConnectionTester`], which
/// performs a genuine bounded IMAP + SMTP dial/handshake using the account's
/// configured endpoints, TLS modes, and keyring credential. A full-daemon
/// offline test injects a stand-in so the RPC can be exercised end to end
/// without touching any live server.
#[tonic::async_trait]
pub trait AccountConnectionTester: Send + Sync {
/// Probe both protocol endpoints for `config`, authenticating with
/// `password`, and report the genuine per-protocol outcome.
async fn probe(
&self,
config: &nuncio_core::AccountConfig,
password: &str,
) -> AccountConnectionReport;
}
/// Production [`AccountConnectionTester`]: builds a real
/// [`nuncio_mail::ImapEngine`] / [`nuncio_mail::SmtpTransportEngine`] from the
/// account's configured endpoints, TLS transport modes, and keyring password,
/// and performs a genuine, bounded connection probe against each. Every
/// result is the real dial/handshake/auth outcome -- never fabricated.
struct RealAccountConnectionTester;
#[tonic::async_trait]
impl AccountConnectionTester for RealAccountConnectionTester {
async fn probe(
&self,
config: &nuncio_core::AccountConfig,
password: &str,
) -> AccountConnectionReport {
let imap = match config.protocol {
nuncio_core::AccountProtocol::ImapSmtp => {
let engine = ImapEngine::with_credentials(
&config.id,
&config.server_host,
config.server_port,
config.imap_tls_mode,
&config.email_address,
password,
);
match engine.probe_connection().await {
Ok(()) => ProtocolProbe::ok(),
Err(e) => ProtocolProbe::failed(e.to_string()),
}
}
nuncio_core::AccountProtocol::Jmap => ProtocolProbe::failed(
"IMAP connection probe is not applicable to a JMAP account".to_string(),
),
};
let smtp = match SmtpTransportEngine::new(
&config.smtp_host,
config.smtp_port,
config.smtp_tls_mode,
&config.email_address,
password,
) {
Ok(engine) => match engine.probe_connection().await {
Ok(()) => ProtocolProbe::ok(),
Err(e) => ProtocolProbe::failed(e.to_string()),
},
Err(e) => ProtocolProbe::failed(e.to_string()),
};
AccountConnectionReport { imap, smtp }
}
}
/// Overridable engine seam for the `nuncio.v1.Accounts` service, mirroring
/// [`MailEngineOverrides`]'s shape. Production always uses the default (a real
/// [`RealAccountConnectionTester`]); only a full-daemon offline E2E test
/// injects a stand-in tester so `TestAccountConnection` can be driven over the
/// real authenticated gRPC API without any live server.
#[derive(Clone, Default)]
pub struct AccountsEngineOverrides {
/// When `Some`, `TestAccountConnection` probes through this tester instead
/// of building real per-account IMAP/SMTP engines from keyring
/// credentials.
pub connection_tester: Option<Arc<dyn AccountConnectionTester>>,
}
impl std::fmt::Debug for AccountsEngineOverrides {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AccountsEngineOverrides")
.field("connection_tester", &self.connection_tester.is_some())
.finish()
}
}
/// `nuncio.v1.Accounts` gRPC service implementation backed by the daemon's
/// live [`DatabaseEngine`] and [`SecretManager`] vault.
///
@@ -314,6 +450,58 @@ fn map_account_config_from_proto(
struct AccountsGrpcService {
db: Arc<DatabaseEngine>,
secrets: Arc<SecretManager>,
connection_tester: Arc<dyn AccountConnectionTester>,
}
/// Rotate an account's stored credential to `new_password` and persist the
/// updated `config`, with a rollback that never destroys a working
/// credential.
///
/// The prior secret (if any) is snapshotted BEFORE the new one is written, so
/// if `save_account` then fails the snapshot is RESTORED -- returning the
/// vault to exactly its pre-call state. This differs deliberately from
/// `add_account`'s rollback, which deletes: an add has no prior secret to
/// preserve, but an update's key may already hold the working credential, and
/// deleting it on a persist failure would leave the still-present account row
/// referencing a missing secret (strictly worse than doing nothing). When
/// there was no prior secret, restore falls back to deleting the just-written
/// one, matching add's behavior for that case. A rollback failure is reported
/// alongside the original persistence error (which stays primary), never in
/// place of it.
async fn rotate_credential_and_persist(
db: &DatabaseEngine,
secrets: &SecretManager,
config: &nuncio_core::AccountConfig,
new_password: &str,
) -> Result<(), Status> {
// Snapshot the prior credential before overwriting it. `None` means the
// key held no secret (or was unreadable), in which case rollback deletes.
let previous = secrets.get_secret(&config.keyring_secret_key).ok();
secrets
.set_secret(&config.keyring_secret_key, new_password)
.map_err(|e| Status::internal(format!("failed to store credential in vault: {e}")))?;
if let Err(e) = db.save_account(config).await {
let mut message = format!("failed to persist account: {e}");
let rollback = match &previous {
Some(prior) => secrets.set_secret(&config.keyring_secret_key, prior),
None => secrets.delete_secret(&config.keyring_secret_key),
};
if let Err(rollback_err) = rollback {
tracing::warn!(
keyring_secret_key = %config.keyring_secret_key,
error = %rollback_err,
"failed to roll back keyring secret after update_account persistence failure"
);
message.push_str(&format!(
" (and failed to roll back the credential: {rollback_err})"
));
}
return Err(Status::internal(message));
}
Ok(())
}
#[tonic::async_trait]
@@ -340,14 +528,33 @@ impl Accounts for AccountsGrpcService {
// SQLite. If this fails, the account row below is deliberately
// never saved, so a failed credential write can never leave behind
// an account config with no retrievable password.
//
// If the credential write succeeds but the account row then fails to
// persist, the two steps are no longer in sync: without a rollback,
// the keyring would keep an orphaned secret that no account row
// references and that `ListAccounts` can never surface again. So on
// that failure we delete the just-written secret before returning,
// restoring the "no credential without a matching account" invariant.
// The original persistence error is always what the caller sees --
// a rollback failure is reported alongside it, never in place of it.
self.secrets
.set_secret(&config.keyring_secret_key, &req.password)
.map_err(|e| Status::internal(format!("failed to store credential in vault: {e}")))?;
self.db
.save_account(&config)
.await
.map_err(|e| Status::internal(format!("failed to persist account: {e}")))?;
if let Err(e) = self.db.save_account(&config).await {
let mut message = format!("failed to persist account: {e}");
if let Err(rollback_err) = self.secrets.delete_secret(&config.keyring_secret_key) {
tracing::warn!(
keyring_secret_key = %config.keyring_secret_key,
error = %rollback_err,
"failed to roll back orphaned keyring secret after add_account persistence failure"
);
message.push_str(&format!(
" (and failed to roll back the credential: {rollback_err})"
));
}
return Err(Status::internal(message));
}
Ok(Response::new(AddAccountResponse { id: config.id }))
}
@@ -367,6 +574,128 @@ impl Accounts for AccountsGrpcService {
Ok(Response::new(ListAccountsResponse { accounts }))
}
async fn update_account(
&self,
request: Request<UpdateAccountRequest>,
) -> Result<Response<UpdateAccountResponse>, Status> {
let req = request.into_inner();
let proto_config = req
.config
.ok_or_else(|| Status::invalid_argument("config is required"))?;
let config = map_account_config_from_proto(proto_config)?;
config
.validate()
.map_err(|e| Status::invalid_argument(e.to_string()))?;
// Updating a non-existent account is a client error, not a silent
// create: reject it so a typo'd id never conjures a new account row.
if self
.db
.get_account(&config.id)
.await
.map_err(|e| Status::internal(format!("failed to look up account: {e}")))?
.is_none()
{
return Err(Status::not_found(format!(
"account '{}' not found",
config.id
)));
}
match req.password {
// A password rotation writes the new credential first, then
// persists. Unlike `add_account` (where no prior secret exists,
// so a failed persist rolls back by DELETING the orphan), an
// update's key may already hold the working credential, so a
// failed persist must RESTORE that prior value rather than delete
// it -- deleting would leave the still-present account row
// referencing a missing secret. See
// [`rotate_credential_and_persist`].
Some(password) if !password.is_empty() => {
rotate_credential_and_persist(&self.db, &self.secrets, &config, &password).await?;
}
// No password change: the existing keyring credential is left
// entirely untouched and only the persisted configuration is
// overwritten.
_ => {
self.db
.save_account(&config)
.await
.map_err(|e| Status::internal(format!("failed to persist account: {e}")))?;
}
}
Ok(Response::new(UpdateAccountResponse {}))
}
async fn remove_account(
&self,
request: Request<RemoveAccountRequest>,
) -> Result<Response<RemoveAccountResponse>, Status> {
let req = request.into_inner();
if req.id.is_empty() {
return Err(Status::invalid_argument("id is required"));
}
let existing = self
.db
.get_account(&req.id)
.await
.map_err(|e| Status::internal(format!("failed to look up account: {e}")))?
.ok_or_else(|| Status::not_found(format!("account '{}' not found", req.id)))?;
self.db
.delete_account(&req.id)
.await
.map_err(|e| Status::internal(format!("failed to remove account: {e}")))?;
// Best-effort credential cleanup: the account row is already gone, so
// a keyring deletion failure must not fail the RPC (which would
// wrongly imply the account still exists). Log it and move on -- an
// orphaned secret with no account row is inert.
if let Err(e) = self.secrets.delete_secret(&existing.keyring_secret_key) {
tracing::warn!(
keyring_secret_key = %existing.keyring_secret_key,
error = %e,
"failed to delete keyring credential after removing account"
);
}
Ok(Response::new(RemoveAccountResponse {}))
}
async fn test_account_connection(
&self,
request: Request<TestAccountConnectionRequest>,
) -> Result<Response<TestAccountConnectionResponse>, Status> {
let req = request.into_inner();
if req.id.is_empty() {
return Err(Status::invalid_argument("id is required"));
}
let config = self
.db
.get_account(&req.id)
.await
.map_err(|e| Status::internal(format!("failed to look up account: {e}")))?
.ok_or_else(|| Status::not_found(format!("account '{}' not found", req.id)))?;
let password = self
.secrets
.get_secret(&config.keyring_secret_key)
.map_err(|e| Status::internal(format!("failed to read credential from vault: {e}")))?;
let report = self.connection_tester.probe(&config, &password).await;
Ok(Response::new(TestAccountConnectionResponse {
imap_ok: report.imap.ok,
smtp_ok: report.smtp.ok,
imap_error: report.imap.error,
smtp_error: report.smtp.error,
}))
}
}
/// Default cap on messages returned by `ListMessages` when the caller
@@ -1026,6 +1355,7 @@ impl Mail for MailGrpcService {
.into_iter()
.map(map_attachment_from_proto)
.collect(),
account_id: req.account_id,
};
let message_id = if let Some(sender) = &self.overrides.message_sender {
@@ -1831,6 +2161,7 @@ pub async fn serve_on_listener(
MailEngineOverrides::default(),
CalendarEngineOverrides::default(),
ContactsEngineOverrides::default(),
AccountsEngineOverrides::default(),
)
.await
}
@@ -1857,6 +2188,7 @@ pub async fn serve_on_listener_with_overrides(
overrides: MailEngineOverrides,
calendar_overrides: CalendarEngineOverrides,
contacts_overrides: ContactsEngineOverrides,
accounts_overrides: AccountsEngineOverrides,
) -> Result<(), GrpcServeError> {
let token: Arc<str> = token.into();
@@ -1869,6 +2201,9 @@ pub async fn serve_on_listener_with_overrides(
let accounts_service = AccountsGrpcService {
db: db.clone(),
secrets: secrets.clone(),
connection_tester: accounts_overrides
.connection_tester
.unwrap_or_else(|| Arc::new(RealAccountConnectionTester)),
};
let accounts_interceptor = BearerAuthInterceptor::new(token.clone());
let accounts_svc = AccountsServer::with_interceptor(accounts_service, accounts_interceptor);
@@ -2050,6 +2385,36 @@ mod tests {
overrides: MailEngineOverrides,
calendar_overrides: CalendarEngineOverrides,
contacts_overrides: ContactsEngineOverrides,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
spawn_test_server_with_every_override(
event_bus,
db,
filter_engine,
secrets,
token,
overrides,
calendar_overrides,
contacts_overrides,
AccountsEngineOverrides::default(),
)
.await
}
/// Widest test spawn helper: threads an [`AccountsEngineOverrides`] too,
/// so a test can inject a stand-in [`AccountConnectionTester`] and drive
/// `TestAccountConnection` over the real authenticated gRPC API without a
/// live server.
#[allow(clippy::too_many_arguments)]
async fn spawn_test_server_with_every_override(
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
filter_engine: Arc<FilterEngine>,
secrets: Arc<SecretManager>,
token: &str,
overrides: MailEngineOverrides,
calendar_overrides: CalendarEngineOverrides,
contacts_overrides: ContactsEngineOverrides,
accounts_overrides: AccountsEngineOverrides,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
@@ -2067,6 +2432,7 @@ mod tests {
overrides,
calendar_overrides,
contacts_overrides,
accounts_overrides,
)
.await;
});
@@ -2654,6 +3020,194 @@ mod tests {
);
}
/// Proves that when `set_secret` succeeds but the subsequent
/// `save_account` genuinely fails, `add_account` rolls back the
/// just-written credential instead of leaving it orphaned in the
/// keyring. The failure is forced deterministically by closing the
/// `DatabaseEngine`'s connection pool before the RPC is made, so
/// `save_account` fails for real (not a simulated/fabricated error).
#[tokio::test]
async fn add_account_rolls_back_keyring_secret_when_save_account_fails() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let db = Arc::new(db);
let secrets = Arc::new(SecretManager::mock());
let (addr, _handle) = spawn_test_server_with(
Arc::new(EventBus::new()),
db.clone(),
Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set")),
secrets.clone(),
"correct-token",
)
.await;
// Close the pool out from under the still-running server so the
// upcoming `save_account` call fails with a real "pool closed"
// error rather than anything contrived.
db.close().await;
let mut client = AccountsClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.add_account(authed_bearer_request(AddAccountRequest {
config: Some(sample_account_config_proto(
"acct-rollback-1",
"nuncio/acct-rollback-1",
)),
password: "will-be-orphaned-without-rollback".to_string(),
}))
.await
.expect_err("save_account must fail once the pool is closed");
assert_eq!(err.code(), Code::Internal);
// The rollback must have deleted the secret written just before the
// failed persistence attempt -- not left it behind, unreferenced by
// any account row.
let after_rollback = secrets.get_secret("nuncio/acct-rollback-1");
assert!(
after_rollback.is_err(),
"keyring secret should have been rolled back, but was still readable: {after_rollback:?}"
);
}
/// Proves the error surfaced to the caller after a rollback describes
/// the original persistence failure, never the rollback itself -- the
/// rollback is a failure-path side effect, not a replacement error.
#[tokio::test]
async fn add_account_rollback_preserves_the_original_persistence_error() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let db = Arc::new(db);
let secrets = Arc::new(SecretManager::mock());
let (addr, _handle) = spawn_test_server_with(
Arc::new(EventBus::new()),
db.clone(),
Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set")),
secrets.clone(),
"correct-token",
)
.await;
db.close().await;
let mut client = AccountsClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.add_account(authed_bearer_request(AddAccountRequest {
config: Some(sample_account_config_proto(
"acct-rollback-2",
"nuncio/acct-rollback-2",
)),
password: "irrelevant-password".to_string(),
}))
.await
.expect_err("save_account must fail once the pool is closed");
let message = err.message();
assert!(
message.contains("failed to persist account"),
"error message must describe the persistence failure, got: {message}"
);
assert!(
!message.contains("failed to store credential in vault"),
"error message must not be the unrelated set_secret failure message, got: {message}"
);
}
/// Regression: a password rotation that fails to persist must RESTORE the
/// prior credential, never destroy it. Seeds an existing credential,
/// forces `save_account` to fail for real (closed pool), and asserts the
/// ORIGINAL secret is still retrievable afterward -- proving the update
/// rollback restores rather than deletes (which would orphan the still
/// present account row). Drives the exact rotation+rollback helper the
/// `update_account` RPC uses.
#[tokio::test]
async fn update_account_password_rotation_restores_prior_credential_on_persist_failure() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let secrets = SecretManager::mock();
let key = "nuncio/acct-rotate-1";
secrets
.set_secret(key, "original-working-password")
.expect("seed the prior credential");
let config = nuncio_core::AccountConfig {
id: "acct-rotate-1".to_string(),
name: "Rotate Account".to_string(),
email_address: "rotate@nuncio.mx".to_string(),
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
keyring_secret_key: key.to_string(),
sync_interval_secs: 60,
};
// Force `save_account` to fail for real by closing the pool, exactly
// as `add_account`'s rollback test does.
db.close().await;
let err = rotate_credential_and_persist(&db, &secrets, &config, "new-rotated-password")
.await
.expect_err("save_account must fail once the pool is closed");
assert_eq!(err.code(), Code::Internal);
assert!(err.message().contains("failed to persist account"));
// The prior credential must be intact -- neither deleted nor left as
// the failed new value.
let restored = secrets.get_secret(key).expect("prior credential survives");
assert_eq!(restored, "original-working-password");
}
/// Companion: when the key held NO prior secret, a failed rotation falls
/// back to deleting the just-written one (matching `add_account`), leaving
/// no orphaned credential.
#[tokio::test]
async fn update_account_password_rotation_deletes_new_credential_when_none_existed() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let secrets = SecretManager::mock();
let key = "nuncio/acct-rotate-2";
let config = nuncio_core::AccountConfig {
id: "acct-rotate-2".to_string(),
name: "Rotate Account 2".to_string(),
email_address: "rotate2@nuncio.mx".to_string(),
protocol: nuncio_core::AccountProtocol::ImapSmtp,
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 465,
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
keyring_secret_key: key.to_string(),
sync_interval_secs: 60,
};
db.close().await;
let err = rotate_credential_and_persist(&db, &secrets, &config, "new-rotated-password")
.await
.expect_err("save_account must fail once the pool is closed");
assert_eq!(err.code(), Code::Internal);
// No prior secret existed, so the just-written one is rolled back by
// deletion -- nothing orphaned.
assert!(secrets.get_secret(key).is_err());
}
// ---- Mail ----
use nuncio_proto::v1::mail_client::MailClient;
@@ -2742,6 +3296,7 @@ mod tests {
body_text: "Body".to_string(),
body_html: None,
attachments: Vec::new(),
account_id: None,
})
.await
.expect_err("missing bearer token must be rejected");
@@ -2999,6 +3554,7 @@ mod tests {
body_text: "Let's discuss the roadmap.".to_string(),
body_html: None,
attachments: Vec::new(),
account_id: None,
}
}
@@ -3286,6 +3842,7 @@ mod tests {
body_text: "Injected send body".to_string(),
body_html: None,
attachments: Vec::new(),
account_id: None,
}))
.await
.expect("send_message succeeds")
+113 -16
View File
@@ -12,13 +12,13 @@
//!
//! # Account selection
//!
//! The `nuncio.v1.Mail` service (like every other RPC on it --
//! `ListMessages`, `GetMessage`, `SearchMessages`, etc.) does not yet
//! disambiguate between multiple configured accounts; it operates over a
//! single, global store. Consistent with that existing scope, `SendMessage`
//! sends from the first account returned by [`DatabaseEngine::list_accounts`].
//! Supporting an explicit multi-account choice is future work, alongside
//! the rest of the `Mail` service's single-account scope.
//! `SendMessage` accepts an optional `account_id`. When the caller supplies
//! one, that specific account is resolved and used as the sender -- a
//! request naming an account that does not exist fails honestly rather than
//! silently falling back to a different identity. When `account_id` is
//! omitted, the account resolves to the first one returned by
//! [`DatabaseEngine::list_accounts`], preserving behavior for callers that
//! predate explicit selection.
//!
//! # Testability
//!
@@ -61,6 +61,13 @@ pub enum SendError {
/// No account is configured to send mail from.
#[error("no account is configured to send mail from")]
NoAccountConfigured,
/// The caller specified an `account_id` that does not match any
/// configured account. Distinct from [`SendError::NoAccountConfigured`]
/// (which means zero accounts exist at all): this means at least one
/// account exists, just not the one requested, so the request must fail
/// rather than silently sending from a different account.
#[error("no account configured with id {0:?}")]
AccountNotFound(String),
/// Failed to read the account's credential from the secret vault.
#[error("failed to read account credential from the secret vault: {0}")]
Vault(#[from] VaultError),
@@ -90,6 +97,9 @@ pub struct ComposeRequest {
pub body_html: Option<String>,
/// File attachments.
pub attachments: Vec<Attachment>,
/// Identifier of the account to send from. `None` preserves the prior
/// "first configured account" behavior.
pub account_id: Option<String>,
}
/// Send `message` through `sender`. Pure seam shared by production and
@@ -113,6 +123,7 @@ fn build_smtp_sender(
let engine = SmtpTransportEngine::new(
&config.smtp_host,
config.smtp_port,
config.smtp_tls_mode,
&config.email_address,
password,
)?;
@@ -120,14 +131,24 @@ fn build_smtp_sender(
}
/// Resolves the account to send mail from. See the module-level "Account
/// selection" doc above for why this is currently the first configured
/// account rather than an explicit per-request choice.
async fn resolve_sending_account(db: &DatabaseEngine) -> Result<AccountConfig, SendError> {
/// selection" doc above: an explicit `account_id` must match a configured
/// account or the request fails; `None` preserves the "first configured
/// account" behavior.
async fn resolve_sending_account(
db: &DatabaseEngine,
account_id: Option<&str>,
) -> Result<AccountConfig, SendError> {
let accounts = db.list_accounts().await?;
accounts
.into_iter()
.next()
.ok_or(SendError::NoAccountConfigured)
match account_id {
Some(id) => accounts
.into_iter()
.find(|a| a.id == id)
.ok_or_else(|| SendError::AccountNotFound(id.to_string())),
None => accounts
.into_iter()
.next()
.ok_or(SendError::NoAccountConfigured),
}
}
/// Generates a daemon-local identifier for a sent message, for reference in
@@ -153,7 +174,7 @@ pub async fn send_message_for_account(
secrets: &SecretManager,
request: ComposeRequest,
) -> Result<String, SendError> {
let config = resolve_sending_account(db).await?;
let config = resolve_sending_account(db, request.account_id.as_deref()).await?;
let password = secrets.get_secret(&config.keyring_secret_key)?;
let sender = build_smtp_sender(&config, &password)?;
@@ -185,7 +206,7 @@ pub async fn send_message_with_injected_sender(
sender: &dyn MessageSender,
request: ComposeRequest,
) -> Result<String, SendError> {
let config = resolve_sending_account(db).await?;
let config = resolve_sending_account(db, request.account_id.as_deref()).await?;
let outbound = OutboundMessage {
from: config.email_address,
@@ -285,6 +306,7 @@ mod tests {
body_text: Some("Body".to_string()),
body_html: None,
attachments: Vec::new(),
account_id: None,
},
)
.await
@@ -313,6 +335,7 @@ mod tests {
body_text: Some("Body".to_string()),
body_html: None,
attachments: Vec::new(),
account_id: None,
},
)
.await
@@ -351,6 +374,7 @@ mod tests {
body_text: Some("Body".to_string()),
body_html: None,
attachments: Vec::new(),
account_id: None,
},
)
.await
@@ -391,6 +415,7 @@ mod tests {
body_text: Some("Body via injected sender.".to_string()),
body_html: None,
attachments: Vec::new(),
account_id: None,
},
)
.await
@@ -422,6 +447,7 @@ mod tests {
body_text: Some("Body".to_string()),
body_html: None,
attachments: Vec::new(),
account_id: None,
},
)
.await
@@ -429,4 +455,75 @@ mod tests {
assert!(matches!(err, SendError::NoAccountConfigured));
assert!(mock.sent_messages().is_empty());
}
/// Proves that with multiple configured accounts, an explicit
/// `account_id` selects that SPECIFIC account as the sender -- not
/// whichever account happens to be first -- by asserting the captured
/// outbound message's `From:` matches the requested account.
#[tokio::test]
async fn send_message_with_injected_sender_uses_the_explicitly_selected_account() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let first = sample_account("acct-first");
let second = sample_account("acct-second");
db.save_account(&first).await.expect("save first account");
db.save_account(&second).await.expect("save second account");
let mock = MockMessageSender::new();
send_message_with_injected_sender(
&db,
&mock,
ComposeRequest {
to: "bob@nuncio.mx".to_string(),
cc: None,
subject: "Hi".to_string(),
body_text: Some("Body".to_string()),
body_html: None,
attachments: Vec::new(),
account_id: Some(second.id.clone()),
},
)
.await
.expect("send succeeds via explicitly selected account");
let sent = mock.sent_messages();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].from, second.email_address);
assert_ne!(sent[0].from, first.email_address);
}
/// An `account_id` naming an account that does not exist must fail with
/// the specific [`SendError::AccountNotFound`] variant -- never a silent
/// fallback to the first configured account, and never confusable with
/// [`SendError::NoAccountConfigured`] (which means zero accounts exist).
#[tokio::test]
async fn send_message_with_injected_sender_rejects_unknown_account_id() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let config = sample_account("acct-only");
db.save_account(&config).await.expect("save account");
let mock = MockMessageSender::new();
let err = send_message_with_injected_sender(
&db,
&mock,
ComposeRequest {
to: "bob@nuncio.mx".to_string(),
cc: None,
subject: "Hi".to_string(),
body_text: Some("Body".to_string()),
body_html: None,
attachments: Vec::new(),
account_id: Some("acct-does-not-exist".to_string()),
},
)
.await
.expect_err("unknown account id must fail");
assert!(matches!(err, SendError::AccountNotFound(id) if id == "acct-does-not-exist"));
assert!(mock.sent_messages().is_empty());
}
}
+135 -4
View File
@@ -18,7 +18,7 @@
//! only places that resolve a real account's credentials and construct a
//! real backend; they then delegate the actual fetch/persist/event-emission
//! work to [`sync_with_backend`].
use nuncio_core::{AccountConfig, AccountProtocol, CoreCommand, EventBus};
use nuncio_core::{AccountConfig, AccountProtocol, CoreCommand, CoreEvent, EventBus};
use nuncio_filter::{FilterEngine, OutboxManager, RuleAction};
use nuncio_mail::{ImapEngine, JmapEngine, MailBackend, MailError};
use nuncio_store::db::DatabaseError;
@@ -235,13 +235,27 @@ async fn is_new_message(db: &nuncio_store::db::DatabaseEngine, message_id: &str)
/// [`MockMailBackend`].
async fn fetch_and_persist(
db: &nuncio_store::db::DatabaseEngine,
event_bus: &EventBus,
backend: &dyn MailBackend,
filter_engine: &FilterEngine,
account_id: Option<&str>,
) -> Result<usize, SyncError> {
let folders = backend.sync_folders().await?;
let mut synced = 0usize;
for folder in folders {
let (emails, _state) = backend.sync_messages(&folder.id, None).await?;
// Resume from the folder's stored checkpoint so the backend can fetch
// only what changed since the last sync. A checkpoint is keyed by
// (account, folder), so a run with no account context (a mock-driven
// `SyncAll` with `account_id == None`) has nowhere to key it and always
// performs a full fetch.
let last_state = match account_id {
Some(acct) => db.get_folder_sync_state(acct, &folder.id).await?,
None => None,
};
let (emails, new_state) = backend
.sync_messages(&folder.id, last_state.as_deref())
.await?;
let fetched = emails.len();
for email in emails {
let is_new = is_new_message(db, &email.id).await;
db.save_email(&email).await?;
@@ -250,6 +264,19 @@ async fn fetch_and_persist(
}
synced += 1;
}
// Persist the returned checkpoint only AFTER this folder's messages
// have landed, so the stored high-water mark can never advance past
// work that actually reached the store.
if let Some(acct) = account_id {
db.save_folder_sync_state(acct, &folder.id, &new_state)
.await?;
}
event_bus.publish_event(CoreEvent::SyncProgress {
account_id: account_id.map(str::to_string),
folder_id: folder.id.clone(),
fetched,
total: synced,
});
}
Ok(synced)
}
@@ -282,7 +309,8 @@ pub async fn sync_with_backend(
None => event_bus.process_command(CoreCommand::SyncAll),
}
let result = fetch_and_persist(db, backend, filter_engine).await;
let result =
fetch_and_persist(db, event_bus, backend, filter_engine, account_id.as_deref()).await;
if let Err(e) = &result {
event_bus.process_command(CoreCommand::ReportError {
@@ -317,6 +345,7 @@ fn build_mail_backend(config: &AccountConfig, password: &str) -> Box<dyn MailBac
&config.id,
&config.server_host,
config.server_port,
config.imap_tls_mode,
&config.email_address,
password,
)),
@@ -393,7 +422,15 @@ pub async fn run_all_accounts_sync(
match secrets.get_secret(&config.keyring_secret_key) {
Ok(password) => {
let backend = build_mail_backend(&config, &password);
match fetch_and_persist(db, backend.as_ref(), filter_engine).await {
match fetch_and_persist(
db,
event_bus,
backend.as_ref(),
filter_engine,
Some(&config.id),
)
.await
{
Ok(count) => total_synced += count,
Err(e) => {
event_bus.process_command(CoreCommand::ReportError {
@@ -618,6 +655,16 @@ mod tests {
account_id: Some("acct-mock-1".to_string())
}
);
// A per-folder progress event is published between start and complete.
assert_eq!(
events.recv().await.expect("progress event"),
CoreEvent::SyncProgress {
account_id: Some("acct-mock-1".to_string()),
folder_id: "inbox".to_string(),
fetched: 2,
total: 2,
}
);
assert_eq!(
events.recv().await.expect("complete event"),
CoreEvent::SyncCompleted {
@@ -653,6 +700,16 @@ mod tests {
events.recv().await.expect("start event"),
CoreEvent::SyncStarted { account_id: None }
);
// The folder is empty, but a progress event still marks it done.
assert_eq!(
events.recv().await.expect("progress event"),
CoreEvent::SyncProgress {
account_id: None,
folder_id: "inbox".to_string(),
fetched: 0,
total: 0,
}
);
assert_eq!(
events.recv().await.expect("complete event"),
CoreEvent::SyncCompleted { account_id: None }
@@ -790,6 +847,15 @@ mod tests {
account_id: Some("acct-jmap-real-1".to_string())
}
);
assert_eq!(
events.recv().await.expect("progress event"),
CoreEvent::SyncProgress {
account_id: Some("acct-jmap-real-1".to_string()),
folder_id: "jmap-inbox".to_string(),
fetched: 1,
total: 1,
}
);
assert_eq!(
events.recv().await.expect("complete event"),
CoreEvent::SyncCompleted {
@@ -1201,4 +1267,69 @@ mod tests {
"no new outbox mutation should be enqueued on a repeat sync"
);
}
#[tokio::test]
async fn sync_with_backend_persists_and_threads_the_folder_checkpoint_across_syncs() {
// The regression this story exists to kill: the returned checkpoint
// must be persisted and fed back into the NEXT sync, so the second
// sync is genuinely incremental rather than a full re-fetch. Proven by
// capturing the exact `since_state` the backend received on each call.
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("ephemeral db");
let event_bus = EventBus::new();
let mock = MockMailBackend::new();
mock.set_returned_state("uidnext-4242");
mock.add_folder(Folder {
id: "inbox".to_string(),
name: "Inbox".to_string(),
total_messages: 1,
unread_messages: 1,
});
mock.add_message(mock_email("m-inc-1", "inbox", "First"));
let filter_engine = empty_filter_engine();
// First sync: no checkpoint exists yet, so the backend is called with
// `None` and does a full fetch (one message).
let first = sync_with_backend(
&db,
&event_bus,
&mock,
&filter_engine,
Some("acct-inc-1".to_string()),
)
.await
.expect("first sync succeeds");
assert_eq!(first, 1);
// The returned checkpoint must now be persisted for (account, folder).
assert_eq!(
db.get_folder_sync_state("acct-inc-1", "inbox")
.await
.expect("read checkpoint"),
Some("uidnext-4242".to_string())
);
// Second sync: the daemon must feed the stored checkpoint back in. The
// mock returns nothing new for a non-None checkpoint -> narrower fetch.
let second = sync_with_backend(
&db,
&event_bus,
&mock,
&filter_engine,
Some("acct-inc-1".to_string()),
)
.await
.expect("second sync succeeds");
assert_eq!(second, 0, "an incremental second sync fetches nothing new");
// The proof of genuine incrementality: the backend saw `None` first,
// then the exact checkpoint the first sync returned.
assert_eq!(
mock.since_state_calls(),
vec![None, Some("uidnext-4242".to_string())],
);
}
}
@@ -0,0 +1,244 @@
//! Offline end-to-end test for the account lifecycle over gRPC.
//!
//! Boots a real `nunciod` daemon gRPC server
//! (`nunciod::grpc::serve_on_listener_with_overrides`) on an ephemeral
//! loopback port, backed by a real temp file-backed [`DatabaseEngine`] and a
//! [`SecretManager::mock`] vault (never the real OS keyring), with an injected
//! stand-in [`AccountConnectionTester`] standing in for real IMAP/SMTP network
//! I/O (via the [`AccountsEngineOverrides`] injection seam).
//!
//! Driving ONLY the authenticated `nuncio-proto` Accounts client with the
//! shared bearer token -- never reaching into daemon internals -- this proves
//! the whole lifecycle end to end:
//! a. `AddAccount` persists an account whose IMAP mode is `StartTls` and
//! SMTP mode is `Plain`.
//! b. `ListAccounts` reflects it AND round-trips both distinct TLS modes
//! (the previously silently-discarded fields).
//! c. `UpdateAccount` genuinely mutates the persisted config (confirmed via
//! a second `ListAccounts`).
//! d. `TestAccountConnection` reports the injected tester's GENUINE
//! per-protocol result (IMAP ok, SMTP failed with real error detail) --
//! never a fabricated OK.
//! e. `RemoveAccount` genuinely deletes it (a final `ListAccounts` is
//! empty).
//!
//! Fully offline: no real network, no real OS keyring, no live server.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use nuncio_core::EventBus;
use nuncio_filter::FilterEngine;
use nuncio_proto::v1::{
AccountConfig, AccountProtocol, AddAccountRequest, ListAccountsRequest, RemoveAccountRequest,
TestAccountConnectionRequest, TlsMode, UpdateAccountRequest,
};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nunciod::grpc::{
serve_on_listener_with_overrides, AccountConnectionReport, AccountConnectionTester,
AccountsEngineOverrides, CalendarEngineOverrides, ContactsEngineOverrides, MailEngineOverrides,
ProtocolProbe,
};
use std::sync::Arc;
use tokio::net::TcpListener;
const ACCOUNT_ID: &str = "acct-lifecycle-e2e-1";
const ACCOUNT_EMAIL: &str = "lifecycle-e2e@nuncio.mx";
const KEYRING_KEY: &str = "nuncio/acct-lifecycle-e2e-1";
/// Injected stand-in [`AccountConnectionTester`]: returns a fixed but honest
/// per-protocol report (IMAP succeeds, SMTP fails with real error detail) so
/// `TestAccountConnection` can be exercised end to end WITHOUT any live
/// server. The daemon's production tester performs a genuine bounded network
/// probe; this stub stands in for that real world exactly as the mock
/// mail/calendar/contacts backends do for their protocols.
struct StubConnectionTester;
#[tonic::async_trait]
impl AccountConnectionTester for StubConnectionTester {
async fn probe(
&self,
_config: &nuncio_core::AccountConfig,
_password: &str,
) -> AccountConnectionReport {
AccountConnectionReport {
imap: ProtocolProbe {
ok: true,
error: None,
},
smtp: ProtocolProbe {
ok: false,
error: Some("SMTP relay refused the connection".to_string()),
},
}
}
}
fn sample_account_config() -> AccountConfig {
AccountConfig {
id: ACCOUNT_ID.to_string(),
name: "Lifecycle E2E Account".to_string(),
email_address: ACCOUNT_EMAIL.to_string(),
protocol: AccountProtocol::ImapSmtp.into(),
server_host: "imap.nuncio.mx".to_string(),
server_port: 143,
use_tls: false,
imap_tls_mode: TlsMode::StartTls.into(),
smtp_tls_mode: TlsMode::Plain.into(),
keyring_secret_key: KEYRING_KEY.to_string(),
sync_interval_secs: 60,
smtp_host: "smtp.nuncio.mx".to_string(),
smtp_port: 25,
}
}
/// Boots the real daemon gRPC server on an ephemeral loopback port with an
/// injected [`AccountsEngineOverrides`]. Returns the server address and the
/// minted token; the `TempDir` guard is returned so the caller controls its
/// lifetime for the whole test.
async fn boot_daemon(
secrets: Arc<SecretManager>,
accounts_overrides: AccountsEngineOverrides,
) -> (std::net::SocketAddr, String, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create temp dir for file-backed db");
let db_path = dir.path().join("accounts_lifecycle_e2e.db");
let db = Arc::new(
DatabaseEngine::connect_file(&db_path, &secrets)
.await
.expect("open temp file-backed database"),
);
let token_bytes = secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("mint gRPC bearer token from mock vault");
let token = hex::encode(token_bytes);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
let event_bus = Arc::new(EventBus::new());
let server_secrets = secrets.clone();
let server_token = token.clone();
let server_filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
tokio::spawn(async move {
let _ = serve_on_listener_with_overrides(
listener,
event_bus,
db,
server_filter_engine,
server_secrets,
server_token,
MailEngineOverrides::default(),
CalendarEngineOverrides::default(),
ContactsEngineOverrides::default(),
accounts_overrides,
)
.await;
});
(addr, token, dir)
}
#[tokio::test]
async fn account_lifecycle_round_trips_over_authenticated_grpc_offline() {
let secrets = Arc::new(SecretManager::mock());
let accounts_overrides = AccountsEngineOverrides {
connection_tester: Some(Arc::new(StubConnectionTester)),
};
let (addr, token, _db_dir) = boot_daemon(secrets, accounts_overrides).await;
let addr_str = addr.to_string();
let mut accounts_client = nuncio_proto::client::connect_accounts(&addr_str, &token)
.await
.expect("accounts client connects");
// ---- (a) AddAccount persists a StartTls/Plain account ----
let add_response = accounts_client
.add_account(AddAccountRequest {
config: Some(sample_account_config()),
password: "lifecycle-secret-password".to_string(),
})
.await
.expect("add_account succeeds")
.into_inner();
assert_eq!(add_response.id, ACCOUNT_ID);
// ---- (b) ListAccounts round-trips BOTH distinct TLS modes ----
let listed = accounts_client
.list_accounts(ListAccountsRequest {})
.await
.expect("list_accounts succeeds")
.into_inner()
.accounts;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].imap_tls_mode(), TlsMode::StartTls);
assert_eq!(listed[0].smtp_tls_mode(), TlsMode::Plain);
// ---- (c) UpdateAccount genuinely mutates the persisted config ----
let mut updated = sample_account_config();
updated.name = "Renamed Lifecycle Account".to_string();
updated.imap_tls_mode = TlsMode::ImplicitTls.into();
accounts_client
.update_account(UpdateAccountRequest {
config: Some(updated),
password: None,
})
.await
.expect("update_account succeeds");
let relisted = accounts_client
.list_accounts(ListAccountsRequest {})
.await
.expect("list_accounts succeeds")
.into_inner()
.accounts;
assert_eq!(relisted.len(), 1);
assert_eq!(relisted[0].name, "Renamed Lifecycle Account");
assert_eq!(relisted[0].imap_tls_mode(), TlsMode::ImplicitTls);
// Untouched fields survive the update.
assert_eq!(relisted[0].smtp_tls_mode(), TlsMode::Plain);
// ---- (d) TestAccountConnection reports the GENUINE per-protocol result ----
let report = accounts_client
.test_account_connection(TestAccountConnectionRequest {
id: ACCOUNT_ID.to_string(),
})
.await
.expect("test_account_connection succeeds")
.into_inner();
assert!(report.imap_ok);
assert!(!report.smtp_ok);
assert_eq!(report.imap_error, None);
assert_eq!(
report.smtp_error.as_deref(),
Some("SMTP relay refused the connection")
);
// ---- (e) RemoveAccount genuinely deletes it ----
accounts_client
.remove_account(RemoveAccountRequest {
id: ACCOUNT_ID.to_string(),
})
.await
.expect("remove_account succeeds");
let after_remove = accounts_client
.list_accounts(ListAccountsRequest {})
.await
.expect("list_accounts succeeds")
.into_inner()
.accounts;
assert!(after_remove.is_empty());
// A test against the now-removed account is an honest not-found, never a
// fabricated success.
let err = accounts_client
.test_account_connection(TestAccountConnectionRequest {
id: ACCOUNT_ID.to_string(),
})
.await
.expect_err("testing a removed account must fail");
assert_eq!(err.code(), tonic::Code::NotFound);
}
+3 -2
View File
@@ -30,8 +30,8 @@ use nuncio_proto::v1::{CalendarSyncRequest, GetEventRequest, ListEventsRequest};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nunciod::grpc::{
serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides,
MailEngineOverrides,
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
ContactsEngineOverrides, MailEngineOverrides,
};
use std::sync::Arc;
use tokio::net::TcpListener;
@@ -95,6 +95,7 @@ async fn boot_daemon(
MailEngineOverrides::default(),
calendar_overrides,
ContactsEngineOverrides::default(),
AccountsEngineOverrides::default(),
)
.await;
});
+3 -2
View File
@@ -39,8 +39,8 @@ use nuncio_proto::v1::{
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nunciod::grpc::{
serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides,
MailEngineOverrides,
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
ContactsEngineOverrides, MailEngineOverrides,
};
use std::sync::Arc;
use tokio::net::TcpListener;
@@ -97,6 +97,7 @@ async fn boot_daemon(
MailEngineOverrides::default(),
CalendarEngineOverrides::default(),
contacts_overrides,
AccountsEngineOverrides::default(),
)
.await;
});
+48
View File
@@ -188,6 +188,54 @@ async fn rejects_calls_to_new_filters_rpcs_without_a_bearer_token() {
assert_eq!(err.code(), Code::Unauthenticated);
}
/// Confirms the newly added `Accounts` lifecycle RPCs
/// (`UpdateAccount`/`RemoveAccount`/`TestAccountConnection`) are each mounted
/// behind the SAME `BearerAuthInterceptor` as every other service -- an
/// unauthenticated call to any of them must be rejected exactly like an
/// unauthenticated `System/GetStatus`, with no carve-out for "just one more"
/// RPC.
#[tokio::test]
async fn rejects_calls_to_new_accounts_rpcs_without_a_bearer_token() {
let event_bus = Arc::new(EventBus::new());
let secrets = SecretManager::mock();
let token = hex::encode(
secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.expect("token provisioned from mock vault"),
);
let (addr, _dir) = start_server(event_bus, token).await;
let mut client =
nuncio_proto::v1::accounts_client::AccountsClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let update_err = client
.update_account(nuncio_proto::v1::UpdateAccountRequest {
config: None,
password: None,
})
.await
.expect_err("unauthenticated update_account must be rejected");
assert_eq!(update_err.code(), Code::Unauthenticated);
let remove_err = client
.remove_account(nuncio_proto::v1::RemoveAccountRequest {
id: "acct-1".to_string(),
})
.await
.expect_err("unauthenticated remove_account must be rejected");
assert_eq!(remove_err.code(), Code::Unauthenticated);
let test_err = client
.test_account_connection(nuncio_proto::v1::TestAccountConnectionRequest {
id: "acct-1".to_string(),
})
.await
.expect_err("unauthenticated test_account_connection must be rejected");
assert_eq!(test_err.code(), Code::Unauthenticated);
}
/// Confirms the streaming `Filters.Triage` RPC is mounted behind the SAME
/// `BearerAuthInterceptor` as every other `Filters` RPC -- an unauthenticated
/// call must be rejected before the stream is ever established, exactly
+4 -2
View File
@@ -43,8 +43,8 @@ use nuncio_proto::v1::{
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nunciod::grpc::{
serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides,
MailEngineOverrides,
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
ContactsEngineOverrides, MailEngineOverrides,
};
use std::sync::Arc;
use tokio::net::TcpListener;
@@ -130,6 +130,7 @@ async fn boot_daemon(
overrides,
CalendarEngineOverrides::default(),
ContactsEngineOverrides::default(),
AccountsEngineOverrides::default(),
)
.await;
});
@@ -304,6 +305,7 @@ async fn full_mail_spine_round_trips_over_authenticated_grpc_with_mocked_protoco
body_text: "Let's discuss the roadmap.".to_string(),
body_html: None,
attachments: Vec::new(),
account_id: None,
})
.await
.expect("send_message succeeds")
+27 -19
View File
@@ -25,22 +25,30 @@ machine (or a new LLM) picks up the work cold.
## Current state of the code (2026-07-30)
- **Phases 0–2 complete.** The daemon boots gRPC-only on loopback
`127.0.0.1:9420` behind a keyring bearer token, serving six services (System,
Accounts, Mail, Filters, Export, Audit), each behind the auth interceptor. The
`127.0.0.1:9420` behind a keyring bearer token, serving eight services (System,
Accounts, Mail, Filters, Calendar, Contacts, Export, Audit), each behind the
auth interceptor. The
`IMAP → store → read → SMTP send` spine works end-to-end over gRPC via the
`nuncio-cli` reference client, tested offline. `nuncio.v1` is published with a
contract-stability golden.
- **Phase 3 (feature completeness) is now COMPLETE — M1–M4 all merged to `dev`.**
- **The feature surface (Phase 3 through M5) is now COMPLETE — M1–M5 all merged
to `dev`** (M1–M4 already promoted to `main`; M5 promotes next).
Filter correctness (3.A), Calendar (M1: CalDAV + RRULE recurrence, API vertical),
Contacts (M2: real CardDAV engine + API vertical), JMAP (M3: real client wired
into Mail sync), and Filters made live (M4: engine wired into sync with a
into Mail sync), Filters made live (M4: engine wired into sync with a
fire-once guard, the `account`-field bug fixed, edit/export/import/logs and
`Filters.Triage` bulk rescan on the gRPC service) are all done.
- **Next milestone: M5 — Mail lifecycle + incremental sync** (account
edit/delete/test-connection + persisted TLS mode, incremental IMAP sync with
progress + per-item FETCH timeout, explicit `SendMessage` account selection,
keyring rollback on failed `AddAccount`). Then M6 (security hardening), then
M7 (API freeze / client-readiness).
`Filters.Triage` bulk rescan on the gRPC service), and Mail lifecycle +
incremental sync (M5: account edit/delete/test-connection with persisted
`imap_tls_mode`/`smtp_tls_mode` driving the real IMAP/SMTP connection, a real
per-folder `{uidvalidity}:{uidnext}` incremental-sync checkpoint with a safe
full re-fetch on UIDVALIDITY change and a per-item FETCH timeout, explicit
`SendMessage` account selection, and keyring rollback on failed `AddAccount`)
are all done.
- **Next milestone: M6 — Security & release hardening** (fail-closed
encryption, zeroized key material, WORM/ledger key-length validation,
non-loopback gRPC bind rejection + an auth-coverage canary, a fail-closed
updater on missing `SHA256SUMS`, real CIDR/DNS SSRF checks for webhooks,
inbound HTML email sanitization). Then M7 (API freeze / client-readiness).
- Full detail and the path to a client-ready API: `docs/ROADMAP.md`.
## Branches & preserved WIP
@@ -94,7 +102,7 @@ GitHub milestones **M1–M7** mirror `docs/ROADMAP.md`. Each milestone holds
`story`-labelled issues with a full, self-contained spec. Order: M1 → M7 (M1–M5
independent-ish feature work; M6 security; M7 the client-readiness gate).
**M1–M4 are DELIVERED — all stories merged to `dev`.** M5 is next.
**M1–M5 are DELIVERED — all stories merged to `dev`.** M6 is next.
**M1 — Finish Calendar (CalDAV)** · milestone #23 · **DONE**
- #185 — Add Calendar API vertical (proto, daemon, CLI, E2E) · **[PROTO]** · merged
@@ -113,13 +121,13 @@ independent-ish feature work; M6 security; M7 the client-readiness gate).
- #192 — Move filter edit/export/import/logs onto the Filters gRPC service · **[PROTO]** · merged
- #193 — Re-expose bulk filter triage as a streaming Filters RPC · **[PROTO]** *(depends on #190)* · merged
**M5 — Mail lifecycle + incremental sync** · milestone #27 ← **next**
- #194 — Account lifecycle over gRPC (edit/delete/test) + persist TLS mode · **[PROTO]** *(prior art: `wip/unreviewed-account-tls-sync`, reference-not-trust)*
- #195 — Incremental IMAP sync with progress reporting and per-item FETCH timeout
- #196 — SendMessage selects an explicit account instead of the first configured · **[PROTO]**
- #197 — Roll back orphaned keyring secret when AddAccount fails to persist
**M5 — Mail lifecycle + incremental sync** · milestone #27 · **DONE**
- #194 — Account lifecycle over gRPC (edit/delete/test) + persist TLS mode · **[PROTO]** *(prior art: `wip/unreviewed-account-tls-sync`, reference-not-trust)* · merged
- #195 — Incremental IMAP sync with progress reporting and per-item FETCH timeout · merged
- #196 — SendMessage selects an explicit account instead of the first configured · **[PROTO]** · merged
- #197 — Roll back orphaned keyring secret when AddAccount fails to persist · merged
**M6 — Security & release hardening** · milestone #28
**M6 — Security & release hardening** · milestone #28 ← **next**
- #198 — Fail closed on encryption failure in `PayloadCipher::encrypt_text_at_rest`
- #199 — Zeroize long-lived engine key material on drop
- #200 — Validate WORM/ledger key length from the vault, fail closed on mismatch
@@ -145,7 +153,7 @@ independent-ish feature work; M6 security; M7 the client-readiness gate).
those two issues if you consider that sensitive.
- Decide the eventual fate of `wip/unreviewed-account-tls-sync` (review-and-adopt
vs discard) once M5 lands a clean implementation.
- Follow-ups filed during M1–M4 that are open but not blocking (not scheduled
- Follow-ups filed during M1–M5 that are open but not blocking (not scheduled
into a milestone yet): #216 (calendar windowing/predicate refinement), #219
(contacts notes handling), #225 (`filter edit` silently re-enables a disabled
rule and resets `created_at`).
rule and resets `created_at`), #233 (M5 sync/SMTP robustness polish).
+24 -11
View File
@@ -53,8 +53,9 @@ offline. The legacy JSON-RPC IPC is removed. The `nuncio.v1` contract is
published with a byte-deterministic `FileDescriptorSet` golden guarding it, plus
external-codegen docs for Swift/C#/TypeScript.
**Phase 3 — Feature completeness — is now COMPLETE**, via milestones M1–M4, all
merged to `dev`:
**The feature surface — Phase 3 through M5 — is now COMPLETE**, via milestones
M1–M5, all merged to `dev` (M1–M4 are already promoted to `main`; M5 promotes
next):
- **3.A Filter correctness** — **done** (account scoping, `ON ACCOUNT`, non-ASCII
offsets, `NOT IN`).
@@ -72,18 +73,30 @@ merged to `dev`:
honestly rejected, filter edit/export/import/logs moved onto the `Filters`
gRPC service, and a `Filters.Triage` server-streaming RPC does retroactive
bulk rescan.
- **Mail lifecycle & incremental sync** — **done** (M5). Account lifecycle
(`UpdateAccount`/`RemoveAccount`/`TestAccountConnection`) is real over gRPC,
with `imap_tls_mode`/`smtp_tls_mode` persisted and actually driving the real
IMAP (implicit/STARTTLS/plain) and SMTP connection. Sync is genuinely
incremental: a real per-folder `{uidvalidity}:{uidnext}` checkpoint narrows
the FETCH instead of re-fetching everything, a UIDVALIDITY change forces a
safe full re-fetch (no silent mail loss), a per-item FETCH timeout replaces
the indefinite hang, and a `SyncProgress` event is emitted. `SendMessage`
takes an explicit `account_id` (honest `AccountNotFound`, no silent
wrong-account fallback), and `AddAccount` rolls back an orphaned keyring
secret if persistence fails.
**Next up: M5 — Mail lifecycle & incremental sync** (account edit/delete/test +
persisted TLS mode, incremental IMAP sync with progress + per-item FETCH
timeout, explicit `SendMessage` account selection, keyring rollback on failed
`AddAccount`). Then M6 (security hardening), then M7 (API freeze / client
readiness).
**Next up: M6 — Security & release hardening** (fail-closed encryption,
zeroized key material, WORM/ledger key-length validation, a rejection of
non-loopback gRPC binds plus an auth-coverage canary, a fail-closed updater on
missing `SHA256SUMS`, real CIDR/DNS SSRF checks for webhooks, inbound HTML
email sanitization). Then M7 (API freeze / client-readiness).
A handful of follow-ups were filed during M1–M4 and remain open, tracked as
their own backlog items rather than blocking M1–M4's completion: calendar
windowing/predicate refinement (#216), contacts notes handling (#219), and a
A handful of follow-ups were filed during M1–M5 and remain open, tracked as
their own backlog items rather than blocking completion: calendar
windowing/predicate refinement (#216), contacts notes handling (#219), a
`filter edit` bug where re-saving a rule silently re-enables it and resets
`created_at` (#225).
`created_at` (#225), and further mail sync/SMTP robustness polish identified
during M5 (#233).
**Explicitly deferred** (out of scope until the backend is client-ready and each
is re-justified as a genuine engine capability): OpenPGP/S-MIME E2EE (#79),
+19 -13
View File
@@ -76,7 +76,7 @@
/* milestone rail */
.miles{position:relative;margin-top:8px}
.miles::before{content:"";position:absolute;left:19px;top:12px;bottom:34px;width:2px;
background:linear-gradient(var(--done),var(--done) 57%,var(--prog) 57%,var(--prog) 71%,var(--line) 71%);border-radius:2px}
background:linear-gradient(var(--done),var(--done) 71%,var(--prog) 71%,var(--prog) 86%,var(--line) 86%);border-radius:2px}
@media (max-width:640px){.miles::before{display:none}}
.m{position:relative;padding-left:52px;margin-bottom:16px}
@media (max-width:640px){.m{padding-left:0}}
@@ -135,14 +135,15 @@
<h2>Where we are · 2026-07-30</h2>
<div class="band">
<h3>Phases 0–2 complete, Phase 3 feature completeness done — M1–M4 all merged</h3>
<h3>Phases 0–2 complete, feature surface (Phase 3–M5) done — M1–M5 all merged</h3>
<p>The daemon boots gRPC-only on loopback <span class="chip">127.0.0.1:9420</span> behind a keyring-minted
bearer token. <code>IMAP → store → read → SMTP send</code> runs end-to-end over gRPC, driven by the
<span class="chip">nuncio-cli</span> reference client, tested fully offline. The legacy JSON-RPC IPC is gone,
and <span class="chip">nuncio.v1</span> is published with a byte-deterministic contract-stability golden.
Milestones M1–M4 are now complete on <span class="chip">dev</span>: Calendar, Contacts, JMAP, and live
Filters are all real, wired, and API-complete. <b style="color:var(--ink)">Next up: M5</b> (mail lifecycle
&amp; incremental sync).</p>
Milestones M1–M5 are now complete on <span class="chip">dev</span> (M1–M4 already promoted to
<span class="chip">main</span>): Calendar, Contacts, JMAP, live Filters, and mail lifecycle + incremental
sync are all real, wired, and API-complete. <b style="color:var(--ink)">Next up: M6</b> (security &amp;
release hardening).</p>
<div class="chips">
<span class="chip svc">System</span><span class="chip svc">Accounts</span><span class="chip svc">Mail</span>
<span class="chip svc">Filters</span><span class="chip svc">Calendar</span><span class="chip svc">Contacts</span>
@@ -158,6 +159,7 @@
<span class="donetag">M2 · Contacts (CardDAV)</span>
<span class="donetag">M3 · JMAP</span>
<span class="donetag">M4 · Filters made live</span>
<span class="donetag">M5 · Mail lifecycle + incremental sync</span>
</div>
</div>
@@ -226,27 +228,31 @@
</div>
</div>
<div class="m prog">
<div class="m done">
<div class="node">M5</div>
<div class="card">
<div class="chead">
<h3 class="ctitle"><span class="code">M5</span>Mail lifecycle + incremental sync</h3>
<span class="stat prog">Next</span>
<span class="stat done">Done</span>
</div>
<p class="cbody">Round out real account management: edit / delete / test-connection with TLS modes
(implicit / STARTTLS / plain) actually wired; explicit send-account selection; and <b>incremental sync</b>
(honor <code>since_state</code> instead of re-fetching the whole mailbox every time) with progress and stall timeouts.</p>
<p class="cbody">Account lifecycle (edit / delete / test-connection) is real over gRPC, with TLS modes
(implicit / STARTTLS / plain) persisted and actually driving the IMAP/SMTP connection. Sync is now
genuinely incremental via a per-folder <code>{uidvalidity}:{uidnext}</code> checkpoint (a UIDVALIDITY
change forces a safe full re-fetch — no silent mail loss), with a per-item FETCH timeout and
<code>SyncProgress</code> events. <code>SendMessage</code> takes an explicit account id (honest
<code>AccountNotFound</code>, no silent wrong-account fallback), and a failed <code>AddAccount</code>
rolls back its orphaned keyring secret.</p>
<div class="exit"><b>Exit:</b> full account lifecycle + incremental sync, offline-E2E-proven, no fabricated IMAP fallbacks.</div>
<div class="issues"><a class="iss">#170</a><a class="iss">#169</a><a class="iss">#182</a><a class="iss">#183</a><a class="iss">#184</a></div>
<div class="issues"><a class="iss">#170</a><a class="iss">#169</a><a class="iss">#182</a><a class="iss">#183</a><a class="iss">#184</a><span class="iss">done — #194–#197 merged</span></div>
</div>
</div>
<div class="m plan">
<div class="m prog">
<div class="node">M6</div>
<div class="card">
<div class="chead">
<h3 class="ctitle"><span class="code">M6</span>Security &amp; release hardening</h3>
<span class="stat plan">Planned</span>
<span class="stat prog">Next</span>
</div>
<p class="cbody">Fail-<i>closed</i> everywhere: fix the silent empty-ciphertext data-loss path; a
signature-verifying updater that refuses to apply without checksums; zeroize key material; validate vault