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
*.db
/conf/secrets.toml

131
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -1,2 +1,7 @@
[app]
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,
}
#[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<Email>,
}
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()?;

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;
mod email_client;
mod server;
pub use conf::Conf;

View file

@ -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<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 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<AppState>,
State(AppState {
db, email_client, ..
}): State<AppState>,
Form(form): Form<SubscribeForm>,
) -> 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()
}

View file

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