formulate 1.2.0

formulate is a standalone server that listens for web form data submissions.
use crate::{mail_template::message_template, stripe_orders::StripeCheckoutLineItems};
use lettre::{
    message::MultiPart,
    transport::smtp::{authentication::Credentials, client::TlsParameters},
    Message, SendmailTransport, SmtpTransport, Transport,
};
use rocket::{figment::providers::Env, serde::Deserialize, Config};
use std::fmt::{self, Display};
use std::sync::LazyLock;

static SENDMAILER: LazyLock<SendmailTransport> = LazyLock::new(SendmailTransport::new);

static SMTPMAILER: LazyLock<SmtpTransport> = LazyLock::new(|| {
    let config = Config::figment()
        .select("application")
        .merge(Env::prefixed("FORMULATE_"))
        .extract::<AppConfig>()
        .unwrap();

    let host = if let Some(host) = config.mail_relay_host {
        host
    } else {
        String::from("placeholder")
    };

    let smtp_username = config.smtp_username.unwrap_or_default();
    let smtp_password = config.smtp_password.unwrap_or_default();

    if smtp_password.is_empty() || smtp_username.is_empty() {
        // Custom, optional TLS configuration
        let params = TlsParameters::builder(host.to_owned()).build().unwrap();
        let mailer_transport = SmtpTransport::builder_dangerous(host)
            .port(config.mail_relay_port)
            .tls(lettre::transport::smtp::client::Tls::Opportunistic(params));
        mailer_transport.build()
    } else {
        let creds = Credentials::new(smtp_username, smtp_password);
        let mailer_transport = SmtpTransport::starttls_relay(&host)
            .unwrap()
            .credentials(creds);
        mailer_transport.build()
    }
});

/// Gives the default SMTP port if none is provided via config
fn default_smtp_port() -> u16 {
    25
}

/// Potential errors which can be created when sending an email
#[derive(Debug)]
pub enum MailConfigError {
    /// Wraps a Figment error that occured while getting a configuration value.
    AppConfig(rocket::figment::error::Error),
    /// Wraps errors that result from lettre's inability to parse to provided email.
    AddressParse(lettre::address::AddressError),
    /// Wraps errors occuring from lettre's inability to create an email message.
    EmailBuild(lettre::error::Error),
    /// Wraps errors occuring from lettre's inability to send email via sendmail command.
    SendmailTransport(lettre::transport::sendmail::Error),
    /// Wraps errors occuring from lettre's inability to relay email to a server via smtp protocol.
    SmtpTransport(lettre::transport::smtp::Error),
    /// Wraps error which may occur generating message body from template.
    TemplateBuild(tera::Error),
}

impl Display for MailConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MailConfigError::AppConfig(err) => write!(f, "Error loading config: {err}."),
            MailConfigError::AddressParse(err) => write!(
                f,
                "A malformed email was detected. Please format your email address properly: {err}."
            ),
            MailConfigError::EmailBuild(err) => write!(f, "Unable to create email message: {err}"),
            MailConfigError::SendmailTransport(err) => {
                write!(f, "Unable to send email because of {err}")
            }
            MailConfigError::SmtpTransport(err) => {
                write!(f, "Unable to send email because of {err}")
            }
            MailConfigError::TemplateBuild(err) => {
                write!(f, "Unable to create mail template because {err}")
            }
        }
    }
}

impl From<rocket::figment::error::Error> for MailConfigError {
    fn from(error: rocket::figment::error::Error) -> Self {
        MailConfigError::AppConfig(error)
    }
}

impl From<lettre::address::AddressError> for MailConfigError {
    fn from(error: lettre::address::AddressError) -> Self {
        MailConfigError::AddressParse(error)
    }
}

impl From<lettre::error::Error> for MailConfigError {
    fn from(error: lettre::error::Error) -> Self {
        MailConfigError::EmailBuild(error)
    }
}

impl From<lettre::transport::sendmail::Error> for MailConfigError {
    fn from(error: lettre::transport::sendmail::Error) -> Self {
        MailConfigError::SendmailTransport(error)
    }
}

impl From<lettre::transport::smtp::Error> for MailConfigError {
    fn from(error: lettre::transport::smtp::Error) -> Self {
        MailConfigError::SmtpTransport(error)
    }
}

impl From<tera::Error> for MailConfigError {
    fn from(error: tera::Error) -> Self {
        MailConfigError::TemplateBuild(error)
    }
}

/// Defines a custom application configuration structure.
#[derive(Deserialize)]
#[serde(crate = "rocket::serde")]
struct AppConfig {
    /// Email address that the message will be sent from.
    #[serde(alias = "SENDING_EMAIL")]
    sending_email: String,
    /// Email address that the message will be sent to.
    #[serde(alias = "DESTINATION_EMAIL")]
    destination_email: String,
    /// SMTP server hostname to use for sending mail
    #[serde(alias = "MAIL_HOST")]
    mail_relay_host: Option<String>,
    /// SMTP server port used to listen for incoming mail
    #[serde(alias = "MAIL_PORT")]
    #[serde(default = "default_smtp_port")]
    mail_relay_port: u16,
    /// User (email address) used to authenticate with SMTP server defined above
    #[serde(alias = "SMTP_USERNAME")]
    smtp_username: Option<String>,
    /// Password used to authenticate with SMTP server defined above
    #[serde(alias = "SMTP_PASSWORD")]
    smtp_password: Option<String>,
}

/// Provides a default subject line for emails sent, if one is not present with form data.
pub fn default_subject_line() -> &'static str {
    "New message from"
}

pub struct OptionalFields<'b> {
    pub company_name: Option<&'b str>,
    pub last_name: Option<&'b str>,
    pub phone_number: Option<&'b str>,
    pub message_summary: Option<&'b str>,
    pub cta_link: Option<&'b str>,
    pub logo_img: Option<&'b str>,
    pub ordered_items: Option<&'b StripeCheckoutLineItems>,
}

/// Sends an email using data from the form contents. Message is sent using the sendmail application
/// to a recipient defined in the application config's "destination_email".
pub fn send_email(
    form_email: &str,
    form_full_name: &str,
    form_subject: &str,
    form_message: &str,
    form_site: &str,
    form_optional: OptionalFields<'_>,
) -> Result<(), MailConfigError> {
    let mail_subject = format!("{} {}", default_subject_line(), form_site);

    // Pulls app config from [application] profile of "Rocket.toml"
    // or environment variables prefixed with "FORMULATE_".
    // Config file can be changed by defining ROCKET_CONFIG environment variable.
    let config = Config::figment()
        .select("application")
        .merge(Env::prefixed("FORMULATE_"))
        .extract::<AppConfig>()?;

    let message_sender = format!("{form_full_name} <{form_email}>");

    let html_body = message_template(
        &message_sender,
        &mail_subject,
        form_message,
        form_subject,
        &form_optional,
        "welcome.html",
    )?;
    let txt_body = message_template(
        &message_sender,
        &mail_subject,
        form_message,
        form_subject,
        &form_optional,
        "welcome.txt",
    )?;

    let reply_to_email = form_email.parse::<lettre::message::Mailbox>()?;
    let sending_email = format!("{form_full_name} <{}>", config.sending_email)
        .parse::<lettre::message::Mailbox>()?;

    let destination_email = config
        .destination_email
        .parse::<lettre::message::Mailbox>()?;

    let email_msg = Message::builder()
        .from(sending_email)
        .reply_to(reply_to_email)
        .to(destination_email)
        .subject(mail_subject)
        .multipart(MultiPart::alternative_plain_html(txt_body, html_body))?;

    if config.mail_relay_host.is_none() || config.mail_relay_host == Some(String::from("")) {
        SENDMAILER.send(&email_msg)?;
    } else {
        SMTPMAILER.send(&email_msg)?;
    }
    Ok(())
}