From ca21a8472581e3ddab7c7736fb164ecd4c91f850 Mon Sep 17 00:00:00 2001 From: azdle Date: Tue, 15 Jul 2025 14:42:23 -0500 Subject: [PATCH] confirmation emails (hacky) --- Cargo.lock | 150 ++++++++++++++++-- Cargo.toml | 5 +- ...1558_make-subscription-status-required.sql | 9 ++ ...61829_create-subscription-tokens-table.sql | 6 + src/conf.rs | 2 + src/email_client/mod.rs | 44 +++-- src/server/mod.rs | 5 +- src/server/routes/subscriptions/mod.rs | 124 ++++++++++++--- tests/fixture/mod.rs | 24 ++- tests/subscriptions.rs | 9 ++ 10 files changed, 334 insertions(+), 44 deletions(-) create mode 100644 migrations/20250715161558_make-subscription-status-required.sql create mode 100644 migrations/20250715161829_create-subscription-tokens-table.sql diff --git a/Cargo.lock b/Cargo.lock index 6042aef..f6dd050 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 2e54cbf..488a2af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/migrations/20250715161558_make-subscription-status-required.sql b/migrations/20250715161558_make-subscription-status-required.sql new file mode 100644 index 0000000..723c246 --- /dev/null +++ b/migrations/20250715161558_make-subscription-status-required.sql @@ -0,0 +1,9 @@ +BEGIN; + + UPDATE subscriptions + SET status = 'confirmed' + where status IS NULL; + + ALTER TABLE subscriptions ALTER COLUMN status SET NOT NULL; + +COMMIT; diff --git a/migrations/20250715161829_create-subscription-tokens-table.sql b/migrations/20250715161829_create-subscription-tokens-table.sql new file mode 100644 index 0000000..ef1a2ce --- /dev/null +++ b/migrations/20250715161829_create-subscription-tokens-table.sql @@ -0,0 +1,6 @@ +CREATE TABLE subscription_tokens( + token TEXT NOT NULL, + subscriber uuid NOT NULL + REFERENCES subscriptions (id), + PRIMARY KEY (token) +); diff --git a/src/conf.rs b/src/conf.rs index 3b1e0a5..edd45b4 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -16,9 +16,11 @@ pub struct App { #[derive(Debug, Deserialize, Clone)] pub struct Email { pub server: String, + pub port: Option, pub username: String, pub password: String, pub sender: String, + pub cert: Option, } #[derive(Debug, Deserialize, Clone)] diff --git a/src/email_client/mod.rs b/src/email_client/mod.rs index 4ee2910..8932978 100644 --- a/src/email_client/mod.rs +++ b/src/email_client/mod.rs @@ -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) -> EmailClient { + pub fn new(conf: Option) -> Result { let Some(conf) = conf else { - return EmailClient::Disabled; + return Ok(EmailClient::Disabled); }; - let inner: AsyncSmtpTransport = - AsyncSmtpTransport::::relay(&conf.server) - .unwrap() - .credentials(Credentials::new( - conf.username.clone(), - conf.password.clone(), - )) - .build(); + let mut inner = AsyncSmtpTransport::::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 = 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<()> { diff --git a/src/server/mod.rs b/src/server/mod.rs index 073d8ec..e82e987 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -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()), diff --git a/src/server/routes/subscriptions/mod.rs b/src/server/routes/subscriptions/mod.rs index 8f15ba1..b325bfc 100644 --- a/src/server/routes/subscriptions/mod.rs +++ b/src/server/routes/subscriptions/mod.rs @@ -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 { - 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, + Query(query): Query, +) -> 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() + } +} diff --git a/tests/fixture/mod.rs b/tests/fixture/mod.rs index 7e33650..48ff36e 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -30,6 +30,7 @@ pub struct TestServer { server_task_handle: JoinHandle<()>, addr: SocketAddr, db: Arc, + 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(); diff --git a/tests/subscriptions.rs b/tests/subscriptions.rs index 6a1194b..ddc9cf6 100644 --- a/tests/subscriptions.rs +++ b/tests/subscriptions.rs @@ -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 }