feat(core): add AccountConfig model and validation schema (GH-6) #35

Merged
KofTwentyTwo merged 1 commits from feature/GH-6-account-config-schema into main 2026-07-23 00:42:15 +00:00
2 changed files with 258 additions and 0 deletions

No files matched your search

+255
View File
@@ -0,0 +1,255 @@
//! Account configuration models and validation logic.
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Protocol engine type supported for account synchronization.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AccountProtocol {
/// JMAP (RFC 8620 / RFC 8621) protocol engine.
Jmap,
/// Legacy IMAP4rev1 + SMTP protocol engines.
ImapSmtp,
}
/// Errors returned when validating an [`AccountConfig`].
#[derive(Error, Debug, PartialEq, Eq)]
pub enum ConfigError {
/// A mandatory configuration field was left empty or blank.
#[error("field '{field}' cannot be empty")]
EmptyField {
/// Name of the missing or blank field.
field: &'static str,
},
/// The specified email address does not follow RFC 5322 format.
#[error("invalid email address format")]
InvalidEmailFormat,
/// The server port number is invalid (must be 1..=65535).
#[error("invalid server port number")]
InvalidPort,
/// The synchronization interval is too short (minimum 10 seconds).
#[error("sync interval must be at least 10 seconds")]
SyncIntervalTooShort,
}
/// Complete configuration schema for a mail & calendar account.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccountConfig {
/// Unique identifier for the account configuration.
pub id: String,
/// User-friendly display name (e.g. "Work Mail").
pub name: String,
/// Primary email address associated with the account.
pub email_address: String,
/// Protocol engine type.
pub protocol: AccountProtocol,
/// Server hostname or IP address.
pub server_host: String,
/// Server port number.
pub server_port: u16,
/// Whether TLS connection encryption is enabled.
pub use_tls: bool,
/// Keyring entry key used to retrieve OS vault credentials.
pub keyring_secret_key: String,
/// Background sync polling interval in seconds (minimum 10s).
pub sync_interval_secs: u64,
}
impl AccountConfig {
/// Minimum allowed background sync interval (10 seconds).
pub const MIN_SYNC_INTERVAL_SECS: u64 = 10;
/// Validate the account configuration fields.
pub fn validate(&self) -> Result<(), ConfigError> {
if self.id.trim().is_empty() {
return Err(ConfigError::EmptyField { field: "id" });
}
if self.name.trim().is_empty() {
return Err(ConfigError::EmptyField { field: "name" });
}
if self.email_address.trim().is_empty() {
return Err(ConfigError::EmptyField {
field: "email_address",
});
}
if !self.email_address.contains('@')
|| !self.email_address.split_once('@').map_or(false, |(_, domain)| domain.contains('.'))
{
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.keyring_secret_key.trim().is_empty() {
return Err(ConfigError::EmptyField {
field: "keyring_secret_key",
});
}
if self.sync_interval_secs < Self::MIN_SYNC_INTERVAL_SECS {
return Err(ConfigError::SyncIntervalTooShort);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_account() -> AccountConfig {
AccountConfig {
id: "acct-123".to_string(),
name: "Personal Mail".to_string(),
email_address: "user@nuncio.mx".to_string(),
protocol: AccountProtocol::Jmap,
server_host: "jmap.nuncio.mx".to_string(),
server_port: 443,
use_tls: true,
keyring_secret_key: "nuncio/acct-123".to_string(),
sync_interval_secs: 60,
}
}
#[test]
fn valid_account_config_passes_validation() {
let config = valid_account();
assert!(config.validate().is_ok());
}
#[test]
fn empty_id_fails_validation() {
let mut config = valid_account();
config.id = " ".to_string();
assert_eq!(
config.validate().unwrap_err(),
ConfigError::EmptyField { field: "id" }
);
}
#[test]
fn empty_name_fails_validation() {
let mut config = valid_account();
config.name = "".to_string();
assert_eq!(
config.validate().unwrap_err(),
ConfigError::EmptyField { field: "name" }
);
}
#[test]
fn empty_email_fails_validation() {
let mut config = valid_account();
config.email_address = "".to_string();
assert_eq!(
config.validate().unwrap_err(),
ConfigError::EmptyField {
field: "email_address"
}
);
}
#[test]
fn invalid_email_format_fails_validation() {
let mut config = valid_account();
config.email_address = "invalidemail".to_string();
assert_eq!(
config.validate().unwrap_err(),
ConfigError::InvalidEmailFormat
);
config.email_address = "user@domainnodot".to_string();
assert_eq!(
config.validate().unwrap_err(),
ConfigError::InvalidEmailFormat
);
}
#[test]
fn empty_server_host_fails_validation() {
let mut config = valid_account();
config.server_host = " ".to_string();
assert_eq!(
config.validate().unwrap_err(),
ConfigError::EmptyField {
field: "server_host"
}
);
}
#[test]
fn zero_port_fails_validation() {
let mut config = valid_account();
config.server_port = 0;
assert_eq!(config.validate().unwrap_err(), ConfigError::InvalidPort);
}
#[test]
fn empty_keyring_key_fails_validation() {
let mut config = valid_account();
config.keyring_secret_key = "".to_string();
assert_eq!(
config.validate().unwrap_err(),
ConfigError::EmptyField {
field: "keyring_secret_key"
}
);
}
#[test]
fn sync_interval_too_short_fails_validation() {
let mut config = valid_account();
config.sync_interval_secs = 5;
assert_eq!(
config.validate().unwrap_err(),
ConfigError::SyncIntervalTooShort
);
}
#[test]
fn account_protocol_serde_roundtrip() {
let jmap_json = serde_json::to_string(&AccountProtocol::Jmap).unwrap();
assert_eq!(jmap_json, "\"jmap\"");
let parsed: AccountProtocol = serde_json::from_str(&jmap_json).unwrap();
assert_eq!(parsed, AccountProtocol::Jmap);
let imap_json = serde_json::to_string(&AccountProtocol::ImapSmtp).unwrap();
assert_eq!(imap_json, "\"imap-smtp\"");
let parsed_imap: AccountProtocol = serde_json::from_str(&imap_json).unwrap();
assert_eq!(parsed_imap, AccountProtocol::ImapSmtp);
}
#[test]
fn full_account_config_serde_roundtrip() {
let config = valid_account();
let json = serde_json::to_string(&config).unwrap();
let deserialized: AccountConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, config);
}
#[test]
fn error_display_formatting() {
assert_eq!(
ConfigError::EmptyField { field: "name" }.to_string(),
"field 'name' cannot be empty"
);
assert_eq!(
ConfigError::InvalidEmailFormat.to_string(),
"invalid email address format"
);
assert_eq!(
ConfigError::InvalidPort.to_string(),
"invalid server port number"
);
assert_eq!(
ConfigError::SyncIntervalTooShort.to_string(),
"sync interval must be at least 10 seconds"
);
}
}
+3
View File
@@ -1,5 +1,8 @@
//! Core event bus, state management, and command orchestration for Nuncio.
pub mod config;
pub use config::{AccountConfig, AccountProtocol, ConfigError};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use thiserror::Error;