diff --git a/.github/workflows/rust.yaml b/.github/workflows/rust.yaml
new file mode 100644
index 0000000..bb18fd5
--- /dev/null
+++ b/.github/workflows/rust.yaml
@@ -0,0 +1,57 @@
+name: Rust
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+ paths:
+ - tools/validation/**
+ schedule:
+ - cron: '30 2 * * *'
+
+env:
+ CARGO_TERM_COLOR: always
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ name: "Build & Test: ./tools/validation"
+ defaults:
+ run:
+ working-directory: ./tools/validation
+
+ steps:
+ - name: Checkout source
+ uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+
+ - name: Check Formatting
+ run: cargo fmt --all -- --check
+
+ - name: Cargo Cache
+ uses: actions/cache@v3
+ with:
+ path: |
+ ~/.cargo/registry/index/
+ ~/.cargo/registry/cache/
+ target/
+ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
+
+ - name: Build project
+ run: cargo build
+
+ - name: Test project
+ run: cargo test --all
+
+ - name: Run clippy
+ uses: giraffate/clippy-action@v1
+ with:
+ reporter: 'github-pr-check'
+ clippy_flags: --no-deps
+ filter_mode: nofilter
+ github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5bd05d6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+target
+.idea
\ No newline at end of file
diff --git a/tools/validation/Cargo.lock b/tools/validation/Cargo.lock
new file mode 100644
index 0000000..6dd8ef3
--- /dev/null
+++ b/tools/validation/Cargo.lock
@@ -0,0 +1,166 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
+
+[[package]]
+name = "autoconfig_validate"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "quick-xml",
+ "regex",
+ "serde",
+ "serde_path_to_error",
+ "thiserror",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
+
+[[package]]
+name = "memchr"
+version = "2.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f24d770aeca0eacb81ac29dfbc55ebcc09312fdd1f8bbecdc7e4a84e000e3b4"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "regex"
+version = "1.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
+
+[[package]]
+name = "serde"
+version = "1.0.203"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.203"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6"
+dependencies = [
+ "itoa",
+ "serde",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.68"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.61"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
diff --git a/tools/validation/Cargo.toml b/tools/validation/Cargo.toml
new file mode 100644
index 0000000..7539b83
--- /dev/null
+++ b/tools/validation/Cargo.toml
@@ -0,0 +1,3 @@
+[workspace]
+resolver = "2"
+members = ["autoconfig_validate"]
\ No newline at end of file
diff --git a/tools/validation/README.md b/tools/validation/README.md
new file mode 100644
index 0000000..82d8af3
--- /dev/null
+++ b/tools/validation/README.md
@@ -0,0 +1,6 @@
+# Autoconfig validation
+
+Tools to use for reading and validating autoconfig configuration files.
+
+For more information on autoconfig files and the autoconfig format,
+see https://github.com/thunderbird/autoconfig
\ No newline at end of file
diff --git a/tools/validation/autoconfig_validate/Cargo.toml b/tools/validation/autoconfig_validate/Cargo.toml
new file mode 100644
index 0000000..92edde1
--- /dev/null
+++ b/tools/validation/autoconfig_validate/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "autoconfig_validate"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+anyhow = "1.0.86"
+quick-xml = { version = "0.34.0", features = ["serde", "serialize"] }
+regex = "1.10.5"
+serde = { version = "1.0.203", features = ["derive"] }
+serde_path_to_error = "0.1.16"
+thiserror = "1.0.61"
diff --git a/tools/validation/autoconfig_validate/resources/test_data/valid_config.xml b/tools/validation/autoconfig_validate/resources/test_data/valid_config.xml
new file mode 100644
index 0000000..0c3d511
--- /dev/null
+++ b/tools/validation/autoconfig_validate/resources/test_data/valid_config.xml
@@ -0,0 +1,57 @@
+
+
+
+
+ office365.com
+ onmicrosoft.com
+
+ mail.protection.outlook.com
+ Microsoft 365
+ Microsoft 365
+
+ outlook.office365.com
+ 993
+ SSL
+ OAuth2
+ %EMAILADDRESS%
+
+
+ outlook.office365.com
+ 995
+ SSL
+ OAuth2
+ %EMAILADDRESS%
+
+ true
+
+
+
+ outlook.office365.com
+ 443
+ %EMAILADDRESS%
+ SSL
+ OAuth2
+ https://outlook.office365.com/owa/
+ https://outlook.office365.com/ews/exchange.asmx
+ true
+
+
+ smtp.office365.com
+ 587
+ STARTTLS
+ OAuth2
+ %EMAILADDRESS%
+
+
+
+
+ login.microsoftonline.com
+ https://outlook.office365.com/IMAP.AccessAsUser.All https://outlook.office365.com/POP.AccessAsUser.All
+ https://outlook.office365.com/SMTP.Send offline_access
+
+
+ https://login.microsoftonline.com/common/oauth2/v2.0/authorize
+ https://login.microsoftonline.com/common/oauth2/v2.0/token
+
+
+
diff --git a/tools/validation/autoconfig_validate/src/error.rs b/tools/validation/autoconfig_validate/src/error.rs
new file mode 100644
index 0000000..5bfadb1
--- /dev/null
+++ b/tools/validation/autoconfig_validate/src/error.rs
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+use std::{env, io};
+use thiserror::Error;
+
+use crate::types::IncomingServerType;
+
+/// An error that arose while deserializing or validating the autoconfig file.
+#[derive(Debug, Error)]
+pub enum ValidationError {
+ #[error("failed to deserialize structure from XML: {0}")]
+ Deserialize(#[from] serde_path_to_error::Error),
+
+ #[error("unsupported clientConfig version: {0}")]
+ UnsupportedVersion(String),
+
+ #[error(
+ "found protocol-specific config for {found:?} on incoming server of type {server_type:?}"
+ )]
+ InvalidIncomingConfig {
+ server_type: IncomingServerType,
+ found: IncomingServerType,
+ },
+
+ #[error("invalid username template: {0}")]
+ InvalidUsernameTemplate(String),
+
+ #[error("encountered an unexpected error: {0}")]
+ Unexpected(#[source] anyhow::Error),
+}
+
+/// An error that arose in a test.
+#[derive(Debug, Error)]
+pub(crate) enum TestError {
+ #[error("failed to read variable from environment: {0}")]
+ EnvRead(#[from] env::VarError),
+
+ #[error("failed to read file: {0}")]
+ Io(#[from] io::Error),
+
+ #[error("validation error: {0}")]
+ Validation(#[from] ValidationError),
+}
diff --git a/tools/validation/autoconfig_validate/src/lib.rs b/tools/validation/autoconfig_validate/src/lib.rs
new file mode 100644
index 0000000..c035ae8
--- /dev/null
+++ b/tools/validation/autoconfig_validate/src/lib.rs
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+pub mod error;
+pub mod types;
+mod validate;
+
+pub use crate::validate::*;
+
+use crate::error::ValidationError;
+use crate::types::ClientConfig;
+
+/// Deserializes the provided XML and returns the result as an instance of
+/// `ClientConfig`.
+pub fn parse_client_config(bytes: &[u8]) -> Result {
+ let de = &mut quick_xml::de::Deserializer::from_reader(bytes);
+ let cfg: ClientConfig = serde_path_to_error::deserialize(de)?;
+ Ok(cfg)
+}
+
+#[cfg(test)]
+mod tests {
+ // TODO: Test parsing an invalid file.
+ use super::*;
+ use crate::error::TestError;
+ use std::{env, fs};
+
+ /// Reads and returns the contents of the test data file with the provided
+ /// name.
+ ///
+ /// Test data files are located in `resources/test_data/` from the root of
+ /// the crate (not the workspace).
+ fn read_test_data(filename: &str) -> Result, TestError> {
+ let root = env::var("CARGO_MANIFEST_DIR")?;
+ let path = format!("{root}/resources/test_data/{filename}");
+
+ let content = fs::read(path)?;
+ Ok(content)
+ }
+
+ /// Tests that a valid autoconfig file parses correctly.
+ #[test]
+ fn test_de() -> Result<(), TestError> {
+ let valid_sample = read_test_data("valid_config.xml")?;
+ parse_client_config(valid_sample.as_slice())?;
+
+ Ok(())
+ }
+}
diff --git a/tools/validation/autoconfig_validate/src/types.rs b/tools/validation/autoconfig_validate/src/types.rs
new file mode 100644
index 0000000..7f28a28
--- /dev/null
+++ b/tools/validation/autoconfig_validate/src/types.rs
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+use serde::Deserialize;
+
+/// The top-level `clientConfig` element.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ClientConfig {
+ #[serde(rename = "@version")]
+ pub version: String,
+
+ pub email_provider: EmailProvider,
+}
+
+/// An email provider (the `emailProvider` element).
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct EmailProvider {
+ #[serde(rename = "@id")]
+ pub provider_id: String,
+
+ pub display_name: String,
+ pub display_short_name: String,
+
+ #[serde(rename = "domain")]
+ pub domains: Vec,
+
+ #[serde(rename = "incomingServer")]
+ pub incoming_servers: Vec,
+
+ #[serde(rename = "outgoingServer")]
+ pub outgoing_servers: Vec,
+}
+
+/// An incoming server, represented with the `incomingServer` element in the
+/// autoconfig file.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct IncomingServer {
+ #[serde(rename = "@type")]
+ pub server_type: IncomingServerType,
+
+ pub hostname: String,
+ pub port: u16,
+ pub socket_type: SocketType,
+ pub username: String,
+ pub authentication: AuthType,
+ pub use_global_preferred_server: Option,
+
+ /// Additional configuration for servers of type `exchange`.
+ #[serde(rename = "ewsURL")]
+ pub ews_url: Option,
+ #[serde(rename = "owaURL")]
+ pub owa_url: Option,
+
+ /// Additional configuration for servers of type `pop3`.
+ pub pop3: Option,
+}
+
+/// The supported values for an incoming server's `type` attribute.
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(rename_all = "lowercase")]
+pub enum IncomingServerType {
+ IMAP,
+ POP3,
+ Exchange,
+}
+
+/// The supported values for a server's `socketType` attribute.
+#[derive(Debug, Deserialize)]
+pub enum SocketType {
+ #[serde(rename = "plain")]
+ Plain,
+ SSL,
+ STARTTLS,
+}
+
+/// The supported values for a server's `authentication` attribute.
+#[derive(Debug, Deserialize)]
+pub enum AuthType {
+ #[serde(rename = "password-cleartext")]
+ PasswordCleartext,
+ #[serde(rename = "password-encrypted")]
+ PasswordEncrypted,
+ NTLM,
+ GSSAPI,
+ #[serde(rename = "client-IP-address")]
+ ClientIPAddress,
+ #[serde(rename = "TLS-client-cert")]
+ TLSClientCert,
+ OAuth2,
+ None,
+}
+
+/// Additional configuration for POP3 servers.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct POP3Config {
+ pub leave_messages_on_server: Option,
+ pub download_on_biff: Option,
+ pub check_interval: Option,
+}
+
+/// A setting for POP3 servers to indicate how long to wait before checking for
+/// new messages.
+#[derive(Debug, Deserialize)]
+pub struct POP3CheckInterval {
+ #[serde(rename = "@minutes")]
+ pub minutes: u32,
+}
+
+/// An outgoing server, represented with the `outgoingServer` element in the
+/// autoconfig file.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct OutgoingServer {
+ #[serde(rename = "@type")]
+ pub server_type: OutgoingServerType,
+
+ pub hostname: String,
+ pub port: u16,
+ pub socket_type: SocketType,
+ pub username: String,
+ pub authentication: AuthType,
+}
+
+/// The supported values for an outgoing server's `type` attribute.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum OutgoingServerType {
+ SMTP,
+}
diff --git a/tools/validation/autoconfig_validate/src/validate.rs b/tools/validation/autoconfig_validate/src/validate.rs
new file mode 100644
index 0000000..c7a5095
--- /dev/null
+++ b/tools/validation/autoconfig_validate/src/validate.rs
@@ -0,0 +1,267 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+use crate::error::ValidationError;
+use crate::types::{ClientConfig, IncomingServer, IncomingServerType};
+use regex::Regex;
+
+/// The values that are known and supported for templating in a server's
+/// `username` element.
+const SUPPORTED_USERNAME_TEMPLATE: [&str; 4] = [
+ "%EMAILADDRESS%",
+ "%EMAILLOCALPART%",
+ "%EMAILDOMAIN%",
+ "%REALNAME%",
+];
+
+/// Performs a series of checks on the provided `ClientConfig`, such as ensuring
+/// its version is supported, that a server's configuration is consistent with
+/// its type, etc.
+///
+/// This function only ensures the configuration *looks* valid, but does not
+/// perform any connectivity check.
+//
+// TODO: Return multiple errors at once (without bailing on the first one),
+// associating each with a path to the invalid element in the config structure.
+pub fn validate(cfg: &ClientConfig) -> Result<(), ValidationError> {
+ // Ensure the config uses a supported version.
+ if cfg.version.as_str() != "1.1" {
+ return Err(ValidationError::UnsupportedVersion(cfg.version.clone()));
+ }
+
+ // Validate the configurations for incoming servers.
+ cfg.email_provider
+ .incoming_servers
+ .iter()
+ .try_for_each(validate_incoming_server)?;
+
+ // Validate the configuration for outgoing servers. This involve
+ cfg.email_provider
+ .outgoing_servers
+ .iter()
+ .try_for_each(|server| validate_username(&server.username))
+}
+
+/// Performs a series of checks on an individual [`IncomingServer`].
+///
+/// Ensures any optional protocol-specific configuration on the server matches
+/// with the server's type, and that it isn't using any unsupported username
+/// template placeholder.
+fn validate_incoming_server(server: &IncomingServer) -> Result<(), ValidationError> {
+ // Ensure that Exchange-specific configuration is only set for Exchange
+ // servers.
+ if (server.ews_url.is_some() || server.owa_url.is_some())
+ && server.server_type != IncomingServerType::Exchange
+ {
+ return Err(ValidationError::InvalidIncomingConfig {
+ server_type: server.server_type.clone(),
+ found: IncomingServerType::Exchange,
+ });
+ }
+
+ // Ensure that POP3-specific configuration is only set for POP3 servers.
+ if server.pop3.is_some() && server.server_type != IncomingServerType::POP3 {
+ return Err(ValidationError::InvalidIncomingConfig {
+ server_type: server.server_type.clone(),
+ found: IncomingServerType::POP3,
+ });
+ }
+
+ // Ensure that the username uses valid template placeholder(s), if any.
+ validate_username(&server.username)?;
+
+ Ok(())
+}
+
+/// Ensures the given username uses only supported template placeholder(s), if
+/// any.
+///
+/// The same `username` element can include multiple placeholders, but they all
+/// must be supported.
+fn validate_username(username: &String) -> Result<(), ValidationError> {
+ let re = match Regex::new("%[^%]*%") {
+ Ok(re) => re,
+ Err(e) => return Err(ValidationError::Unexpected(e.into())),
+ };
+
+ for re_match in re.find_iter(username.as_str()) {
+ if !SUPPORTED_USERNAME_TEMPLATE.contains(&re_match.as_str()) {
+ return Err(ValidationError::InvalidUsernameTemplate(
+ re_match.as_str().into(),
+ ));
+ }
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ /// Tests for the validation checks that are implemented in this module.
+ ///
+ /// TODO: validation of a full `ClientConfig` class that:
+ /// * is valid
+ /// * contains at least one invalid incoming server
+ /// * contains at least one invalid outgoing server (i.e. invalid username)
+ use super::*;
+
+ use crate::error::ValidationError;
+ use crate::types::{AuthType, POP3Config, SocketType};
+
+ /// Instantiates a [`IncomingServer`] to use in tests.
+ ///
+ /// The instance generated by this method is valid as far as the validation
+ /// checks in this module are concerned.
+ fn generate_incoming_server() -> IncomingServer {
+ IncomingServer {
+ server_type: IncomingServerType::IMAP,
+ hostname: "test.invalid".to_string(),
+ port: 993,
+ socket_type: SocketType::SSL,
+ username: "%EMAILADDRESS%".to_string(),
+ authentication: AuthType::PasswordCleartext,
+ ews_url: None,
+ owa_url: None,
+ pop3: None,
+ use_global_preferred_server: None,
+ }
+ }
+
+ /// Tests that a valid [`IncomingServer`] passes validation with no error.
+ #[test]
+ fn test_validate_incoming_server_valid() -> Result<(), ValidationError> {
+ let server = generate_incoming_server();
+ validate_incoming_server(&server)
+ }
+
+ /// Tests that an [`IncomingServer`] which type isn't POP3 and has a POP3
+ /// configuration fails validation with the correct error.
+ #[test]
+ fn test_validate_incoming_server_unexpected_pop3() -> Result<(), ValidationError> {
+ let mut server = generate_incoming_server();
+ server.pop3 = Some(POP3Config {
+ leave_messages_on_server: None,
+ download_on_biff: None,
+ check_interval: None,
+ });
+
+ let result = validate_incoming_server(&server);
+
+ assert!(result.is_err(), "result should be an error");
+ let err = result.err().unwrap();
+ assert!(
+ matches!(
+ err,
+ ValidationError::InvalidIncomingConfig {
+ server_type: IncomingServerType::IMAP,
+ found: IncomingServerType::POP3,
+ },
+ ),
+ "error should be a ValidationError::InvalidIncomingConfig, with found = POP3"
+ );
+
+ Ok(())
+ }
+
+ /// Tests that an [`IncomingServer`] which type isn't Exchange and has an
+ /// EWS URL correctly fails validation.
+ #[test]
+ fn test_validate_incoming_server_unexpected_exchange_ews() -> Result<(), ValidationError> {
+ let mut server = generate_incoming_server();
+ server.ews_url = Some("test.invalid".to_string());
+
+ let result = validate_incoming_server(&server);
+
+ assert!(result.is_err(), "result should be an error");
+ let err = result.err().unwrap();
+ assert!(
+ matches!(
+ err,
+ ValidationError::InvalidIncomingConfig {
+ server_type: IncomingServerType::IMAP,
+ found: IncomingServerType::Exchange,
+ }
+ ),
+ "error should be a ValidationError::InvalidIncomingConfig, with found = Exchange"
+ );
+
+ Ok(())
+ }
+
+ /// Tests that an [`IncomingServer`] which type isn't Exchange and has an
+ /// OWA URL correctly fails validation.
+ #[test]
+ fn test_validate_incoming_server_unexpected_exchange_owa() -> Result<(), ValidationError> {
+ let mut server = generate_incoming_server();
+ server.owa_url = Some("test.invalid".to_string());
+
+ let result = validate_incoming_server(&server);
+
+ assert!(result.is_err(), "result should be an error");
+ let err = result.err().unwrap();
+ assert!(
+ matches!(
+ err,
+ ValidationError::InvalidIncomingConfig {
+ server_type: IncomingServerType::IMAP,
+ found: IncomingServerType::Exchange,
+ }
+ ),
+ "error should be a ValidationError::InvalidIncomingConfig, with found = Exchange"
+ );
+
+ Ok(())
+ }
+
+ /// Tests that a username that contains no placeholder passes username
+ /// validation with no error.
+ #[test]
+ fn test_validate_username_no_placeholder() -> Result<(), ValidationError> {
+ validate_username(&"foo".into())
+ }
+
+ /// Tests that a username that contains a known placeholder passes username
+ /// validation with no error.
+ #[test]
+ fn test_validate_username_known_placeholder() -> Result<(), ValidationError> {
+ validate_username(&"%EMAILADDRESS%".into())
+ }
+
+ /// Tests that a username that contains an unknown placeholder correctly
+ /// fails username validation.
+ #[test]
+ fn test_validate_username_unknown_placeholder() -> Result<(), ValidationError> {
+ assert!(
+ matches!(
+ validate_username(&"%FOO%".to_string()),
+ Err(ValidationError::InvalidUsernameTemplate(..))
+ ),
+ "username validation should return a ValidationError::InvalidUsernameTemplate error"
+ );
+
+ Ok(())
+ }
+
+ /// Tests that a username that contains multiple known placeholders passes
+ /// username validation with no error.
+ #[test]
+ fn test_validate_username_multiple_placeholder_valid() -> Result<(), ValidationError> {
+ validate_username(&"%EMAILLOCALPART%.%EMAILDOMAIN%".to_string())
+ }
+
+ /// Tests that a username that contains multiple placeholders with at least
+ /// one unknown correctly fails username validation
+ #[test]
+ fn test_validate_username_multiple_placeholder_one_unknown() -> Result<(), ValidationError> {
+ assert!(
+ matches!(
+ validate_username(&"%EMAILLOCALPART%.%FOO%".to_string()),
+ Err(ValidationError::InvalidUsernameTemplate(..))
+ ),
+ "username validation should return a ValidationError::InvalidUsernameTemplate error"
+ );
+
+ Ok(())
+ }
+}