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
|
||||
*.db
|
||||
/conf/secrets.toml
|
||||
|
|
131
Cargo.lock
generated
131
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1,2 +1,7 @@
|
|||
[app]
|
||||
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,
|
||||
}
|
||||
|
||||
#[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
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;
|
||||
mod email_client;
|
||||
mod server;
|
||||
|
||||
pub use conf::Conf;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ impl TestServer {
|
|||
},
|
||||
database: conf::Database { url },
|
||||
debug: true,
|
||||
email: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
Loading…
Add table
Reference in a new issue