Promote to main: M5 (Mail lifecycle + incremental sync) #235
No files matched your search
@@ -258,6 +258,12 @@ pub enum AccountSubcommand {
|
||||
/// Updated SMTP server port.
|
||||
#[arg(long, help = "Updated SMTP server port")]
|
||||
smtp_port: Option<u16>,
|
||||
/// Updated IMAP transport mode (implicit_tls, start_tls, plain).
|
||||
#[arg(long, help = "Updated IMAP transport mode")]
|
||||
imap_mode: Option<String>,
|
||||
/// Updated SMTP transport mode (implicit_tls, start_tls, plain).
|
||||
#[arg(long, help = "Updated SMTP transport mode")]
|
||||
smtp_mode: Option<String>,
|
||||
},
|
||||
/// Remove a configured account profile.
|
||||
Delete {
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] }
|
||||
+859
-31
File diff suppressed because it is too large.
Load diff
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -30,8 +30,8 @@ use nuncio_proto::v1::{CalendarSyncRequest, GetEventRequest, ListEventsRequest};
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use nunciod::grpc::{
|
||||
serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides,
|
||||
MailEngineOverrides,
|
||||
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
|
||||
ContactsEngineOverrides, MailEngineOverrides,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -95,6 +95,7 @@ async fn boot_daemon(
|
||||
MailEngineOverrides::default(),
|
||||
calendar_overrides,
|
||||
ContactsEngineOverrides::default(),
|
||||
AccountsEngineOverrides::default(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
@@ -39,8 +39,8 @@ use nuncio_proto::v1::{
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use nunciod::grpc::{
|
||||
serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides,
|
||||
MailEngineOverrides,
|
||||
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
|
||||
ContactsEngineOverrides, MailEngineOverrides,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -97,6 +97,7 @@ async fn boot_daemon(
|
||||
MailEngineOverrides::default(),
|
||||
CalendarEngineOverrides::default(),
|
||||
contacts_overrides,
|
||||
AccountsEngineOverrides::default(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
@@ -188,6 +188,54 @@ async fn rejects_calls_to_new_filters_rpcs_without_a_bearer_token() {
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
|
||||
/// Confirms the newly added `Accounts` lifecycle RPCs
|
||||
/// (`UpdateAccount`/`RemoveAccount`/`TestAccountConnection`) are each mounted
|
||||
/// behind the SAME `BearerAuthInterceptor` as every other service -- an
|
||||
/// unauthenticated call to any of them must be rejected exactly like an
|
||||
/// unauthenticated `System/GetStatus`, with no carve-out for "just one more"
|
||||
/// RPC.
|
||||
#[tokio::test]
|
||||
async fn rejects_calls_to_new_accounts_rpcs_without_a_bearer_token() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let secrets = SecretManager::mock();
|
||||
let token = hex::encode(
|
||||
secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.expect("token provisioned from mock vault"),
|
||||
);
|
||||
|
||||
let (addr, _dir) = start_server(event_bus, token).await;
|
||||
let mut client =
|
||||
nuncio_proto::v1::accounts_client::AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
|
||||
let update_err = client
|
||||
.update_account(nuncio_proto::v1::UpdateAccountRequest {
|
||||
config: None,
|
||||
password: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("unauthenticated update_account must be rejected");
|
||||
assert_eq!(update_err.code(), Code::Unauthenticated);
|
||||
|
||||
let remove_err = client
|
||||
.remove_account(nuncio_proto::v1::RemoveAccountRequest {
|
||||
id: "acct-1".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("unauthenticated remove_account must be rejected");
|
||||
assert_eq!(remove_err.code(), Code::Unauthenticated);
|
||||
|
||||
let test_err = client
|
||||
.test_account_connection(nuncio_proto::v1::TestAccountConnectionRequest {
|
||||
id: "acct-1".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("unauthenticated test_account_connection must be rejected");
|
||||
assert_eq!(test_err.code(), Code::Unauthenticated);
|
||||
}
|
||||
|
||||
/// Confirms the streaming `Filters.Triage` RPC is mounted behind the SAME
|
||||
/// `BearerAuthInterceptor` as every other `Filters` RPC -- an unauthenticated
|
||||
/// call must be rejected before the stream is ever established, exactly
|
||||
|
||||
@@ -43,8 +43,8 @@ use nuncio_proto::v1::{
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use nunciod::grpc::{
|
||||
serve_on_listener_with_overrides, CalendarEngineOverrides, ContactsEngineOverrides,
|
||||
MailEngineOverrides,
|
||||
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
|
||||
ContactsEngineOverrides, MailEngineOverrides,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -130,6 +130,7 @@ async fn boot_daemon(
|
||||
overrides,
|
||||
CalendarEngineOverrides::default(),
|
||||
ContactsEngineOverrides::default(),
|
||||
AccountsEngineOverrides::default(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
& 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 &
|
||||
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 & 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
|
||||
|
||||
Reference in new issue
Block a user