| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,9 @@ | ||
| #[tracing::instrument] | ||
| #[actix_web::get("/health-check/")] | ||
| pub async fn health_check() -> actix_web::HttpResponse { | ||
| tracing::event!(target: "backend", tracing::Level::INFO, "Accessing health-check endpoint."); | ||
|
|
||
| actix_web::HttpResponse::Ok().json(crate::types::SuccessResponse { | ||
| message: "Application is safe and healthy.".to_string(), | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,3 +2,4 @@ mod health; | |
| mod users; | ||
|
|
||
| pub use health::health_check; | ||
| pub use users::auth_routes_config; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,5 @@ | ||
| mod register; | ||
|
|
||
| pub fn auth_routes_config(cfg: &mut actix_web::web::ServiceConfig) { | ||
| cfg.service(actix_web::web::scope("/users").service(register::register_user)); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| use sqlx::Row; | ||
|
|
||
| #[derive(serde::Deserialize, Debug, serde::Serialize)] | ||
| pub struct NewUser { | ||
| email: String, | ||
| password: String, | ||
| first_name: String, | ||
| last_name: String, | ||
| } | ||
|
|
||
| #[derive(serde::Deserialize, serde::Serialize)] | ||
| pub struct CreateNewUser { | ||
| email: String, | ||
| password: String, | ||
| first_name: String, | ||
| last_name: String, | ||
| } | ||
|
|
||
| #[tracing::instrument(name = "Adding a new user", | ||
| skip( pool, new_user, redis_pool), | ||
| fields( | ||
| new_user_email = %new_user.email, | ||
| new_user_first_name = %new_user.first_name, | ||
| new_user_last_name = %new_user.last_name | ||
| ))] | ||
| #[actix_web::post("/register/")] | ||
| pub async fn register_user( | ||
| pool: actix_web::web::Data<sqlx::postgres::PgPool>, | ||
| new_user: actix_web::web::Json<NewUser>, | ||
| redis_pool: actix_web::web::Data<deadpool_redis::Pool>, | ||
| ) -> actix_web::HttpResponse { | ||
| let mut transaction = match pool.begin().await { | ||
| Ok(transaction) => transaction, | ||
| Err(e) => { | ||
| tracing::event!(target: "backend", tracing::Level::ERROR, "Unable to begin DB transaction: {:#?}", e); | ||
| return actix_web::HttpResponse::InternalServerError().json( | ||
| crate::types::ErrorResponse { | ||
| error: "Something unexpected happend. Kindly try again.".to_string(), | ||
| }, | ||
| ); | ||
| } | ||
| }; | ||
| let hashed_password = crate::utils::hash(new_user.0.password.as_bytes()).await; | ||
|
|
||
| let create_new_user = CreateNewUser { | ||
| password: hashed_password, | ||
| email: new_user.0.email, | ||
| first_name: new_user.0.first_name, | ||
| last_name: new_user.0.last_name, | ||
| }; | ||
|
|
||
| let user_id = match insert_created_user_into_db(&mut transaction, &create_new_user).await { | ||
| Ok(id) => id, | ||
| Err(e) => { | ||
| tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to insert user into DB: {:#?}", e); | ||
| let error_message = if e | ||
| .as_database_error() | ||
| .unwrap() | ||
| .code() | ||
| .unwrap() | ||
| .parse::<i32>() | ||
| .unwrap() | ||
| == 23505 | ||
| { | ||
| crate::types::ErrorResponse { | ||
| error: "A user with that email address already exists".to_string(), | ||
| } | ||
| } else { | ||
| crate::types::ErrorResponse { | ||
| error: "Error inserting user into the database".to_string(), | ||
| } | ||
| }; | ||
| return actix_web::HttpResponse::InternalServerError().json(error_message); | ||
| } | ||
| }; | ||
|
|
||
| // send confirmation email to the new user. | ||
| let mut redis_con = redis_pool | ||
| .get() | ||
| .await | ||
| .map_err(|e| { | ||
| tracing::event!(target: "backend", tracing::Level::ERROR, "{}", e); | ||
| actix_web::HttpResponse::InternalServerError().json(crate::types::ErrorResponse { | ||
| error: "We cannot activate your account at the moment".to_string(), | ||
| }) | ||
| }) | ||
| .expect("Redis connection cannot be gotten."); | ||
|
|
||
| crate::utils::send_multipart_email( | ||
| "RustAuth - Let's get you verified".to_string(), | ||
| user_id, | ||
| create_new_user.email, | ||
| create_new_user.first_name, | ||
| create_new_user.last_name, | ||
| "verification_email.html", | ||
| &mut redis_con, | ||
| ) | ||
| .await | ||
| .unwrap(); | ||
|
|
||
| if transaction.commit().await.is_err() { | ||
| return actix_web::HttpResponse::InternalServerError().finish(); | ||
| } | ||
|
|
||
| tracing::event!(target: "backend", tracing::Level::INFO, "User created successfully."); | ||
| actix_web::HttpResponse::Ok().json(crate::types::SuccessResponse { | ||
| message: "Your account was created successfully. Check your email address to activate your account as we just sent you an activation link. Ensure you activate your account before the link expires".to_string(), | ||
| }) | ||
| } | ||
|
|
||
| #[tracing::instrument(name = "Inserting new user into DB.", skip(transaction, new_user),fields( | ||
| new_user_email = %new_user.email, | ||
| new_user_first_name = %new_user.first_name, | ||
| new_user_last_name = %new_user.last_name | ||
| ))] | ||
| async fn insert_created_user_into_db( | ||
| transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, | ||
| new_user: &CreateNewUser, | ||
| ) -> Result<uuid::Uuid, sqlx::Error> { | ||
| let user_id = match sqlx::query( | ||
| "INSERT INTO users (email, password, first_name, last_name) VALUES ($1, $2, $3, $4) RETURNING id", | ||
| ) | ||
| .bind(&new_user.email) | ||
| .bind(&new_user.password) | ||
| .bind(&new_user.first_name) | ||
| .bind(&new_user.last_name) | ||
| .map(|row: sqlx::postgres::PgRow| -> uuid::Uuid{ | ||
| row.get("id") | ||
| }) | ||
| .fetch_one(&mut *transaction) | ||
| .await | ||
| { | ||
| Ok(id) => id, | ||
| Err(e) => { | ||
| tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to insert user into DB: {:#?}", e); | ||
| return Err(e); | ||
| } | ||
| }; | ||
|
|
||
| match sqlx::query( | ||
| "INSERT INTO user_profile (user_id) | ||
| VALUES ($1) | ||
| ON CONFLICT (user_id) | ||
| DO NOTHING | ||
| RETURNING user_id", | ||
| ) | ||
| .bind(user_id) | ||
| .map(|row: sqlx::postgres::PgRow| -> uuid::Uuid { row.get("user_id") }) | ||
| .fetch_one(&mut *transaction) | ||
| .await | ||
| { | ||
| Ok(id) => { | ||
| tracing::event!(target: "sqlx",tracing::Level::INFO, "User profile created successfully {}.", id); | ||
| Ok(id) | ||
| } | ||
| Err(e) => { | ||
| tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to insert user's profile into DB: {:#?}", e); | ||
| Err(e) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| #[derive(serde::Serialize)] | ||
| pub struct ErrorResponse { | ||
| pub error: String, | ||
| } | ||
|
|
||
| #[derive(serde::Serialize)] | ||
| pub struct SuccessResponse { | ||
| pub message: String, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| mod general; | ||
| mod tokens; | ||
|
|
||
| pub use general::{ErrorResponse, SuccessResponse}; | ||
| pub use tokens::ConfirmationToken; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| #[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] | ||
| pub struct ConfirmationToken { | ||
| pub user_id: uuid::Uuid, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| pub mod password; | ||
| pub mod tokens; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| use argon2::{ | ||
| password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, | ||
| Argon2, | ||
| }; | ||
|
|
||
| #[tracing::instrument(name = "Hashing user password", skip(password))] | ||
| pub async fn hash(password: &[u8]) -> String { | ||
| let salt = SaltString::generate(&mut OsRng); | ||
| Argon2::default() | ||
| .hash_password(password, &salt) | ||
| .expect("Unable to hash password.") | ||
| .to_string() | ||
| } | ||
|
|
||
| #[tracing::instrument(name = "Verifying user password", skip(password, hash))] | ||
| pub async fn verify_password( | ||
| hash: &str, | ||
| password: &[u8], | ||
| ) -> Result<(), argon2::password_hash::Error> { | ||
| let parsed_hash = PasswordHash::new(hash)?; | ||
| Argon2::default().verify_password(password, &parsed_hash) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| use argon2::password_hash::rand_core::{OsRng, RngCore}; | ||
| use core::convert::TryFrom; | ||
| use deadpool_redis::redis::AsyncCommands; | ||
| use hex; | ||
| use pasetors::claims::{Claims, ClaimsValidationRules}; | ||
| use pasetors::keys::SymmetricKey; | ||
| use pasetors::token::UntrustedToken; | ||
| use pasetors::{local, version4::V4, Local}; | ||
|
|
||
| /// Store the session key prefix as a const so it can't be typo'd anywhere it's used. | ||
| const SESSION_KEY_PREFIX: &str = "valid_session_key_for_{}"; | ||
|
|
||
| /// Issues a pasetor token to a user. The token has the user's id encoded. | ||
| /// A session_key is also encoded. This key is used to destroy the token | ||
| /// as soon as it's been verified. Depending on its usage, the token issued | ||
| /// has at most an hour to live. Which means, it is destroyed after its time-to-live. | ||
| #[tracing::instrument(name = "Issue pasetors token", skip(redis_connection))] | ||
| pub async fn issue_confirmation_token_pasetors( | ||
| user_id: uuid::Uuid, | ||
| redis_connection: &mut deadpool_redis::redis::aio::Connection, | ||
| is_for_password_change: Option<bool>, | ||
| ) -> Result<String, deadpool_redis::redis::RedisError> { | ||
| // I just generate 128 bytes of random data for the session key | ||
| // from something that is cryptographically secure (rand::CryptoRng) | ||
| // | ||
| // You don't necessarily need a random value, but you'll want something | ||
| // that is sufficiently not able to be guessed (you don't want someone getting | ||
| // an old token that is supposed to not be live, and being able to get a valid | ||
| // token from that). | ||
| let session_key: String = { | ||
| let mut buff = [0_u8; 128]; | ||
| OsRng.fill_bytes(&mut buff); | ||
| hex::encode(buff) | ||
| }; | ||
|
|
||
| let redis_key = { | ||
| if is_for_password_change.is_some() { | ||
| format!( | ||
| "{}{}is_for_password_change", | ||
| SESSION_KEY_PREFIX, session_key | ||
| ) | ||
| } else { | ||
| format!("{}{}", SESSION_KEY_PREFIX, session_key) | ||
| } | ||
| }; | ||
|
|
||
| redis_connection | ||
| .set( | ||
| redis_key.clone(), | ||
| // I just want to validate that the key exists to indicate the session is "live". | ||
| String::new(), | ||
| ) | ||
| .await | ||
| .map_err(|e| { | ||
| tracing::event!(target: "backend", tracing::Level::ERROR, "RedisError (set): {}", e); | ||
| e | ||
| })?; | ||
|
|
||
| let settings = crate::settings::get_settings().expect("Cannot load settings."); | ||
| let current_date_time = chrono::Local::now(); | ||
| let dt = { | ||
| if is_for_password_change.is_some() { | ||
| current_date_time + chrono::Duration::hours(1) | ||
| } else { | ||
| current_date_time + chrono::Duration::minutes(settings.secret.token_expiration) | ||
| } | ||
| }; | ||
|
|
||
| let time_to_live = { | ||
| if is_for_password_change.is_some() { | ||
| chrono::Duration::hours(1) | ||
| } else { | ||
| chrono::Duration::minutes(settings.secret.token_expiration) | ||
| } | ||
| }; | ||
|
|
||
| redis_connection | ||
| .expire( | ||
| redis_key.clone(), | ||
| time_to_live.num_seconds().try_into().unwrap(), | ||
| ) | ||
| .await | ||
| .map_err(|e| { | ||
| tracing::event!(target: "backend", tracing::Level::ERROR, "RedisError (expiry): {}", e); | ||
| e | ||
| })?; | ||
|
|
||
| let mut claims = Claims::new().unwrap(); | ||
| // Set custom expiration, default is 1 hour | ||
| claims.expiration(&dt.to_rfc3339()).unwrap(); | ||
| claims | ||
| .add_additional("user_id", serde_json::json!(user_id)) | ||
| .unwrap(); | ||
| claims | ||
| .add_additional("session_key", serde_json::json!(session_key)) | ||
| .unwrap(); | ||
|
|
||
| let sk = SymmetricKey::<V4>::from(settings.secret.secret_key.as_bytes()).unwrap(); | ||
| Ok(local::encrypt( | ||
| &sk, | ||
| &claims, | ||
| None, | ||
| Some(settings.secret.hmac_secret.as_bytes()), | ||
| ) | ||
| .unwrap()) | ||
| } | ||
|
|
||
| /// Verifies and destroys a token. A token is destroyed immediately | ||
| /// it has successfully been verified and all encoded data extracted. | ||
| /// Redis is used for such destruction. | ||
| #[tracing::instrument(name = "Verify pasetors token", skip(token, redis_connection))] | ||
| pub async fn verify_confirmation_token_pasetor( | ||
| token: String, | ||
| redis_connection: &mut deadpool_redis::redis::aio::Connection, | ||
| is_password: Option<bool>, | ||
| ) -> Result<crate::types::ConfirmationToken, String> { | ||
| let settings = crate::settings::get_settings().expect("Cannot load settings."); | ||
| let sk = SymmetricKey::<V4>::from(settings.secret.secret_key.as_bytes()).unwrap(); | ||
|
|
||
| let validation_rules = ClaimsValidationRules::new(); | ||
| let untrusted_token = UntrustedToken::<Local, V4>::try_from(&token) | ||
| .map_err(|e| format!("TokenValiation: {}", e))?; | ||
| let trusted_token = local::decrypt( | ||
| &sk, | ||
| &untrusted_token, | ||
| &validation_rules, | ||
| None, | ||
| Some(settings.secret.hmac_secret.as_bytes()), | ||
| ) | ||
| .map_err(|e| format!("Pasetor: {}", e))?; | ||
| let claims = trusted_token.payload_claims().unwrap(); | ||
|
|
||
| let uid = serde_json::to_value(claims.get_claim("user_id").unwrap()).unwrap(); | ||
|
|
||
| match serde_json::from_value::<String>(uid) { | ||
| Ok(uuid_string) => match uuid::Uuid::parse_str(&uuid_string) { | ||
| Ok(user_uuid) => { | ||
| let sss_key = | ||
| serde_json::to_value(claims.get_claim("session_key").unwrap()).unwrap(); | ||
| let session_key = match serde_json::from_value::<String>(sss_key) { | ||
| Ok(session_key) => session_key, | ||
| Err(e) => return Err(format!("{}", e)), | ||
| }; | ||
|
|
||
| let redis_key = { | ||
| if is_password.is_some() { | ||
| format!( | ||
| "{}{}is_for_password_change", | ||
| SESSION_KEY_PREFIX, session_key | ||
| ) | ||
| } else { | ||
| format!("{}{}", SESSION_KEY_PREFIX, session_key) | ||
| } | ||
| }; | ||
|
|
||
| if redis_connection | ||
| .get::<_, Option<String>>(redis_key.clone()) | ||
| .await | ||
| .map_err(|e| format!("{}", e))? | ||
| .is_none() | ||
| { | ||
| return Err("Token has been used or expired.".to_string()); | ||
| } | ||
| redis_connection | ||
| .del(redis_key.clone()) | ||
| .await | ||
| .map_err(|e| format!("{}", e))?; | ||
| Ok(crate::types::ConfirmationToken { user_id: user_uuid }) | ||
| } | ||
| Err(e) => Err(format!("{}", e)), | ||
| }, | ||
|
|
||
| Err(e) => Err(format!("{}", e)), | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| use lettre::AsyncTransport; | ||
|
|
||
| #[tracing::instrument( | ||
| name = "Generic e-mail sending function.", | ||
| skip( | ||
| recipient_email, | ||
| recipient_first_name, | ||
| recipient_last_name, | ||
| subject, | ||
| html_content, | ||
| text_content | ||
| ), | ||
| fields( | ||
| recipient_email = %recipient_email, | ||
| recipient_first_name = %recipient_first_name, | ||
| recipient_last_name = %recipient_last_name | ||
| ) | ||
| )] | ||
| pub async fn send_email( | ||
| sender_email: Option<String>, | ||
| recipient_email: String, | ||
| recipient_first_name: String, | ||
| recipient_last_name: String, | ||
| subject: impl Into<String>, | ||
| html_content: impl Into<String>, | ||
| text_content: impl Into<String>, | ||
| ) -> Result<(), String> { | ||
| let settings = crate::settings::get_settings().expect("Failed to read settings."); | ||
|
|
||
| let email = lettre::Message::builder() | ||
| .from( | ||
| format!( | ||
| "{} <{}>", | ||
| "JohnWrites", | ||
| if sender_email.is_some() { | ||
| sender_email.unwrap() | ||
| } else { | ||
| settings.email.host_user.clone() | ||
| } | ||
| ) | ||
| .parse() | ||
| .unwrap(), | ||
| ) | ||
| .to(format!( | ||
| "{} <{}>", | ||
| [recipient_first_name, recipient_last_name].join(" "), | ||
| recipient_email | ||
| ) | ||
| .parse() | ||
| .unwrap()) | ||
| .subject(subject) | ||
| .multipart( | ||
| lettre::message::MultiPart::alternative() | ||
| .singlepart( | ||
| lettre::message::SinglePart::builder() | ||
| .header(lettre::message::header::ContentType::TEXT_PLAIN) | ||
| .body(text_content.into()), | ||
| ) | ||
| .singlepart( | ||
| lettre::message::SinglePart::builder() | ||
| .header(lettre::message::header::ContentType::TEXT_HTML) | ||
| .body(html_content.into()), | ||
| ), | ||
| ) | ||
| .unwrap(); | ||
|
|
||
| let creds = lettre::transport::smtp::authentication::Credentials::new( | ||
| settings.email.host_user, | ||
| settings.email.host_user_password, | ||
| ); | ||
|
|
||
| // Open a remote connection to gmail | ||
| let mailer: lettre::AsyncSmtpTransport<lettre::Tokio1Executor> = | ||
| lettre::AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(&settings.email.host) | ||
| .unwrap() | ||
| .credentials(creds) | ||
| .build(); | ||
|
|
||
| // Send the email | ||
| match mailer.send(email).await { | ||
| Ok(_) => { | ||
| tracing::event!(target: "backend", tracing::Level::INFO, "Email successfully sent!"); | ||
| Ok(()) | ||
| } | ||
| Err(e) => { | ||
| tracing::event!(target: "backend", tracing::Level::ERROR, "Could not send email: {:#?}", e); | ||
| Err(format!("Could not send email: {:#?}", e)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[tracing::instrument( | ||
| name = "Generic multipart e-mail sending function.", | ||
| skip(redis_connection), | ||
| fields( | ||
| recipient_user_id = %user_id, | ||
| recipient_email = %recipient_email, | ||
| recipient_first_name = %recipient_first_name, | ||
| recipient_last_name = %recipient_last_name | ||
| ) | ||
| )] | ||
| pub async fn send_multipart_email( | ||
| subject: String, | ||
| user_id: uuid::Uuid, | ||
| recipient_email: String, | ||
| recipient_first_name: String, | ||
| recipient_last_name: String, | ||
| template_name: &str, | ||
| redis_connection: &mut deadpool_redis::redis::aio::Connection, | ||
| ) -> Result<(), String> { | ||
| let settings = crate::settings::get_settings().expect("Unable to load settings."); | ||
| let title = subject.clone(); | ||
|
|
||
| let issued_token = match crate::utils::issue_confirmation_token_pasetors( | ||
| user_id, | ||
| redis_connection, | ||
| None, | ||
| ) | ||
| .await | ||
| { | ||
| Ok(t) => t, | ||
| Err(e) => { | ||
| tracing::event!(target: "backend", tracing::Level::ERROR, "{}", e); | ||
| return Err(format!("{}", e)); | ||
| } | ||
| }; | ||
| let web_address = { | ||
| if settings.debug { | ||
| format!( | ||
| "{}:{}", | ||
| settings.application.base_url, settings.application.port, | ||
| ) | ||
| } else { | ||
| settings.application.base_url | ||
| } | ||
| }; | ||
| let confirmation_link = { | ||
| if template_name == "password_reset_email.html" { | ||
| format!( | ||
| "{}/users/password/confirm/change_password?token={}", | ||
| web_address, issued_token, | ||
| ) | ||
| } else { | ||
| format!( | ||
| "{}/users/register/confirm/?token={}", | ||
| web_address, issued_token, | ||
| ) | ||
| } | ||
| }; | ||
| let current_date_time = chrono::Local::now(); | ||
| let dt = current_date_time + chrono::Duration::minutes(settings.secret.token_expiration); | ||
|
|
||
| let template = crate::ENV.get_template(template_name).unwrap(); | ||
| let ctx = minijinja::context! { | ||
| title => &title, | ||
| confirmation_link => &confirmation_link, | ||
| domain => &settings.frontend_url, | ||
| expiration_time => &settings.secret.token_expiration, | ||
| exact_time => &dt.format("%A %B %d, %Y at %r").to_string() | ||
| }; | ||
| let html_text = template.render(ctx).unwrap(); | ||
|
|
||
| let text = format!( | ||
| r#" | ||
| Tap the link below to confirm your email address. | ||
| {} | ||
| "#, | ||
| confirmation_link | ||
| ); | ||
| tokio::spawn(send_email( | ||
| None, | ||
| recipient_email, | ||
| recipient_first_name, | ||
| recipient_last_name, | ||
| subject, | ||
| html_text, | ||
| text, | ||
| )); | ||
| Ok(()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| mod auth; | ||
| mod emails; | ||
|
|
||
| pub use auth::password::{hash, verify_password}; | ||
| pub use auth::tokens::{issue_confirmation_token_pasetors, verify_confirmation_token_pasetor}; | ||
| pub use emails::{send_email, send_multipart_email}; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>{{ title }}</title> | ||
| </head> | ||
|
|
||
| <body> | ||
| <table | ||
| style=" | ||
| max-width: 555px; | ||
| width: 100%; | ||
| font-family: 'Open Sans', Segoe, 'Segoe UI', 'DejaVu Sans', | ||
| 'Trebuchet MS', Verdana, sans-serif; | ||
| background: #fff; | ||
| font-size: 13px; | ||
| color: #323232; | ||
| " | ||
| cellspacing="0" | ||
| cellpadding="0" | ||
| border="0" | ||
| bgcolor="#ffffff" | ||
| align="center" | ||
| > | ||
| <tbody> | ||
| <tr> | ||
| <td align="left"> | ||
| <h1 style="text-align: center"> | ||
| <span style="font-size: 15px"> | ||
| <strong>{{ title }}</strong> | ||
| </span> | ||
| </h1> | ||
|
|
||
| <p>Tap the button below to verify your email address.</p> | ||
|
|
||
| <table | ||
| style=" | ||
| max-width: 555px; | ||
| width: 100%; | ||
| font-family: 'Open Sans', arial, sans-serif; | ||
| font-size: 13px; | ||
| color: #323232; | ||
| " | ||
| cellspacing="0" | ||
| cellpadding="0" | ||
| border="0" | ||
| bgcolor="#ffffff" | ||
| align="center" | ||
| > | ||
| <tbody> | ||
| <tr> | ||
| <td height="10"> </td> | ||
| </tr> | ||
| <tr> | ||
| <td style="text-align: center"> | ||
| <a | ||
| href="{{ confirmation_link }}" | ||
| style=" | ||
| color: #fff; | ||
| background-color: hsla(199, 69%, 84%, 1); | ||
| width: 320px; | ||
| font-size: 16px; | ||
| border-radius: 3px; | ||
| line-height: 44px; | ||
| height: 44px; | ||
| font-family: 'Open Sans', Arial, helvetica, sans-serif; | ||
| text-align: center; | ||
| text-decoration: none; | ||
| display: inline-block; | ||
| " | ||
| target="_blank" | ||
| data-saferedirecturl="https://www.google.com/url?q={{ confirmation_link }}" | ||
| > | ||
| <span style="color: #000000"> | ||
| <strong>Verify email address</strong> | ||
| </span> | ||
| </a> | ||
| </td> | ||
| </tr> | ||
| </tbody> | ||
| </table> | ||
|
|
||
| <table | ||
| style=" | ||
| max-width: 555px; | ||
| width: 100%; | ||
| font-family: 'Open Sans', arial, sans-serif; | ||
| font-size: 13px; | ||
| color: #323232; | ||
| " | ||
| cellspacing="0" | ||
| cellpadding="0" | ||
| border="0" | ||
| bgcolor="#ffffff" | ||
| align="center" | ||
| > | ||
| <tbody> | ||
| <tr> | ||
| <td height="10"> </td> | ||
| </tr> | ||
| <tr> | ||
| <td align="left"> | ||
| <p align="center"> </p> | ||
| If the above button doesn't work, try copying and pasting | ||
| the link below into your browser. If you continue to | ||
| experience problems, please contact us. | ||
| <br /> | ||
| {{ confirmation_link }} | ||
| <br /> | ||
| </td> | ||
| </tr> | ||
| <tr> | ||
| <td> | ||
| <p align="center"> </p> | ||
| <br /> | ||
| <p style="padding-bottom: 15px; margin: 0"> | ||
| Kindly note that this link will expire in | ||
| <strong>{{expiration_time}} minutes</strong>. The exact | ||
| expiration date and time is: | ||
| <strong>{{ exact_time }}</strong>. | ||
| </p> | ||
| </td> | ||
| </tr> | ||
| </tbody> | ||
| </table> | ||
| </td> | ||
| </tr> | ||
| </tbody> | ||
| </table> | ||
| </body> | ||
| </html> |