feat: production-wire Calendar sync via first-class CalDAV accounts #272
No files matched your search
@@ -202,11 +202,19 @@ pub enum UpdateSubcommand {
|
||||
pub enum AccountSubcommand {
|
||||
/// List all configured mail and calendar accounts.
|
||||
List,
|
||||
/// Add and configure a new mail account securely.
|
||||
/// Add and configure a new mail or CalDAV account securely.
|
||||
Add {
|
||||
/// Email address or account username.
|
||||
#[arg(short, long, help = "Email address or account username")]
|
||||
email: String,
|
||||
/// Account protocol: imap-smtp (default), jmap, or caldav. A caldav
|
||||
/// account requires --collection-url and ignores the IMAP/SMTP flags.
|
||||
#[arg(long, default_value = "imap-smtp", help = "Account protocol")]
|
||||
protocol: String,
|
||||
/// Fully-qualified CalDAV calendar-collection URL (required for a
|
||||
/// caldav account; ignored for mail accounts).
|
||||
#[arg(long, help = "CalDAV calendar-collection URL (caldav only)")]
|
||||
collection_url: Option<String>,
|
||||
/// IMAP server hostname.
|
||||
#[arg(long, default_value = "mail.kof22.com", help = "IMAP server hostname")]
|
||||
imap_host: String,
|
||||
@@ -569,6 +577,8 @@ mod tests {
|
||||
Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "james.maes@kof22.com".to_string(),
|
||||
protocol: "imap-smtp".to_string(),
|
||||
collection_url: None,
|
||||
imap_host: "mail.kof22.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "mail.kof22.com".to_string(),
|
||||
|
||||
+144
-17
@@ -39,6 +39,20 @@ fn map_tls_mode_to_proto(mode: nuncio_core::TlsMode) -> nuncio_proto::v1::TlsMod
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an `account add --protocol` string into its wire-format
|
||||
/// `nuncio.v1.AccountProtocol` value. Accepts both the kebab-case
|
||||
/// serialization (`imap-smtp`) and common aliases.
|
||||
fn parse_account_protocol(protocol: &str) -> Result<nuncio_proto::v1::AccountProtocol, String> {
|
||||
match protocol.to_ascii_lowercase().as_str() {
|
||||
"imap-smtp" | "imap_smtp" | "imap" => Ok(nuncio_proto::v1::AccountProtocol::ImapSmtp),
|
||||
"jmap" => Ok(nuncio_proto::v1::AccountProtocol::Jmap),
|
||||
"caldav" | "cal-dav" | "cal_dav" => Ok(nuncio_proto::v1::AccountProtocol::Caldav),
|
||||
other => Err(format!(
|
||||
"invalid protocol '{other}' (expected imap-smtp, jmap, or caldav)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a `nuncio.v1.Message` (as returned by the daemon's `Mail` gRPC
|
||||
/// service) into the JSON shape used by `mail list`/`mail read`'s
|
||||
/// `--json` output.
|
||||
@@ -270,6 +284,8 @@ impl HeadlessRunner {
|
||||
AccountSubcommand::List => self.handle_accounts_list(json_mode).await,
|
||||
AccountSubcommand::Add {
|
||||
email,
|
||||
protocol,
|
||||
collection_url,
|
||||
imap_host,
|
||||
imap_port,
|
||||
smtp_host,
|
||||
@@ -280,6 +296,8 @@ impl HeadlessRunner {
|
||||
} => {
|
||||
self.handle_add_account(
|
||||
email,
|
||||
protocol,
|
||||
collection_url.as_deref(),
|
||||
imap_host,
|
||||
*imap_port,
|
||||
smtp_host,
|
||||
@@ -1603,6 +1621,8 @@ impl HeadlessRunner {
|
||||
async fn handle_add_account(
|
||||
&self,
|
||||
email: &str,
|
||||
protocol: &str,
|
||||
collection_url: Option<&str>,
|
||||
imap_host: &str,
|
||||
imap_port: u16,
|
||||
smtp_host: &str,
|
||||
@@ -1621,23 +1641,43 @@ impl HeadlessRunner {
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
let account_protocol = match parse_account_protocol(protocol) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
let is_caldav = account_protocol == nuncio_proto::v1::AccountProtocol::Caldav;
|
||||
if is_caldav && collection_url.map(str::trim).unwrap_or_default().is_empty() {
|
||||
return Self::render_error("a caldav account requires --collection-url", json_mode);
|
||||
}
|
||||
|
||||
let keyring_key = format!("nuncio/{}", email);
|
||||
let account_id = format!("acct-{}", email.replace('@', "-at-").replace('.', "-"));
|
||||
|
||||
// A CalDAV account is addressed by its collection URL, not by mail
|
||||
// host/port endpoints, so those are left empty for it.
|
||||
let proto_config = nuncio_proto::v1::AccountConfig {
|
||||
id: account_id.clone(),
|
||||
name: email.to_string(),
|
||||
email_address: email.to_string(),
|
||||
protocol: nuncio_proto::v1::AccountProtocol::ImapSmtp.into(),
|
||||
server_host: imap_host.to_string(),
|
||||
server_port: u32::from(imap_port),
|
||||
protocol: account_protocol.into(),
|
||||
server_host: if is_caldav {
|
||||
String::new()
|
||||
} else {
|
||||
imap_host.to_string()
|
||||
},
|
||||
server_port: if is_caldav { 0 } else { u32::from(imap_port) },
|
||||
use_tls: true,
|
||||
imap_tls_mode: map_tls_mode_to_proto(imap_tls_mode).into(),
|
||||
smtp_tls_mode: map_tls_mode_to_proto(smtp_tls_mode).into(),
|
||||
keyring_secret_key: keyring_key.clone(),
|
||||
sync_interval_secs: 300,
|
||||
smtp_host: smtp_host.to_string(),
|
||||
smtp_port: u32::from(smtp_port),
|
||||
smtp_host: if is_caldav {
|
||||
String::new()
|
||||
} else {
|
||||
smtp_host.to_string()
|
||||
},
|
||||
smtp_port: if is_caldav { 0 } else { u32::from(smtp_port) },
|
||||
collection_url: collection_url.unwrap_or_default().to_string(),
|
||||
};
|
||||
|
||||
let mut client = match self.connect_accounts_client().await {
|
||||
@@ -1654,7 +1694,23 @@ impl HeadlessRunner {
|
||||
{
|
||||
Ok(response) => {
|
||||
let account_id = response.into_inner().id;
|
||||
if json_mode {
|
||||
if is_caldav {
|
||||
let collection = collection_url.unwrap_or_default();
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"configured": true,
|
||||
"account_id": account_id,
|
||||
"email": email,
|
||||
"protocol": "caldav",
|
||||
"collection_url": collection,
|
||||
"keyring_key": keyring_key
|
||||
}))
|
||||
} else {
|
||||
format!(
|
||||
"CalDAV account '{email}' (ID: {account_id}) added via nunciod daemon with collection URL {collection}"
|
||||
)
|
||||
}
|
||||
} else if json_mode {
|
||||
format_json(&json!({
|
||||
"configured": true,
|
||||
"account_id": account_id,
|
||||
@@ -1716,6 +1772,7 @@ impl HeadlessRunner {
|
||||
"smtp_tls_mode": a.smtp_tls_mode().as_str_name(),
|
||||
"keyring_secret_key": a.keyring_secret_key,
|
||||
"sync_interval_secs": a.sync_interval_secs,
|
||||
"collection_url": a.collection_url,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -1726,17 +1783,24 @@ impl HeadlessRunner {
|
||||
accounts.len()
|
||||
);
|
||||
for a in &accounts {
|
||||
out.push_str(&format!(
|
||||
"\n [{}] {} <{}> IMAP {}:{} SMTP {}:{} TLS={}",
|
||||
a.id,
|
||||
a.name,
|
||||
a.email_address,
|
||||
a.server_host,
|
||||
a.server_port,
|
||||
a.smtp_host,
|
||||
a.smtp_port,
|
||||
a.use_tls,
|
||||
));
|
||||
if a.protocol() == nuncio_proto::v1::AccountProtocol::Caldav {
|
||||
out.push_str(&format!(
|
||||
"\n [{}] {} <{}> CalDAV {}",
|
||||
a.id, a.name, a.email_address, a.collection_url,
|
||||
));
|
||||
} else {
|
||||
out.push_str(&format!(
|
||||
"\n [{}] {} <{}> IMAP {}:{} SMTP {}:{} TLS={}",
|
||||
a.id,
|
||||
a.name,
|
||||
a.email_address,
|
||||
a.server_host,
|
||||
a.server_port,
|
||||
a.smtp_host,
|
||||
a.smtp_port,
|
||||
a.use_tls,
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -2413,6 +2477,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_account_protocol_accepts_known_names_and_rejects_garbage() {
|
||||
assert_eq!(
|
||||
parse_account_protocol("imap-smtp").expect("valid"),
|
||||
nuncio_proto::v1::AccountProtocol::ImapSmtp
|
||||
);
|
||||
assert_eq!(
|
||||
parse_account_protocol("jmap").expect("valid"),
|
||||
nuncio_proto::v1::AccountProtocol::Jmap
|
||||
);
|
||||
assert_eq!(
|
||||
parse_account_protocol("caldav").expect("valid"),
|
||||
nuncio_proto::v1::AccountProtocol::Caldav
|
||||
);
|
||||
assert_eq!(
|
||||
parse_account_protocol("CalDAV").expect("case-insensitive"),
|
||||
nuncio_proto::v1::AccountProtocol::Caldav
|
||||
);
|
||||
let err = parse_account_protocol("smoke-signals").expect_err("garbage rejected");
|
||||
assert!(err.contains("invalid protocol"));
|
||||
}
|
||||
|
||||
/// `account add --protocol caldav` with no `--collection-url` must be
|
||||
/// rejected BEFORE dialing the daemon -- proven by pointing at an address
|
||||
/// nothing is listening on and confirming the failure is the validation
|
||||
/// error, not a connection error.
|
||||
#[tokio::test]
|
||||
async fn account_add_caldav_requires_collection_url_before_dialing() {
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), "127.0.0.1:1".into())
|
||||
.await
|
||||
.expect("runner init");
|
||||
|
||||
let out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "cal@nuncio.mx".to_string(),
|
||||
protocol: "caldav".to_string(),
|
||||
collection_url: None,
|
||||
imap_host: "unused".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "unused".to_string(),
|
||||
smtp_port: 465,
|
||||
imap_mode: "implicit_tls".to_string(),
|
||||
smtp_mode: "implicit_tls".to_string(),
|
||||
password: crate::args::PasswordArg("pw".to_string()),
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(out.contains("requires --collection-url"));
|
||||
assert!(!out.contains("unreachable"));
|
||||
}
|
||||
|
||||
/// `account add` must reject an invalid `--imap-mode`/`--smtp-mode`
|
||||
/// string BEFORE ever dialing the daemon -- proven here by pointing at
|
||||
/// an address nothing is listening on and confirming the failure is the
|
||||
@@ -2429,6 +2549,8 @@ mod tests {
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "x@y.com".to_string(),
|
||||
protocol: "imap-smtp".to_string(),
|
||||
collection_url: None,
|
||||
imap_host: "imap.y.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "smtp.y.com".to_string(),
|
||||
@@ -2449,6 +2571,8 @@ mod tests {
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "x@y.com".to_string(),
|
||||
protocol: "imap-smtp".to_string(),
|
||||
collection_url: None,
|
||||
imap_host: "imap.y.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "smtp.y.com".to_string(),
|
||||
@@ -2690,6 +2814,7 @@ mod tests {
|
||||
sync_interval_secs: 300,
|
||||
smtp_host: "smtp.nuncio.mx".to_string(),
|
||||
smtp_port: 465,
|
||||
collection_url: String::new(),
|
||||
}],
|
||||
}))
|
||||
}
|
||||
@@ -2719,6 +2844,8 @@ mod tests {
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "james.maes@kof22.com".to_string(),
|
||||
protocol: "imap-smtp".to_string(),
|
||||
collection_url: None,
|
||||
imap_host: "mail.kof22.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "mail.kof22.com".to_string(),
|
||||
|
||||
@@ -116,6 +116,8 @@ async fn account_add_and_list_report_honest_errors_when_daemon_unreachable() {
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "test@nuncio.mx".to_string(),
|
||||
protocol: "imap-smtp".to_string(),
|
||||
collection_url: None,
|
||||
imap_host: "mail.nuncio.mx".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "mail.nuncio.mx".to_string(),
|
||||
|
||||
@@ -11,6 +11,21 @@ pub enum AccountProtocol {
|
||||
Jmap,
|
||||
/// Legacy IMAP4rev1 + SMTP protocol engines.
|
||||
ImapSmtp,
|
||||
/// CalDAV (RFC 4791) calendar-collection protocol engine. A CalDAV
|
||||
/// account is a standalone account entry addressed by its
|
||||
/// `collection_url` and its own keyring credential; it does not use the
|
||||
/// IMAP/JMAP/SMTP mail endpoints.
|
||||
CalDav,
|
||||
}
|
||||
|
||||
impl AccountProtocol {
|
||||
/// Whether this protocol speaks WebDAV against a collection URL (as
|
||||
/// opposed to a mail protocol addressed by host/port). Such accounts
|
||||
/// require [`AccountConfig::collection_url`] and do not require the
|
||||
/// IMAP/SMTP mail endpoint fields.
|
||||
pub fn is_dav(self) -> bool {
|
||||
matches!(self, AccountProtocol::CalDav)
|
||||
}
|
||||
}
|
||||
|
||||
/// Security and transport encryption mode for mail protocol streams (IMAP & SMTP).
|
||||
@@ -43,6 +58,9 @@ pub enum ConfigError {
|
||||
/// The synchronization interval is too short (minimum 10 seconds).
|
||||
#[error("sync interval must be at least 10 seconds")]
|
||||
SyncIntervalTooShort,
|
||||
/// A DAV-protocol account's collection URL is not a valid http(s) URL.
|
||||
#[error("collection_url must be a valid http(s) URL")]
|
||||
InvalidCollectionUrl,
|
||||
}
|
||||
|
||||
/// Complete configuration schema for a mail & calendar account.
|
||||
@@ -76,6 +94,12 @@ pub struct AccountConfig {
|
||||
pub keyring_secret_key: String,
|
||||
/// Background sync polling interval in seconds (minimum 10s).
|
||||
pub sync_interval_secs: u64,
|
||||
/// Fully-qualified collection URL for DAV-protocol accounts (CalDAV
|
||||
/// today, CardDAV later). Set for accounts whose protocol speaks WebDAV;
|
||||
/// empty for mail accounts. This is a full URL (scheme + host + path),
|
||||
/// not a bare host, because a DAV client dispatches requests directly at
|
||||
/// the collection URL.
|
||||
pub collection_url: String,
|
||||
}
|
||||
|
||||
impl AccountConfig {
|
||||
@@ -103,19 +127,34 @@ impl AccountConfig {
|
||||
{
|
||||
return Err(ConfigError::InvalidEmailFormat);
|
||||
}
|
||||
if self.server_host.trim().is_empty() {
|
||||
return Err(ConfigError::EmptyField {
|
||||
field: "server_host",
|
||||
});
|
||||
}
|
||||
if self.server_port == 0 {
|
||||
return Err(ConfigError::InvalidPort);
|
||||
}
|
||||
if self.smtp_host.trim().is_empty() {
|
||||
return Err(ConfigError::EmptyField { field: "smtp_host" });
|
||||
}
|
||||
if self.smtp_port == 0 {
|
||||
return Err(ConfigError::InvalidPort);
|
||||
if self.protocol.is_dav() {
|
||||
// A DAV-protocol account is addressed by its collection URL, not
|
||||
// by mail host/port endpoints, so it validates the URL instead of
|
||||
// the IMAP/SMTP fields (which are left empty for such accounts).
|
||||
let url = self.collection_url.trim();
|
||||
if url.is_empty() {
|
||||
return Err(ConfigError::EmptyField {
|
||||
field: "collection_url",
|
||||
});
|
||||
}
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
return Err(ConfigError::InvalidCollectionUrl);
|
||||
}
|
||||
} else {
|
||||
if self.server_host.trim().is_empty() {
|
||||
return Err(ConfigError::EmptyField {
|
||||
field: "server_host",
|
||||
});
|
||||
}
|
||||
if self.server_port == 0 {
|
||||
return Err(ConfigError::InvalidPort);
|
||||
}
|
||||
if self.smtp_host.trim().is_empty() {
|
||||
return Err(ConfigError::EmptyField { field: "smtp_host" });
|
||||
}
|
||||
if self.smtp_port == 0 {
|
||||
return Err(ConfigError::InvalidPort);
|
||||
}
|
||||
}
|
||||
if self.keyring_secret_key.trim().is_empty() {
|
||||
return Err(ConfigError::EmptyField {
|
||||
@@ -149,9 +188,64 @@ mod tests {
|
||||
smtp_tls_mode: TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-123".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_caldav_account() -> AccountConfig {
|
||||
AccountConfig {
|
||||
id: "acct-cal-1".to_string(),
|
||||
name: "Work Calendar".to_string(),
|
||||
email_address: "user@nuncio.mx".to_string(),
|
||||
protocol: AccountProtocol::CalDav,
|
||||
server_host: String::new(),
|
||||
server_port: 0,
|
||||
smtp_host: String::new(),
|
||||
smtp_port: 0,
|
||||
use_tls: true,
|
||||
imap_tls_mode: TlsMode::ImplicitTls,
|
||||
smtp_tls_mode: TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-cal-1".to_string(),
|
||||
sync_interval_secs: 300,
|
||||
collection_url: "https://caldav.example.com/calendars/user/work/".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_caldav_account_passes_without_mail_endpoints() {
|
||||
let config = valid_caldav_account();
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caldav_account_requires_collection_url() {
|
||||
let mut config = valid_caldav_account();
|
||||
config.collection_url = " ".to_string();
|
||||
assert_eq!(
|
||||
config.validate().unwrap_err(),
|
||||
ConfigError::EmptyField {
|
||||
field: "collection_url"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caldav_account_rejects_non_http_collection_url() {
|
||||
let mut config = valid_caldav_account();
|
||||
config.collection_url = "ftp://caldav.example.com/work/".to_string();
|
||||
assert_eq!(
|
||||
config.validate().unwrap_err(),
|
||||
ConfigError::InvalidCollectionUrl
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_protocol_is_dav_only_for_dav_protocols() {
|
||||
assert!(AccountProtocol::CalDav.is_dav());
|
||||
assert!(!AccountProtocol::Jmap.is_dav());
|
||||
assert!(!AccountProtocol::ImapSmtp.is_dav());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_account_config_passes_validation() {
|
||||
let config = valid_account();
|
||||
@@ -275,6 +369,11 @@ mod tests {
|
||||
assert_eq!(imap_json, "\"imap-smtp\"");
|
||||
let parsed_imap: AccountProtocol = serde_json::from_str(&imap_json).unwrap();
|
||||
assert_eq!(parsed_imap, AccountProtocol::ImapSmtp);
|
||||
|
||||
let caldav_json = serde_json::to_string(&AccountProtocol::CalDav).unwrap();
|
||||
assert_eq!(caldav_json, "\"cal-dav\"");
|
||||
let parsed_caldav: AccountProtocol = serde_json::from_str(&caldav_json).unwrap();
|
||||
assert_eq!(parsed_caldav, AccountProtocol::CalDav);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Binary file not shown.
@@ -175,6 +175,10 @@ enum AccountProtocol {
|
||||
ACCOUNT_PROTOCOL_UNSPECIFIED = 0;
|
||||
ACCOUNT_PROTOCOL_JMAP = 1;
|
||||
ACCOUNT_PROTOCOL_IMAP_SMTP = 2;
|
||||
// CalDAV (RFC 4791) calendar-collection account. A CalDAV account is its
|
||||
// own first-class account entry: it carries `collection_url` and its own
|
||||
// keyring credential, and does not require the IMAP/SMTP mail endpoints.
|
||||
ACCOUNT_PROTOCOL_CALDAV = 3;
|
||||
}
|
||||
|
||||
// Mirrors `nuncio_core::TlsMode`.
|
||||
@@ -218,6 +222,12 @@ message AccountConfig {
|
||||
string smtp_host = 12;
|
||||
// SMTP server port number (1..=65535), used for outbound mail.
|
||||
uint32 smtp_port = 13;
|
||||
// Fully-qualified collection URL for DAV-protocol accounts (e.g. a CalDAV
|
||||
// calendar collection or, later, a CardDAV address-book collection). Set
|
||||
// for accounts whose protocol speaks WebDAV; empty for mail accounts. This
|
||||
// is a full URL (scheme + host + path), not a bare host, because a DAV
|
||||
// client dispatches requests directly at the collection URL.
|
||||
string collection_url = 14;
|
||||
}
|
||||
|
||||
// AddAccountRequest carries the full account configuration plus the raw
|
||||
|
||||
@@ -503,7 +503,8 @@ impl DatabaseEngine {
|
||||
smtp_host TEXT,
|
||||
smtp_port INTEGER,
|
||||
imap_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls',
|
||||
smtp_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls'
|
||||
smtp_tls_mode TEXT NOT NULL DEFAULT 'implicit_tls',
|
||||
collection_url TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS filter_rules (
|
||||
@@ -709,6 +710,7 @@ impl DatabaseEngine {
|
||||
|
||||
self.ensure_accounts_smtp_columns().await?;
|
||||
self.ensure_accounts_tls_mode_columns().await?;
|
||||
self.ensure_accounts_dav_columns().await?;
|
||||
self.backfill_message_fts().await?;
|
||||
|
||||
Ok(())
|
||||
@@ -795,6 +797,37 @@ impl DatabaseEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Additive, backfill-safe migration that adds the `collection_url`
|
||||
/// column to a pre-existing `accounts` table that predates DAV-protocol
|
||||
/// (CalDAV/CardDAV) accounts.
|
||||
///
|
||||
/// A fresh database already gets this column 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 column is added as a `NULL`-able column,
|
||||
/// so every existing (mail) row keeps loading successfully with an empty
|
||||
/// collection URL (see [`Self::list_accounts`], which maps a `NULL`
|
||||
/// `collection_url` to an empty string). 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_dav_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 == "collection_url") {
|
||||
sqlx::query("ALTER TABLE accounts ADD COLUMN collection_url TEXT")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Backfill `messages_fts` for any `messages` row that does not yet have a matching FTS
|
||||
/// entry -- e.g. rows written before the FTS5 index existed, or written directly by a
|
||||
/// process that bypassed `save_email`'s explicit index population. Run automatically as
|
||||
@@ -846,8 +879,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, imap_tls_mode, smtp_tls_mode)
|
||||
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, collection_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&config.id)
|
||||
@@ -863,6 +896,7 @@ impl DatabaseEngine {
|
||||
.bind(config.smtp_port as i64)
|
||||
.bind(tls_mode_to_db(config.imap_tls_mode))
|
||||
.bind(tls_mode_to_db(config.smtp_tls_mode))
|
||||
.bind(&config.collection_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(DatabaseError::Query)?;
|
||||
@@ -908,9 +942,10 @@ impl DatabaseEngine {
|
||||
Option<i64>,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, name, email_address, protocol, server_host, server_port, use_tls, keyring_secret_key, sync_interval_secs, smtp_host, smtp_port, imap_tls_mode, smtp_tls_mode
|
||||
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, collection_url
|
||||
FROM accounts
|
||||
"#,
|
||||
)
|
||||
@@ -935,6 +970,7 @@ impl DatabaseEngine {
|
||||
smtp_port,
|
||||
imap_tls_mode,
|
||||
smtp_tls_mode,
|
||||
collection_url,
|
||||
)| {
|
||||
let protocol = serde_json::from_str(&protocol_str)
|
||||
.unwrap_or(nuncio_core::AccountProtocol::ImapSmtp);
|
||||
@@ -963,6 +999,7 @@ impl DatabaseEngine {
|
||||
smtp_tls_mode: tls_mode_from_db(&smtp_tls_mode),
|
||||
keyring_secret_key,
|
||||
sync_interval_secs: sync_interval_secs as u64,
|
||||
collection_url: collection_url.unwrap_or_default(),
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -2280,6 +2317,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-contended-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
engine.save_account(&acct).await.unwrap();
|
||||
engine.close().await;
|
||||
@@ -2853,6 +2891,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-test-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
|
||||
engine
|
||||
@@ -2896,6 +2935,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::Plain,
|
||||
keyring_secret_key: "nuncio/acct-tls-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
|
||||
engine.save_account(&acct).await.expect("save succeeds");
|
||||
@@ -2915,6 +2955,44 @@ mod tests {
|
||||
assert_eq!(fetched, acct);
|
||||
}
|
||||
|
||||
/// A CalDAV account's `collection_url` must survive save/reload, and a
|
||||
/// mail account's empty `collection_url` must round-trip as empty (not
|
||||
/// NULL-mangled), proving the backfill-safe column persists both.
|
||||
#[tokio::test]
|
||||
async fn caldav_collection_url_survives_save_and_reload() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let caldav = nuncio_core::AccountConfig {
|
||||
id: "acct-caldav-store-1".to_string(),
|
||||
name: "Work Calendar".to_string(),
|
||||
email_address: "cal@nuncio.mx".to_string(),
|
||||
protocol: nuncio_core::AccountProtocol::CalDav,
|
||||
server_host: String::new(),
|
||||
server_port: 0,
|
||||
smtp_host: String::new(),
|
||||
smtp_port: 0,
|
||||
use_tls: true,
|
||||
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-caldav-store-1".to_string(),
|
||||
sync_interval_secs: 300,
|
||||
collection_url: "https://dav.example.com/calendars/user/work/".to_string(),
|
||||
};
|
||||
engine.save_account(&caldav).await.expect("save succeeds");
|
||||
|
||||
let fetched = engine
|
||||
.get_account("acct-caldav-store-1")
|
||||
.await
|
||||
.expect("get succeeds")
|
||||
.expect("account present");
|
||||
assert_eq!(fetched, caldav);
|
||||
assert_eq!(fetched.protocol, nuncio_core::AccountProtocol::CalDav);
|
||||
assert_eq!(
|
||||
fetched.collection_url,
|
||||
"https://dav.example.com/calendars/user/work/"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -2936,6 +3014,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-del-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
engine.save_account(&acct).await.expect("save succeeds");
|
||||
|
||||
@@ -3127,6 +3206,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::StartTls,
|
||||
keyring_secret_key: "nuncio/acct-post-migration".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
engine
|
||||
.save_account(&new_acct)
|
||||
|
||||
@@ -25,6 +25,7 @@ type SalvagedAccountRow = (
|
||||
Option<i64>,
|
||||
String,
|
||||
String,
|
||||
Option<String>,
|
||||
);
|
||||
|
||||
/// Summary report of database self-healing recovery output.
|
||||
@@ -383,7 +384,7 @@ impl SqliteRecoveryEngine {
|
||||
|
||||
async fn salvage_accounts(pool: &sqlx::SqlitePool) -> Vec<nuncio_core::AccountConfig> {
|
||||
let rows: Result<Vec<SalvagedAccountRow>, _> = sqlx::query_as(
|
||||
"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"
|
||||
"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, collection_url FROM accounts"
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await;
|
||||
@@ -406,6 +407,7 @@ impl SqliteRecoveryEngine {
|
||||
smtp_port,
|
||||
imap_tls_mode_raw,
|
||||
smtp_tls_mode_raw,
|
||||
collection_url,
|
||||
)| {
|
||||
let protocol = serde_json::from_str(&protocol_str)
|
||||
.unwrap_or(nuncio_core::AccountProtocol::ImapSmtp);
|
||||
@@ -436,6 +438,7 @@ impl SqliteRecoveryEngine {
|
||||
smtp_tls_mode,
|
||||
keyring_secret_key,
|
||||
sync_interval_secs: sync_interval_secs as u64,
|
||||
collection_url: collection_url.unwrap_or_default(),
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -608,6 +611,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-test-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
engine.save_account(&acct).await.unwrap();
|
||||
assert!(engine.check_integrity().await.unwrap());
|
||||
@@ -669,6 +673,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-salvage-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
engine.save_account(&acct).await.unwrap();
|
||||
|
||||
@@ -806,6 +811,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::Plain,
|
||||
keyring_secret_key: "nuncio/acct-starttls-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
engine.save_account(&acct).await.unwrap();
|
||||
}
|
||||
|
||||
@@ -4,17 +4,16 @@
|
||||
//! fetches events from a [`CalendarBackend`] for a single calendar collection and
|
||||
//! time window, then persists each event via [`DatabaseEngine::save_calendar_event`].
|
||||
//!
|
||||
//! # No production entry point yet
|
||||
//! # Production entry point
|
||||
//!
|
||||
//! Unlike `sync.rs`'s `run_account_sync`/`run_all_accounts_sync`, this module
|
||||
//! deliberately has NO production entry point that resolves a real account's
|
||||
//! CalDAV collection URL and credential and builds a real `CalDavClient` from
|
||||
//! them: `nuncio_core::AccountConfig` carries no CalDAV fields today, so there
|
||||
//! is nothing persisted to build one from. [`sync_with_backend`] is the only
|
||||
//! function here; it is exercised directly in tests with `MockCalendarBackend`,
|
||||
//! and it is the same seam a future story's production entry point will call
|
||||
//! once per-account CalDAV configuration exists.
|
||||
use nuncio_cal::CalendarBackend;
|
||||
//! [`sync_caldav_account`] is the production entry point: it builds a real
|
||||
//! [`nuncio_cal::CalDavClient`] from a persisted [`nuncio_core::AccountConfig`]
|
||||
//! (its `collection_url`) plus the credential resolved from the OS keyring,
|
||||
//! then delegates to [`sync_with_backend`]. [`sync_with_backend`] is the
|
||||
//! shared fetch-and-persist seam that both the production client and the
|
||||
//! test-only `MockCalendarBackend` flow through, mirroring `sync.rs`'s
|
||||
//! `fetch_and_persist`.
|
||||
use nuncio_cal::{CalDavAccountConfig, CalDavClient, CalendarBackend};
|
||||
use nuncio_store::db::DatabaseError;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -55,6 +54,33 @@ pub async fn sync_with_backend(
|
||||
Ok(synced)
|
||||
}
|
||||
|
||||
/// Production entry point: build a real [`CalDavClient`] for `account` from its
|
||||
/// persisted `collection_url` and the `password` resolved from the OS keyring
|
||||
/// vault, then fetch-and-persist `calendar_id`'s events in `[start_window,
|
||||
/// end_window]` via [`sync_with_backend`]. Returns the number of events synced.
|
||||
///
|
||||
/// `account.collection_url` must be a fully-qualified CalDAV calendar
|
||||
/// collection URL (validated at `AddAccount` time); the account's
|
||||
/// `email_address` is used as the DAV basic-auth username. No fabricated data
|
||||
/// is ever returned -- a transport or persistence failure surfaces as a
|
||||
/// [`CalendarSyncError`].
|
||||
pub async fn sync_caldav_account(
|
||||
db: &nuncio_store::db::DatabaseEngine,
|
||||
account: &nuncio_core::AccountConfig,
|
||||
password: &str,
|
||||
calendar_id: &str,
|
||||
start_window: i64,
|
||||
end_window: i64,
|
||||
) -> Result<usize, CalendarSyncError> {
|
||||
let client = CalDavClient::new(CalDavAccountConfig {
|
||||
account_id: account.id.clone(),
|
||||
caldav_url: account.collection_url.clone(),
|
||||
username: account.email_address.clone(),
|
||||
auth_token: password.to_string(),
|
||||
});
|
||||
sync_with_backend(db, &client, calendar_id, start_window, end_window).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+122
-24
@@ -223,6 +223,7 @@ fn map_account_protocol_to_proto(protocol: nuncio_core::AccountProtocol) -> Acco
|
||||
match protocol {
|
||||
nuncio_core::AccountProtocol::Jmap => AccountProtocolProto::Jmap,
|
||||
nuncio_core::AccountProtocol::ImapSmtp => AccountProtocolProto::ImapSmtp,
|
||||
nuncio_core::AccountProtocol::CalDav => AccountProtocolProto::Caldav,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +237,7 @@ fn map_account_protocol_from_proto(
|
||||
match protocol {
|
||||
AccountProtocolProto::Jmap => Ok(nuncio_core::AccountProtocol::Jmap),
|
||||
AccountProtocolProto::ImapSmtp => Ok(nuncio_core::AccountProtocol::ImapSmtp),
|
||||
AccountProtocolProto::Caldav => Ok(nuncio_core::AccountProtocol::CalDav),
|
||||
AccountProtocolProto::Unspecified => {
|
||||
Err(Status::invalid_argument("account protocol is required"))
|
||||
}
|
||||
@@ -284,6 +286,7 @@ fn map_account_config_to_proto(config: nuncio_core::AccountConfig) -> AccountCon
|
||||
sync_interval_secs: config.sync_interval_secs,
|
||||
smtp_host: config.smtp_host,
|
||||
smtp_port: u32::from(config.smtp_port),
|
||||
collection_url: config.collection_url,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +318,7 @@ fn map_account_config_from_proto(
|
||||
smtp_tls_mode,
|
||||
keyring_secret_key: config.keyring_secret_key,
|
||||
sync_interval_secs: config.sync_interval_secs,
|
||||
collection_url: config.collection_url,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -405,8 +409,22 @@ impl AccountConnectionTester for RealAccountConnectionTester {
|
||||
nuncio_core::AccountProtocol::Jmap => ProtocolProbe::failed(
|
||||
"IMAP connection probe is not applicable to a JMAP account".to_string(),
|
||||
),
|
||||
nuncio_core::AccountProtocol::CalDav => ProtocolProbe::failed(
|
||||
"IMAP connection probe is not applicable to a CalDAV account".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
// A CalDAV account has no SMTP endpoint to probe; report that honestly
|
||||
// rather than dialing the (empty) mail endpoint fields.
|
||||
if config.protocol == nuncio_core::AccountProtocol::CalDav {
|
||||
return AccountConnectionReport {
|
||||
imap,
|
||||
smtp: ProtocolProbe::failed(
|
||||
"SMTP connection probe is not applicable to a CalDAV account".to_string(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let smtp = match SmtpTransportEngine::new(
|
||||
&config.smtp_host,
|
||||
config.smtp_port,
|
||||
@@ -872,15 +890,16 @@ fn expand_events_for_window(
|
||||
///
|
||||
/// Production (every existing caller of [`serve`] / [`serve_on_listener`])
|
||||
/// never constructs a non-default instance: [`Default`] yields
|
||||
/// `calendar_backend: None`, which routes `Calendar/Sync` to an honest
|
||||
/// error, since there is currently no persisted per-account CalDAV
|
||||
/// configuration to build a real backend from. Only a full-daemon E2E test
|
||||
/// supplies `Some(..)`, standing in a [`nuncio_cal::MockCalendarBackend`]
|
||||
/// for real network I/O.
|
||||
/// `calendar_backend: None`, which routes `Calendar/Sync` through the real
|
||||
/// production path -- resolving configured CalDAV accounts, building a real
|
||||
/// [`nuncio_cal::CalDavClient`] from each account's collection URL and
|
||||
/// keyring credential, and syncing genuinely fetched events. Only a
|
||||
/// full-daemon E2E test that wants to bypass the real network transport
|
||||
/// supplies `Some(..)`, standing in a [`nuncio_cal::MockCalendarBackend`].
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CalendarEngineOverrides {
|
||||
/// When `Some`, `Calendar/Sync` fetches from this backend instead of
|
||||
/// returning an honest "no CalDAV configuration" error.
|
||||
/// When `Some`, `Calendar/Sync` fetches from this injected backend instead
|
||||
/// of building a real per-account CalDAV client. Used only by tests.
|
||||
pub calendar_backend: Option<Arc<dyn CalendarBackend>>,
|
||||
}
|
||||
|
||||
@@ -897,14 +916,93 @@ impl std::fmt::Debug for CalendarEngineOverrides {
|
||||
///
|
||||
/// `ListEvents`/`GetEvent` always read genuinely persisted data. `Sync`
|
||||
/// fetches from [`CalendarEngineOverrides::calendar_backend`] when injected;
|
||||
/// otherwise it returns an honest error, since there is no persisted
|
||||
/// per-account CalDAV configuration to build a real backend from yet (see
|
||||
/// `nunciod::calendar_sync`'s doc comment).
|
||||
/// otherwise it runs the real production path -- resolving configured CalDAV
|
||||
/// accounts and building a real [`nuncio_cal::CalDavClient`] per account from
|
||||
/// its persisted collection URL and keyring credential. It returns an honest
|
||||
/// error only when no CalDAV account is configured (see
|
||||
/// `nunciod::calendar_sync`).
|
||||
struct CalendarGrpcService {
|
||||
db: Arc<DatabaseEngine>,
|
||||
secrets: Arc<SecretManager>,
|
||||
overrides: CalendarEngineOverrides,
|
||||
}
|
||||
|
||||
impl CalendarGrpcService {
|
||||
/// Production `Calendar/Sync` path (no injected test backend): resolve the
|
||||
/// configured CalDAV account(s), build a real [`nuncio_cal::CalDavClient`]
|
||||
/// per account from its persisted collection URL and keyring credential,
|
||||
/// and sync the requested calendar/window into the store. Returns the
|
||||
/// total number of events synced across the resolved accounts.
|
||||
///
|
||||
/// A request naming a specific `account_id` is scoped to that account; an
|
||||
/// empty `account_id` syncs every configured CalDAV account. When no
|
||||
/// CalDAV account matches, this returns an honest error rather than a
|
||||
/// fabricated zero-count success -- there is genuinely no remote calendar
|
||||
/// to fetch from.
|
||||
async fn sync_configured_caldav_accounts(
|
||||
&self,
|
||||
req: &CalendarSyncRequest,
|
||||
) -> Result<usize, Status> {
|
||||
let caldav_accounts: Vec<nuncio_core::AccountConfig> = self
|
||||
.db
|
||||
.list_accounts()
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to list accounts: {e}")))?
|
||||
.into_iter()
|
||||
.filter(|a| a.protocol == nuncio_core::AccountProtocol::CalDav)
|
||||
.filter(|a| req.account_id.is_empty() || a.id == req.account_id)
|
||||
.collect();
|
||||
|
||||
if caldav_accounts.is_empty() {
|
||||
return Err(Status::failed_precondition(if req.account_id.is_empty() {
|
||||
"no CalDAV account is configured; add one with protocol CALDAV and a \
|
||||
collection_url before syncing"
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"no CalDAV account with id '{}' is configured",
|
||||
req.account_id
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
let mut total = 0usize;
|
||||
for account in caldav_accounts {
|
||||
// The credential lives ONLY in the OS keyring vault, keyed by the
|
||||
// account's `keyring_secret_key`; it is read just-in-time here and
|
||||
// wrapped so its backing memory is zeroized on drop.
|
||||
let password = zeroize::Zeroizing::new(
|
||||
self.secrets
|
||||
.get_secret(&account.keyring_secret_key)
|
||||
.map_err(|e| {
|
||||
Status::internal(format!(
|
||||
"failed to read CalDAV credential for account '{}': {e}",
|
||||
account.id
|
||||
))
|
||||
})?,
|
||||
);
|
||||
|
||||
total += crate::calendar_sync::sync_caldav_account(
|
||||
&self.db,
|
||||
&account,
|
||||
&password,
|
||||
&req.calendar_id,
|
||||
req.start_window,
|
||||
req.end_window,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Status::internal(format!(
|
||||
"CalDAV sync failed for account '{}': {e}",
|
||||
account.id
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Calendar for CalendarGrpcService {
|
||||
async fn sync(
|
||||
@@ -923,13 +1021,7 @@ impl Calendar for CalendarGrpcService {
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("calendar sync failed: {e}")))?,
|
||||
None => {
|
||||
return Err(Status::internal(format!(
|
||||
"no CalDAV configuration exists for account '{}'; per-account CalDAV \
|
||||
configuration is not yet implemented",
|
||||
req.account_id
|
||||
)));
|
||||
}
|
||||
None => self.sync_configured_caldav_accounts(&req).await?,
|
||||
};
|
||||
|
||||
Ok(Response::new(CalendarSyncResponse {
|
||||
@@ -2275,6 +2367,7 @@ pub async fn serve_on_listener_with_overrides(
|
||||
// this function's doc comment.
|
||||
let calendar_service = CalendarGrpcService {
|
||||
db: db.clone(),
|
||||
secrets: secrets.clone(),
|
||||
overrides: calendar_overrides,
|
||||
};
|
||||
let calendar_interceptor = BearerAuthInterceptor::new(token.clone());
|
||||
@@ -2875,6 +2968,7 @@ mod tests {
|
||||
smtp_tls_mode: TlsModeProto::ImplicitTls.into(),
|
||||
keyring_secret_key: keyring_secret_key.to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
smtp_host: "smtp.nuncio.mx".to_string(),
|
||||
smtp_port: 465,
|
||||
}
|
||||
@@ -3263,6 +3357,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: key.to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
|
||||
// Force `save_account` to fail for real by closing the pool, exactly
|
||||
@@ -3306,6 +3401,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: key.to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
|
||||
db.close().await;
|
||||
@@ -3752,6 +3848,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-send-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
db.save_account(&config).await.expect("save account");
|
||||
secrets
|
||||
@@ -3920,6 +4017,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-injected-send-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
// Deliberately never stores a keyring credential -- this path must
|
||||
// never need one.
|
||||
@@ -4036,6 +4134,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-injected-send-fail-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
db.save_account(&config).await.expect("save account");
|
||||
|
||||
@@ -5417,12 +5516,12 @@ mod tests {
|
||||
assert_eq!(err.code(), Code::NotFound);
|
||||
}
|
||||
|
||||
/// With no `CalendarEngineOverrides::calendar_backend` injected, `Sync`
|
||||
/// returns an honest error -- there is no persisted per-account CalDAV
|
||||
/// configuration to build a real backend from -- never a fabricated
|
||||
/// `synced_count`.
|
||||
/// With no `CalendarEngineOverrides::calendar_backend` injected AND no
|
||||
/// CalDAV account configured, `Sync` returns an honest
|
||||
/// `FailedPrecondition` error naming the missing account -- never a
|
||||
/// fabricated `synced_count`.
|
||||
#[tokio::test]
|
||||
async fn calendar_sync_without_override_reports_honest_error() {
|
||||
async fn calendar_sync_without_override_or_config_reports_honest_error() {
|
||||
use nuncio_proto::v1::calendar_client::CalendarClient;
|
||||
use nuncio_proto::v1::CalendarSyncRequest;
|
||||
|
||||
@@ -5441,9 +5540,8 @@ mod tests {
|
||||
}))
|
||||
.await
|
||||
.expect_err("sync with no injected backend and no CalDAV config must fail");
|
||||
assert_eq!(err.code(), Code::Internal);
|
||||
assert_eq!(err.code(), Code::FailedPrecondition);
|
||||
assert!(err.message().contains("acct-cal-no-config"));
|
||||
assert!(err.message().contains("no CalDAV configuration"));
|
||||
}
|
||||
|
||||
/// With a [`CalendarEngineOverrides::calendar_backend`] injected, `Sync`
|
||||
|
||||
@@ -162,6 +162,7 @@ mod tests {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: "nuncio/acct-orch-1".to_string(),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
};
|
||||
engine.save_account(&acct).await.unwrap();
|
||||
engine.close().await;
|
||||
|
||||
@@ -59,6 +59,10 @@ pub enum OutboxExecuteError {
|
||||
/// A database error occurred while resolving the account/message.
|
||||
#[error("database error: {0}")]
|
||||
Store(#[from] DatabaseError),
|
||||
/// The resolved account is not a mail account (e.g. a CalDAV account), so
|
||||
/// it has no mail backend to execute an outbound mutation against.
|
||||
#[error("account '{0}' is not a mail account")]
|
||||
NotAMailAccount(String),
|
||||
}
|
||||
|
||||
/// Injectable seam for the resources a mutation needs: the per-account mail
|
||||
@@ -423,7 +427,8 @@ impl RemoteExecutionEnv for ProductionExecutionEnv {
|
||||
) -> Result<Box<dyn MailBackend>, OutboxExecuteError> {
|
||||
let config = self.account(account_id).await?;
|
||||
let password = self.secrets.get_secret(&config.keyring_secret_key)?;
|
||||
Ok(crate::sync::build_mail_backend(&config, &password))
|
||||
crate::sync::build_mail_backend(&config, &password)
|
||||
.map_err(|_| OutboxExecuteError::NotAMailAccount(account_id.to_string()))
|
||||
}
|
||||
|
||||
async fn message_sender(
|
||||
|
||||
@@ -243,6 +243,7 @@ mod tests {
|
||||
smtp_tls_mode: TlsMode::ImplicitTls,
|
||||
keyring_secret_key: format!("nuncio/{id}"),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ pub enum SyncError {
|
||||
/// A database persistence error occurred while saving synced messages.
|
||||
#[error("database error: {0}")]
|
||||
Store(#[from] DatabaseError),
|
||||
/// The account's protocol is not a mail protocol (e.g. a CalDAV account),
|
||||
/// so it has no inbound mail backend to sync.
|
||||
#[error("account '{0}' is not a mail account and cannot be mail-synced")]
|
||||
NotAMailAccount(String),
|
||||
}
|
||||
|
||||
/// Map a [`RuleAction`] to the short mutation tag persisted on
|
||||
@@ -339,22 +343,28 @@ async fn find_account(
|
||||
/// keyring by the caller). This is the ONLY place a concrete protocol engine
|
||||
/// is chosen; every other function in this module only ever sees `&dyn
|
||||
/// MailBackend`.
|
||||
pub(crate) fn build_mail_backend(config: &AccountConfig, password: &str) -> Box<dyn MailBackend> {
|
||||
pub(crate) fn build_mail_backend(
|
||||
config: &AccountConfig,
|
||||
password: &str,
|
||||
) -> Result<Box<dyn MailBackend>, SyncError> {
|
||||
match config.protocol {
|
||||
AccountProtocol::ImapSmtp => Box::new(ImapEngine::with_credentials(
|
||||
AccountProtocol::ImapSmtp => Ok(Box::new(ImapEngine::with_credentials(
|
||||
&config.id,
|
||||
&config.server_host,
|
||||
config.server_port,
|
||||
config.imap_tls_mode,
|
||||
&config.email_address,
|
||||
password,
|
||||
)),
|
||||
AccountProtocol::Jmap => Box::new(JmapEngine::with_credentials(
|
||||
))),
|
||||
AccountProtocol::Jmap => Ok(Box::new(JmapEngine::with_credentials(
|
||||
&config.id,
|
||||
&config.server_host,
|
||||
&config.email_address,
|
||||
password,
|
||||
)),
|
||||
))),
|
||||
// A DAV-protocol account (e.g. CalDAV) has no inbound mail backend;
|
||||
// it is synced through its own domain service, not the mail path.
|
||||
AccountProtocol::CalDav => Err(SyncError::NotAMailAccount(config.id.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,7 +386,7 @@ pub async fn run_account_sync(
|
||||
let setup = async {
|
||||
let config = find_account(db, account_id).await?;
|
||||
let password = secrets.get_secret(&config.keyring_secret_key)?;
|
||||
Ok::<_, SyncError>(build_mail_backend(&config, &password))
|
||||
build_mail_backend(&config, &password)
|
||||
}
|
||||
.await;
|
||||
|
||||
@@ -419,9 +429,26 @@ pub async fn run_all_accounts_sync(
|
||||
match db.list_accounts().await {
|
||||
Ok(accounts) => {
|
||||
for config in accounts {
|
||||
// DAV-protocol accounts (e.g. CalDAV) are not mail accounts;
|
||||
// they sync through their own domain service, so skip them
|
||||
// here rather than attempting to build a mail backend.
|
||||
if config.protocol.is_dav() {
|
||||
continue;
|
||||
}
|
||||
match secrets.get_secret(&config.keyring_secret_key) {
|
||||
Ok(password) => {
|
||||
let backend = build_mail_backend(&config, &password);
|
||||
let backend = match build_mail_backend(&config, &password) {
|
||||
Ok(backend) => backend,
|
||||
Err(e) => {
|
||||
event_bus.process_command(CoreCommand::ReportError {
|
||||
message: format!(
|
||||
"sync skipped for account '{}': {e}",
|
||||
config.id
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match fetch_and_persist(
|
||||
db,
|
||||
event_bus,
|
||||
@@ -500,6 +527,7 @@ mod tests {
|
||||
smtp_tls_mode: TlsMode::ImplicitTls,
|
||||
keyring_secret_key: format!("nuncio/{id}"),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,6 +619,7 @@ mod tests {
|
||||
smtp_tls_mode: TlsMode::ImplicitTls,
|
||||
keyring_secret_key: format!("nuncio/{id}"),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ fn sample_account_config() -> AccountConfig {
|
||||
sync_interval_secs: 60,
|
||||
smtp_host: "smtp.nuncio.mx".to_string(),
|
||||
smtp_port: 25,
|
||||
collection_url: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Full-daemon offline end-to-end test for the PRODUCTION CalDAV sync path.
|
||||
//!
|
||||
//! Unlike `calendar_e2e_test.rs`, which injects a `MockCalendarBackend` through
|
||||
//! the [`nunciod::grpc::CalendarEngineOverrides`] seam, this test drives the
|
||||
//! real production path with NO injected backend: it configures a first-class
|
||||
//! CalDAV account over the authenticated `Accounts/AddAccount` RPC (protocol
|
||||
//! CALDAV + a `collection_url` pointing at a WireMock server + a keyring
|
||||
//! credential), then calls `Calendar/Sync`. The daemon resolves the configured
|
||||
//! CalDAV account, reads its credential from the (mock) vault, builds a REAL
|
||||
//! [`nuncio_cal::CalDavClient`] from the persisted collection URL, issues a
|
||||
//! genuine CalDAV `REPORT` against WireMock, and persists the fetched events.
|
||||
//!
|
||||
//! This proves the production wiring end to end while remaining fully offline:
|
||||
//! WireMock stands in for the remote CalDAV server, and a
|
||||
//! [`SecretManager::mock`] vault stands in for the OS keyring -- no real
|
||||
//! network and no real keyring are ever touched.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use nuncio_core::EventBus;
|
||||
use nuncio_filter::FilterEngine;
|
||||
use nuncio_proto::v1::{
|
||||
AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
|
||||
AddAccountRequest, CalendarSyncRequest, ListEventsRequest, TlsMode as TlsModeProto,
|
||||
};
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use nunciod::grpc::{
|
||||
serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides,
|
||||
ContactsEngineOverrides, MailEngineOverrides,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tonic::Code;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
const ACCOUNT_ID: &str = "acct-caldav-e2e-1";
|
||||
const ACCOUNT_EMAIL: &str = "james.maes@nuncio.mx";
|
||||
const KEYRING_KEY: &str = "nuncio/acct-caldav-e2e-1";
|
||||
const CALDAV_PASSWORD: &str = "wiremock-app-token";
|
||||
const CALENDAR_ID: &str = "cal-work";
|
||||
|
||||
/// A canned CalDAV `multistatus` response with two VEVENTs, mirroring the
|
||||
/// transport-level fixture used by `nuncio-cal`'s own WireMock test.
|
||||
fn multistatus_body() -> String {
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response>
|
||||
<d:href>/calendars/work/standup.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<c:calendar-data>BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Production CalDAV Standup
|
||||
LOCATION:Conference Room B
|
||||
DTSTART:20240715T130000Z
|
||||
DTEND:20240715T140000Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR</c:calendar-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/calendars/work/review.ics</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<c:calendar-data>BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
SUMMARY:Production UTC Review
|
||||
DTSTART:20240301T120000Z
|
||||
DTEND:20240301T130000Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR</c:calendar-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Boots the real daemon gRPC server with DEFAULT overrides (no injected
|
||||
/// calendar backend), backed by a real temp file-backed [`DatabaseEngine`] and
|
||||
/// the given mock vault. Returns the server address and the minted bearer
|
||||
/// token; the `TempDir` guard is returned so the caller controls its lifetime.
|
||||
async fn boot_daemon(
|
||||
secrets: Arc<SecretManager>,
|
||||
) -> (std::net::SocketAddr, String, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("create temp dir for file-backed db");
|
||||
let db_path = dir.path().join("caldav_prod_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(),
|
||||
// No injected calendar backend: exercise the real production path.
|
||||
CalendarEngineOverrides::default(),
|
||||
ContactsEngineOverrides::default(),
|
||||
AccountsEngineOverrides::default(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
(addr, token, dir)
|
||||
}
|
||||
|
||||
/// The full offline proof of the production CalDAV sync path: configure a
|
||||
/// CalDAV account over the real `Accounts` API, then `Calendar/Sync` builds a
|
||||
/// real `CalDavClient` and syncs events fetched from WireMock into the store,
|
||||
/// immediately visible to `ListEvents` -- all over the authenticated gRPC API.
|
||||
#[tokio::test]
|
||||
async fn caldav_production_sync_builds_real_client_and_persists_events() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/calendars/work/"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(multistatus_body()))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
let collection_url = format!("{}/calendars/work/", mock_server.uri());
|
||||
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let (addr, token, _db_dir) = boot_daemon(secrets).await;
|
||||
let addr_str = addr.to_string();
|
||||
|
||||
// ---- Configure a first-class CalDAV account over the Accounts API ----
|
||||
let mut accounts_client = nuncio_proto::client::connect_accounts(&addr_str, &token)
|
||||
.await
|
||||
.expect("accounts client connects");
|
||||
accounts_client
|
||||
.add_account(AddAccountRequest {
|
||||
config: Some(AccountConfigProto {
|
||||
id: ACCOUNT_ID.to_string(),
|
||||
name: "Work Calendar".to_string(),
|
||||
email_address: ACCOUNT_EMAIL.to_string(),
|
||||
protocol: AccountProtocolProto::Caldav.into(),
|
||||
server_host: String::new(),
|
||||
server_port: 0,
|
||||
use_tls: true,
|
||||
imap_tls_mode: TlsModeProto::ImplicitTls.into(),
|
||||
smtp_tls_mode: TlsModeProto::ImplicitTls.into(),
|
||||
keyring_secret_key: KEYRING_KEY.to_string(),
|
||||
sync_interval_secs: 300,
|
||||
smtp_host: String::new(),
|
||||
smtp_port: 0,
|
||||
collection_url: collection_url.clone(),
|
||||
}),
|
||||
password: CALDAV_PASSWORD.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("CalDAV account is accepted and persisted");
|
||||
|
||||
// ---- Calendar/Sync runs the PRODUCTION path (no injected backend) ----
|
||||
let mut calendar_client = nuncio_proto::client::connect_calendar(&addr_str, &token)
|
||||
.await
|
||||
.expect("calendar client connects");
|
||||
let sync_response = calendar_client
|
||||
.sync(CalendarSyncRequest {
|
||||
account_id: ACCOUNT_ID.to_string(),
|
||||
calendar_id: CALENDAR_ID.to_string(),
|
||||
start_window: 1_700_000_000,
|
||||
end_window: 1_800_000_000,
|
||||
})
|
||||
.await
|
||||
.expect("production CalDAV sync succeeds")
|
||||
.into_inner();
|
||||
assert_eq!(sync_response.synced_count, 2);
|
||||
|
||||
// Immediately visible: `Sync` awaits full fetch-and-persist completion.
|
||||
let list_response = calendar_client
|
||||
.list_events(ListEventsRequest {
|
||||
account_id: ACCOUNT_ID.to_string(),
|
||||
calendar_id: CALENDAR_ID.to_string(),
|
||||
start_window: 1_700_000_000,
|
||||
end_window: 1_800_000_000,
|
||||
})
|
||||
.await
|
||||
.expect("list_events succeeds")
|
||||
.into_inner();
|
||||
assert_eq!(list_response.events.len(), 2);
|
||||
let summaries: Vec<&str> = list_response
|
||||
.events
|
||||
.iter()
|
||||
.map(|e| e.summary.as_str())
|
||||
.collect();
|
||||
assert!(summaries.contains(&"Production CalDAV Standup"));
|
||||
assert!(summaries.contains(&"Production UTC Review"));
|
||||
|
||||
// ---- With no CalDAV account matching, the honest error still fires ----
|
||||
let missing = calendar_client
|
||||
.sync(CalendarSyncRequest {
|
||||
account_id: "acct-does-not-exist".to_string(),
|
||||
calendar_id: CALENDAR_ID.to_string(),
|
||||
start_window: 1_700_000_000,
|
||||
end_window: 1_800_000_000,
|
||||
})
|
||||
.await
|
||||
.expect_err("a sync targeting an unconfigured account must fail honestly");
|
||||
assert_eq!(missing.code(), Code::FailedPrecondition);
|
||||
|
||||
// ---- An unauthenticated call to Calendar is rejected ----
|
||||
let mut unauthenticated_client =
|
||||
nuncio_proto::v1::calendar_client::CalendarClient::connect(format!("http://{addr_str}"))
|
||||
.await
|
||||
.expect("client connects at the transport level without any auth");
|
||||
let err = unauthenticated_client
|
||||
.list_events(ListEventsRequest {
|
||||
account_id: ACCOUNT_ID.to_string(),
|
||||
calendar_id: CALENDAR_ID.to_string(),
|
||||
start_window: 0,
|
||||
end_window: i64::MAX,
|
||||
})
|
||||
.await
|
||||
.expect_err("missing bearer token must be rejected");
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
@@ -97,6 +97,7 @@ fn sample_account() -> nuncio_core::AccountConfig {
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
keyring_secret_key: format!("nuncio/{ACCOUNT_ID}"),
|
||||
sync_interval_secs: 60,
|
||||
collection_url: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ fn sample_account_config() -> AccountConfig {
|
||||
sync_interval_secs: 60,
|
||||
smtp_host: "smtp.nuncio.mx".to_string(),
|
||||
smtp_port: 465,
|
||||
collection_url: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user