feat(contacts): drive contacts sync from stored CardDAV accounts #431
No files matched your search
@@ -211,13 +211,15 @@ pub enum AccountSubcommand {
|
||||
/// 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.
|
||||
/// Account protocol: imap-smtp (default), jmap, caldav, or carddav. A
|
||||
/// caldav or carddav 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)")]
|
||||
/// Fully-qualified DAV collection URL -- a CalDAV calendar collection
|
||||
/// or a CardDAV address book (required for a caldav or carddav
|
||||
/// account; ignored for mail accounts).
|
||||
#[arg(long, help = "DAV collection URL (caldav/carddav only)")]
|
||||
collection_url: Option<String>,
|
||||
/// IMAP server hostname.
|
||||
#[arg(long, default_value = "mail.kof22.com", help = "IMAP server hostname")]
|
||||
|
||||
@@ -75,8 +75,9 @@ fn parse_account_protocol(protocol: &str) -> Result<nuncio_proto::v1::AccountPro
|
||||
"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),
|
||||
"carddav" | "card-dav" | "card_dav" => Ok(nuncio_proto::v1::AccountProtocol::Carddav),
|
||||
other => Err(format!(
|
||||
"invalid protocol '{other}' (expected imap-smtp, jmap, or caldav)"
|
||||
"invalid protocol '{other}' (expected imap-smtp, jmap, caldav, or carddav)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -104,6 +105,10 @@ fn account_transport_json(config: &nuncio_proto::v1::AccountConfig) -> serde_jso
|
||||
"protocol": "caldav",
|
||||
"collection_url": t.collection_url,
|
||||
}),
|
||||
Some(Transport::Carddav(t)) => json!({
|
||||
"protocol": "carddav",
|
||||
"collection_url": t.collection_url,
|
||||
}),
|
||||
None => json!({ "protocol": "unspecified" }),
|
||||
}
|
||||
}
|
||||
@@ -135,6 +140,7 @@ fn account_transport_summary(config: &nuncio_proto::v1::AccountConfig) -> String
|
||||
),
|
||||
Some(Transport::Jmap(t)) => format!("JMAP {}", t.endpoint_host),
|
||||
Some(Transport::Dav(t)) => format!("CalDAV {}", t.collection_url),
|
||||
Some(Transport::Carddav(t)) => format!("CardDAV {}", t.collection_url),
|
||||
None => "(no transport configured)".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -2117,9 +2123,20 @@ impl HeadlessRunner {
|
||||
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);
|
||||
// Both DAV flavours are addressed by a collection URL rather than a
|
||||
// mail host/port, so neither can be added without one.
|
||||
let dav_kind = match account_protocol {
|
||||
nuncio_proto::v1::AccountProtocol::Caldav => Some("caldav"),
|
||||
nuncio_proto::v1::AccountProtocol::Carddav => Some("carddav"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kind) = dav_kind {
|
||||
if collection_url.map(str::trim).unwrap_or_default().is_empty() {
|
||||
return Self::render_error(
|
||||
&format!("a {kind} account requires --collection-url"),
|
||||
json_mode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let keyring_key = format!("nuncio/{}", email);
|
||||
@@ -2152,6 +2169,13 @@ impl HeadlessRunner {
|
||||
collection_url: collection_url.unwrap_or_default().to_string(),
|
||||
})
|
||||
}
|
||||
nuncio_proto::v1::AccountProtocol::Carddav => {
|
||||
nuncio_proto::v1::account_config::Transport::Carddav(
|
||||
nuncio_proto::v1::DavTransport {
|
||||
collection_url: collection_url.unwrap_or_default().to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
nuncio_proto::v1::AccountProtocol::Unspecified => {
|
||||
return Self::render_error("account protocol is required", json_mode)
|
||||
}
|
||||
@@ -2191,20 +2215,25 @@ impl HeadlessRunner {
|
||||
.map(|c| c.id)
|
||||
.unwrap_or(account_id);
|
||||
let is_jmap = account_protocol == nuncio_proto::v1::AccountProtocol::Jmap;
|
||||
if is_caldav {
|
||||
if let Some(kind) = dav_kind {
|
||||
let collection = collection_url.unwrap_or_default();
|
||||
let label = if kind == "carddav" {
|
||||
"CardDAV"
|
||||
} else {
|
||||
"CalDAV"
|
||||
};
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"configured": true,
|
||||
"account_id": account_id,
|
||||
"email": email,
|
||||
"protocol": "caldav",
|
||||
"protocol": kind,
|
||||
"collection_url": collection,
|
||||
"keyring_key": keyring_key
|
||||
}))
|
||||
} else {
|
||||
format!(
|
||||
"CalDAV account '{email}' (ID: {account_id}) added via nunciod daemon with collection URL {collection}"
|
||||
"{label} account '{email}' (ID: {account_id}) added via nunciod daemon with collection URL {collection}"
|
||||
)
|
||||
}
|
||||
} else if is_jmap {
|
||||
|
||||
@@ -16,6 +16,10 @@ pub enum AccountProtocol {
|
||||
/// `collection_url` and its own keyring credential; it does not use the
|
||||
/// IMAP/JMAP/SMTP mail endpoints.
|
||||
CalDav,
|
||||
/// CardDAV (RFC 6352) address-book-collection protocol engine. Like
|
||||
/// CalDAV, a CardDAV account is a standalone account entry addressed by
|
||||
/// its `collection_url` and its own keyring credential.
|
||||
CardDav,
|
||||
}
|
||||
|
||||
impl AccountProtocol {
|
||||
@@ -24,8 +28,22 @@ impl AccountProtocol {
|
||||
/// require [`AccountConfig::collection_url`] and do not require the
|
||||
/// IMAP/SMTP mail endpoint fields.
|
||||
pub fn is_dav(self) -> bool {
|
||||
matches!(self, AccountProtocol::CalDav | AccountProtocol::CardDav)
|
||||
}
|
||||
|
||||
/// Whether this protocol is specifically CalDAV. Callers that drive
|
||||
/// calendar sync must use this rather than [`Self::is_dav`], which is also
|
||||
/// true for CardDAV address books.
|
||||
pub fn is_caldav(self) -> bool {
|
||||
matches!(self, AccountProtocol::CalDav)
|
||||
}
|
||||
|
||||
/// Whether this protocol is specifically CardDAV. Callers that drive
|
||||
/// contacts sync must use this rather than [`Self::is_dav`], which is also
|
||||
/// true for CalDAV calendars.
|
||||
pub fn is_carddav(self) -> bool {
|
||||
matches!(self, AccountProtocol::CardDav)
|
||||
}
|
||||
}
|
||||
|
||||
/// Security and transport encryption mode for mail protocol streams (IMAP & SMTP).
|
||||
@@ -104,9 +122,11 @@ pub struct JmapTransport {
|
||||
pub endpoint_host: String,
|
||||
}
|
||||
|
||||
/// DAV transport parameters (CalDAV RFC 4791 today, CardDAV later). A DAV
|
||||
/// account is addressed by a fully-qualified collection URL rather than a mail
|
||||
/// host/port, because a DAV client dispatches requests directly at that URL.
|
||||
/// DAV transport parameters (CalDAV RFC 4791 calendars and CardDAV RFC 6352
|
||||
/// address books). A DAV account is addressed by a fully-qualified collection
|
||||
/// URL rather than a mail host/port, because a DAV client dispatches requests
|
||||
/// directly at that URL. Which flavour of DAV is spoken is carried by the
|
||||
/// enclosing [`Transport`] variant, not by this struct.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DavTransport {
|
||||
/// Fully-qualified collection URL (scheme + host + path).
|
||||
@@ -124,8 +144,10 @@ pub enum Transport {
|
||||
ImapSmtp(ImapSmtpTransport),
|
||||
/// JMAP mail.
|
||||
Jmap(JmapTransport),
|
||||
/// WebDAV collection (CalDAV today, CardDAV later).
|
||||
/// CalDAV calendar collection.
|
||||
Dav(DavTransport),
|
||||
/// CardDAV address-book collection.
|
||||
CardDav(DavTransport),
|
||||
}
|
||||
|
||||
impl Transport {
|
||||
@@ -135,6 +157,7 @@ impl Transport {
|
||||
Transport::ImapSmtp(_) => AccountProtocol::ImapSmtp,
|
||||
Transport::Jmap(_) => AccountProtocol::Jmap,
|
||||
Transport::Dav(_) => AccountProtocol::CalDav,
|
||||
Transport::CardDav(_) => AccountProtocol::CardDav,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,11 +234,23 @@ impl AccountConfig {
|
||||
self.transport.protocol()
|
||||
}
|
||||
|
||||
/// Whether this account speaks WebDAV against a collection URL.
|
||||
/// Whether this account speaks WebDAV against a collection URL (CalDAV or
|
||||
/// CardDAV). Callers driving a specific protocol's sync must use
|
||||
/// [`Self::is_caldav`] or [`Self::is_carddav`] instead.
|
||||
pub fn is_dav(&self) -> bool {
|
||||
matches!(self.transport, Transport::Dav(_) | Transport::CardDav(_))
|
||||
}
|
||||
|
||||
/// Whether this account is specifically a CalDAV calendar account.
|
||||
pub fn is_caldav(&self) -> bool {
|
||||
matches!(self.transport, Transport::Dav(_))
|
||||
}
|
||||
|
||||
/// Whether this account is specifically a CardDAV address-book account.
|
||||
pub fn is_carddav(&self) -> bool {
|
||||
matches!(self.transport, Transport::CardDav(_))
|
||||
}
|
||||
|
||||
/// The account's IMAP/SMTP transport parameters, present only for an
|
||||
/// IMAP/SMTP account (the only transport with an SMTP outbound endpoint).
|
||||
pub fn imap_smtp(&self) -> Option<&ImapSmtpTransport> {
|
||||
@@ -225,10 +260,10 @@ impl AccountConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// The account's DAV collection URL, present only for a DAV account.
|
||||
/// The account's DAV collection URL, present for either DAV flavour.
|
||||
pub fn dav_collection_url(&self) -> Option<&str> {
|
||||
match &self.transport {
|
||||
Transport::Dav(t) => Some(t.collection_url.as_str()),
|
||||
Transport::Dav(t) | Transport::CardDav(t) => Some(t.collection_url.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -294,9 +329,15 @@ impl AccountConfig {
|
||||
});
|
||||
}
|
||||
}
|
||||
Transport::Dav(t) => {
|
||||
// A DAV account is addressed by its collection URL, not by mail
|
||||
// host/port endpoints.
|
||||
Transport::Dav(t) | Transport::CardDav(t) => {
|
||||
// Both DAV flavours are addressed by their collection URL, not
|
||||
// by mail host/port endpoints, so they validate identically;
|
||||
// only the endpoint label in the error differs.
|
||||
let endpoint = if self.is_carddav() {
|
||||
"carddav"
|
||||
} else {
|
||||
"caldav"
|
||||
};
|
||||
let url = t.collection_url.trim();
|
||||
if url.is_empty() {
|
||||
return Err(ConfigError::EmptyField {
|
||||
@@ -313,7 +354,7 @@ impl AccountConfig {
|
||||
let host = url_host(url).unwrap_or_default();
|
||||
if !is_loopback_host(host) {
|
||||
return Err(ConfigError::CleartextToRemoteHost {
|
||||
endpoint: "caldav",
|
||||
endpoint,
|
||||
host: host.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -299,6 +299,10 @@ enum AccountProtocol {
|
||||
// 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;
|
||||
// CardDAV (RFC 6352) address-book-collection account. Like CalDAV, it is its
|
||||
// own first-class account entry carrying `collection_url` and its own
|
||||
// keyring credential.
|
||||
ACCOUNT_PROTOCOL_CARDDAV = 4;
|
||||
}
|
||||
|
||||
// Mirrors `nuncio_core::TlsMode`.
|
||||
@@ -337,12 +341,14 @@ message JmapTransport {
|
||||
string endpoint_host = 1;
|
||||
}
|
||||
|
||||
// DAV transport parameters (CalDAV RFC 4791 today, CardDAV later). A DAV
|
||||
// account is addressed by a fully-qualified collection URL rather than a mail
|
||||
// host/port, because a DAV client dispatches requests directly at that URL.
|
||||
// DAV transport parameters (CalDAV RFC 4791 calendars and CardDAV RFC 6352
|
||||
// address books). A DAV account is addressed by a fully-qualified collection
|
||||
// URL rather than a mail host/port, because a DAV client dispatches requests
|
||||
// directly at that URL. Which flavour of DAV is spoken is carried by the
|
||||
// `transport` oneof case, not by this message.
|
||||
message DavTransport {
|
||||
// Fully-qualified collection URL (scheme + host + path), e.g. a CalDAV
|
||||
// calendar collection or, later, a CardDAV address-book collection.
|
||||
// calendar collection or a CardDAV address-book collection.
|
||||
string collection_url = 1;
|
||||
}
|
||||
|
||||
@@ -385,6 +391,7 @@ message AccountConfig {
|
||||
ImapSmtpTransport imap_smtp = 15;
|
||||
JmapTransport jmap = 16;
|
||||
DavTransport dav = 17;
|
||||
DavTransport carddav = 19;
|
||||
}
|
||||
// Whether the filter engine runs against this account's incoming mail.
|
||||
//
|
||||
@@ -1322,10 +1329,11 @@ message ExportResponse {
|
||||
// `get_calendar_event`), plus a real `Sync` that fetches events from a
|
||||
// `CalendarBackend` and persists them.
|
||||
//
|
||||
// This is an honest surface: there is currently no way to persist a
|
||||
// per-account CalDAV collection URL/credential, so `Sync` in production (no
|
||||
// injected test backend) has no real CalDAV connection to make and returns
|
||||
// an error rather than a fabricated success. There are deliberately no
|
||||
// This is an honest surface: in production `Sync` drives real CalDAV accounts
|
||||
// resolved from the store (`ACCOUNT_PROTOCOL_CALDAV` + `dav.collection_url`,
|
||||
// credential read from the OS keyring). With no such account configured it
|
||||
// returns `FAILED_PRECONDITION` rather than a fabricated success. There are
|
||||
// deliberately no
|
||||
// create/delete RPCs here -- this service does not add real CalDAV write
|
||||
// support.
|
||||
//
|
||||
@@ -1428,10 +1436,10 @@ message GetEventResponse {
|
||||
// them, and a `CreateContact` that persists a locally-authored contact
|
||||
// directly to that same store.
|
||||
//
|
||||
// This is an honest surface: there is currently no way to persist a
|
||||
// per-account CardDAV address book collection URL/credential, so `Sync` in
|
||||
// production (no injected test backend) has no real CardDAV connection to
|
||||
// make and returns an error rather than a fabricated success.
|
||||
// This is an honest surface: in production `Sync` drives real CardDAV accounts
|
||||
// resolved from the store (`ACCOUNT_PROTOCOL_CARDDAV` + `carddav.collection_url`,
|
||||
// credential read from the OS keyring). With no such account configured it
|
||||
// returns `FAILED_PRECONDITION` rather than a fabricated success.
|
||||
// `CreateContact` is NOT a CardDAV write-back -- it only ever writes to the
|
||||
// daemon's own local store, the same way the address book has always worked
|
||||
// here, just now actually persisted through the daemon's real store instead
|
||||
@@ -1445,10 +1453,11 @@ message GetEventResponse {
|
||||
// See `nunciod::grpc::serve_on_listener`.
|
||||
service Contacts {
|
||||
// Sync fetches contacts for `account_id` from the account's contacts
|
||||
// backend and persists them via the store. With no per-account CardDAV
|
||||
// configuration available in production, this returns `Status::internal`
|
||||
// describing that gap unless a test double has been injected -- never a
|
||||
// fabricated `synced_count`.
|
||||
// backend and persists them via the store. In production it builds a real
|
||||
// CardDAV client per configured CardDAV account; if `account_id` names an
|
||||
// account that is not CardDAV, or no CardDAV account is configured at all,
|
||||
// it returns `FAILED_PRECONDITION` -- never a fabricated `synced_count`.
|
||||
// An empty `account_id` syncs every configured CardDAV account.
|
||||
rpc Sync(ContactsSyncRequest) returns (ContactsSyncResponse);
|
||||
// ListContacts returns a keyset-paginated page of persisted contacts for
|
||||
// `account_id`, ordered by interaction count (descending) then display
|
||||
|
||||
@@ -1404,7 +1404,7 @@ impl DatabaseEngine {
|
||||
TlsMode::ImplicitTls,
|
||||
String::new(),
|
||||
),
|
||||
Transport::Dav(t) => (
|
||||
Transport::Dav(t) | Transport::CardDav(t) => (
|
||||
String::new(),
|
||||
0u16,
|
||||
TlsMode::ImplicitTls,
|
||||
@@ -1563,6 +1563,11 @@ impl DatabaseEngine {
|
||||
collection_url: collection_url.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
nuncio_core::AccountProtocol::CardDav => {
|
||||
nuncio_core::Transport::CardDav(nuncio_core::DavTransport {
|
||||
collection_url: collection_url.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
};
|
||||
Ok(nuncio_core::AccountConfig {
|
||||
id,
|
||||
@@ -4724,6 +4729,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A CardDAV account round-trips as CardDAV, not as the CalDAV variant it
|
||||
/// shares physical columns with: both flavours persist their URL in
|
||||
/// `collection_url`, so only the `protocol` discriminator distinguishes
|
||||
/// them on reload, and confusing the two would silently point contacts
|
||||
/// sync at a calendar collection (or vice versa).
|
||||
#[tokio::test]
|
||||
async fn carddav_account_round_trips_as_carddav_not_caldav() {
|
||||
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
|
||||
|
||||
let carddav = nuncio_core::AccountConfig {
|
||||
id: "acct-carddav-store-1".to_string(),
|
||||
name: "Work Address Book".to_string(),
|
||||
email_address: "card@nuncio.mx".to_string(),
|
||||
keyring_secret_key: "nuncio/acct-carddav-store-1".to_string(),
|
||||
sync_interval_secs: 300,
|
||||
filters_enabled: false,
|
||||
transport: nuncio_core::Transport::CardDav(nuncio_core::DavTransport {
|
||||
collection_url: "https://dav.example.com/addressbooks/user/default/".to_string(),
|
||||
}),
|
||||
};
|
||||
engine.save_account(&carddav).await.expect("save succeeds");
|
||||
|
||||
let fetched = engine
|
||||
.get_account("acct-carddav-store-1")
|
||||
.await
|
||||
.expect("get succeeds")
|
||||
.expect("account present");
|
||||
assert_eq!(fetched, carddav);
|
||||
assert_eq!(fetched.protocol(), nuncio_core::AccountProtocol::CardDav);
|
||||
assert!(fetched.is_carddav());
|
||||
assert!(!fetched.is_caldav());
|
||||
assert_eq!(
|
||||
fetched.dav_collection_url(),
|
||||
Some("https://dav.example.com/addressbooks/user/default/")
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression proof that accounts of DIFFERENT transports coexist on disk
|
||||
/// and each loads back as its exact original transport variant -- the flat
|
||||
/// physical columns must not blur one transport into another on reload.
|
||||
|
||||
@@ -458,6 +458,11 @@ impl SqliteRecoveryEngine {
|
||||
collection_url: collection_url.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
nuncio_core::AccountProtocol::CardDav => {
|
||||
nuncio_core::Transport::CardDav(nuncio_core::DavTransport {
|
||||
collection_url: collection_url.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
};
|
||||
nuncio_core::AccountConfig {
|
||||
id,
|
||||
|
||||
@@ -5,18 +5,12 @@
|
||||
//! single account, then persists each contact via
|
||||
//! [`DatabaseEngine::save_contact`].
|
||||
//!
|
||||
//! # No production entry point yet
|
||||
//!
|
||||
//! 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
|
||||
//! CardDAV address book collection URL and credential and builds a real
|
||||
//! `CardDavClient` from them: `nuncio_core::AccountConfig` carries no CardDAV
|
||||
//! 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 `MockContactsBackend`, and it is the same seam a future
|
||||
//! story's production entry point will call once per-account CardDAV
|
||||
//! configuration exists.
|
||||
use nuncio_contacts::ContactsBackend;
|
||||
//! [`sync_carddav_account`] is the production entry point: it resolves a real
|
||||
//! account's persisted CardDAV address-book collection URL, builds a real
|
||||
//! [`CardDavClient`] from it plus the credential the caller read from the OS
|
||||
//! keyring, and funnels through the same [`sync_with_backend`] seam that tests
|
||||
//! drive with `MockContactsBackend`.
|
||||
use nuncio_contacts::{CardDavAccountConfig, CardDavClient, ContactsBackend};
|
||||
use nuncio_store::db::DatabaseError;
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -68,6 +62,34 @@ pub async fn sync_with_backend(
|
||||
Ok(synced)
|
||||
}
|
||||
|
||||
/// Production entry point: build a real [`CardDavClient`] for `account` from
|
||||
/// its persisted `collection_url` and the `password` resolved from the OS
|
||||
/// keyring vault, then fetch-and-persist that account's contacts via
|
||||
/// [`sync_with_backend`]. Returns the number of contacts synced.
|
||||
///
|
||||
/// `account`'s collection URL must be a fully-qualified CardDAV address-book
|
||||
/// 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
|
||||
/// [`ContactsSyncError`].
|
||||
pub async fn sync_carddav_account(
|
||||
db: &nuncio_store::db::DatabaseEngine,
|
||||
account: &nuncio_core::AccountConfig,
|
||||
password: &str,
|
||||
) -> Result<usize, ContactsSyncError> {
|
||||
tracing::info!(
|
||||
account_id = %account.id,
|
||||
"contacts sync: dispatching CardDAV sync for account"
|
||||
);
|
||||
let client = CardDavClient::new(CardDavAccountConfig {
|
||||
account_id: account.id.clone(),
|
||||
carddav_url: account.dav_collection_url().unwrap_or_default().to_string(),
|
||||
username: account.email_address.clone(),
|
||||
auth_token: nuncio_core::redact::Redacted::new(password.to_string()),
|
||||
});
|
||||
sync_with_backend(db, &client, &account.id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+96
-19
@@ -398,6 +398,7 @@ fn transport_kind(transport: &nuncio_core::Transport) -> &'static str {
|
||||
nuncio_core::Transport::ImapSmtp(_) => "imap_smtp",
|
||||
nuncio_core::Transport::Jmap(_) => "jmap",
|
||||
nuncio_core::Transport::Dav(_) => "dav",
|
||||
nuncio_core::Transport::CardDav(_) => "carddav",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,6 +423,9 @@ fn map_account_config_to_proto(config: nuncio_core::AccountConfig) -> AccountCon
|
||||
nuncio_core::Transport::Dav(t) => TransportProto::Dav(DavTransportProto {
|
||||
collection_url: t.collection_url,
|
||||
}),
|
||||
nuncio_core::Transport::CardDav(t) => TransportProto::Carddav(DavTransportProto {
|
||||
collection_url: t.collection_url,
|
||||
}),
|
||||
};
|
||||
AccountConfigProto {
|
||||
id: config.id,
|
||||
@@ -480,10 +484,15 @@ fn map_account_config_from_proto(
|
||||
Some(TransportProto::Dav(t)) => nuncio_core::Transport::Dav(nuncio_core::DavTransport {
|
||||
collection_url: t.collection_url,
|
||||
}),
|
||||
Some(TransportProto::Carddav(t)) => {
|
||||
nuncio_core::Transport::CardDav(nuncio_core::DavTransport {
|
||||
collection_url: t.collection_url,
|
||||
})
|
||||
}
|
||||
None => {
|
||||
return Err(errors::status(
|
||||
ErrorReason::ValidationFailed,
|
||||
"account transport is required (one of imap_smtp, jmap, dav must be set)",
|
||||
"account transport is required (one of imap_smtp, jmap, dav, carddav must be set)",
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -583,13 +592,13 @@ impl AccountConnectionTester for RealAccountConnectionTester {
|
||||
),
|
||||
}
|
||||
}
|
||||
nuncio_core::Transport::Dav(_) => {
|
||||
nuncio_core::Transport::Dav(_) | nuncio_core::Transport::CardDav(_) => {
|
||||
return AccountConnectionReport {
|
||||
imap: ProtocolProbe::failed(
|
||||
"IMAP connection probe is not applicable to a CalDAV account".to_string(),
|
||||
"IMAP connection probe is not applicable to a DAV account".to_string(),
|
||||
),
|
||||
smtp: ProtocolProbe::failed(
|
||||
"SMTP connection probe is not applicable to a CalDAV account".to_string(),
|
||||
"SMTP connection probe is not applicable to a DAV account".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1239,7 +1248,7 @@ impl CalendarGrpcService {
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to list accounts: {e}")))?
|
||||
.into_iter()
|
||||
.filter(|a| a.is_dav())
|
||||
.filter(|a| a.is_caldav())
|
||||
.filter(|a| req.account_id.is_empty() || a.id == req.account_id)
|
||||
.collect();
|
||||
|
||||
@@ -1465,15 +1474,88 @@ impl std::fmt::Debug for ContactsEngineOverrides {
|
||||
/// `ListContacts`/`GetContact` always read genuinely persisted data.
|
||||
/// `CreateContact` persists a locally-authored contact directly to the
|
||||
/// store -- real local persistence, never a fabricated CardDAV write-back.
|
||||
/// `Sync` fetches from [`ContactsEngineOverrides::contacts_backend`] when
|
||||
/// injected; otherwise it returns an honest error, since there is no
|
||||
/// persisted per-account CardDAV configuration to build a real backend from
|
||||
/// yet (see `nunciod::contacts_sync`'s doc comment).
|
||||
/// `Sync` fetches from [`ContactsEngineOverrides::contacts_backend`] when a
|
||||
/// test double is injected; otherwise it takes the production path, building a
|
||||
/// real `CardDavClient` per configured CardDAV account (see
|
||||
/// [`Self::sync_configured_carddav_accounts`]).
|
||||
struct ContactsGrpcService {
|
||||
db: Arc<DatabaseEngine>,
|
||||
secrets: Arc<SecretManager>,
|
||||
overrides: ContactsEngineOverrides,
|
||||
}
|
||||
|
||||
impl ContactsGrpcService {
|
||||
/// Production path: resolve every configured CardDAV account from the
|
||||
/// store, build a real `CardDavClient` per account from its persisted
|
||||
/// collection URL and keyring credential, and sync its contacts into the
|
||||
/// store. Returns the total number of contacts synced.
|
||||
///
|
||||
/// A request naming a specific `account_id` is scoped to that account; an
|
||||
/// empty `account_id` syncs every configured CardDAV account. When no
|
||||
/// CardDAV account matches -- including when the named account exists but
|
||||
/// speaks some other protocol -- this returns an honest error rather than
|
||||
/// a fabricated zero-count success, because there is genuinely no remote
|
||||
/// address book to fetch from.
|
||||
async fn sync_configured_carddav_accounts(
|
||||
&self,
|
||||
req: &ContactsSyncRequest,
|
||||
) -> Result<usize, Status> {
|
||||
let carddav_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.is_carddav())
|
||||
.filter(|a| req.account_id.is_empty() || a.id == req.account_id)
|
||||
.collect();
|
||||
|
||||
if carddav_accounts.is_empty() {
|
||||
return Err(errors::status(
|
||||
ErrorReason::NotConfigured,
|
||||
if req.account_id.is_empty() {
|
||||
"no CardDAV account is configured; add one with protocol CARDDAV and a \
|
||||
collection_url before syncing"
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"no CardDAV account with id '{}' is configured",
|
||||
req.account_id
|
||||
)
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let mut total = 0usize;
|
||||
for account in carddav_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 CardDAV credential for account '{}': {e}",
|
||||
account.id
|
||||
))
|
||||
})?,
|
||||
);
|
||||
|
||||
total += crate::contacts_sync::sync_carddav_account(&self.db, &account, &password)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Status::internal(format!(
|
||||
"CardDAV sync failed for account '{}': {e}",
|
||||
account.id
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Contacts for ContactsGrpcService {
|
||||
async fn sync(
|
||||
@@ -1488,13 +1570,7 @@ impl Contacts for ContactsGrpcService {
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("contacts sync failed: {e}")))?
|
||||
}
|
||||
None => {
|
||||
return Err(Status::internal(format!(
|
||||
"no CardDAV configuration exists for account '{}'; per-account CardDAV \
|
||||
configuration is not yet implemented",
|
||||
req.account_id
|
||||
)));
|
||||
}
|
||||
None => self.sync_configured_carddav_accounts(&req).await?,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
@@ -3751,6 +3827,7 @@ pub async fn serve_on_listener_with_overrides_and_shutdown(
|
||||
// this function's doc comment.
|
||||
let contacts_service = ContactsGrpcService {
|
||||
db,
|
||||
secrets,
|
||||
overrides: contacts_overrides,
|
||||
};
|
||||
let contacts_interceptor = BearerAuthInterceptor::new(token);
|
||||
@@ -8356,10 +8433,10 @@ mod tests {
|
||||
account_id: "acct-contacts-no-config".to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect_err("sync with no injected backend and no CardDAV config must fail");
|
||||
assert_eq!(err.code(), Code::Internal);
|
||||
.expect_err("sync with no injected backend and no CardDAV account must fail");
|
||||
assert_eq!(err.code(), Code::FailedPrecondition);
|
||||
assert!(err.message().contains("acct-contacts-no-config"));
|
||||
assert!(err.message().contains("no CardDAV configuration"));
|
||||
assert!(err.message().contains("no CardDAV account"));
|
||||
}
|
||||
|
||||
/// With a [`ContactsEngineOverrides::contacts_backend`] injected, `Sync`
|
||||
|
||||
@@ -482,7 +482,9 @@ pub(crate) fn build_mail_backend(
|
||||
))),
|
||||
// A DAV-protocol account (e.g. CalDAV) has no inbound mail backend;
|
||||
// it is synced through its own domain service, not the mail path.
|
||||
Transport::Dav(_) => Err(SyncError::NotAMailAccount(config.id.clone())),
|
||||
Transport::Dav(_) | Transport::CardDav(_) => {
|
||||
Err(SyncError::NotAMailAccount(config.id.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Full-daemon offline end-to-end test for the PRODUCTION CardDAV sync path.
|
||||
//!
|
||||
//! Unlike `contacts_e2e_test.rs`, which injects a `MockContactsBackend` through
|
||||
//! the [`nunciod::grpc::ContactsEngineOverrides`] seam, this test drives the
|
||||
//! real production path with NO injected backend: it configures a first-class
|
||||
//! CardDAV account over the authenticated `Accounts/AddAccount` RPC (protocol
|
||||
//! CARDDAV + a `collection_url` pointing at a WireMock server + a keyring
|
||||
//! credential), then calls `Contacts/Sync`. The daemon resolves the configured
|
||||
//! CardDAV account, reads its credential from the (mock) vault, builds a REAL
|
||||
//! [`nuncio_contacts::CardDavClient`] from the persisted collection URL, issues
|
||||
//! a genuine CardDAV `REPORT` against WireMock, and persists the fetched
|
||||
//! contacts.
|
||||
//!
|
||||
//! This proves the production wiring end to end while remaining fully offline:
|
||||
//! WireMock stands in for the remote CardDAV 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, AddAccountRequest, ContactsSyncRequest,
|
||||
ListContactsRequest,
|
||||
};
|
||||
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-carddav-e2e-1";
|
||||
const ACCOUNT_EMAIL: &str = "james.maes@nuncio.mx";
|
||||
const KEYRING_KEY: &str = "nuncio/acct-carddav-e2e-1";
|
||||
const CARDDAV_PASSWORD: &str = "wiremock-app-token";
|
||||
|
||||
/// A mail account added alongside the CardDAV one, to prove `Contacts/Sync`
|
||||
/// refuses an account that is not a CardDAV account rather than silently
|
||||
/// syncing nothing.
|
||||
const MAIL_ACCOUNT_ID: &str = "acct-carddav-e2e-mail";
|
||||
|
||||
/// A canned CardDAV `multistatus` response with two vCards, mirroring the
|
||||
/// transport-level fixture used by `nuncio-contacts`'s own WireMock test.
|
||||
fn multistatus_body() -> String {
|
||||
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:response>
|
||||
<d:href>/addressbooks/contacts/james.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Production CardDAV Maes
|
||||
N:Maes;James;;;
|
||||
ORG:KofTwentyTwo
|
||||
TITLE:Founder
|
||||
EMAIL;TYPE=WORK:james.maes@kof22.com
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/addressbooks/contacts/alice.vcf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<card:address-data>BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Production CardDAV Dev
|
||||
EMAIL:alice@nuncio.mx
|
||||
END:VCARD</card:address-data>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Boots the real daemon gRPC server with DEFAULT overrides (no injected
|
||||
/// contacts 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("carddav_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(),
|
||||
CalendarEngineOverrides::default(),
|
||||
// No injected contacts backend: exercise the real production path.
|
||||
ContactsEngineOverrides::default(),
|
||||
AccountsEngineOverrides::default(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
(addr, token, dir)
|
||||
}
|
||||
|
||||
/// The full offline proof of the production CardDAV sync path: configure a
|
||||
/// CardDAV account over the real `Accounts` API, then `Contacts/Sync` builds a
|
||||
/// real `CardDavClient` and syncs contacts fetched from WireMock into the
|
||||
/// store, immediately visible to `ListContacts` -- all over the authenticated
|
||||
/// gRPC API.
|
||||
#[tokio::test]
|
||||
async fn carddav_production_sync_builds_real_client_and_persists_contacts() {
|
||||
let mock_server = MockServer::start().await;
|
||||
Mock::given(method("REPORT"))
|
||||
.and(path("/addressbooks/contacts/"))
|
||||
.respond_with(ResponseTemplate::new(207).set_body_string(multistatus_body()))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
let collection_url = format!("{}/addressbooks/contacts/", 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 CardDAV account over the Accounts API ----
|
||||
// The password travels to the daemon ONLY on this RPC and is written only
|
||||
// to the mock vault; the sync path below reads it back from there.
|
||||
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 Address Book".to_string(),
|
||||
email_address: ACCOUNT_EMAIL.to_string(),
|
||||
keyring_secret_key: KEYRING_KEY.to_string(),
|
||||
sync_interval: Some(nuncio_proto::time::duration_from_secs(300)),
|
||||
filters_enabled: false,
|
||||
transport: Some(nuncio_proto::v1::account_config::Transport::Carddav(
|
||||
nuncio_proto::v1::DavTransport {
|
||||
collection_url: collection_url.clone(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
password: CARDDAV_PASSWORD.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("CardDAV account is accepted and persisted");
|
||||
|
||||
// A non-CardDAV account, so the "not a CardDAV account" assertion below
|
||||
// exercises a real, configured account rather than a missing one.
|
||||
accounts_client
|
||||
.add_account(AddAccountRequest {
|
||||
config: Some(AccountConfigProto {
|
||||
id: MAIL_ACCOUNT_ID.to_string(),
|
||||
name: "Work Mail".to_string(),
|
||||
email_address: ACCOUNT_EMAIL.to_string(),
|
||||
keyring_secret_key: "nuncio/acct-carddav-e2e-mail".to_string(),
|
||||
sync_interval: Some(nuncio_proto::time::duration_from_secs(300)),
|
||||
filters_enabled: false,
|
||||
transport: Some(nuncio_proto::v1::account_config::Transport::ImapSmtp(
|
||||
nuncio_proto::v1::ImapSmtpTransport {
|
||||
imap_host: "127.0.0.1".to_string(),
|
||||
imap_port: 1143,
|
||||
imap_tls_mode: nuncio_proto::v1::TlsMode::Plain.into(),
|
||||
smtp_host: "127.0.0.1".to_string(),
|
||||
smtp_port: 1025,
|
||||
smtp_tls_mode: nuncio_proto::v1::TlsMode::Plain.into(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
password: "mail-password".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("mail account is accepted and persisted");
|
||||
|
||||
// ---- Contacts/Sync runs the PRODUCTION path (no injected backend) ----
|
||||
let mut contacts_client = nuncio_proto::client::connect_contacts(&addr_str, &token)
|
||||
.await
|
||||
.expect("contacts client connects");
|
||||
let sync_response = contacts_client
|
||||
.sync(ContactsSyncRequest {
|
||||
account_id: ACCOUNT_ID.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("production CardDAV sync succeeds")
|
||||
.into_inner();
|
||||
assert_eq!(sync_response.synced_count, 2);
|
||||
|
||||
// Immediately visible: `Sync` awaits full fetch-and-persist completion.
|
||||
let list_response = contacts_client
|
||||
.list_contacts(ListContactsRequest {
|
||||
account_id: ACCOUNT_ID.to_string(),
|
||||
page_size: 0,
|
||||
page_token: String::new(),
|
||||
})
|
||||
.await
|
||||
.expect("list_contacts succeeds")
|
||||
.into_inner();
|
||||
assert_eq!(list_response.contacts.len(), 2);
|
||||
let names: Vec<&str> = list_response
|
||||
.contacts
|
||||
.iter()
|
||||
.map(|c| c.display_name.as_str())
|
||||
.collect();
|
||||
assert!(names.contains(&"Production CardDAV Maes"));
|
||||
assert!(names.contains(&"Production CardDAV Dev"));
|
||||
|
||||
// ---- A configured account that is NOT CardDAV fails honestly ----
|
||||
let not_carddav = contacts_client
|
||||
.sync(ContactsSyncRequest {
|
||||
account_id: MAIL_ACCOUNT_ID.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("a sync targeting a non-CardDAV account must fail honestly");
|
||||
assert_eq!(not_carddav.code(), Code::FailedPrecondition);
|
||||
assert!(not_carddav.message().contains(MAIL_ACCOUNT_ID));
|
||||
|
||||
// ---- An account with no CardDAV configuration at all fails honestly ----
|
||||
let missing = contacts_client
|
||||
.sync(ContactsSyncRequest {
|
||||
account_id: "acct-does-not-exist".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("a sync targeting an unconfigured account must fail honestly");
|
||||
assert_eq!(missing.code(), Code::FailedPrecondition);
|
||||
|
||||
// ---- An unauthenticated call to Contacts is rejected ----
|
||||
let mut unauthenticated_client =
|
||||
nuncio_proto::v1::contacts_client::ContactsClient::connect(format!("http://{addr_str}"))
|
||||
.await
|
||||
.expect("client connects at the transport level without any auth");
|
||||
let err = unauthenticated_client
|
||||
.list_contacts(ListContactsRequest {
|
||||
account_id: ACCOUNT_ID.to_string(),
|
||||
page_size: 0,
|
||||
page_token: String::new(),
|
||||
})
|
||||
.await
|
||||
.expect_err("missing bearer token must be rejected");
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
Reference in new issue
Block a user