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"
dependencies = [
"axum-core",
"axum-macros",
"bytes",
"form_urlencoded",
"futures-util",
@ -219,6 +220,17 @@ dependencies = [
"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]]
name = "backtrace"
version = "0.3.75"
@ -387,6 +399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
dependencies = [
"crossbeam-utils",
"portable-atomic",
]
[[package]]
@ -452,7 +465,7 @@ dependencies = [
"aes-gcm",
"base64 0.22.1",
"percent-encoding",
"rand",
"rand 0.8.5",
"subtle",
"time",
"version_check",
@ -544,7 +557,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
dependencies = [
"generic-array",
"rand_core",
"rand_core 0.6.4",
"typenum",
]
@ -695,6 +708,8 @@ dependencies = [
"concurrent-queue",
"parking",
"pin-project-lite",
"portable-atomic",
"portable-atomic-util",
]
[[package]]
@ -1475,6 +1490,20 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "matchers"
version = "0.1.0"
@ -1580,7 +1609,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.5",
"smallvec",
"zeroize",
]
@ -1737,6 +1766,16 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "pem-rfc7468"
version = "0.7.0"
@ -1868,6 +1907,21 @@ dependencies = [
"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]]
name = "potential_utf"
version = "0.1.2"
@ -1954,8 +2008,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"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]]
@ -1965,7 +2029,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"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]]
@ -1977,6 +2051,28 @@ dependencies = [
"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]]
name = "redox_syscall"
version = "0.5.12"
@ -2115,7 +2211,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
@ -2388,7 +2484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@ -2556,7 +2652,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.5",
"rsa",
"serde",
"sha1",
@ -2596,7 +2692,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
@ -3082,6 +3178,16 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "try-lock"
version = "0.2.5"
@ -3314,6 +3420,18 @@ dependencies = [
"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]]
name = "whoami"
version = "1.6.0"
@ -3682,6 +3800,15 @@ dependencies = [
"hashlink",
]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.0"
@ -3719,7 +3846,10 @@ dependencies = [
"http-body-util",
"hyper",
"lettre",
"maik",
"pin-project",
"rand 0.9.1",
"regex",
"reqwest",
"serde",
"serde_json",

View file

@ -9,13 +9,14 @@ license = "MIT OR Apache-2.0"
[dependencies]
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"] }
config = { version = "0.15", features = ["toml"] }
futures-util = "0.3"
hyper = "1.1"
lettre = { version = "0.11.17", features = ["tokio1", "tokio1-native-tls", "tracing", "web"] }
pin-project = "1.1.0"
rand = "0.9.1"
serde = { version = "1.0.164", features = ["derive"] }
serde_json = "1.0.99"
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]
bollard = { git = "https://github.com/fussybeaver/bollard.git", rev = "50a25a0" }
http-body-util = "0.1.3"
maik = "0.2.0"
regex = "1.11.1"
reqwest = { version = "0.12", features = ["cookies"] }
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)]
pub struct Email {
pub server: String,
pub port: Option<u16>,
pub username: String,
pub password: String,
pub sender: String,
pub cert: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]

View file

@ -3,7 +3,10 @@ use std::time::Duration;
use anyhow::{Context as _, Result};
use lettre::{
message::{header::ContentType, Mailbox},
transport::smtp::authentication::Credentials,
transport::smtp::{
authentication::Credentials,
client::{Certificate, TlsParametersBuilder},
},
AsyncSmtpTransport, AsyncTransport as _, Message, Tokio1Executor,
};
use tokio::time::timeout;
@ -20,21 +23,38 @@ pub(crate) enum 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 {
return EmailClient::Disabled;
return Ok(EmailClient::Disabled);
};
let inner: AsyncSmtpTransport<Tokio1Executor> =
AsyncSmtpTransport::<Tokio1Executor>::relay(&conf.server)
.unwrap()
.credentials(Credentials::new(
conf.username.clone(),
conf.password.clone(),
))
.build();
let mut inner = AsyncSmtpTransport::<Tokio1Executor>::relay(&conf.server).unwrap();
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<()> {

View file

@ -1,6 +1,6 @@
pub mod routes;
use anyhow::Result;
use anyhow::{Context as _, Result};
use axum::extract::FromRef;
use axum_extra::extract::cookie::Key;
use pin_project::pin_project;
@ -47,7 +47,8 @@ impl ZeroToAxum {
.connect(&conf.database.url)
.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 {
conf: Arc::new(conf.clone()),

View file

@ -1,5 +1,12 @@
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 sqlx::types::chrono::Utc;
use tracing::{debug, error, info};
@ -8,7 +15,9 @@ use uuid::Uuid;
use crate::server::AppState;
pub fn build() -> Router<AppState> {
Router::new().route("/", post(subscribe))
Router::new()
.route("/", post(subscribe))
.route("/confirm/", get(confirm))
}
#[derive(Deserialize)]
@ -29,31 +38,56 @@ pub async fn subscribe(
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
.send_email(
form.email
.parse()
.map_err(|_| SubscribeError::InvalidEmail)?,
"Test".to_owned(),
"This was a test.".to_owned(),
format!("Please confirm your subscription: https://example.com/subscriptions/confirm/?token={token}"),
)
.await?;
debug!("email sent");
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")?;
txn.commit().await.context("commit transaction")?;
Ok(())
}
@ -78,3 +112,57 @@ impl IntoResponse for SubscribeError {
.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<()>,
addr: SocketAddr,
db: Arc<TestDb>,
pub mock_smtp_server: maik::MockServer,
}
impl TestServer {
@ -42,13 +43,26 @@ impl TestServer {
let db = get_shared_db().await;
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 {
app: conf::App {
listen: "[::]:0".parse().unwrap(),
},
database: conf::Database { url },
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
.unwrap();
@ -60,6 +74,7 @@ impl TestServer {
server_task_handle,
addr,
db,
mock_smtp_server,
}
}
@ -68,6 +83,13 @@ impl TestServer {
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
pub async fn shutdown(self) -> Result<()> {
self.server_task_handle.abort();

View file

@ -2,6 +2,8 @@ pub mod fixture;
use fixture::TestServer;
use anyhow::Result;
use maik::MailAssertion;
use regex::bytes::Regex;
use test_log::test as traced;
#[traced(tokio::test)]
@ -19,6 +21,13 @@ async fn subscribe_succeeds_with_valid_input() -> Result<()> {
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
}