basic emails
This commit is contained in:
parent
187f6003b8
commit
fdd80f3d8a
10 changed files with 254 additions and 4 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,2 +1,3 @@
|
||||||
/target
|
/target
|
||||||
*.db
|
*.db
|
||||||
|
/conf/secrets.toml
|
||||||
|
|
131
Cargo.lock
generated
131
Cargo.lock
generated
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>"
|
||||||
|
|
10
src/conf.rs
10
src/conf.rs
|
@ -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
78
src/email_client/mod.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,7 @@ impl TestServer {
|
||||||
},
|
},
|
||||||
database: conf::Database { url },
|
database: conf::Database { url },
|
||||||
debug: true,
|
debug: true,
|
||||||
|
email: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
Loading…
Add table
Reference in a new issue