494 changes: 483 additions & 11 deletions backend/Cargo.lock

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions backend/Cargo.toml
Expand Up @@ -13,10 +13,18 @@ name = "backend"

[dependencies]
actix-web = "4"
argon2 = "0.5"
chrono = "0.4"
config = { version = "0.13", features = ["yaml"] }
deadpool-redis = "0.11"
dotenv = "0.15"
hex = "0.4"
lettre = { version = "0.10", features = ["builder", "tokio1-native-tls"] }
minijinja = { version = "0.32", features = ["source"] }
once_cell = "1.17"
pasetors = "0.6"
serde = "1"
serde_json = { version = "1", features = ["raw_value"] }
sqlx = { version = "0.6", features = [
"runtime-actix-rustls",
"postgres",
Expand All @@ -34,3 +42,4 @@ tracing-subscriber = { version = "0.3", features = [
'json',
'tracing-log',
] }
uuid = { version = "1", features = ["v4", "serde"] }
5 changes: 5 additions & 0 deletions backend/settings/base.yaml
Expand Up @@ -15,3 +15,8 @@ redis:
pool_max_idle: 8
pool_timeout_seconds: 1
pool_expire_seconds: 60

email:
host: "smtp.gmail.com"
host_user: ""
host_user_password: ""
7 changes: 7 additions & 0 deletions backend/settings/development.yaml
Expand Up @@ -4,3 +4,10 @@ application:
base_url: "http://127.0.0.1"

debug: true

secret:
secret_key: "YkDU_%q({@QV&5-Z}SONy,7YO?[qF6F6"
token_expiration: 30
hmac_secret: "3daad17f50d3577ae06406213073aa28e5cda75b97f5f35170e63653bbb66d8d"

frontend_url: "https://localhost:3000"
7 changes: 7 additions & 0 deletions backend/settings/production.yaml
Expand Up @@ -4,3 +4,10 @@ application:
base_url: "https://johnwrites-actix-backend.onrender.com"

debug: false

secret:
secret_key: ""
token_expiration: 0
hmac_secret: ""

frontend_url: ""
9 changes: 9 additions & 0 deletions backend/src/lib.rs
Expand Up @@ -2,3 +2,12 @@ pub mod routes;
pub mod settings;
pub mod startup;
pub mod telemetry;
pub mod types;
pub mod utils;

pub static ENV: once_cell::sync::Lazy<minijinja::Environment<'static>> =
once_cell::sync::Lazy::new(|| {
let mut env = minijinja::Environment::new();
env.set_source(minijinja::Source::from_path("templates"));
env
});
1 change: 1 addition & 0 deletions backend/src/main.rs
@@ -1,6 +1,7 @@
#[tokio::main]
async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
dotenv::from_filename(".env.development").ok();

let settings = backend::settings::get_settings().expect("Failed to read settings.");

Expand Down
7 changes: 5 additions & 2 deletions backend/src/routes/health.rs
@@ -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::DEBUG, "Accessing health-check endpoint.");
actix_web::HttpResponse::Ok().json("Application is safe and healthy.")
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(),
})
}
1 change: 1 addition & 0 deletions backend/src/routes/mod.rs
Expand Up @@ -2,3 +2,4 @@ mod health;
mod users;

pub use health::health_check;
pub use users::auth_routes_config;
4 changes: 4 additions & 0 deletions backend/src/routes/users/mod.rs
@@ -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));
}
161 changes: 161 additions & 0 deletions backend/src/routes/users/register.rs
@@ -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)
}
}
}
19 changes: 18 additions & 1 deletion backend/src/settings.rs
Expand Up @@ -7,6 +7,9 @@ pub struct Settings {
pub debug: bool,
pub database: DatabaseSettings,
pub redis: RedisSettings,
pub secret: Secret,
pub email: EmailSettings,
pub frontend_url: String,
}

/// Application's specific settings to expose `port`,
Expand All @@ -30,6 +33,20 @@ pub struct RedisSettings {
pub pool_expire_seconds: u64,
}

#[derive(serde::Deserialize, Clone)]
pub struct Secret {
pub secret_key: String,
pub token_expiration: i64,
pub hmac_secret: String,
}

#[derive(serde::Deserialize, Clone)]
pub struct EmailSettings {
pub host: String,
pub host_user: String,
pub host_user_password: String,
}

/// Database settings for the entire app
#[derive(serde::Deserialize, Clone)]
pub struct DatabaseSettings {
Expand Down Expand Up @@ -102,7 +119,7 @@ impl TryFrom<String> for Environment {
/// For this to work, you the environment variable MUST be in uppercase and starts with `APP`,
/// a `_` separator then the category of settings,
/// followed by `__` separator, and then the variable, e.g.
/// `APP__APPLICATION_PORT=5001` for `port` to be set as `5001`
/// `APP_APPLICATION__PORT=5001` for `port` to be set as `5001`
pub fn get_settings() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
let settings_directory = base_path.join("settings");
Expand Down
4 changes: 3 additions & 1 deletion backend/src/startup.rs
Expand Up @@ -57,7 +57,7 @@ async fn run(
let pool = actix_web::web::Data::new(db_pool);

// Redis connection pool
let cfg = deadpool_redis::Config::from_url(settings.clone().redis.uri);
let cfg = deadpool_redis::Config::from_url(settings.redis.uri);
let redis_pool = cfg
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("Cannot create deadpool redis.");
Expand All @@ -66,6 +66,8 @@ async fn run(
let server = actix_web::HttpServer::new(move || {
actix_web::App::new()
.service(crate::routes::health_check)
// Authentication routes
.configure(crate::routes::auth_routes_config)
// Add database pool to application state
.app_data(pool.clone())
// Add redis pool to application state
Expand Down
4 changes: 1 addition & 3 deletions backend/src/telemetry.rs
Expand Up @@ -21,9 +21,7 @@ pub fn get_subscriber(debug: bool) -> impl tracing::Subscriber + Send + Sync {
None
};

let subscriber = subscriber.with(json_log);

subscriber
subscriber.with(json_log)
}

pub fn init_subscriber(subscriber: impl tracing::Subscriber + Send + Sync) {
Expand Down
9 changes: 9 additions & 0 deletions backend/src/types/general.rs
@@ -0,0 +1,9 @@
#[derive(serde::Serialize)]
pub struct ErrorResponse {
pub error: String,
}

#[derive(serde::Serialize)]
pub struct SuccessResponse {
pub message: String,
}
5 changes: 5 additions & 0 deletions backend/src/types/mod.rs
@@ -0,0 +1,5 @@
mod general;
mod tokens;

pub use general::{ErrorResponse, SuccessResponse};
pub use tokens::ConfirmationToken;
4 changes: 4 additions & 0 deletions backend/src/types/tokens.rs
@@ -0,0 +1,4 @@
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct ConfirmationToken {
pub user_id: uuid::Uuid,
}
2 changes: 2 additions & 0 deletions backend/src/utils/auth/mod.rs
@@ -0,0 +1,2 @@
pub mod password;
pub mod tokens;
22 changes: 22 additions & 0 deletions backend/src/utils/auth/password.rs
@@ -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)
}
175 changes: 175 additions & 0 deletions backend/src/utils/auth/tokens.rs
@@ -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)),
}
}
180 changes: 180 additions & 0 deletions backend/src/utils/emails.rs
@@ -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(())
}
6 changes: 6 additions & 0 deletions backend/src/utils/mod.rs
@@ -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};
132 changes: 132 additions & 0 deletions backend/templates/verification_email.html
@@ -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">&nbsp;</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">&nbsp;</td>
</tr>
<tr>
<td align="left">
<p align="center">&nbsp;</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">&nbsp;</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>