upgrading to actix-v4-beta

This commit is contained in:
realaravinth 2021-06-30 20:13:12 +05:30
parent 9ed458ebfa
commit 9f940c317a
No known key found for this signature in database
GPG key ID: AD9F0F08E855ED88
24 changed files with 728 additions and 1192 deletions

1442
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -22,27 +22,31 @@ name = "tests-migrate"
path = "./src/tests-migrate.rs"
[dependencies]
actix-web = "3.3.2"
actix = "0.10"
actix-identity = "0.3"
actix-http = "2.2"
actix-rt = "1"
actix-cors= "0.5.4"
actix-service = "1.0.6"
#actix-web = "3.3.2"
actix-web = "4.0.0-beta.8"
actix = "0.12"
#actix-identity = "0.3"
actix-identity = "0.4.0-beta.2"
actix-http = "3.0.0-beta.8"
#actix-http = "2.2"
actix-rt = "2"
#actix-cors= "0.5.4"
actix-cors = "0.6.0-beta.2"
#actix-service = "0.0.6"
actix-service = "2.0.0"
my-codegen = {package = "actix-web-codegen", git ="https://github.com/realaravinth/actix-web"}
mime_guess = "2.0.3"
rust-embed = "5.9.0"
cache-buster = { version = "0.2.0", git = "https://github.com/realaravinth/cache-buster" }
futures = "0.3.14"
futures = "0.3.15"
sqlx = { version = "0.4.0", features = [ "runtime-actix-rustls", "postgres", "time", "offline" ] }
sqlx = { version = "0.5.5", features = [ "runtime-actix-rustls", "postgres", "time", "offline" ] }
argon2-creds = { branch = "master", git = "https://github.com/realaravinth/argon2-creds"}
#argon2-creds = { version="*", path = "../../argon2-creds/" }
config = "0.11"
validator = { version = "0.13", features = ["derive"]}
validator = { version = "0.14", features = ["derive"]}
derive_builder = "0.10"
derive_more = "0.99"
@ -59,7 +63,6 @@ log = "0.4"
lazy_static = "1.4"
# m_captcha = { version = "0.1.2", git = "https://github.com/mCaptcha/mCaptcha" }
libmcaptcha = { branch = "master", git = "https://github.com/mCaptcha/libmcaptcha", features = ["full"] }
#libmcaptcha = { path = "../libmcaptcha", features = ["full"]}

View file

@ -49,8 +49,9 @@ pool = 4
url = "redis://127.0.0.1"
pool = 4
#[smtp]
#from = "admin@domain.com"
#url = "smtp.domain.com"
#username = "admin"
#password = "password"
[smtp]
from = "admin@localhost"
reply_to = "admin@localhost"
url = "localhost:10025"
username = "admin"
password = "password"

View file

@ -226,7 +226,6 @@ async fn signout(id: Identity) -> impl Responder {
id.forget();
}
HttpResponse::Found()
.header(header::LOCATION, "/login")
.append_header((header::LOCATION, "/login"))
.finish()
.into_body()
}

View file

@ -24,11 +24,11 @@ use crate::errors::*;
use crate::AppData;
pub struct Notification {
pub name: String,
pub heading: String,
pub message: String,
pub received: OffsetDateTime,
pub id: i32,
pub name: Option<String>,
pub heading: Option<String>,
pub message: Option<String>,
pub received: Option<OffsetDateTime>,
pub id: Option<i32>,
}
#[derive(Deserialize, Serialize)]
@ -43,11 +43,11 @@ pub struct NotificationResp {
impl From<Notification> for NotificationResp {
fn from(n: Notification) -> Self {
NotificationResp {
name: n.name,
heading: n.heading,
received: n.received.unix_timestamp(),
id: n.id,
message: n.message,
name: n.name.unwrap(),
heading: n.heading.unwrap(),
received: n.received.unwrap().unix_timestamp(),
id: n.id.unwrap(),
message: n.message.unwrap(),
}
}
}

View file

@ -161,7 +161,7 @@ mod tests {
#[test]
fn feature() {
actix_rt::System::new("trest")
actix_rt::System::new()
.block_on(async move { get_pow_config_works().await });
}

View file

@ -62,7 +62,7 @@ pub fn handle_embedded_file(path: &str) -> HttpResponse {
Cow::Owned(bytes) => bytes.into(),
};
HttpResponse::Ok()
.set(header::CacheControl(vec![header::CacheDirective::MaxAge(
.insert_header(header::CacheControl(vec![header::CacheDirective::MaxAge(
CACHE_AGE,
)]))
.content_type(from_path(path).first_or_octet_stream().as_ref())
@ -73,7 +73,7 @@ pub fn handle_embedded_file(path: &str) -> HttpResponse {
}
async fn dist(path: web::Path<String>) -> impl Responder {
handle_embedded_file(&path.0)
handle_embedded_file(&path)
}
async fn spec() -> HttpResponse {
@ -101,7 +101,7 @@ mod tests {
let mut app = test::init_service(
App::new()
.wrap(actix_middleware::NormalizePath::new(
actix_middleware::normalize::TrailingSlash::Trim,
actix_middleware::TrailingSlash::Trim,
))
.configure(services),
)

View file

@ -19,57 +19,92 @@ use lettre::{
message::{header, MultiPart, SinglePart},
AsyncTransport, Message,
};
use sailfish::TemplateOnce;
use crate::AppData;
use crate::errors::*;
use crate::Data;
use crate::SETTINGS;
// The html we want to send.
const HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello from Lettre!</title>
</head>
<body>
<div style="display: flex; flex-direction: column; align-items: center;">
<h2 style="font-family: Arial, Helvetica, sans-serif;">Hello from Lettre!</h2>
<h4 style="font-family: Arial, Helvetica, sans-serif;">A mailer library for Rust</h4>
</div>
</body>
</html>"#;
const PAGE: &str = "Login";
async fn verification(data: &AppData) {
#[derive(Clone, TemplateOnce)]
#[template(path = "email/verification/index.html")]
struct IndexPage<'a> {
verification_link: &'a str,
}
impl<'a> IndexPage<'a> {
fn new(verification_link: &'a str) -> Self {
Self { verification_link }
}
}
async fn verification(
data: &Data,
to: &str,
verification_link: &str,
) -> ServiceResult<()> {
if let Some(smtp) = SETTINGS.smtp.as_ref() {
let from = format!("mCaptcha Admin <{}>", smtp.from);
let reply_to = format!("mCaptcha Admin <{}>", smtp.reply_to);
const SUBJECT: &str = "[mCaptcha] Please verify your email";
let plain_text = format!(
"
Welcome to mCaptcha!
Please verify your email address to continue.
VERIFICATION LINK: {}
Please ignore this email if you weren't expecting it.
With best regards,
Admin
instance: {}
project website: {}",
verification_link,
SETTINGS.server.domain,
crate::PKG_HOMEPAGE
);
let html = IndexPage::new(verification_link).render_once().unwrap();
let email = Message::builder()
.from(from.parse().unwrap())
.reply_to("Yuin <yuin@domain.tld>".parse().unwrap())
.to("Hei <hei@domain.tld>".parse().unwrap())
.reply_to(reply_to.parse().unwrap())
.to(to.parse().unwrap())
.subject(SUBJECT)
.multipart(
MultiPart::alternative() // This is composed of two parts.
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_PLAIN)
.body(String::from(
"Hello from Lettre! A mailer library for Rust",
)), // Every message should have a plain text fallback.
.body(plain_text), // Every message should have a plain text fallback.
)
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_HTML)
.body(String::from(HTML)),
.body(html),
),
)
.unwrap();
// unwrap is OK as SETTINGS.smtp is check at the start
match data.mailer.as_ref().unwrap().send(email).await {
Ok(_) => println!("Email sent successfully!"),
Err(e) => panic!("Could not send email: {:?}", e),
}
data.mailer.as_ref().unwrap().send(email).await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[actix_rt::test]
async fn email_verification_works() {
const TO_ADDR: &str = "Hello <newuser@localhost>";
const VERIFICATION_LINK: &str = "https://localhost";
let data = Data::new().await;
verification(&data, TO_ADDR, VERIFICATION_LINK).await.unwrap();
}
}

View file

@ -18,18 +18,28 @@
use std::convert::From;
use actix_web::{
dev::HttpResponseBuilder,
dev::BaseHttpResponseBuilder as HttpResponseBuilder,
error::ResponseError,
http::{header, StatusCode},
HttpResponse,
};
use argon2_creds::errors::CredsError;
use derive_more::{Display, Error};
use lettre::transport::smtp::Error as SmtpError;
use libmcaptcha::errors::CaptchaError;
use serde::{Deserialize, Serialize};
use url::ParseError;
use validator::ValidationErrors;
#[derive(Debug, Display, Error)]
pub struct SmtpErrorWrapper(SmtpError);
impl std::cmp::PartialEq for SmtpErrorWrapper {
fn eq(&self, other: &Self) -> bool {
self.0.status() == other.0.status()
}
}
#[derive(Debug, Display, PartialEq, Error)]
#[cfg(not(tarpaulin_include))]
pub enum ServiceError {
@ -81,6 +91,10 @@ pub enum ServiceError {
#[display(fmt = "Email not available")]
EmailTaken,
/// Unable to send email
#[display(fmt = "Unable to send email, contact admin")]
UnableToSendEmail(SmtpErrorWrapper),
/// when the a token name is already taken
/// token not found
#[display(fmt = "Token not found. Is token registered?")]
@ -101,10 +115,14 @@ impl ResponseError for ServiceError {
#[cfg(not(tarpaulin_include))]
fn error_response(&self) -> HttpResponse {
HttpResponseBuilder::new(self.status_code())
.set_header(header::CONTENT_TYPE, "application/json; charset=UTF-8")
.json(ErrorToResponse {
error: self.to_string(),
})
.append_header((header::CONTENT_TYPE, "application/json; charset=UTF-8"))
.body(
serde_json::to_string(&ErrorToResponse {
error: self.to_string(),
})
.unwrap(),
)
.into()
}
#[cfg(not(tarpaulin_include))]
@ -130,10 +148,18 @@ impl ResponseError for ServiceError {
ServiceError::EmailTaken => StatusCode::BAD_REQUEST,
ServiceError::TokenNotFound => StatusCode::NOT_FOUND,
ServiceError::CaptchaError(e) => match e {
CaptchaError::MailboxError => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::BAD_REQUEST,
},
ServiceError::CaptchaError(e) => {
log::error!("{}", e);
match e {
CaptchaError::MailboxError => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::BAD_REQUEST,
}
}
ServiceError::UnableToSendEmail(e) => {
log::error!("{}", e.0);
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
}
@ -189,6 +215,14 @@ impl From<sqlx::Error> for ServiceError {
}
}
#[cfg(not(tarpaulin_include))]
impl From<SmtpError> for ServiceError {
#[cfg(not(tarpaulin_include))]
fn from(e: SmtpError) -> Self {
ServiceError::UnableToSendEmail(SmtpErrorWrapper(e))
}
}
#[cfg(not(tarpaulin_include))]
pub type ServiceResult<V> = std::result::Result<V, ServiceError>;
@ -223,10 +257,10 @@ impl ResponseError for PageError {
use crate::PAGES;
match self.status_code() {
StatusCode::INTERNAL_SERVER_ERROR => HttpResponse::Found()
.header(header::LOCATION, PAGES.errors.internal_server_error)
.append_header((header::LOCATION, PAGES.errors.internal_server_error))
.finish(),
_ => HttpResponse::Found()
.header(header::LOCATION, PAGES.errors.unknown_error)
.append_header((header::LOCATION, PAGES.errors.unknown_error))
.finish(),
}
}

View file

@ -105,6 +105,7 @@ async fn main() -> std::io::Result<()> {
let data = Data::new().await;
sqlx::migrate!("./migrations/").run(&data.db).await.unwrap();
let data = actix_web::web::Data::new(data);
println!("Starting server on: http://{}", SETTINGS.server.get_ip());
@ -117,9 +118,9 @@ async fn main() -> std::io::Result<()> {
)
.wrap(get_identity_service())
.wrap(actix_middleware::Compress::default())
.data(data.clone())
.app_data(data.clone())
.wrap(actix_middleware::NormalizePath::new(
actix_middleware::normalize::TrailingSlash::Trim,
actix_middleware::TrailingSlash::Trim,
))
.configure(v1::services)
.configure(widget::services)
@ -149,7 +150,7 @@ pub fn get_identity_service() -> IdentityService<CookieIdentityPolicy> {
CookieIdentityPolicy::new(cookie_secret.as_bytes())
.name("Authorization")
//TODO change cookie age
.max_age(216000)
.max_age_secs(216000)
.domain(&SETTINGS.server.domain)
.secure(false),
)

View file

@ -16,8 +16,9 @@
*/
#![allow(clippy::type_complexity)]
use std::task::{Context, Poll};
//use std::task::{Context, Poll};
use actix_http::body::AnyBody;
use actix_identity::Identity;
use actix_service::{Service, Transform};
use actix_web::dev::{ServiceRequest, ServiceResponse};
@ -29,13 +30,12 @@ use crate::PAGES;
pub struct CheckLogin;
impl<S, B> Transform<S> for CheckLogin
impl<S> Transform<S, ServiceRequest> for CheckLogin
where
S: Service<Request = ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S: Service<ServiceRequest, Response = ServiceResponse<AnyBody>, Error = Error>,
S::Future: 'static,
{
type Request = ServiceRequest;
type Response = ServiceResponse<B>;
type Response = ServiceResponse<AnyBody>;
type Error = Error;
type Transform = CheckLoginMiddleware<S>;
type InitError = ();
@ -49,21 +49,41 @@ pub struct CheckLoginMiddleware<S> {
service: S,
}
impl<S, B> Service for CheckLoginMiddleware<S>
impl<S> Service<ServiceRequest> for CheckLoginMiddleware<S>
where
S: Service<Request = ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S: Service<ServiceRequest, Response = ServiceResponse<AnyBody>, Error = Error>,
S::Future: 'static,
{
type Request = ServiceRequest;
type Response = ServiceResponse<B>;
type Response = ServiceResponse<AnyBody>;
type Error = Error;
type Future = Either<S::Future, Ready<Result<Self::Response, Self::Error>>>;
fn poll_ready(&mut self, cx: &mut Context) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(cx)
}
// fn poll_ready(&mut self, cx: &mut Context) -> Poll<Result<(), Self::Error>> {
// self.service.poll_ready(cx)
// }
//
actix_service::forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
// let (r, mut pl) = req.into_parts();
// // TODO investigate when the bellow statement will
// // return error
// if let Ok(Some(_)) = Identity::from_request(&r, &mut pl)
// .into_inner()
// .map(|x| x.identity())
// {
// let req = ServiceRequest::from_parts(r, pl);
// Either::Left(self.service.call(req))
// } else {
// let resp = actix_http::ResponseBuilder::new(http::StatusCode::FOUND)
// .insert_header((http::header::LOCATION, PAGES.auth.login))
// .finish();
// let req = ServiceRequest::from_parts(r, pl);
// Either::Right(ok(req.into_response(resp)))
// }
fn call(&mut self, req: ServiceRequest) -> Self::Future {
let (r, mut pl) = req.into_parts();
// TODO investigate when the bellow statement will
@ -72,15 +92,14 @@ where
.into_inner()
.map(|x| x.identity())
{
let req = ServiceRequest::from_parts(r, pl).ok().unwrap();
let req = ServiceRequest::from_parts(r, pl);
Either::Left(self.service.call(req))
} else {
let req = ServiceRequest::from_parts(r, pl).ok().unwrap();
let req = ServiceRequest::from_parts(r, pl); //.ok().unwrap();
Either::Right(ok(req.into_response(
HttpResponse::Found()
.header(http::header::LOCATION, PAGES.auth.login)
.finish()
.into_body(),
.insert_header((http::header::LOCATION, PAGES.auth.login))
.finish(),
)))
}
}

View file

@ -50,7 +50,7 @@ lazy_static! {
}
async fn error(path: web::Path<usize>) -> impl Responder {
let resp = match path.0 {
let resp = match path.into_inner() {
500 => HttpResponse::InternalServerError()
.content_type("text/html; charset=utf-8")
.body(&*INTERNAL_SERVER_ERROR_BODY),

View file

@ -54,14 +54,8 @@ mod tests {
let (data, _, signin_resp) = register_and_signin(NAME, EMAIL, PASSWORD).await;
let cookies = get_cookie!(signin_resp);
let mut app = test::init_service(
App::new()
.wrap(get_identity_service())
.configure(crate::api::v1::services)
.configure(services)
.data(data.clone()),
)
.await;
let mut app = get_app!(data).await;
let urls = vec![
PAGES.home,

View file

@ -66,7 +66,7 @@ pub async fn view_sitekey(
id: Identity,
) -> PageResult<impl Responder> {
let username = id.identity().unwrap();
let key = path.0;
let key = path.into_inner();
let config = sqlx::query_as!(
McaptchaConfig,

View file

@ -41,6 +41,7 @@ pub struct Captcha {
#[derive(Debug, Clone, Deserialize)]
pub struct Smtp {
pub from: String,
pub reply_to: String,
pub url: String,
pub username: String,
pub password: String,

View file

@ -37,7 +37,7 @@ fn handle_assets(path: &str) -> HttpResponse {
};
HttpResponse::Ok()
.set(header::CacheControl(vec![
.insert_header(header::CacheControl(vec![
header::CacheDirective::Public,
header::CacheDirective::Extension("immutable".into(), None),
header::CacheDirective::MaxAge(CACHE_AGE),
@ -51,7 +51,7 @@ fn handle_assets(path: &str) -> HttpResponse {
#[get("/assets/{_:.*}")]
pub async fn static_files(path: web::Path<String>) -> impl Responder {
handle_assets(&path.0)
handle_assets(&path)
}
#[derive(RustEmbed)]
@ -67,7 +67,7 @@ fn handle_favicons(path: &str) -> HttpResponse {
};
HttpResponse::Ok()
.set(header::CacheControl(vec![
.insert_header(header::CacheControl(vec![
header::CacheDirective::Public,
header::CacheDirective::Extension("immutable".into(), None),
header::CacheDirective::MaxAge(CACHE_AGE),
@ -82,7 +82,7 @@ fn handle_favicons(path: &str) -> HttpResponse {
#[get("/{file}")]
pub async fn favicons(path: web::Path<String>) -> impl Responder {
debug!("searching favicons");
handle_favicons(&path.0)
handle_favicons(&path)
}
#[cfg(test)]

View file

@ -43,7 +43,7 @@ macro_rules! post_request {
($serializable:expr, $uri:expr) => {
test::TestRequest::post()
.uri($uri)
.header(header::CONTENT_TYPE, "application/json")
.insert_header((header::CONTENT_TYPE, "application/json"))
.set_payload(serde_json::to_string($serializable).unwrap())
};
}
@ -67,7 +67,7 @@ macro_rules! get_app {
App::new()
.wrap(get_identity_service())
.wrap(actix_middleware::NormalizePath::new(
actix_middleware::normalize::TrailingSlash::Trim,
actix_middleware::TrailingSlash::Trim,
))
.configure(crate::api::v1::services)
.configure(crate::widget::services)
@ -81,7 +81,7 @@ macro_rules! get_app {
App::new()
.wrap(get_identity_service())
.wrap(actix_middleware::NormalizePath::new(
actix_middleware::normalize::TrailingSlash::Trim,
actix_middleware::TrailingSlash::Trim,
))
.configure(crate::api::v1::services)
.configure(crate::widget::services)
@ -89,7 +89,7 @@ macro_rules! get_app {
.configure(crate::pages::services)
.configure(crate::static_assets::services)
//.data(std::sync::Arc::new(crate::data::Data::new().await))
.data($data.clone()),
.app_data(actix_web::web::Data::new($data.clone())),
)
};
}

View file

@ -82,7 +82,7 @@ fn handle_widget_assets(path: &str) -> HttpResponse {
};
HttpResponse::Ok()
.set(header::CacheControl(vec![header::CacheDirective::MaxAge(
.insert_header(header::CacheControl(vec![header::CacheDirective::MaxAge(
crate::CACHE_AGE,
)]))
.content_type(from_path(path).first_or_octet_stream().as_ref())
@ -94,7 +94,7 @@ fn handle_widget_assets(path: &str) -> HttpResponse {
#[get("/widget/{_:.*}")]
pub async fn widget_assets(path: web::Path<String>) -> impl Responder {
handle_widget_assets(&path.0)
handle_widget_assets(&path)
}
pub fn services(cfg: &mut web::ServiceConfig) {

View file

@ -0,0 +1,59 @@
<div>
<hr />
</div>
<footer role="contentinfo" class="details__container">
<div class="details__row1">
<p class="details__copyright-text">&copy; mCaptcha developers</p>
<ul class="details">
<li class="details__copyright"></li>
<li class="details__item">
<a class="details__link" href="<.= crate::PKG_HOMEPAGE .>">Homepage</a>
</li>
<li class="details__item">
<a
class="details__link"
href="<.= crate::PKG_HOMEPAGE .><.= crate::PAGES.about .>"
>About</a
>
</li>
<li class="details__item">
<a
class="details__link"
href="<.= crate::PKG_HOMEPAGE .><.= crate::PAGES.privacy .>"
>Privacy</a
>
</li>
</ul>
</div>
<ul class="details">
<li class="details__item">
<a
class="details__link"
href="<.= crate::PKG_HOMEPAGE .><.= crate::PAGES.security .>"
>Security</a
>
</li>
<li class="details__item">
<a
class="details__link"
href="<.= crate::PKG_HOMEPAGE .><.= crate::PAGES.donate .>"
>Donate</a
>
</li>
<li class="details__item">
<a
class="details__link"
href="<.= crate::PKG_HOMEPAGE .><.= crate::PAGES.thanks .>"
>Thanks</a
>
</li>
<li class="details__item">
<a class="details__link" href="<.= &*crate::SOURCE_FILES_OF_INSTANCE .>">
v<.= crate::VERSION .>-<.= crate::GIT_COMMIT_HASH[0..8] .>
</a>
</li>
</ul>
</footer>

View file

@ -0,0 +1,38 @@
.details__container {
display: flex;
font-size: 14px;
flex-direction: column;
}
.details__copyright {
font-size: 14px;
flex: 2;
}
.details {
list-style: none;
bottom: 0px;
box-sizing: border-box;
display: flex;
font-size: 14px;
margin: auto;
padding: 0;
}
.details__item {
margin: auto 10px;
list-style: none;
flex: 1;
}
.details__link {
color: rgb(3, 102, 214);
}
.details__row1 {
display: flex;
}
.details__row1 > .details {
flex: 1;
}

View file

@ -0,0 +1,15 @@
* {
font-family: Arial, Helvetica, sans-serif;
background-color: #f0f0f0;
}
.container {
display: flex;
flex-direction: column;
max-width: 450px;
margin: auto;
}
h1 {
align-self: center;
}

View file

@ -0,0 +1 @@
font-size: 1.2rem; font-weight: 500;

View file

@ -0,0 +1,4 @@
.verification__link {
align-self: center;
font-size: 1.2rem;
}

View file

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><.= PAGE .> | <.= crate::pages::NAME .></title>
<style type="text/css" media="screen">
<. include!("../components/footer/main.css"); .>
<. include!("../css/base.css"); .>
<. include!("../css/message-text.css"); .>
<. include!("./css/verification__link.css"); .>;
</style>
</head>
<body>
<div class="container">
<h1>
Welcome to mCaptcha!
</h1>
<p class="message__text">
Please verify your email address to continue.
</p>
<button class="button">
Click here to verify
</button>
<p class="message__text">
If you were not able to see the verification button, click the following
link:
</p>
<a
class="verification__link"
href="<.= verification_link .>"
target="_blank"
><.= verification_link .></a
>
<p class="message__text">
The link expires in 15 minutes. Please ignore this email if you weren't
expecting it.
</p>
<p class="message__text">
With best regards,<br />
Admin<br />
</p>
<. include!("../components/footer/index.html"); .>
</div>
</body>
</html>