From fdd80f3d8aa130196a21cd88ced65ee917437a2b Mon Sep 17 00:00:00 2001 From: azdle Date: Tue, 15 Jul 2025 10:44:11 -0500 Subject: [PATCH] basic emails --- .gitignore | 1 + Cargo.lock | 131 +++++++++++++++++++++++++ Cargo.toml | 1 + conf/default.toml | 5 + src/conf.rs | 10 ++ src/email_client/mod.rs | 78 +++++++++++++++ src/lib.rs | 1 + src/server/mod.rs | 7 +- src/server/routes/subscriptions/mod.rs | 23 ++++- tests/fixture/mod.rs | 1 + 10 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 src/email_client/mod.rs diff --git a/.gitignore b/.gitignore index 2c4918c..b9e3aa5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target *.db +/conf/secrets.toml diff --git a/Cargo.lock b/Cargo.lock index 4838ea4..6042aef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -348,6 +360,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "cipher" version = "0.4.4" @@ -612,6 +634,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -887,6 +925,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "hashbrown" @@ -971,6 +1013,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "1.3.1" @@ -1325,6 +1378,36 @@ dependencies = [ "spin", ] +[[package]] +name = "lettre" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb2a0354e9ece2fcdcf9fa53417f6de587230c0c248068eb058fa26c4a753179" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "tokio-native-tls", + "tracing", + "url", + "web-time", +] + [[package]] name = "libc" version = "0.2.172" @@ -1466,6 +1549,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1815,6 +1907,15 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" +[[package]] +name = "psm" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f" +dependencies = [ + "cc", +] + [[package]] name = "publicsuffix" version = "2.3.0" @@ -1834,6 +1935,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.2.0" @@ -2534,6 +2641,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3184,6 +3304,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "whoami" version = "1.6.0" @@ -3588,6 +3718,7 @@ dependencies = [ "futures-util", "http-body-util", "hyper", + "lettre", "pin-project", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 0b8fc02..2e54cbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ 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" serde = { version = "1.0.164", features = ["derive"] } serde_json = "1.0.99" diff --git a/conf/default.toml b/conf/default.toml index 1b6350a..f25c35e 100644 --- a/conf/default.toml +++ b/conf/default.toml @@ -1,2 +1,7 @@ [app] listen = "[::]:3742" + +[email] +server = "smtp.fastmail.com" +username = "patrick@psbarrett.com" +sender = "Z2A Bot " diff --git a/src/conf.rs b/src/conf.rs index 89a329a..3b1e0a5 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -13,12 +13,21 @@ pub struct App { pub listen: SocketAddr, } +#[derive(Debug, Deserialize, Clone)] +pub struct Email { + pub server: String, + pub username: String, + pub password: String, + pub sender: String, +} + #[derive(Debug, Deserialize, Clone)] #[allow(unused)] pub struct Conf { pub debug: bool, pub database: Database, pub app: App, + pub email: Option, } impl Conf { @@ -30,6 +39,7 @@ impl Conf { let s = Config::builder() .add_source(File::with_name("conf/default")) .add_source(File::with_name(&format!("conf/{}", mode)).required(false)) + .add_source(File::with_name("conf/secrets").required(false)) .add_source(Environment::with_prefix("z2a")) .build()?; diff --git a/src/email_client/mod.rs b/src/email_client/mod.rs new file mode 100644 index 0000000..4ee2910 --- /dev/null +++ b/src/email_client/mod.rs @@ -0,0 +1,78 @@ +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use lettre::{ + message::{header::ContentType, Mailbox}, + transport::smtp::authentication::Credentials, + AsyncSmtpTransport, AsyncTransport as _, Message, Tokio1Executor, +}; +use tokio::time::timeout; + +use crate::conf; + +#[derive(Clone)] +pub(crate) enum EmailClient { + Disabled, + Enabled { + inner: AsyncSmtpTransport, + conf: conf::Email, + }, +} + +impl EmailClient { + pub fn new(conf: Option) -> EmailClient { + let Some(conf) = conf else { + return EmailClient::Disabled; + }; + + let inner: AsyncSmtpTransport = + AsyncSmtpTransport::::relay(&conf.server) + .unwrap() + .credentials(Credentials::new( + conf.username.clone(), + conf.password.clone(), + )) + .build(); + + EmailClient::Enabled { inner, conf } + } + + pub async fn send_email(&self, to: Mailbox, subject: String, body: String) -> Result<()> { + let EmailClient::Enabled { inner, conf } = self else { + return Ok(()); + }; + + let email = Message::builder() + .from(conf.sender.parse().unwrap()) + .to(to) + .subject(subject) + .header(ContentType::TEXT_PLAIN) + .header(PostmarkMessageStream("outbound")) + .body(body) + .context("build email")?; + + timeout(Duration::from_secs(5), inner.send(email)) + .await + .context("timeout while sending email")? + .context("send email")?; + + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub struct PostmarkMessageStream<'a>(pub &'a str); + +impl<'a> lettre::message::header::Header for PostmarkMessageStream<'a> { + fn name() -> lettre::message::header::HeaderName { + lettre::message::header::HeaderName::new_from_ascii_str("X-PM-Message-Stream") + } + + fn parse(_: &str) -> Result> { + unimplemented!() + } + + fn display(&self) -> lettre::message::header::HeaderValue { + lettre::message::header::HeaderValue::new(Self::name(), self.0.into()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 0a8c5f8..f7c027e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod conf; +mod email_client; mod server; pub use conf::Conf; diff --git a/src/server/mod.rs b/src/server/mod.rs index 1e32477..073d8ec 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -15,7 +15,8 @@ use tokio::signal; use tower_http::trace::TraceLayer; use tracing::info; -use crate::Conf; +use crate::email_client::EmailClient; +use crate::{email_client, Conf}; #[pin_project] pub struct ZeroToAxum { @@ -46,11 +47,14 @@ impl ZeroToAxum { .connect(&conf.database.url) .await?; + let email_client = email_client::EmailClient::new(conf.email.clone()); + let app_state = AppState { conf: Arc::new(conf.clone()), // TODO: pull from config key: Key::generate(), db, + email_client, }; let app = routes::build() @@ -79,6 +83,7 @@ pub struct AppState { // The key used to encrypt cookies. key: Key, db: PgPool, + email_client: EmailClient, } impl FromRef for Key { diff --git a/src/server/routes/subscriptions/mod.rs b/src/server/routes/subscriptions/mod.rs index 8d1198d..97c2811 100644 --- a/src/server/routes/subscriptions/mod.rs +++ b/src/server/routes/subscriptions/mod.rs @@ -2,7 +2,7 @@ use anyhow::Context; use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Form, Router}; use serde::Deserialize; use sqlx::types::chrono::Utc; -use tracing::info; +use tracing::{debug, error, info}; use uuid::Uuid; use crate::server::AppState; @@ -18,7 +18,9 @@ pub struct SubscribeForm { } pub async fn subscribe( - State(AppState { db, .. }): State, + State(AppState { + db, email_client, .. + }): State, Form(form): Form, ) -> Result<(), SubscribeError> { info!(form.name, form.email, "subscribe attempt"); @@ -27,6 +29,18 @@ pub async fn subscribe( return Err(SubscribeError::InvalidEmail); } + email_client + .send_email( + form.email + .parse() + .map_err(|_| SubscribeError::InvalidEmail)?, + "Test".to_owned(), + "This was a test.".to_owned(), + ) + .await?; + + debug!("email sent"); + sqlx::query!( r#" INSERT INTO subscriptions (id, email, name, subscribed_at) @@ -56,7 +70,10 @@ impl IntoResponse for SubscribeError { fn into_response(self) -> axum::response::Response { match self { SubscribeError::InvalidEmail => (StatusCode::BAD_REQUEST, "Invalid Email Address"), - SubscribeError::Unknown(_e) => (StatusCode::INTERNAL_SERVER_ERROR, "Unknown Error"), + SubscribeError::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 446b4a3..7e33650 100644 --- a/tests/fixture/mod.rs +++ b/tests/fixture/mod.rs @@ -48,6 +48,7 @@ impl TestServer { }, database: conf::Database { url }, debug: true, + email: None, }) .await .unwrap();