userp 0.0.4

Userp is a user account system inspired by Next Auth, with OAuth, password and email support, a ready-made Axum router with Askama templates, and more on the way!
Documentation
pub mod login;
#[cfg(feature = "password")]
pub mod reset;
pub mod signup;
pub mod verify;

use chrono::{DateTime, Duration, Utc};
use lettre::{message::header::ContentType, Message, SmtpTransport, Transport};
use thiserror::Error;
use url::Url;
use uuid::Uuid;

use crate::{
    config::Allow,
    core::CoreUserp,
    traits::{UserpCookies, UserpStore},
};

#[derive(Debug, Clone)]
pub struct EmailConfig {
    pub allow_login: Option<Allow>,
    pub allow_signup: Option<Allow>,
    pub challenge_lifetime: Duration,
    pub base_url: Url,
    pub smtp: SmtpSettings,
}

impl EmailConfig {
    pub fn new(base_url: Url, smtp: SmtpSettings) -> Self {
        Self {
            allow_login: None,
            allow_signup: None,
            challenge_lifetime: Duration::minutes(5),
            base_url,
            smtp,
        }
    }

    pub fn with_allow_signup(mut self, allow_signup: Allow) -> Self {
        self.allow_signup = Some(allow_signup);
        self
    }

    pub fn with_allow_login(mut self, allow_login: Allow) -> Self {
        self.allow_login = Some(allow_login);
        self
    }

    pub fn with_challenge_lifetime(mut self, challenge_lifetime: Duration) -> Self {
        self.challenge_lifetime = challenge_lifetime;
        self
    }
}

#[derive(Debug, Clone)]
pub struct SmtpSettings {
    pub server_url: String,
    pub username: String,
    pub password: String,
    pub from: String,
    pub starttls: bool,
}

impl SmtpSettings {
    pub fn new(
        server_url: impl Into<String>,
        username: impl Into<String>,
        password: impl Into<String>,
        from: impl Into<String>,
        starttls: bool,
    ) -> Self {
        Self {
            server_url: server_url.into(),
            username: username.into(),
            password: password.into(),
            from: from.into(),
            starttls,
        }
    }
}

pub trait UserEmail: Send + Sync {
    fn get_user_id(&self) -> Uuid;
    fn get_address(&self) -> &str;
    fn get_verified(&self) -> bool;
    fn get_allow_link_login(&self) -> bool;
}

pub trait EmailChallenge: Send + Sync {
    fn get_address(&self) -> &str;
    fn get_code(&self) -> &str;
    fn get_next(&self) -> &Option<String>;
    fn get_expires(&self) -> DateTime<Utc>;
}

#[derive(Debug, Error)]
pub enum SendEmailChallengeError<StoreError: std::error::Error> {
    #[error(transparent)]
    Url(url::ParseError),
    #[error(transparent)]
    Address(lettre::address::AddressError),
    #[error(transparent)]
    MessageBuilding(lettre::error::Error),
    #[error(transparent)]
    Transport(lettre::transport::smtp::Error),
    #[error(transparent)]
    Store(#[from] StoreError),
}

impl<S: UserpStore, C: UserpCookies> CoreUserp<S, C> {
    async fn send_email_challenge(
        &self,
        path: String,
        address: String,
        message: String,
        next: Option<String>,
    ) -> Result<(), SendEmailChallengeError<S::Error>> {
        let code = Uuid::new_v4().to_string().replace('-', "");

        let challenge = self
            .store
            .email_create_challenge(
                address,
                code,
                next,
                Utc::now() + self.email.challenge_lifetime,
            )
            .await?;

        let code = challenge.get_code();

        let url = self
            .email
            .base_url
            .join(&format!("{path}?code={code}"))
            .map_err(SendEmailChallengeError::Url)?;

        let email = Message::builder()
            .from(
                self.email
                    .smtp
                    .from
                    .parse()
                    .map_err(SendEmailChallengeError::Address)?,
            )
            .to(challenge
                .get_address()
                .parse()
                .map_err(SendEmailChallengeError::Address)?)
            .subject("Login link")
            .header(ContentType::TEXT_HTML)
            .body(format!("<a href=\"{url}\">{message}</a>"))
            .map_err(SendEmailChallengeError::MessageBuilding)?;

        let transport = if self.email.smtp.starttls {
            SmtpTransport::starttls_relay
        } else {
            SmtpTransport::relay
        };

        let mailer = (transport)(self.email.smtp.server_url.as_str())
            .map_err(SendEmailChallengeError::Transport)?
            .credentials(lettre::transport::smtp::authentication::Credentials::new(
                self.email.smtp.username.clone(),
                self.email.smtp.password.clone(),
            ))
            .build();

        mailer
            .send(&email)
            .map_err(SendEmailChallengeError::Transport)?;

        Ok(())
    }
}