basic emails

This commit is contained in:
azdle 2025-07-15 10:44:11 -05:00
parent 187f6003b8
commit fdd80f3d8a
10 changed files with 254 additions and 4 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target /target
*.db *.db
/conf/secrets.toml

131
Cargo.lock generated
View file

@ -52,6 +52,18 @@ dependencies = [
"subtle", "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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.3" version = "1.1.3"
@ -348,6 +360,16 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@ -612,6 +634,22 @@ dependencies = [
"serde", "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]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@ -887,6 +925,10 @@ name = "hashbrown"
version = "0.14.5" version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@ -971,6 +1013,17 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "http" name = "http"
version = "1.3.1" version = "1.3.1"
@ -1325,6 +1378,36 @@ dependencies = [
"spin", "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]] [[package]]
name = "libc" name = "libc"
version = "0.2.172" version = "0.2.172"
@ -1466,6 +1549,15 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.46.0"
@ -1815,6 +1907,15 @@ version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "psm"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "publicsuffix" name = "publicsuffix"
version = "2.3.0" version = "2.3.0"
@ -1834,6 +1935,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "quoted_printable"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
[[package]] [[package]]
name = "r-efi" name = "r-efi"
version = "5.2.0" version = "5.2.0"
@ -2534,6 +2641,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 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]] [[package]]
name = "stringprep" name = "stringprep"
version = "0.1.5" version = "0.1.5"
@ -3184,6 +3304,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "whoami" name = "whoami"
version = "1.6.0" version = "1.6.0"
@ -3588,6 +3718,7 @@ dependencies = [
"futures-util", "futures-util",
"http-body-util", "http-body-util",
"hyper", "hyper",
"lettre",
"pin-project", "pin-project",
"reqwest", "reqwest",
"serde", "serde",

View file

@ -14,6 +14,7 @@ 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"] }
pin-project = "1.1.0" pin-project = "1.1.0"
serde = { version = "1.0.164", features = ["derive"] } serde = { version = "1.0.164", features = ["derive"] }
serde_json = "1.0.99" serde_json = "1.0.99"

View file

@ -1,2 +1,7 @@
[app] [app]
listen = "[::]:3742" listen = "[::]:3742"
[email]
server = "smtp.fastmail.com"
username = "patrick@psbarrett.com"
sender = "Z2A Bot <bot@azdle.net>"

View file

@ -13,12 +13,21 @@ pub struct App {
pub listen: SocketAddr, 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)] #[derive(Debug, Deserialize, Clone)]
#[allow(unused)] #[allow(unused)]
pub struct Conf { pub struct Conf {
pub debug: bool, pub debug: bool,
pub database: Database, pub database: Database,
pub app: App, pub app: App,
pub email: Option<Email>,
} }
impl Conf { impl Conf {
@ -30,6 +39,7 @@ impl Conf {
let s = Config::builder() let s = Config::builder()
.add_source(File::with_name("conf/default")) .add_source(File::with_name("conf/default"))
.add_source(File::with_name(&format!("conf/{}", mode)).required(false)) .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")) .add_source(Environment::with_prefix("z2a"))
.build()?; .build()?;

78
src/email_client/mod.rs Normal file
View file

@ -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<Tokio1Executor>,
conf: conf::Email,
},
}
impl EmailClient {
pub fn new(conf: Option<conf::Email>) -> EmailClient {
let Some(conf) = conf else {
return EmailClient::Disabled;
};
let inner: AsyncSmtpTransport<Tokio1Executor> =
AsyncSmtpTransport::<Tokio1Executor>::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<Self, Box<dyn std::error::Error + Send + Sync>> {
unimplemented!()
}
fn display(&self) -> lettre::message::header::HeaderValue {
lettre::message::header::HeaderValue::new(Self::name(), self.0.into())
}
}

View file

@ -1,4 +1,5 @@
pub mod conf; pub mod conf;
mod email_client;
mod server; mod server;
pub use conf::Conf; pub use conf::Conf;

View file

@ -15,7 +15,8 @@ use tokio::signal;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing::info; use tracing::info;
use crate::Conf; use crate::email_client::EmailClient;
use crate::{email_client, Conf};
#[pin_project] #[pin_project]
pub struct ZeroToAxum { pub struct ZeroToAxum {
@ -46,11 +47,14 @@ impl ZeroToAxum {
.connect(&conf.database.url) .connect(&conf.database.url)
.await?; .await?;
let email_client = email_client::EmailClient::new(conf.email.clone());
let app_state = AppState { let app_state = AppState {
conf: Arc::new(conf.clone()), conf: Arc::new(conf.clone()),
// TODO: pull from config // TODO: pull from config
key: Key::generate(), key: Key::generate(),
db, db,
email_client,
}; };
let app = routes::build() let app = routes::build()
@ -79,6 +83,7 @@ pub struct AppState {
// The key used to encrypt cookies. // The key used to encrypt cookies.
key: Key, key: Key,
db: PgPool, db: PgPool,
email_client: EmailClient,
} }
impl FromRef<AppState> for Key { impl FromRef<AppState> for Key {

View file

@ -2,7 +2,7 @@ use anyhow::Context;
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Form, Router}; use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Form, Router};
use serde::Deserialize; use serde::Deserialize;
use sqlx::types::chrono::Utc; use sqlx::types::chrono::Utc;
use tracing::info; use tracing::{debug, error, info};
use uuid::Uuid; use uuid::Uuid;
use crate::server::AppState; use crate::server::AppState;
@ -18,7 +18,9 @@ pub struct SubscribeForm {
} }
pub async fn subscribe( pub async fn subscribe(
State(AppState { db, .. }): State<AppState>, State(AppState {
db, email_client, ..
}): State<AppState>,
Form(form): Form<SubscribeForm>, Form(form): Form<SubscribeForm>,
) -> Result<(), SubscribeError> { ) -> Result<(), SubscribeError> {
info!(form.name, form.email, "subscribe attempt"); info!(form.name, form.email, "subscribe attempt");
@ -27,6 +29,18 @@ pub async fn subscribe(
return Err(SubscribeError::InvalidEmail); 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!( sqlx::query!(
r#" r#"
INSERT INTO subscriptions (id, email, name, subscribed_at) INSERT INTO subscriptions (id, email, name, subscribed_at)
@ -56,7 +70,10 @@ impl IntoResponse for SubscribeError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
match self { match self {
SubscribeError::InvalidEmail => (StatusCode::BAD_REQUEST, "Invalid Email Address"), 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() .into_response()
} }

View file

@ -48,6 +48,7 @@ impl TestServer {
}, },
database: conf::Database { url }, database: conf::Database { url },
debug: true, debug: true,
email: None,
}) })
.await .await
.unwrap(); .unwrap();