maincopy-server 0.1.0

Self-hosted publishing server with exact previews and explicit release approval
Documentation
use serde::Deserialize;
use url::Url;

use super::{ConfigurationDiagnostic, SenderAddress, validated_field};

#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SubscriptionMode {
    Paused,
    Enabled,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct SubscriptionPolicy {
    mode: SubscriptionMode,
    operator_name: String,
    postal_address: String,
    purpose: String,
    privacy_url: Url,
    contact_address: SenderAddress,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct SubscriptionPolicyView<'policy> {
    pub mode: SubscriptionMode,
    pub operator_name: &'policy str,
    pub postal_address: &'policy str,
    pub purpose: &'policy str,
    pub privacy_url: &'policy Url,
    pub contact_address: &'policy SenderAddress,
}

impl SubscriptionPolicy {
    pub(crate) fn view(&self) -> SubscriptionPolicyView<'_> {
        SubscriptionPolicyView {
            mode: self.mode,
            operator_name: &self.operator_name,
            postal_address: &self.postal_address,
            purpose: &self.purpose,
            privacy_url: &self.privacy_url,
            contact_address: &self.contact_address,
        }
    }
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct SubscriptionCandidate {
    mode: SubscriptionMode,
    operator_name: String,
    postal_address: String,
    purpose: String,
    privacy_url: String,
    contact_address: String,
}

impl SubscriptionCandidate {
    pub(super) fn validate(
        self,
        diagnostics: &mut Vec<ConfigurationDiagnostic>,
    ) -> Option<SubscriptionPolicy> {
        let operator_name = validated_field(
            public_text(self.operator_name, 200),
            "mail.subscriptions.operator_name",
            "mail operator name must contain 1 to 200 bytes of public text without control characters",
            diagnostics,
        );
        let postal_address = validated_field(
            public_text(self.postal_address, 500),
            "mail.subscriptions.postal_address",
            "mail postal address must contain 1 to 500 bytes of public text without control characters",
            diagnostics,
        );
        let purpose = validated_field(
            public_text(self.purpose, 2000),
            "mail.subscriptions.purpose",
            "mail purpose must contain 1 to 2000 bytes of public text without control characters",
            diagnostics,
        );
        let privacy_url = validated_field(
            privacy_url(&self.privacy_url),
            "mail.subscriptions.privacy_url",
            "mail privacy notice must be a bounded HTTPS URL without credentials",
            diagnostics,
        );
        let contact_address = validated_field(
            SenderAddress::parse(&self.contact_address).ok(),
            "mail.subscriptions.contact_address",
            "mail contact address must be a monitored public ASCII mailbox",
            diagnostics,
        );
        match (
            operator_name,
            postal_address,
            purpose,
            privacy_url,
            contact_address,
        ) {
            (
                Some(operator_name),
                Some(postal_address),
                Some(purpose),
                Some(privacy_url),
                Some(contact_address),
            ) => Some(SubscriptionPolicy {
                mode: self.mode,
                operator_name,
                postal_address,
                purpose,
                privacy_url,
                contact_address,
            }),
            _ => None,
        }
    }
}

fn public_text(value: String, limit: usize) -> Option<String> {
    (!value.is_empty()
        && value.trim() == value
        && value.len() <= limit
        && !value.chars().any(char::is_control))
    .then_some(value)
}

fn privacy_url(value: &str) -> Option<Url> {
    if value.len() > 2048 || value.chars().any(char::is_control) || value.contains('\\') {
        return None;
    }
    let parsed = Url::parse(value).ok()?;
    (parsed.scheme() == "https"
        && parsed.host_str().is_some()
        && parsed.username().is_empty()
        && parsed.password().is_none())
    .then_some(parsed)
}