confirmation emails (hacky)

This commit is contained in:
azdle 2025-07-15 14:42:23 -05:00
parent 486fe68d3b
commit ca21a84725
10 changed files with 334 additions and 44 deletions

150
Cargo.lock generated
View file

@ -148,6 +148,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5"
dependencies = [ dependencies = [
"axum-core", "axum-core",
"axum-macros",
"bytes", "bytes",
"form_urlencoded", "form_urlencoded",
"futures-util", "futures-util",
@ -219,6 +220,17 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "axum-macros"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "backtrace" name = "backtrace"
version = "0.3.75" version = "0.3.75"
@ -387,6 +399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [ dependencies = [
"crossbeam-utils", "crossbeam-utils",
"portable-atomic",
] ]
[[package]] [[package]]
@ -452,7 +465,7 @@ dependencies = [
"aes-gcm", "aes-gcm",
"base64 0.22.1", "base64 0.22.1",
"percent-encoding", "percent-encoding",
"rand", "rand 0.8.5",
"subtle", "subtle",
"time", "time",
"version_check", "version_check",
@ -544,7 +557,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core", "rand_core 0.6.4",
"typenum", "typenum",
] ]
@ -695,6 +708,8 @@ dependencies = [
"concurrent-queue", "concurrent-queue",
"parking", "parking",
"pin-project-lite", "pin-project-lite",
"portable-atomic",
"portable-atomic-util",
] ]
[[package]] [[package]]
@ -1475,6 +1490,20 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "maik"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8f0b47dc7cc74760332828e060ec705339b5e863894f575d81691546bbc836"
dependencies = [
"base64 0.22.1",
"lazy_static",
"native-tls",
"rcgen",
"regex",
"wg",
]
[[package]] [[package]]
name = "matchers" name = "matchers"
version = "0.1.0" version = "0.1.0"
@ -1580,7 +1609,7 @@ dependencies = [
"num-integer", "num-integer",
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand", "rand 0.8.5",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@ -1737,6 +1766,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pem"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3"
dependencies = [
"base64 0.22.1",
"serde",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@ -1868,6 +1907,21 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "portable-atomic"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.2" version = "0.1.2"
@ -1954,8 +2008,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
] ]
[[package]] [[package]]
@ -1965,7 +2029,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
] ]
[[package]] [[package]]
@ -1977,6 +2051,28 @@ dependencies = [
"getrandom 0.2.16", "getrandom 0.2.16",
] ]
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom 0.3.3",
]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.12" version = "0.5.12"
@ -2115,7 +2211,7 @@ dependencies = [
"num-traits", "num-traits",
"pkcs1", "pkcs1",
"pkcs8", "pkcs8",
"rand_core", "rand_core 0.6.4",
"signature", "signature",
"spki", "spki",
"subtle", "subtle",
@ -2388,7 +2484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest",
"rand_core", "rand_core 0.6.4",
] ]
[[package]] [[package]]
@ -2556,7 +2652,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rand", "rand 0.8.5",
"rsa", "rsa",
"serde", "serde",
"sha1", "sha1",
@ -2596,7 +2692,7 @@ dependencies = [
"md-5", "md-5",
"memchr", "memchr",
"once_cell", "once_cell",
"rand", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@ -3082,6 +3178,16 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc"
[[package]]
name = "triomphe"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85"
dependencies = [
"serde",
"stable_deref_trait",
]
[[package]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@ -3314,6 +3420,18 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "wg"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aafc5e81e847f05d6770e074faf7b1cd4a5dec9a0e88eac5d55e20fdfebee9a"
dependencies = [
"event-listener",
"futures-core",
"parking_lot",
"triomphe",
]
[[package]] [[package]]
name = "whoami" name = "whoami"
version = "1.6.0" version = "1.6.0"
@ -3682,6 +3800,15 @@ dependencies = [
"hashlink", "hashlink",
] ]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.0" version = "0.8.0"
@ -3719,7 +3846,10 @@ dependencies = [
"http-body-util", "http-body-util",
"hyper", "hyper",
"lettre", "lettre",
"maik",
"pin-project", "pin-project",
"rand 0.9.1",
"regex",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -9,13 +9,14 @@ license = "MIT OR Apache-2.0"
[dependencies] [dependencies]
anyhow = { version = "1.0.71", features = ["backtrace"] } anyhow = { version = "1.0.71", features = ["backtrace"] }
axum = {version = "0.8", features = ["tokio", "http1", "http2"] } axum = { version = "0.8", features = ["tokio", "http1", "http2", "macros"] }
axum-extra = { version = "0.10", features = ["cookie-private", "typed-header"] } axum-extra = { version = "0.10", features = ["cookie-private", "typed-header"] }
config = { version = "0.15", features = ["toml"] } config = { version = "0.15", features = ["toml"] }
futures-util = "0.3" futures-util = "0.3"
hyper = "1.1" hyper = "1.1"
lettre = { version = "0.11.17", features = ["tokio1", "tokio1-native-tls", "tracing", "web"] } lettre = { version = "0.11.17", features = ["tokio1", "tokio1-native-tls", "tracing", "web"] }
pin-project = "1.1.0" pin-project = "1.1.0"
rand = "0.9.1"
serde = { version = "1.0.164", features = ["derive"] } serde = { version = "1.0.164", features = ["derive"] }
serde_json = "1.0.99" serde_json = "1.0.99"
sqlx = { version = "0.8.5", features = ["runtime-tokio", "macros", "postgres", "uuid", "chrono", "migrate"] } sqlx = { version = "0.8.5", features = ["runtime-tokio", "macros", "postgres", "uuid", "chrono", "migrate"] }
@ -33,6 +34,8 @@ uuid = { version = "1.16.0", features = ["v4"] }
[dev-dependencies] [dev-dependencies]
bollard = { git = "https://github.com/fussybeaver/bollard.git", rev = "50a25a0" } bollard = { git = "https://github.com/fussybeaver/bollard.git", rev = "50a25a0" }
http-body-util = "0.1.3" http-body-util = "0.1.3"
maik = "0.2.0"
regex = "1.11.1"
reqwest = { version = "0.12", features = ["cookies"] } reqwest = { version = "0.12", features = ["cookies"] }
test-log = { version = "0.2.12", default-features = false, features = ["trace"] } test-log = { version = "0.2.12", default-features = false, features = ["trace"] }

View file

@ -0,0 +1,9 @@
BEGIN;
UPDATE subscriptions
SET status = 'confirmed'
where status IS NULL;
ALTER TABLE subscriptions ALTER COLUMN status SET NOT NULL;
COMMIT;

View file

@ -0,0 +1,6 @@
CREATE TABLE subscription_tokens(
token TEXT NOT NULL,
subscriber uuid NOT NULL
REFERENCES subscriptions (id),
PRIMARY KEY (token)
);

View file

@ -16,9 +16,11 @@ pub struct App {
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct Email { pub struct Email {
pub server: String, pub server: String,
pub port: Option<u16>,
pub username: String, pub username: String,
pub password: String, pub password: String,
pub sender: String, pub sender: String,
pub cert: Option<String>,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]

View file

@ -3,7 +3,10 @@ use std::time::Duration;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use lettre::{ use lettre::{
message::{header::ContentType, Mailbox}, message::{header::ContentType, Mailbox},
transport::smtp::authentication::Credentials, transport::smtp::{
authentication::Credentials,
client::{Certificate, TlsParametersBuilder},
},
AsyncSmtpTransport, AsyncTransport as _, Message, Tokio1Executor, AsyncSmtpTransport, AsyncTransport as _, Message, Tokio1Executor,
}; };
use tokio::time::timeout; use tokio::time::timeout;
@ -20,21 +23,38 @@ pub(crate) enum EmailClient {
} }
impl EmailClient { impl EmailClient {
pub fn new(conf: Option<conf::Email>) -> EmailClient { pub fn new(conf: Option<conf::Email>) -> Result<EmailClient> {
let Some(conf) = conf else { let Some(conf) = conf else {
return EmailClient::Disabled; return Ok(EmailClient::Disabled);
}; };
let inner: AsyncSmtpTransport<Tokio1Executor> = let mut inner = AsyncSmtpTransport::<Tokio1Executor>::relay(&conf.server).unwrap();
AsyncSmtpTransport::<Tokio1Executor>::relay(&conf.server)
.unwrap()
.credentials(Credentials::new(
conf.username.clone(),
conf.password.clone(),
))
.build();
EmailClient::Enabled { inner, conf } if let Some(port) = conf.port {
inner = inner.port(port);
}
if let Some(ref cert) = conf.cert {
let cert =
Certificate::from_pem(cert.as_bytes()).context("parse certificate as PEM")?;
let tls_param = TlsParametersBuilder::new(conf.server.clone())
.add_root_certificate(cert)
.build()
.context("build tls params")?;
inner = inner.tls(lettre::transport::smtp::client::Tls::Opportunistic(
tls_param,
));
}
let inner: AsyncSmtpTransport<Tokio1Executor> = inner
.credentials(Credentials::new(
conf.username.clone(),
conf.password.clone(),
))
.build();
Ok(EmailClient::Enabled { inner, conf })
} }
pub async fn send_email(&self, to: Mailbox, subject: String, body: String) -> Result<()> { pub async fn send_email(&self, to: Mailbox, subject: String, body: String) -> Result<()> {

View file

@ -1,6 +1,6 @@
pub mod routes; pub mod routes;
use anyhow::Result; use anyhow::{Context as _, Result};
use axum::extract::FromRef; use axum::extract::FromRef;
use axum_extra::extract::cookie::Key; use axum_extra::extract::cookie::Key;
use pin_project::pin_project; use pin_project::pin_project;
@ -47,7 +47,8 @@ impl ZeroToAxum {
.connect(&conf.database.url) .connect(&conf.database.url)
.await?; .await?;
let email_client = email_client::EmailClient::new(conf.email.clone()); let email_client =
email_client::EmailClient::new(conf.email.clone()).context("build email client")?;
let app_state = AppState { let app_state = AppState {
conf: Arc::new(conf.clone()), conf: Arc::new(conf.clone()),

View file

@ -1,5 +1,12 @@
use anyhow::Context; use anyhow::Context;
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Form, Router}; use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Form, Router,
};
use rand::{distr::Alphanumeric, rng, Rng as _};
use serde::Deserialize; use serde::Deserialize;
use sqlx::types::chrono::Utc; use sqlx::types::chrono::Utc;
use tracing::{debug, error, info}; use tracing::{debug, error, info};
@ -8,7 +15,9 @@ use uuid::Uuid;
use crate::server::AppState; use crate::server::AppState;
pub fn build() -> Router<AppState> { pub fn build() -> Router<AppState> {
Router::new().route("/", post(subscribe)) Router::new()
.route("/", post(subscribe))
.route("/confirm/", get(confirm))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -29,31 +38,56 @@ pub async fn subscribe(
return Err(SubscribeError::InvalidEmail); return Err(SubscribeError::InvalidEmail);
} }
debug!("email sent");
let mut txn = db.begin().await.context("start database transaction")?;
let subscriber_id = Uuid::new_v4();
sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at, status)
VALUES ($1, $2, $3, $4, 'pending_confirmation')
"#,
subscriber_id,
form.email,
form.name,
Utc::now()
)
.execute(&mut *txn)
.await
.context("insert subscription into database")?;
let token: String = {
let mut rng = rng();
std::iter::repeat_with(|| rng.sample(Alphanumeric))
.map(char::from)
.take(25)
.collect()
};
sqlx::query!(
r#"
INSERT INTO subscription_tokens (subscriber, token)
VALUES ($1, $2)
"#,
subscriber_id,
token
)
.execute(&mut *txn)
.await
.context("insert subscription token into database")?;
email_client email_client
.send_email( .send_email(
form.email form.email
.parse() .parse()
.map_err(|_| SubscribeError::InvalidEmail)?, .map_err(|_| SubscribeError::InvalidEmail)?,
"Test".to_owned(), "Test".to_owned(),
"This was a test.".to_owned(), format!("Please confirm your subscription: https://example.com/subscriptions/confirm/?token={token}"),
) )
.await?; .await?;
debug!("email sent"); txn.commit().await.context("commit transaction")?;
sqlx::query!(
r#"
INSERT INTO subscriptions (id, email, name, subscribed_at, status)
VALUES ($1, $2, $3, $4, 'confirmed')
"#,
Uuid::new_v4(),
form.email,
form.name,
Utc::now()
)
.execute(&db)
.await
.context("insert subscription into database")?;
Ok(()) Ok(())
} }
@ -78,3 +112,57 @@ impl IntoResponse for SubscribeError {
.into_response() .into_response()
} }
} }
#[derive(Deserialize)]
pub struct ConfirmQuery {
token: String,
}
pub async fn confirm(
State(AppState { db, .. }): State<AppState>,
Query(query): Query<ConfirmQuery>,
) -> Result<(), ConfirmError> {
info!(query.token, "confirm attempt");
if query.token.is_empty() {
return Err(ConfirmError::InvalidToken);
}
let rows = sqlx::query!(
r#"
UPDATE subscriptions SET status = 'confirmed' FROM subscription_tokens
WHERE status = 'pending_confirmation' AND subscription_tokens.subscriber = id AND subscription_tokens.token = $1;
"#,
query.token
)
.execute(&db)
.await
.context("insert subscription into database")?.rows_affected();
if rows < 1 {
return Err(ConfirmError::InvalidToken);
}
Ok(())
}
#[derive(thiserror::Error, Debug)]
pub enum ConfirmError {
#[error("Invalid Confirmation Token")]
InvalidToken,
#[error("Unknown Error: {0}")]
Unknown(#[from] anyhow::Error),
}
impl IntoResponse for ConfirmError {
fn into_response(self) -> axum::response::Response {
match self {
ConfirmError::InvalidToken => (StatusCode::BAD_REQUEST, "Invalid Confirmation Token"),
ConfirmError::Unknown(e) => {
error!(?e, "returning INTERNAL SERVER ERROR");
(StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error")
}
}
.into_response()
}
}

View file

@ -30,6 +30,7 @@ pub struct TestServer {
server_task_handle: JoinHandle<()>, server_task_handle: JoinHandle<()>,
addr: SocketAddr, addr: SocketAddr,
db: Arc<TestDb>, db: Arc<TestDb>,
pub mock_smtp_server: maik::MockServer,
} }
impl TestServer { impl TestServer {
@ -42,13 +43,26 @@ impl TestServer {
let db = get_shared_db().await; let db = get_shared_db().await;
let url = dbg!(db.get_url()); let url = dbg!(db.get_url());
let mock_smtp_server = maik::MockServer::builder()
.add_mailbox("bot@example.com", "1234")
.assert_after_n_emails(1)
.build();
mock_smtp_server.start();
let server = ZeroToAxum::serve(Conf { let server = ZeroToAxum::serve(Conf {
app: conf::App { app: conf::App {
listen: "[::]:0".parse().unwrap(), listen: "[::]:0".parse().unwrap(),
}, },
database: conf::Database { url }, database: conf::Database { url },
debug: true, debug: true,
email: None, email: Some(conf::Email {
server: mock_smtp_server.host().to_string(),
port: Some(mock_smtp_server.port()),
username: "bot@example.com".to_owned(),
password: "1234".to_owned(),
sender: "bot@example.com".to_owned(),
cert: Some(String::from_utf8_lossy(mock_smtp_server.cert_pem()).to_string()),
}),
}) })
.await .await
.unwrap(); .unwrap();
@ -60,6 +74,7 @@ impl TestServer {
server_task_handle, server_task_handle,
addr, addr,
db, db,
mock_smtp_server,
} }
} }
@ -68,6 +83,13 @@ impl TestServer {
format!("http://{}{path}", self.addr) format!("http://{}{path}", self.addr)
} }
/// Request a graceful shutdown and return imideately.
pub async fn start_shutdown(&self) -> Result<()> {
self.server_task_handle.abort();
Ok(())
}
/// Request a graceful shutdown and then wait for shutdown to complete /// Request a graceful shutdown and then wait for shutdown to complete
pub async fn shutdown(self) -> Result<()> { pub async fn shutdown(self) -> Result<()> {
self.server_task_handle.abort(); self.server_task_handle.abort();

View file

@ -2,6 +2,8 @@ pub mod fixture;
use fixture::TestServer; use fixture::TestServer;
use anyhow::Result; use anyhow::Result;
use maik::MailAssertion;
use regex::bytes::Regex;
use test_log::test as traced; use test_log::test as traced;
#[traced(tokio::test)] #[traced(tokio::test)]
@ -19,6 +21,13 @@ async fn subscribe_succeeds_with_valid_input() -> Result<()> {
assert_eq!(resp.status(), 200, "subscribe succeeds"); assert_eq!(resp.status(), 200, "subscribe succeeds");
assert!(
server
.mock_smtp_server
.assert(MailAssertion::new().body_matches(Regex::new(r"example\.com")?)),
"email sent has link"
);
server.shutdown().await server.shutdown().await
} }